Compare commits
428 Commits
Author | SHA1 | Date | |
---|---|---|---|
884e37fe1b | |||
cc6a73211e | |||
2299b86d0f | |||
6d293a1aac | |||
a2311aee24 | |||
5571c0d01f | |||
98e1bad413 | |||
7ff52d99e6 | |||
cc440a4110 | |||
f5149a0c37 | |||
ca928636fd | |||
4a8297d594 | |||
915e4408e1 | |||
fd9eac06f6 | |||
403942dfc0 | |||
35dc513760 | |||
01861f0b6a | |||
8c10f1e96a | |||
5f7fc0b8e8 | |||
700c179774 | |||
cabe02f7d0 | |||
5ceb9f2e83 | |||
fe1c7669b6 | |||
4eac10981f | |||
c869bccc04 | |||
61c111db69 | |||
34f63c1cdf | |||
a643d9e811 | |||
2239ddfad1 | |||
12fbe36b9a | |||
46e34bb89a | |||
c9453d3023 | |||
fc766724c4 | |||
38c394c0af | |||
67e2b06d37 | |||
be10a8d99e | |||
fbeec600b7 | |||
1a54592d4c | |||
94a527caf2 | |||
0a1bbbdd47 | |||
82b63e4bd4 | |||
e1d1aff0c1 | |||
2e5f9ee01f | |||
f901a0020f | |||
fc2be75c3b | |||
96c131940b | |||
b9435a255b | |||
56045f0faf | |||
08d1e60238 | |||
d88b3ac770 | |||
40e329d37f | |||
23a0721d8d | |||
2b2c665eb6 | |||
954173a2c2 | |||
91e6dc6668 | |||
e9d8a8fcd8 | |||
4c84686395 | |||
61fd1849ed | |||
a67158f8f6 | |||
5c6bfd8b49 | |||
d9ecf51c6d | |||
5d31eb9172 | |||
fadce244c5 | |||
cbe7c6ca6d | |||
b03938fb2e | |||
8061a23fdc | |||
cd8a480cd0 | |||
b8b87714eb | |||
bf2f96621d | |||
2d771f04be | |||
3a12472d4b | |||
d5a686a5d8 | |||
690f89e29a | |||
82a6a53dc4 | |||
cdd31b1047 | |||
5bad949cfa | |||
3826646d06 | |||
74071e8997 | |||
3ce34803f3 | |||
232f73172f | |||
ff1bb7142b | |||
7155bf635a | |||
c306ff8009 | |||
b344abce06 | |||
b3c666c872 | |||
6a671cac84 | |||
fe87c3a7de | |||
2013f8cbd9 | |||
2325842471 | |||
c80e58b049 | |||
be0ae5eba4 | |||
2b84f64554 | |||
0a658a76e8 | |||
50dc79d865 | |||
8e5377a416 | |||
4299fd6fbd | |||
1d6a53f7cd | |||
bb2993b0c0 | |||
f6946c1165 | |||
8e219d8006 | |||
53565eb9e6 | |||
965e78d8ad | |||
74b81d3e23 | |||
a0fba6387f | |||
d28bdf2064 | |||
edf0c00e34 | |||
107d161379 | |||
f2c184f769 | |||
b45986ecfc | |||
a2c2452ec5 | |||
5194258b48 | |||
3fe7eb41ac | |||
7fb9e2f0a1 | |||
1d443f7b76 | |||
6ad4fba9cd | |||
3dda6531b5 | |||
4d11517e21 | |||
308e4ac69d | |||
de09e1498b | |||
c26c4686c5 | |||
c95f4fcc47 | |||
71af74fc8a | |||
56e972c371 | |||
7feb6da062 | |||
af71874f9d | |||
3fa8818a27 | |||
be46d8aa14 | |||
20f55058ac | |||
e9d1a53e03 | |||
38141759fd | |||
7fb3e3bc2c | |||
05ac5158f8 | |||
ec257a4b57 | |||
5ebb89a6d5 | |||
900d60d1ae | |||
bc792c145f | |||
4432484acd | |||
7ad3f9e0cb | |||
060a52f7a2 | |||
c17430d211 | |||
8fd99bb617 | |||
ce388eb6c8 | |||
1850f9787e | |||
c6d55fad1c | |||
0c647d8f21 | |||
5d1f87582e | |||
ef086b3f81 | |||
b4be1db712 | |||
5d44ebdfd8 | |||
9859604c81 | |||
d31e495f6b | |||
7c4102be44 | |||
1fd50e23d9 | |||
9635448f18 | |||
e7f1ca332e | |||
3d2e40865e | |||
5132141c68 | |||
e37f6792c9 | |||
e6b1136a14 | |||
d7bc01ccb4 | |||
27beff3f8f | |||
c6c545b99f | |||
6d5a2fae6a | |||
8819eabcd0 | |||
3582d960ca | |||
02e10a301a | |||
f0187434d2 | |||
34af52e3c3 | |||
965df82c1c | |||
df53f06094 | |||
140d3c6010 | |||
a65dccac92 | |||
740e1cfac1 | |||
b62f31d385 | |||
1c740b9bbc | |||
380256eda7 | |||
74b695c089 | |||
6d1e705e4b | |||
8abe20dba5 | |||
ed7a8ac0fd | |||
74eee034d0 | |||
d19d23fe37 | |||
4ce577d7d8 | |||
a340fad109 | |||
555ad388bc | |||
2f27ad5bef | |||
c6487bf9d4 | |||
ff3dd28cd7 | |||
a14ffa93ed | |||
672fcbcbdf | |||
cb4258dd6d | |||
6fc21f82af | |||
13e3ef5875 | |||
21b3320e66 | |||
5c47e63ae3 | |||
f59378002e | |||
531e90e8ad | |||
8fc33131dd | |||
62716eb545 | |||
14d5d1e8d6 | |||
4306ed739f | |||
1f87bc00e8 | |||
ff9ff4bdcf | |||
578233d66d | |||
5e7f790f87 | |||
d64f354ee0 | |||
ba3d8c6d4e | |||
4f7f87b10d | |||
4273f99644 | |||
ffe834bedd | |||
e448e009c9 | |||
b6802c51bb | |||
2515c1ea1f | |||
0ef6409f75 | |||
ed18b81ad8 | |||
b46cec6fab | |||
6c122666a0 | |||
7ddc9ececf | |||
4eebbd9692 | |||
338b49c965 | |||
f438f97571 | |||
9b273115a0 | |||
58d1add810 | |||
c189615ca4 | |||
5687852dfb | |||
d74c62dbb7 | |||
987e98ebc0 | |||
7083f22577 | |||
7b10eb68bc | |||
f277010991 | |||
729a7fd107 | |||
c8230c949d | |||
3c98960efe | |||
c5d0d91a7d | |||
fb06e9db44 | |||
d47e2e231b | |||
cb89f34455 | |||
11ab4a4ba6 | |||
5dc8387ad9 | |||
26b70bb625 | |||
f30a96d7e9 | |||
a1627b7fbf | |||
7c3b8c8f44 | |||
b19f4fa939 | |||
41c3e06ce4 | |||
8a3df7a689 | |||
196a115c99 | |||
005f9083aa | |||
12dbae56c4 | |||
a98723c57b | |||
d5bd3b8383 | |||
617dd29f23 | |||
b0a4a735f3 | |||
41770e38b8 | |||
d8a6614543 | |||
8d76b5130e | |||
43fc4b9b8d | |||
3ed8d7f1d2 | |||
ea7c194d7e | |||
041a905fc0 | |||
10d1be8bd1 | |||
6e1d35eda4 | |||
52b5151fe0 | |||
f9fc033de6 | |||
116d00a557 | |||
329e3d5362 | |||
47e8944f06 | |||
e7c43ae390 | |||
b8b51b34d3 | |||
d083036719 | |||
7fe81c710b | |||
9993f65627 | |||
fe01d5418d | |||
2f7b9932a0 | |||
1eed16b732 | |||
ede1160943 | |||
3814f0cb18 | |||
24315b8203 | |||
3c200d0dc6 | |||
9f1e724875 | |||
f838e7f893 | |||
edb893ecd3 | |||
436a4c367f | |||
1813ce0cfa | |||
7683347997 | |||
46ffc7a73d | |||
e0a807d625 | |||
dffcf6d2ce | |||
84d239e4b1 | |||
fcefc64117 | |||
81fbb380b4 | |||
d7ac8a3dcf | |||
bcd3418e2c | |||
ef9e5cb5b3 | |||
e797cfeb8c | |||
22bae7f766 | |||
aa669e9f53 | |||
898a1af7b5 | |||
f762bcf48f | |||
5d4effa360 | |||
dd05478bf3 | |||
9450915404 | |||
8d126e183f | |||
bfb08cf5fc | |||
a7bcf4b5c1 | |||
cd49ca44b1 | |||
734362396f | |||
88147bea66 | |||
cca43c68a6 | |||
480e2d2d8f | |||
be100ce7ec | |||
eca91d32ed | |||
1f95212494 | |||
0173104c84 | |||
6e33fa775d | |||
e244ff70e6 | |||
ace782a26b | |||
90289a0db2 | |||
7e7e1a2844 | |||
ddd028736c | |||
e1d35a64da | |||
39807ef480 | |||
39723b1299 | |||
8cd004bede | |||
4f112dd386 | |||
b806b1ed1f | |||
1d0a79e33c | |||
d4a690ebbc | |||
68687897f3 | |||
a7250fd9bf | |||
eabe80b790 | |||
fe77f87110 | |||
32c27d7c07 | |||
14b871b57a | |||
9d5fce2752 | |||
d333151731 | |||
b2e500a714 | |||
b705795b44 | |||
250f4ff1ae | |||
6bed180790 | |||
10fbc3f638 | |||
f65dc6fc42 | |||
9833bac6e4 | |||
7d412b20d7 | |||
9bfcd3c50c | |||
55c2ce6695 | |||
493677e0aa | |||
710c26d016 | |||
24415018b7 | |||
c50b9a2000 | |||
af9bd14eed | |||
9e1ff16e96 | |||
f7c1fd77f2 | |||
641315537d | |||
a895bd8560 | |||
ca86a08f3e | |||
e118422441 | |||
b3777cffbf | |||
39c9c17007 | |||
3ab4eacf9f | |||
cff3d1b6bd | |||
f41db78831 | |||
73f7d14e7b | |||
f6ed6b10a7 | |||
b5aaee4d15 | |||
c849d6b3d4 | |||
a9908a7df4 | |||
063c769158 | |||
f8e9871300 | |||
78a62a9575 | |||
85fde6219e | |||
4eb9346d8d | |||
11966a52ba | |||
8cf81b5459 | |||
cc958a39b3 | |||
9065686cc5 | |||
9a41cb10a1 | |||
6957e52d0d | |||
9cd9e90be0 | |||
2839dc60b4 | |||
f3548a2327 | |||
79883d6940 | |||
b2bc993416 | |||
453b3f0da5 | |||
63ae3f0746 | |||
da4cc6489f | |||
1102a3a4f3 | |||
1402a12f04 | |||
f049b5d7ee | |||
14ed4ca354 | |||
535ff96ab3 | |||
57482f81fc | |||
a31ce3c400 | |||
319f0aed90 | |||
826dcf0f86 | |||
b2411aee74 | |||
731acdced0 | |||
35b3bca1e6 | |||
3c413497ae | |||
1b5e516413 | |||
20cb5cecc4 | |||
08dc24605d | |||
bb7e9e94ee | |||
2680a1c872 | |||
20a7ce591c | |||
474e844ed9 | |||
b34955f2fb | |||
2bd0f0f14d | |||
8b77d9ff93 | |||
a9c7360020 | |||
d02c87602b | |||
9f966643b5 | |||
5746e2a3d6 | |||
d5c2231794 | |||
fc8e257a10 | |||
2e9bf382fb | |||
de48c848da | |||
9cdcc828a7 | |||
b28d446d07 | |||
274a38a588 | |||
fff89a9957 | |||
5613657c8f | |||
26bb16dd40 | |||
f0d39bb27b | |||
4c17310ebf | |||
fd36672877 | |||
d67c57056b | |||
59c55ef574 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -38,3 +38,4 @@ excludes
|
|||||||
*.mo
|
*.mo
|
||||||
zh_Hans
|
zh_Hans
|
||||||
test_temp.py
|
test_temp.py
|
||||||
|
dummy.js
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
exclude src/accounting/static/js/dummy.js
|
||||||
include src/accounting/translations/*
|
include src/accounting/translations/*
|
||||||
include src/accounting/translations/*/LC_MESSAGES/*
|
include src/accounting/translations/*/LC_MESSAGES/*
|
||||||
include docs/*
|
include docs/*
|
||||||
@ -22,6 +23,7 @@ include docs/source/*
|
|||||||
include docs/source/_static/*
|
include docs/source/_static/*
|
||||||
include docs/source/_templates/*
|
include docs/source/_templates/*
|
||||||
include tests/*
|
include tests/*
|
||||||
|
exclude tests/test_temp.py
|
||||||
include tests/test_site/*
|
include tests/test_site/*
|
||||||
include tests/test_site/templates/*
|
include tests/test_site/templates/*
|
||||||
include tests/test_site/translations/*
|
include tests/test_site/translations/*
|
||||||
|
@ -28,10 +28,10 @@ accounting.account.forms module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.account.query module
|
accounting.account.queries module
|
||||||
-------------------------------
|
---------------------------------
|
||||||
|
|
||||||
.. automodule:: accounting.account.query
|
.. automodule:: accounting.account.queries
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
@ -20,10 +20,10 @@ accounting.base\_account.converters module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.base\_account.query module
|
accounting.base\_account.queries module
|
||||||
-------------------------------------
|
---------------------------------------
|
||||||
|
|
||||||
.. automodule:: accounting.base_account.query
|
.. automodule:: accounting.base_account.queries
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
@ -28,10 +28,10 @@ accounting.currency.forms module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.currency.query module
|
accounting.currency.queries module
|
||||||
--------------------------------
|
----------------------------------
|
||||||
|
|
||||||
.. automodule:: accounting.currency.query
|
.. automodule:: accounting.currency.queries
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
69
docs/source/accounting.report.period.rst
Normal file
69
docs/source/accounting.report.period.rst
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
accounting.report.period package
|
||||||
|
================================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.report.period.chooser module
|
||||||
|
---------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.period.chooser
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.period.description module
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.period.description
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.period.month\_end module
|
||||||
|
------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.period.month_end
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.period.parser module
|
||||||
|
--------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.period.parser
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.period.period module
|
||||||
|
--------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.period.period
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.period.shortcuts module
|
||||||
|
-----------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.period.shortcuts
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.period.specification module
|
||||||
|
---------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.period.specification
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.period
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
69
docs/source/accounting.report.reports.rst
Normal file
69
docs/source/accounting.report.reports.rst
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
accounting.report.reports package
|
||||||
|
=================================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.report.reports.balance\_sheet module
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.balance_sheet
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.reports.income\_expenses module
|
||||||
|
-------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.income_expenses
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.reports.income\_statement module
|
||||||
|
--------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.income_statement
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.reports.journal module
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.journal
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.reports.ledger module
|
||||||
|
---------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.ledger
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.reports.search module
|
||||||
|
---------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.search
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.reports.trial\_balance module
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.trial_balance
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
47
docs/source/accounting.report.rst
Normal file
47
docs/source/accounting.report.rst
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
accounting.report package
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Subpackages
|
||||||
|
-----------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 4
|
||||||
|
|
||||||
|
accounting.report.period
|
||||||
|
accounting.report.reports
|
||||||
|
accounting.report.utils
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.report.converters module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.converters
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.template\_filters module
|
||||||
|
------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.template_filters
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.views module
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.views
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
77
docs/source/accounting.report.utils.rst
Normal file
77
docs/source/accounting.report.utils.rst
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
accounting.report.utils package
|
||||||
|
===============================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.report.utils.base\_page\_params module
|
||||||
|
-------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.base_page_params
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.utils.base\_report module
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.base_report
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.utils.csv\_export module
|
||||||
|
------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.csv_export
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.utils.ie\_account module
|
||||||
|
------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.ie_account
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.utils.option\_link module
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.option_link
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.utils.report\_chooser module
|
||||||
|
----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.report_chooser
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.utils.report\_type module
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.report_type
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.utils.urls module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.urls
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
@ -10,6 +10,7 @@ Subpackages
|
|||||||
accounting.account
|
accounting.account
|
||||||
accounting.base_account
|
accounting.base_account
|
||||||
accounting.currency
|
accounting.currency
|
||||||
|
accounting.report
|
||||||
accounting.transaction
|
accounting.transaction
|
||||||
accounting.utils
|
accounting.utils
|
||||||
|
|
||||||
@ -32,6 +33,22 @@ accounting.models module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.template\_filters module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.template_filters
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.template\_globals module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.template_globals
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
Module contents
|
Module contents
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
45
docs/source/accounting.transaction.forms.rst
Normal file
45
docs/source/accounting.transaction.forms.rst
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
accounting.transaction.forms package
|
||||||
|
====================================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.transaction.forms.currency module
|
||||||
|
--------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.forms.currency
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.forms.journal\_entry module
|
||||||
|
--------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.forms.journal_entry
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.forms.reorder module
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.forms.reorder
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.forms.transaction module
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.forms.transaction
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.forms
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
@ -1,6 +1,15 @@
|
|||||||
accounting.transaction package
|
accounting.transaction package
|
||||||
==============================
|
==============================
|
||||||
|
|
||||||
|
Subpackages
|
||||||
|
-----------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 4
|
||||||
|
|
||||||
|
accounting.transaction.forms
|
||||||
|
accounting.transaction.utils
|
||||||
|
|
||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
@ -12,34 +21,10 @@ accounting.transaction.converters module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.transaction.dispatcher module
|
accounting.transaction.template\_filters module
|
||||||
----------------------------------------
|
-----------------------------------------------
|
||||||
|
|
||||||
.. automodule:: accounting.transaction.dispatcher
|
.. automodule:: accounting.transaction.template_filters
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.transaction.forms module
|
|
||||||
-----------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.transaction.forms
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.transaction.query module
|
|
||||||
-----------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.transaction.query
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.transaction.template module
|
|
||||||
--------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.transaction.template
|
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
53
docs/source/accounting.transaction.utils.rst
Normal file
53
docs/source/accounting.transaction.utils.rst
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
accounting.transaction.utils package
|
||||||
|
====================================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.transaction.utils.account\_option module
|
||||||
|
---------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.utils.account_option
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.utils.offset\_alias module
|
||||||
|
-------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.utils.offset_alias
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.utils.operators module
|
||||||
|
---------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.utils.operators
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.utils.original\_entries module
|
||||||
|
-----------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.utils.original_entries
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.utils.summary\_editor module
|
||||||
|
---------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.utils.summary_editor
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.utils
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
@ -4,6 +4,14 @@ accounting.utils package
|
|||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
accounting.utils.cast module
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.cast
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.utils.flash\_errors module
|
accounting.utils.flash\_errors module
|
||||||
-------------------------------------
|
-------------------------------------
|
||||||
|
|
||||||
@ -60,6 +68,14 @@ accounting.utils.strip\_text module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.txn\_types module
|
||||||
|
----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.txn_types
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.utils.user module
|
accounting.utils.user module
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
|
|||||||
project = 'Mia! Accounting Flask'
|
project = 'Mia! Accounting Flask'
|
||||||
copyright = '2023, imacat'
|
copyright = '2023, imacat'
|
||||||
author = 'imacat'
|
author = 'imacat'
|
||||||
release = '0.0.0'
|
release = '0.4.0'
|
||||||
|
|
||||||
# -- General configuration ---------------------------------------------------
|
# -- General configuration ---------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||||
@ -28,5 +28,5 @@ exclude_patterns = []
|
|||||||
# -- Options for HTML output -------------------------------------------------
|
# -- Options for HTML output -------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||||
|
|
||||||
html_theme = 'nature'
|
html_theme = 'sphinx_rtd_theme'
|
||||||
html_static_path = ['_static']
|
html_static_path = ['_static']
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
name = mia-accounting-flask
|
name = mia-accounting-flask
|
||||||
version = 0.3.0
|
version = 0.6.0
|
||||||
author = imacat
|
author = imacat
|
||||||
author_email = imacat@mail.imacat.idv.tw
|
author_email = imacat@mail.imacat.idv.tw
|
||||||
description = The Mia! Accounting Flask project.
|
description = The Mia! Accounting Flask project.
|
||||||
|
@ -17,13 +17,12 @@
|
|||||||
"""The accounting application.
|
"""The accounting application.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, Blueprint
|
from flask import Flask, Blueprint
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
from accounting.utils.user import AbstractUserUtils
|
from accounting.utils.user import UserUtilityInterface
|
||||||
|
|
||||||
db: SQLAlchemy = SQLAlchemy()
|
db: SQLAlchemy = SQLAlchemy()
|
||||||
"""The database instance."""
|
"""The database instance."""
|
||||||
@ -31,19 +30,13 @@ data_dir: Path = Path(__file__).parent / "data"
|
|||||||
"""The data directory."""
|
"""The data directory."""
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask, user_utils: AbstractUserUtils,
|
def init_app(app: Flask, user_utils: UserUtilityInterface,
|
||||||
url_prefix: str = "/accounting",
|
url_prefix: str = "/accounting") -> None:
|
||||||
can_view_func: t.Callable[[], bool] | None = None,
|
|
||||||
can_edit_func: t.Callable[[], bool] | None = None) -> None:
|
|
||||||
"""Initialize the application.
|
"""Initialize the application.
|
||||||
|
|
||||||
:param app: The Flask application.
|
:param app: The Flask application.
|
||||||
:param user_utils: The user utilities.
|
:param user_utils: The user utilities.
|
||||||
:param url_prefix: The URL prefix of the accounting application.
|
:param url_prefix: The URL prefix of the accounting application.
|
||||||
:param can_view_func: A callback that returns whether the current user can
|
|
||||||
view the accounting data.
|
|
||||||
:param can_edit_func: A callback that returns whether the current user can
|
|
||||||
edit the accounting data.
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
# The database instance must be set before loading everything
|
# The database instance must be set before loading everything
|
||||||
@ -58,11 +51,25 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
|||||||
template_folder="templates",
|
template_folder="templates",
|
||||||
static_folder="static")
|
static_folder="static")
|
||||||
|
|
||||||
|
from .template_filters import format_amount, format_date, default
|
||||||
|
bp.add_app_template_filter(format_amount, "accounting_format_amount")
|
||||||
|
bp.add_app_template_filter(format_date, "accounting_format_date")
|
||||||
|
bp.add_app_template_filter(default, "accounting_default")
|
||||||
|
|
||||||
|
from .template_globals import currency_options, default_currency_code
|
||||||
|
bp.add_app_template_global(currency_options,
|
||||||
|
"accounting_currency_options")
|
||||||
|
bp.add_app_template_global(default_currency_code,
|
||||||
|
"accounting_default_currency_code")
|
||||||
|
|
||||||
from . import locale
|
from . import locale
|
||||||
locale.init_app(app, bp)
|
locale.init_app(app, bp)
|
||||||
|
|
||||||
from .utils import permission
|
from .utils import permission
|
||||||
permission.init_app(bp, can_view_func, can_edit_func)
|
permission.init_app(bp, user_utils)
|
||||||
|
|
||||||
|
from .utils import next_uri
|
||||||
|
next_uri.init_app(bp)
|
||||||
|
|
||||||
from . import base_account
|
from . import base_account
|
||||||
base_account.init_app(app, bp)
|
base_account.init_app(app, bp)
|
||||||
@ -76,7 +83,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
|||||||
from . import transaction
|
from . import transaction
|
||||||
transaction.init_app(app, bp)
|
transaction.init_app(app, bp)
|
||||||
|
|
||||||
from .utils import next_uri
|
from . import report
|
||||||
next_uri.init_app(bp)
|
report.init_app(app, bp)
|
||||||
|
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
|
@ -18,7 +18,6 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
from secrets import randbelow
|
from secrets import randbelow
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@ -30,7 +29,7 @@ from accounting.utils.user import has_user, get_user_pk
|
|||||||
|
|
||||||
AccountData = tuple[int, str, int, str, str, str, bool]
|
AccountData = tuple[int, str, int, str, str, str, bool]
|
||||||
"""The format of the account data, as a list of (ID, base account code, number,
|
"""The format of the account data, as a list of (ID, base account code, number,
|
||||||
English, Traditional Chinese, Simplified Chinese, is-pay-off-needed) tuples."""
|
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
|
||||||
|
|
||||||
|
|
||||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||||
@ -93,14 +92,36 @@ def init_accounts_command(username: str) -> None:
|
|||||||
data: list[AccountData] = []
|
data: list[AccountData] = []
|
||||||
for base in bases_to_add:
|
for base in bases_to_add:
|
||||||
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
||||||
is_pay_off_needed: bool = True if re.match("^[12]1[34]", base.code) \
|
is_need_offset: bool = __is_need_offset(base.code)
|
||||||
else False
|
|
||||||
data.append((get_new_id(), base.code, 1, base.title_l10n,
|
data.append((get_new_id(), base.code, 1, base.title_l10n,
|
||||||
l10n["zh_Hant"], l10n["zh_Hans"], is_pay_off_needed))
|
l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset))
|
||||||
__add_accounting_accounts(data, creator_pk)
|
__add_accounting_accounts(data, creator_pk)
|
||||||
click.echo(F"{len(data)} added. Accounting accounts initialized.")
|
click.echo(F"{len(data)} added. Accounting accounts initialized.")
|
||||||
|
|
||||||
|
|
||||||
|
def __is_need_offset(base_code: str) -> bool:
|
||||||
|
"""Checks that whether entries in the account need offset.
|
||||||
|
|
||||||
|
:param base_code: The code of the base account.
|
||||||
|
:return: True if entries in the account need offset, or False otherwise.
|
||||||
|
"""
|
||||||
|
# Assets
|
||||||
|
if base_code[0] == "1":
|
||||||
|
if base_code[:3] in {"113", "114", "118", "184"}:
|
||||||
|
return True
|
||||||
|
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
|
||||||
|
"1581", "1611", "1851", ""}:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
# Liabilities
|
||||||
|
if base_code[0] == "2":
|
||||||
|
if base_code in {"2111", "2114", "2284", "2293"}:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
# Only assets and liabilities need offset
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
|
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
|
||||||
-> None:
|
-> None:
|
||||||
"""Adds the accounts.
|
"""Adds the accounts.
|
||||||
@ -113,7 +134,7 @@ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
|
|||||||
base_code=x[1],
|
base_code=x[1],
|
||||||
no=x[2],
|
no=x[2],
|
||||||
title_l10n=x[3],
|
title_l10n=x[3],
|
||||||
is_pay_off_needed=x[6],
|
is_need_offset=x[6],
|
||||||
created_by_id=creator_pk,
|
created_by_id=creator_pk,
|
||||||
updated_by_id=creator_pk)
|
updated_by_id=creator_pk)
|
||||||
for x in data]
|
for x in data]
|
||||||
|
@ -53,6 +53,20 @@ class BaseAccountAvailable:
|
|||||||
"The base account is not available."))
|
"The base account is not available."))
|
||||||
|
|
||||||
|
|
||||||
|
class NoOffsetNominalAccount:
|
||||||
|
"""The validator to check nominal account is not to be offset."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
|
||||||
|
assert isinstance(form, AccountForm)
|
||||||
|
if not field.data:
|
||||||
|
return
|
||||||
|
if form.base_code.data is None:
|
||||||
|
return
|
||||||
|
if form.base_code.data[0] not in {"1", "2", "3"}:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"A nominal account does not need offset."))
|
||||||
|
|
||||||
|
|
||||||
class AccountForm(FlaskForm):
|
class AccountForm(FlaskForm):
|
||||||
"""The form to create or edit an account."""
|
"""The form to create or edit an account."""
|
||||||
base_code = StringField(
|
base_code = StringField(
|
||||||
@ -66,8 +80,9 @@ class AccountForm(FlaskForm):
|
|||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
|
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
|
||||||
"""The title."""
|
"""The title."""
|
||||||
is_pay_off_needed = BooleanField()
|
is_need_offset = BooleanField(
|
||||||
"""Whether the the entries of this account need pay-off."""
|
validators=[NoOffsetNominalAccount()])
|
||||||
|
"""Whether the the entries of this account need offset."""
|
||||||
|
|
||||||
def populate_obj(self, obj: Account) -> None:
|
def populate_obj(self, obj: Account) -> None:
|
||||||
"""Populates the form data into an account object.
|
"""Populates the form data into an account object.
|
||||||
@ -87,7 +102,10 @@ class AccountForm(FlaskForm):
|
|||||||
obj.base_code = self.base_code.data
|
obj.base_code = self.base_code.data
|
||||||
obj.no = count + 1
|
obj.no = count + 1
|
||||||
obj.title = self.title.data
|
obj.title = self.title.data
|
||||||
obj.is_pay_off_needed = self.is_pay_off_needed.data
|
if self.base_code.data[0] in {"1", "2", "3"}:
|
||||||
|
obj.is_need_offset = self.is_need_offset.data
|
||||||
|
else:
|
||||||
|
obj.is_need_offset = False
|
||||||
if is_new:
|
if is_new:
|
||||||
current_user_pk: int = get_current_user_pk()
|
current_user_pk: int = get_current_user_pk()
|
||||||
obj.created_by_id = current_user_pk
|
obj.created_by_id = current_user_pk
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
"""The account query.
|
"""The queries for the account management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@ -47,8 +47,8 @@ def get_account_query() -> list[Account]:
|
|||||||
Account.title_l10n.contains(k),
|
Account.title_l10n.contains(k),
|
||||||
code.contains(k),
|
code.contains(k),
|
||||||
Account.id.in_(l10n_matches)]
|
Account.id.in_(l10n_matches)]
|
||||||
if k in gettext("Pay-off needed"):
|
if k in gettext("Need offset"):
|
||||||
sub_conditions.append(Account.is_pay_off_needed)
|
sub_conditions.append(Account.is_need_offset)
|
||||||
conditions.append(sa.or_(*sub_conditions))
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
|
|
||||||
return Account.query.filter(*conditions)\
|
return Account.query.filter(*conditions)\
|
@ -27,13 +27,14 @@ from werkzeug.datastructures import ImmutableMultiDict
|
|||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import lazy_gettext
|
from accounting.locale import lazy_gettext
|
||||||
from accounting.models import Account, BaseAccount
|
from accounting.models import Account, BaseAccount
|
||||||
|
from accounting.utils.cast import s
|
||||||
from accounting.utils.flash_errors import flash_form_errors
|
from accounting.utils.flash_errors import flash_form_errors
|
||||||
from accounting.utils.next_uri import inherit_next, or_next
|
from accounting.utils.next_uri import inherit_next, or_next
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
from accounting.utils.permission import can_view, has_permission, can_edit
|
from accounting.utils.permission import can_view, has_permission, can_edit
|
||||||
from accounting.utils.user import get_current_user_pk
|
from accounting.utils.user import get_current_user_pk
|
||||||
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
|
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
|
||||||
from .query import get_account_query
|
from .queries import get_account_query
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("account", __name__)
|
bp: Blueprint = Blueprint("account", __name__)
|
||||||
"""The view blueprint for the account management."""
|
"""The view blueprint for the account management."""
|
||||||
@ -86,7 +87,7 @@ def add_account() -> redirect:
|
|||||||
form.populate_obj(account)
|
form.populate_obj(account)
|
||||||
db.session.add(account)
|
db.session.add(account)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The account is added successfully"), "success")
|
flash(s(lazy_gettext("The account is added successfully")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(account)))
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
|
|
||||||
|
|
||||||
@ -138,12 +139,12 @@ def update_account(account: Account) -> redirect:
|
|||||||
with db.session.no_autoflush:
|
with db.session.no_autoflush:
|
||||||
form.populate_obj(account)
|
form.populate_obj(account)
|
||||||
if not account.is_modified:
|
if not account.is_modified:
|
||||||
flash(lazy_gettext("The account was not modified."), "success")
|
flash(s(lazy_gettext("The account was not modified.")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(account)))
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
account.updated_by_id = get_current_user_pk()
|
account.updated_by_id = get_current_user_pk()
|
||||||
account.updated_at = sa.func.now()
|
account.updated_at = sa.func.now()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The account is updated successfully."), "success")
|
flash(s(lazy_gettext("The account is updated successfully.")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(account)))
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
|
|
||||||
|
|
||||||
@ -159,7 +160,7 @@ def delete_account(account: Account) -> redirect:
|
|||||||
account.delete()
|
account.delete()
|
||||||
sort_accounts_in(account.base_code, account.id)
|
sort_accounts_in(account.base_code, account.id)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The account is deleted successfully."), "success")
|
flash(s(lazy_gettext("The account is deleted successfully.")), "success")
|
||||||
return redirect(or_next(__get_list_uri()))
|
return redirect(or_next(__get_list_uri()))
|
||||||
|
|
||||||
|
|
||||||
@ -186,10 +187,10 @@ def sort_accounts(base: BaseAccount) -> redirect:
|
|||||||
form: AccountReorderForm = AccountReorderForm(base)
|
form: AccountReorderForm = AccountReorderForm(base)
|
||||||
form.save_order()
|
form.save_order()
|
||||||
if not form.is_modified:
|
if not form.is_modified:
|
||||||
flash(lazy_gettext("The order was not modified."), "success")
|
flash(s(lazy_gettext("The order was not modified.")), "success")
|
||||||
return redirect(or_next(__get_list_uri()))
|
return redirect(or_next(__get_list_uri()))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The order is updated successfully."), "success")
|
flash(s(lazy_gettext("The order is updated successfully.")), "success")
|
||||||
return redirect(or_next(__get_list_uri()))
|
return redirect(or_next(__get_list_uri()))
|
||||||
|
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
"""The base account query.
|
"""The queries for the base account management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
@ -34,7 +34,7 @@ def list_accounts() -> str:
|
|||||||
|
|
||||||
:return: The account list.
|
:return: The account list.
|
||||||
"""
|
"""
|
||||||
from .query import get_base_account_query
|
from .queries import get_base_account_query
|
||||||
accounts: list[BaseAccount] = get_base_account_query()
|
accounts: list[BaseAccount] = get_base_account_query()
|
||||||
pagination: Pagination = Pagination[BaseAccount](accounts)
|
pagination: Pagination = Pagination[BaseAccount](accounts)
|
||||||
return render_template("accounting/base-account/list.html",
|
return render_template("accounting/base-account/list.html",
|
||||||
|
@ -17,8 +17,6 @@
|
|||||||
"""The forms for the currency management.
|
"""The forms for the currency management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, ValidationError
|
from wtforms import StringField, ValidationError
|
||||||
from wtforms.validators import DataRequired, Regexp, NoneOf
|
from wtforms.validators import DataRequired, Regexp, NoneOf
|
||||||
@ -30,22 +28,24 @@ from accounting.utils.strip_text import strip_text
|
|||||||
from accounting.utils.user import get_current_user_pk
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
|
||||||
|
|
||||||
|
class CodeUnique:
|
||||||
|
"""The validator to check if the code is unique."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, CurrencyForm)
|
||||||
|
if field.data == "":
|
||||||
|
return
|
||||||
|
if form.obj_code is not None and form.obj_code == field.data:
|
||||||
|
return
|
||||||
|
if db.session.get(Currency, field.data) is not None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"Code conflicts with another currency."))
|
||||||
|
|
||||||
|
|
||||||
class CurrencyForm(FlaskForm):
|
class CurrencyForm(FlaskForm):
|
||||||
"""The form to create or edit a currency."""
|
"""The form to create or edit a currency."""
|
||||||
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
|
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
|
||||||
"""The reserved codes that are not available."""
|
"""The reserved codes that are not available."""
|
||||||
|
|
||||||
class CodeUnique:
|
|
||||||
"""The validator to check if the code is unique."""
|
|
||||||
def __call__(self, form: CurrencyForm, field: StringField) -> None:
|
|
||||||
if field.data == "":
|
|
||||||
return
|
|
||||||
if form.obj_code is not None and form.obj_code == field.data:
|
|
||||||
return
|
|
||||||
if db.session.get(Currency, field.data) is not None:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"Code conflicts with another currency."))
|
|
||||||
|
|
||||||
code = StringField(
|
code = StringField(
|
||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
validators=[DataRequired(lazy_gettext("Please fill in the code.")),
|
validators=[DataRequired(lazy_gettext("Please fill in the code.")),
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
"""The currency query.
|
"""The queries for the currency management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
@ -27,6 +27,7 @@ from werkzeug.datastructures import ImmutableMultiDict
|
|||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import lazy_gettext
|
from accounting.locale import lazy_gettext
|
||||||
from accounting.models import Currency
|
from accounting.models import Currency
|
||||||
|
from accounting.utils.cast import s
|
||||||
from accounting.utils.flash_errors import flash_form_errors
|
from accounting.utils.flash_errors import flash_form_errors
|
||||||
from accounting.utils.next_uri import inherit_next, or_next
|
from accounting.utils.next_uri import inherit_next, or_next
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
@ -47,7 +48,7 @@ def list_currencies() -> str:
|
|||||||
|
|
||||||
:return: The currency list.
|
:return: The currency list.
|
||||||
"""
|
"""
|
||||||
from .query import get_currency_query
|
from .queries import get_currency_query
|
||||||
currencies: list[Currency] = get_currency_query()
|
currencies: list[Currency] = get_currency_query()
|
||||||
pagination: Pagination = Pagination[Currency](currencies)
|
pagination: Pagination = Pagination[Currency](currencies)
|
||||||
return render_template("accounting/currency/list.html",
|
return render_template("accounting/currency/list.html",
|
||||||
@ -88,7 +89,7 @@ def add_currency() -> redirect:
|
|||||||
form.populate_obj(currency)
|
form.populate_obj(currency)
|
||||||
db.session.add(currency)
|
db.session.add(currency)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The currency is added successfully"), "success")
|
flash(s(lazy_gettext("The currency is added successfully")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
|
|
||||||
|
|
||||||
@ -141,12 +142,12 @@ def update_currency(currency: Currency) -> redirect:
|
|||||||
with db.session.no_autoflush:
|
with db.session.no_autoflush:
|
||||||
form.populate_obj(currency)
|
form.populate_obj(currency)
|
||||||
if not currency.is_modified:
|
if not currency.is_modified:
|
||||||
flash(lazy_gettext("The currency was not modified."), "success")
|
flash(s(lazy_gettext("The currency was not modified.")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
currency.updated_by_id = get_current_user_pk()
|
currency.updated_by_id = get_current_user_pk()
|
||||||
currency.updated_at = sa.func.now()
|
currency.updated_at = sa.func.now()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The currency is updated successfully."), "success")
|
flash(s(lazy_gettext("The currency is updated successfully.")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
|
|
||||||
|
|
||||||
@ -161,7 +162,7 @@ def delete_currency(currency: Currency) -> redirect:
|
|||||||
"""
|
"""
|
||||||
currency.delete()
|
currency.delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The currency is deleted successfully."), "success")
|
flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
|
||||||
return redirect(or_next(url_for("accounting.currency.list")))
|
return redirect(or_next(url_for("accounting.currency.list")))
|
||||||
|
|
||||||
|
|
||||||
@ -182,4 +183,3 @@ def __get_detail_uri(currency: Currency) -> str:
|
|||||||
:return: The detail URI of the currency.
|
:return: The detail URI of the currency.
|
||||||
"""
|
"""
|
||||||
return url_for("accounting.currency.detail", currency=currency)
|
return url_for("accounting.currency.detail", currency=currency)
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import typing as t
|
import typing as t
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@ -52,7 +53,7 @@ class BaseAccount(db.Model):
|
|||||||
|
|
||||||
:return: The string representation of the base account.
|
:return: The string representation of the base account.
|
||||||
"""
|
"""
|
||||||
return F"{self.code} {self.title}"
|
return f"{self.code} {self.title}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
@ -113,8 +114,8 @@ class Account(db.Model):
|
|||||||
"""The account number under the base account."""
|
"""The account number under the base account."""
|
||||||
title_l10n = db.Column("title", db.String, nullable=False)
|
title_l10n = db.Column("title", db.String, nullable=False)
|
||||||
"""The title."""
|
"""The title."""
|
||||||
is_pay_off_needed = db.Column(db.Boolean, nullable=False, default=False)
|
is_need_offset = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
"""Whether the entries of this account need pay-off."""
|
"""Whether the entries of this account need offset."""
|
||||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
server_default=db.func.now())
|
server_default=db.func.now())
|
||||||
"""The time of creation."""
|
"""The time of creation."""
|
||||||
@ -141,17 +142,11 @@ class Account(db.Model):
|
|||||||
entries = db.relationship("JournalEntry", back_populates="account")
|
entries = db.relationship("JournalEntry", back_populates="account")
|
||||||
"""The journal entries."""
|
"""The journal entries."""
|
||||||
|
|
||||||
__CASH = "1111-001"
|
CASH_CODE: str = "1111-001"
|
||||||
"""The code of the cash account,"""
|
"""The code of the cash account,"""
|
||||||
__RECEIVABLE = "1141-001"
|
ACCUMULATED_CHANGE_CODE: str = "3351-001"
|
||||||
"""The code of the receivable account,"""
|
|
||||||
__PAYABLE = "2141-001"
|
|
||||||
"""The code of the payable account,"""
|
|
||||||
__ACCUMULATED_CHANGE = "3351-001"
|
|
||||||
"""The code of the accumulated-change account,"""
|
"""The code of the accumulated-change account,"""
|
||||||
__BROUGHT_FORWARD = "3352-001"
|
NET_CHANGE_CODE: str = "3353-001"
|
||||||
"""The code of the brought-forward account,"""
|
|
||||||
__NET_CHANGE = "3353-001"
|
|
||||||
"""The code of the net-change account,"""
|
"""The code of the net-change account,"""
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
@ -159,7 +154,7 @@ class Account(db.Model):
|
|||||||
|
|
||||||
:return: The string representation of this account.
|
:return: The string representation of this account.
|
||||||
"""
|
"""
|
||||||
return F"{self.base_code}-{self.no:03d} {self.title}"
|
return f"{self.base_code}-{self.no:03d} {self.title}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def code(self) -> str:
|
def code(self) -> str:
|
||||||
@ -167,7 +162,7 @@ class Account(db.Model):
|
|||||||
|
|
||||||
:return: The code.
|
:return: The code.
|
||||||
"""
|
"""
|
||||||
return F"{self.base_code}-{self.no:03d}"
|
return f"{self.base_code}-{self.no:03d}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
@ -204,23 +199,50 @@ class Account(db.Model):
|
|||||||
self.l10n.append(AccountL10n(locale=current_locale, title=value))
|
self.l10n.append(AccountL10n(locale=current_locale, title=value))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_in_use(self) -> bool:
|
def is_real(self) -> bool:
|
||||||
"""Returns whether the account is in use.
|
"""Returns whether the account is a real account.
|
||||||
|
|
||||||
:return: True if the account is in use, or False otherwise.
|
:return: True if the account is a real account, or False otherwise.
|
||||||
"""
|
"""
|
||||||
if not hasattr(self, "__is_in_use"):
|
return self.base_code[0] in {"1", "2", "3"}
|
||||||
setattr(self, "__is_in_use", len(self.entries) > 0)
|
|
||||||
return getattr(self, "__is_in_use")
|
|
||||||
|
|
||||||
@is_in_use.setter
|
@property
|
||||||
def is_in_use(self, is_in_use: bool) -> None:
|
def is_nominal(self) -> bool:
|
||||||
"""Sets whether the account is in use.
|
"""Returns whether the account is a nominal account.
|
||||||
|
|
||||||
|
:return: True if the account is a nominal account, or False otherwise.
|
||||||
|
"""
|
||||||
|
return not self.is_real
|
||||||
|
|
||||||
|
@property
|
||||||
|
def query_values(self) -> list[str]:
|
||||||
|
"""Returns the values to be queried.
|
||||||
|
|
||||||
|
:return: The values to be queried.
|
||||||
|
"""
|
||||||
|
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_modified(self) -> bool:
|
||||||
|
"""Returns whether a product account was modified.
|
||||||
|
|
||||||
|
:return: True if modified, or False otherwise.
|
||||||
|
"""
|
||||||
|
if db.session.is_modified(self):
|
||||||
|
return True
|
||||||
|
for l10n in self.l10n:
|
||||||
|
if db.session.is_modified(l10n):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Deletes this account.
|
||||||
|
|
||||||
:param is_in_use: True if the account is in use, or False otherwise.
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
setattr(self, "__is_in_use", is_in_use)
|
AccountL10n.query.filter(AccountL10n.account == self).delete()
|
||||||
|
cls: t.Type[t.Self] = self.__class__
|
||||||
|
cls.query.filter(cls.id == self.id).delete()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_by_code(cls, code: str) -> t.Self | None:
|
def find_by_code(cls, code: str) -> t.Self | None:
|
||||||
@ -276,37 +298,13 @@ class Account(db.Model):
|
|||||||
cls.base_code != "3353")\
|
cls.base_code != "3353")\
|
||||||
.order_by(cls.base_code, cls.no).all()
|
.order_by(cls.base_code, cls.no).all()
|
||||||
|
|
||||||
@property
|
|
||||||
def query_values(self) -> list[str]:
|
|
||||||
"""Returns the values to be queried.
|
|
||||||
|
|
||||||
:return: The values to be queried.
|
|
||||||
"""
|
|
||||||
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def cash(cls) -> t.Self:
|
def cash(cls) -> t.Self:
|
||||||
"""Returns the cash account.
|
"""Returns the cash account.
|
||||||
|
|
||||||
:return: The cash account
|
:return: The cash account
|
||||||
"""
|
"""
|
||||||
return cls.find_by_code(cls.__CASH)
|
return cls.find_by_code(cls.CASH_CODE)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def receivable(cls) -> t.Self:
|
|
||||||
"""Returns the receivable account.
|
|
||||||
|
|
||||||
:return: The receivable account
|
|
||||||
"""
|
|
||||||
return cls.find_by_code(cls.__RECEIVABLE)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def payable(cls) -> t.Self:
|
|
||||||
"""Returns the payable account.
|
|
||||||
|
|
||||||
:return: The payable account
|
|
||||||
"""
|
|
||||||
return cls.find_by_code(cls.__PAYABLE)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def accumulated_change(cls) -> t.Self:
|
def accumulated_change(cls) -> t.Self:
|
||||||
@ -314,45 +312,7 @@ class Account(db.Model):
|
|||||||
|
|
||||||
:return: The accumulated-change account
|
:return: The accumulated-change account
|
||||||
"""
|
"""
|
||||||
return cls.find_by_code(cls.__ACCUMULATED_CHANGE)
|
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def brought_forward(cls) -> t.Self:
|
|
||||||
"""Returns the brought-forward account.
|
|
||||||
|
|
||||||
:return: The brought-forward account
|
|
||||||
"""
|
|
||||||
return cls.find_by_code(cls.__BROUGHT_FORWARD)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def net_change(cls) -> t.Self:
|
|
||||||
"""Returns the net-change account.
|
|
||||||
|
|
||||||
:return: The net-change account
|
|
||||||
"""
|
|
||||||
return cls.find_by_code(cls.__NET_CHANGE)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_modified(self) -> bool:
|
|
||||||
"""Returns whether a product account was modified.
|
|
||||||
|
|
||||||
:return: True if modified, or False otherwise.
|
|
||||||
"""
|
|
||||||
if db.session.is_modified(self):
|
|
||||||
return True
|
|
||||||
for l10n in self.l10n:
|
|
||||||
if db.session.is_modified(l10n):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
|
||||||
"""Deletes this account.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
AccountL10n.query.filter(AccountL10n.account == self).delete()
|
|
||||||
cls: t.Type[t.Self] = self.__class__
|
|
||||||
cls.query.filter(cls.id == self.id).delete()
|
|
||||||
|
|
||||||
|
|
||||||
class AccountL10n(db.Model):
|
class AccountL10n(db.Model):
|
||||||
@ -411,7 +371,7 @@ class Currency(db.Model):
|
|||||||
|
|
||||||
:return: The string representation of the currency.
|
:return: The string representation of the currency.
|
||||||
"""
|
"""
|
||||||
return F"{self.name} ({self.code})"
|
return f"{self.name} ({self.code})"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -607,7 +567,7 @@ class Transaction(db.Model):
|
|||||||
for currency in self.currencies:
|
for currency in self.currencies:
|
||||||
if len(currency.debit) > 1:
|
if len(currency.debit) > 1:
|
||||||
return False
|
return False
|
||||||
if currency.debit[0].account.code != "1111-001":
|
if currency.debit[0].account.code != Account.CASH_CODE:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -621,10 +581,25 @@ class Transaction(db.Model):
|
|||||||
for currency in self.currencies:
|
for currency in self.currencies:
|
||||||
if len(currency.credit) > 1:
|
if len(currency.credit) > 1:
|
||||||
return False
|
return False
|
||||||
if currency.credit[0].account.code != "1111-001":
|
if currency.credit[0].account.code != Account.CASH_CODE:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_delete(self) -> bool:
|
||||||
|
"""Returns whether the transaction can be deleted.
|
||||||
|
|
||||||
|
:return: True if the transaction can be deleted, or False otherwise.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__can_delete"):
|
||||||
|
def has_offset() -> bool:
|
||||||
|
for entry in self.entries:
|
||||||
|
if len(entry.offsets) > 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
setattr(self, "__can_delete", not has_offset())
|
||||||
|
return getattr(self, "__can_delete")
|
||||||
|
|
||||||
def delete(self) -> None:
|
def delete(self) -> None:
|
||||||
"""Deletes the transaction.
|
"""Deletes the transaction.
|
||||||
|
|
||||||
@ -654,15 +629,15 @@ class JournalEntry(db.Model):
|
|||||||
"""True for a debit entry, or False for a credit entry."""
|
"""True for a debit entry, or False for a credit entry."""
|
||||||
no = db.Column(db.Integer, nullable=False)
|
no = db.Column(db.Integer, nullable=False)
|
||||||
"""The entry number under the transaction and debit or credit."""
|
"""The entry number under the transaction and debit or credit."""
|
||||||
pay_off_target_id = db.Column(db.Integer,
|
original_entry_id = db.Column(db.Integer,
|
||||||
db.ForeignKey(id, onupdate="CASCADE"),
|
db.ForeignKey(id, onupdate="CASCADE"),
|
||||||
nullable=True)
|
nullable=True)
|
||||||
"""The ID of the pay-off target entry."""
|
"""The ID of the original entry."""
|
||||||
pay_off_target = db.relationship("JournalEntry", back_populates="pay_off",
|
original_entry = db.relationship("JournalEntry", back_populates="offsets",
|
||||||
remote_side=id, passive_deletes=True)
|
remote_side=id, passive_deletes=True)
|
||||||
"""The pay-off target entry."""
|
"""The original entry."""
|
||||||
pay_off = db.relationship("JournalEntry", back_populates="pay_off_target")
|
offsets = db.relationship("JournalEntry", back_populates="original_entry")
|
||||||
"""The pay-off entries."""
|
"""The offset entries."""
|
||||||
currency_code = db.Column(db.String,
|
currency_code = db.Column(db.String,
|
||||||
db.ForeignKey(Currency.code, onupdate="CASCADE"),
|
db.ForeignKey(Currency.code, onupdate="CASCADE"),
|
||||||
nullable=False)
|
nullable=False)
|
||||||
@ -681,6 +656,21 @@ class JournalEntry(db.Model):
|
|||||||
amount = db.Column(db.Numeric(14, 2), nullable=False)
|
amount = db.Column(db.Numeric(14, 2), nullable=False)
|
||||||
"""The amount."""
|
"""The amount."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the journal entry.
|
||||||
|
|
||||||
|
:return: The string representation of the journal entry.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__str"):
|
||||||
|
from accounting.template_filters import format_date, format_amount
|
||||||
|
setattr(self, "__str",
|
||||||
|
gettext("%(date)s %(summary)s %(amount)s",
|
||||||
|
date=format_date(self.transaction.date),
|
||||||
|
summary="" if self.summary is None
|
||||||
|
else self.summary,
|
||||||
|
amount=format_amount(self.amount)))
|
||||||
|
return getattr(self, "__str")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def eid(self) -> int | None:
|
def eid(self) -> int | None:
|
||||||
"""Returns the journal entry ID. This is the alternative name of the
|
"""Returns the journal entry ID. This is the alternative name of the
|
||||||
@ -697,3 +687,75 @@ class JournalEntry(db.Model):
|
|||||||
:return: The account code.
|
:return: The account code.
|
||||||
"""
|
"""
|
||||||
return self.account.code
|
return self.account.code
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit(self) -> Decimal | None:
|
||||||
|
"""Returns the debit amount.
|
||||||
|
|
||||||
|
:return: The debit amount, or None if this is not a debit entry.
|
||||||
|
"""
|
||||||
|
return self.amount if self.is_debit else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_need_offset(self) -> bool:
|
||||||
|
"""Returns whether the entry needs offset.
|
||||||
|
|
||||||
|
:return: True if the entry needs offset, or False otherwise.
|
||||||
|
"""
|
||||||
|
if not self.account.is_need_offset:
|
||||||
|
return False
|
||||||
|
if self.account.base_code[0] == "1" and not self.is_debit:
|
||||||
|
return False
|
||||||
|
if self.account.base_code[0] == "2" and self.is_debit:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit(self) -> Decimal | None:
|
||||||
|
"""Returns the credit amount.
|
||||||
|
|
||||||
|
:return: The credit amount, or None if this is not a credit entry.
|
||||||
|
"""
|
||||||
|
return None if self.is_debit else self.amount
|
||||||
|
|
||||||
|
@property
|
||||||
|
def net_balance(self) -> Decimal:
|
||||||
|
"""Returns the net balance.
|
||||||
|
|
||||||
|
:return: The net balance.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__net_balance"):
|
||||||
|
setattr(self, "__net_balance", self.amount + sum(
|
||||||
|
[x.amount if x.is_debit == self.is_debit else -x.amount
|
||||||
|
for x in self.offsets]))
|
||||||
|
return getattr(self, "__net_balance")
|
||||||
|
|
||||||
|
@net_balance.setter
|
||||||
|
def net_balance(self, net_balance: Decimal) -> None:
|
||||||
|
"""Sets the net balance.
|
||||||
|
|
||||||
|
:param net_balance: The net balance.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__net_balance", net_balance)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def query_values(self) -> tuple[list[str], list[str]]:
|
||||||
|
"""Returns the values to be queried.
|
||||||
|
|
||||||
|
:return: The values to be queried.
|
||||||
|
"""
|
||||||
|
def format_amount(value: Decimal) -> str:
|
||||||
|
whole: int = int(value)
|
||||||
|
frac: Decimal = (value - whole).normalize()
|
||||||
|
return str(whole) + str(abs(frac))[1:]
|
||||||
|
|
||||||
|
txn_day: date = self.transaction.date
|
||||||
|
summary: str = "" if self.summary is None else self.summary
|
||||||
|
return ([summary],
|
||||||
|
[str(txn_day.year),
|
||||||
|
"{}/{}".format(txn_day.year, txn_day.month),
|
||||||
|
"{}/{}".format(txn_day.month, txn_day.day),
|
||||||
|
"{}/{}/{}".format(txn_day.year, txn_day.month, txn_day.day),
|
||||||
|
format_amount(self.amount),
|
||||||
|
format_amount(self.net_balance)])
|
||||||
|
35
src/accounting/report/__init__.py
Normal file
35
src/accounting/report/__init__.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The report management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import Flask, Blueprint
|
||||||
|
|
||||||
|
|
||||||
|
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||||
|
"""Initialize the application.
|
||||||
|
|
||||||
|
:param app: The Flask application.
|
||||||
|
:param bp: The blueprint of the accounting application.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from .converters import PeriodConverter, IncomeExpensesAccountConverter
|
||||||
|
app.url_map.converters["period"] = PeriodConverter
|
||||||
|
app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter
|
||||||
|
|
||||||
|
from .views import bp as report_bp
|
||||||
|
bp.register_blueprint(report_bp, url_prefix="/reports")
|
79
src/accounting/report/converters.py
Normal file
79
src/accounting/report/converters.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The path converters for the report management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from flask import abort
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
|
from accounting.models import Account
|
||||||
|
from .period import Period, get_period
|
||||||
|
from .utils.ie_account import IncomeExpensesAccount
|
||||||
|
|
||||||
|
|
||||||
|
class PeriodConverter(BaseConverter):
|
||||||
|
"""The supplier converter to convert the period specification from and to
|
||||||
|
the corresponding period in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> Period:
|
||||||
|
"""Converts a period specification to a period.
|
||||||
|
|
||||||
|
:param value: The period specification.
|
||||||
|
:return: The corresponding period.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return get_period(value)
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
def to_url(self, value: Period) -> str:
|
||||||
|
"""Converts a period to its specification.
|
||||||
|
|
||||||
|
:param value: The period.
|
||||||
|
:return: Its specification.
|
||||||
|
"""
|
||||||
|
return value.spec
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeExpensesAccountConverter(BaseConverter):
|
||||||
|
"""The supplier converter to convert the income and expenses log pseudo
|
||||||
|
account code from and to the corresponding pseudo account in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> IncomeExpensesAccount:
|
||||||
|
"""Converts an account code to an account.
|
||||||
|
|
||||||
|
:param value: The account code.
|
||||||
|
:return: The corresponding account.
|
||||||
|
"""
|
||||||
|
if value == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||||
|
return IncomeExpensesAccount.current_assets_and_liabilities()
|
||||||
|
if not re.match("^[12][12]", value):
|
||||||
|
abort(404)
|
||||||
|
account: Account | None = Account.find_by_code(value)
|
||||||
|
if account is None:
|
||||||
|
abort(404)
|
||||||
|
return IncomeExpensesAccount(account)
|
||||||
|
|
||||||
|
def to_url(self, value: IncomeExpensesAccount) -> str:
|
||||||
|
"""Converts an account to account code.
|
||||||
|
|
||||||
|
:param value: The account.
|
||||||
|
:return: Its code.
|
||||||
|
"""
|
||||||
|
return value.code
|
22
src/accounting/report/period/__init__.py
Normal file
22
src/accounting/report/period/__init__.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The period utility.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from .chooser import PeriodChooser
|
||||||
|
from .parser import get_period
|
||||||
|
from .period import Period
|
97
src/accounting/report/period/chooser.py
Normal file
97
src/accounting/report/period/chooser.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The period chooser.
|
||||||
|
|
||||||
|
This file is largely taken from the NanoParma ERP project, first written in
|
||||||
|
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from accounting.models import Transaction
|
||||||
|
from .period import Period
|
||||||
|
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
||||||
|
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
|
||||||
|
|
||||||
|
|
||||||
|
class PeriodChooser:
|
||||||
|
"""The period chooser."""
|
||||||
|
|
||||||
|
def __init__(self, get_url: t.Callable[[Period], str]):
|
||||||
|
"""Constructs a period chooser.
|
||||||
|
|
||||||
|
:param get_url: The callback to return the URL of the current report in
|
||||||
|
a period.
|
||||||
|
"""
|
||||||
|
self.__get_url: t.Callable[[Period], str] = get_url
|
||||||
|
"""The callback to return the URL of the current report in a period."""
|
||||||
|
|
||||||
|
# Shortcut periods
|
||||||
|
self.this_month_url: str = get_url(ThisMonth())
|
||||||
|
"""The URL for this month."""
|
||||||
|
self.last_month_url: str = get_url(LastMonth())
|
||||||
|
"""The URL for last month."""
|
||||||
|
self.since_last_month_url: str = get_url(SinceLastMonth())
|
||||||
|
"""The URL since last mint."""
|
||||||
|
self.this_year_url: str = get_url(ThisYear())
|
||||||
|
"""The URL for this year."""
|
||||||
|
self.last_year_url: str = get_url(LastYear())
|
||||||
|
"""The URL for last year."""
|
||||||
|
self.today_url: str = get_url(Today())
|
||||||
|
"""The URL for today."""
|
||||||
|
self.yesterday_url: str = get_url(Yesterday())
|
||||||
|
"""The URL for yesterday."""
|
||||||
|
self.all_url: str = get_url(AllTime())
|
||||||
|
"""The URL for all period."""
|
||||||
|
self.url_template: str = get_url(TemplatePeriod())
|
||||||
|
"""The URL template."""
|
||||||
|
|
||||||
|
first: Transaction | None \
|
||||||
|
= Transaction.query.order_by(Transaction.date).first()
|
||||||
|
start: date | None = None if first is None else first.date
|
||||||
|
|
||||||
|
# Attributes
|
||||||
|
self.data_start: date | None = start
|
||||||
|
"""The start of the data."""
|
||||||
|
self.has_data: bool = start is not None
|
||||||
|
"""Whether there is any data."""
|
||||||
|
self.has_last_month: bool = False
|
||||||
|
"""Where there is data in last month."""
|
||||||
|
self.has_last_year: bool = False
|
||||||
|
"""Whether there is data in last year."""
|
||||||
|
self.has_yesterday: bool = False
|
||||||
|
"""Whether there is data in yesterday."""
|
||||||
|
self.available_years: list[int] = []
|
||||||
|
"""The available years."""
|
||||||
|
|
||||||
|
if self.has_data:
|
||||||
|
today: date = date.today()
|
||||||
|
self.has_last_month = start < date(today.year, today.month, 1)
|
||||||
|
self.has_last_year = start.year < today.year
|
||||||
|
self.has_yesterday = start < today
|
||||||
|
if start.year < today.year - 1:
|
||||||
|
self.available_years \
|
||||||
|
= reversed(range(start.year, today.year - 1))
|
||||||
|
|
||||||
|
def year_url(self, year: int) -> str:
|
||||||
|
"""Returns the period URL of a year.
|
||||||
|
|
||||||
|
:param year: The year
|
||||||
|
:return: The period URL of the year.
|
||||||
|
"""
|
||||||
|
return self.__get_url(YearPeriod(year))
|
179
src/accounting/report/period/description.py
Normal file
179
src/accounting/report/period/description.py
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The period description composer.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from accounting.locale import gettext
|
||||||
|
|
||||||
|
|
||||||
|
def get_desc(start: date | None, end: date | None) -> str:
|
||||||
|
"""Returns the period description.
|
||||||
|
|
||||||
|
:param start: The start of the period.
|
||||||
|
:param end: The end of the period.
|
||||||
|
:return: The period description.
|
||||||
|
"""
|
||||||
|
if start is None and end is None:
|
||||||
|
return gettext("for all time")
|
||||||
|
if start is None:
|
||||||
|
return __get_until_desc(end)
|
||||||
|
if end is None:
|
||||||
|
return __get_since_desc(start)
|
||||||
|
try:
|
||||||
|
return __get_year_desc(start, end)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return __get_month_desc(start, end)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return __get_day_desc(start, end)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_since_desc(start: date) -> str:
|
||||||
|
"""Returns the description without the end day.
|
||||||
|
|
||||||
|
:param start: The start of the period.
|
||||||
|
:return: The description without the end day.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_start_desc() -> str:
|
||||||
|
"""Returns the description of the start day.
|
||||||
|
|
||||||
|
:return: The description of the start day.
|
||||||
|
"""
|
||||||
|
if start.month == 1 and start.day == 1:
|
||||||
|
return str(start.year)
|
||||||
|
if start.day == 1:
|
||||||
|
return __format_month(start)
|
||||||
|
return __format_day(start)
|
||||||
|
|
||||||
|
return gettext("since %(start)s", start=get_start_desc())
|
||||||
|
|
||||||
|
|
||||||
|
def __get_until_desc(end: date) -> str:
|
||||||
|
"""Returns the description without the start day.
|
||||||
|
|
||||||
|
:param end: The end of the period.
|
||||||
|
:return: The description without the start day.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_end_desc() -> str:
|
||||||
|
"""Returns the description of the end day.
|
||||||
|
|
||||||
|
:return: The description of the end day.
|
||||||
|
"""
|
||||||
|
if end.month == 12 and end.day == 31:
|
||||||
|
return str(end.year)
|
||||||
|
if (end + timedelta(days=1)).day == 1:
|
||||||
|
return __format_month(end)
|
||||||
|
return __format_day(end)
|
||||||
|
|
||||||
|
return gettext("until %(end)s", end=get_end_desc())
|
||||||
|
|
||||||
|
|
||||||
|
def __get_year_desc(start: date, end: date) -> str:
|
||||||
|
"""Returns the description as a year range.
|
||||||
|
|
||||||
|
:param start: The start of the period.
|
||||||
|
:param end: The end of the period.
|
||||||
|
:return: The description as a year range.
|
||||||
|
:raise ValueError: The period is not a year range.
|
||||||
|
"""
|
||||||
|
if start.month != 1 or start.day != 1 \
|
||||||
|
or end.month != 12 or end.day != 31:
|
||||||
|
raise ValueError
|
||||||
|
start_text: str = str(start.year)
|
||||||
|
if start.year == end.year:
|
||||||
|
return __get_in_desc(start_text)
|
||||||
|
return __get_from_to_desc(start_text, str(end.year))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_month_desc(start: date, end: date) -> str:
|
||||||
|
"""Returns the description as a month range.
|
||||||
|
|
||||||
|
:param start: The start of the period.
|
||||||
|
:param end: The end of the period.
|
||||||
|
:return: The description as a month range.
|
||||||
|
:raise ValueError: The period is not a month range.
|
||||||
|
"""
|
||||||
|
if start.day != 1 or (end + timedelta(days=1)).day != 1:
|
||||||
|
raise ValueError
|
||||||
|
start_text: str = __format_month(start)
|
||||||
|
if start.year == end.year and start.month == end.month:
|
||||||
|
return __get_in_desc(start_text)
|
||||||
|
if start.year == end.year:
|
||||||
|
return __get_from_to_desc(start_text, str(end.month))
|
||||||
|
return __get_from_to_desc(start_text, __format_month(end))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_day_desc(start: date, end: date) -> str:
|
||||||
|
"""Returns the description as a day range.
|
||||||
|
|
||||||
|
:param start: The start of the period.
|
||||||
|
:param end: The end of the period.
|
||||||
|
:return: The description as a day range.
|
||||||
|
:raise ValueError: The period is a month or year range.
|
||||||
|
"""
|
||||||
|
start_text: str = __format_day(start)
|
||||||
|
if start == end:
|
||||||
|
return __get_in_desc(start_text)
|
||||||
|
if start.year == end.year and start.month == end.month:
|
||||||
|
return __get_from_to_desc(start_text, str(end.day))
|
||||||
|
if start.year == end.year:
|
||||||
|
end_month_day: str = f"{end.month}/{end.day}"
|
||||||
|
return __get_from_to_desc(start_text, end_month_day)
|
||||||
|
return __get_from_to_desc(start_text, __format_day(end))
|
||||||
|
|
||||||
|
|
||||||
|
def __format_month(month: date) -> str:
|
||||||
|
"""Formats a month.
|
||||||
|
|
||||||
|
:param month: The month.
|
||||||
|
:return: The formatted month.
|
||||||
|
"""
|
||||||
|
return f"{month.year}/{month.month}"
|
||||||
|
|
||||||
|
|
||||||
|
def __format_day(day: date) -> str:
|
||||||
|
"""Formats a day.
|
||||||
|
|
||||||
|
:param day: The day.
|
||||||
|
:return: The formatted day.
|
||||||
|
"""
|
||||||
|
return f"{day.year}/{day.month}/{day.day}"
|
||||||
|
|
||||||
|
|
||||||
|
def __get_in_desc(period: str) -> str:
|
||||||
|
"""Returns the description of a whole year, month, or day.
|
||||||
|
|
||||||
|
:param period: The time period.
|
||||||
|
:return: The description of a whole year, month, or day.
|
||||||
|
"""
|
||||||
|
return gettext("in %(period)s", period=period)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_from_to_desc(start: str, end: str) -> str:
|
||||||
|
"""Returns the description of a separated start and end.
|
||||||
|
|
||||||
|
:param start: The start.
|
||||||
|
:param end: The end.
|
||||||
|
:return: The description of the separated start and end.
|
||||||
|
"""
|
||||||
|
return gettext("in %(start)s-%(end)s", start=start, end=end)
|
31
src/accounting/report/period/month_end.py
Normal file
31
src/accounting/report/period/month_end.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The utility to return the end of a month.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import calendar
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
def month_end(day: date) -> date:
|
||||||
|
"""Returns the end day of month for a date.
|
||||||
|
|
||||||
|
:param day: The date.
|
||||||
|
:return: The end day of the month of that day.
|
||||||
|
"""
|
||||||
|
last_day: int = calendar.monthrange(day.year, day.month)[1]
|
||||||
|
return date(day.year, day.month, last_day)
|
119
src/accounting/report/period/parser.py
Normal file
119
src/accounting/report/period/parser.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The period specification parser.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import calendar
|
||||||
|
import re
|
||||||
|
import typing as t
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from .period import Period
|
||||||
|
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
||||||
|
LastYear, Today, Yesterday, AllTime
|
||||||
|
|
||||||
|
DATE_SPEC_RE: str = r"(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?"
|
||||||
|
"""The regular expression of a date specification."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_period(spec: str | None = None) -> Period:
|
||||||
|
"""Returns a period instance.
|
||||||
|
|
||||||
|
:param spec: The period specification, or omit for the default.
|
||||||
|
:return: The period instance.
|
||||||
|
:raise ValueError: When the period specification is invalid.
|
||||||
|
"""
|
||||||
|
if spec is None:
|
||||||
|
return ThisMonth()
|
||||||
|
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = {
|
||||||
|
"this-month": lambda: ThisMonth(),
|
||||||
|
"last-month": lambda: LastMonth(),
|
||||||
|
"since-last-month": lambda: SinceLastMonth(),
|
||||||
|
"this-year": lambda: ThisYear(),
|
||||||
|
"last-year": lambda: LastYear(),
|
||||||
|
"today": lambda: Today(),
|
||||||
|
"yesterday": lambda: Yesterday(),
|
||||||
|
"all-time": lambda: AllTime(),
|
||||||
|
}
|
||||||
|
if spec in named_periods:
|
||||||
|
return named_periods[spec]()
|
||||||
|
start, end = __parse_spec(spec)
|
||||||
|
if start is not None and end is not None and start > end:
|
||||||
|
raise ValueError
|
||||||
|
return Period(start, end)
|
||||||
|
|
||||||
|
|
||||||
|
def __parse_spec(text: str) -> tuple[date | None, date | None]:
|
||||||
|
"""Parses the period specification.
|
||||||
|
|
||||||
|
:param text: The period specification.
|
||||||
|
:return: The start and end day of the period. The start and end day
|
||||||
|
may be None.
|
||||||
|
:raise ValueError: When the date is invalid.
|
||||||
|
"""
|
||||||
|
if text == "-":
|
||||||
|
return None, None
|
||||||
|
m = re.match(f"^{DATE_SPEC_RE}$", text)
|
||||||
|
if m is not None:
|
||||||
|
return __get_start(m[1], m[2], m[3]), \
|
||||||
|
__get_end(m[1], m[2], m[3])
|
||||||
|
m = re.match(f"^{DATE_SPEC_RE}-$", text)
|
||||||
|
if m is not None:
|
||||||
|
return __get_start(m[1], m[2], m[3]), None
|
||||||
|
m = re.match(f"-{DATE_SPEC_RE}$", text)
|
||||||
|
if m is not None:
|
||||||
|
return None, __get_end(m[1], m[2], m[3])
|
||||||
|
m = re.match(f"^{DATE_SPEC_RE}-{DATE_SPEC_RE}$", text)
|
||||||
|
if m is not None:
|
||||||
|
return __get_start(m[1], m[2], m[3]), \
|
||||||
|
__get_end(m[4], m[5], m[6])
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
|
||||||
|
def __get_start(year: str, month: str | None, day: str | None) -> date:
|
||||||
|
"""Returns the start of the period from the date representation.
|
||||||
|
|
||||||
|
:param year: The year.
|
||||||
|
:param month: The month, if any.
|
||||||
|
:param day: The day, if any.
|
||||||
|
:return: The start of the period.
|
||||||
|
:raise ValueError: When the date is invalid.
|
||||||
|
"""
|
||||||
|
if day is not None:
|
||||||
|
return date(int(year), int(month), int(day))
|
||||||
|
if month is not None:
|
||||||
|
return date(int(year), int(month), 1)
|
||||||
|
return date(int(year), 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_end(year: str, month: str | None, day: str | None) -> date:
|
||||||
|
"""Returns the end of the period from the date representation.
|
||||||
|
|
||||||
|
:param year: The year.
|
||||||
|
:param month: The month, if any.
|
||||||
|
:param day: The day, if any.
|
||||||
|
:return: The end of the period.
|
||||||
|
:raise ValueError: When the date is invalid.
|
||||||
|
"""
|
||||||
|
if day is not None:
|
||||||
|
return date(int(year), int(month), int(day))
|
||||||
|
if month is not None:
|
||||||
|
year_n: int = int(year)
|
||||||
|
month_n: int = int(month)
|
||||||
|
day_n: int = calendar.monthrange(year_n, month_n)[1]
|
||||||
|
return date(year_n, month_n, day_n)
|
||||||
|
return date(int(year), 12, 31)
|
129
src/accounting/report/period/period.py
Normal file
129
src/accounting/report/period/period.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The date period.
|
||||||
|
|
||||||
|
This file is largely taken from the NanoParma ERP project, first written in
|
||||||
|
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from .description import get_desc
|
||||||
|
from .month_end import month_end
|
||||||
|
from .specification import get_spec
|
||||||
|
|
||||||
|
|
||||||
|
class Period:
|
||||||
|
"""A date period."""
|
||||||
|
|
||||||
|
def __init__(self, start: date | None, end: date | None):
|
||||||
|
"""Constructs a new date period.
|
||||||
|
|
||||||
|
:param start: The start date, or None from the very beginning.
|
||||||
|
:param end: The end date, or None till no end.
|
||||||
|
"""
|
||||||
|
self.start: date | None = start
|
||||||
|
"""The start of the period."""
|
||||||
|
self.end: date | None = end
|
||||||
|
"""The end of the period."""
|
||||||
|
self.is_default: bool = False
|
||||||
|
"""Whether the is the default period."""
|
||||||
|
self.is_this_month: bool = False
|
||||||
|
"""Whether the period is this month."""
|
||||||
|
self.is_last_month: bool = False
|
||||||
|
"""Whether the period is last month."""
|
||||||
|
self.is_since_last_month: bool = False
|
||||||
|
"""Whether the period is since last month."""
|
||||||
|
self.is_this_year: bool = False
|
||||||
|
"""Whether the period is this year."""
|
||||||
|
self.is_last_year: bool = False
|
||||||
|
"""Whether the period is last year."""
|
||||||
|
self.is_today: bool = False
|
||||||
|
"""Whether the period is today."""
|
||||||
|
self.is_yesterday: bool = False
|
||||||
|
"""Whether the period is yesterday."""
|
||||||
|
self.is_all: bool = start is None and end is None
|
||||||
|
"""Whether the period is all time."""
|
||||||
|
self.spec: str = ""
|
||||||
|
"""The period specification."""
|
||||||
|
self.desc: str = ""
|
||||||
|
"""The text description."""
|
||||||
|
self.is_a_month: bool = False
|
||||||
|
"""Whether the period is a whole month."""
|
||||||
|
self.is_type_month: bool = False
|
||||||
|
"""Whether the period is for the month chooser."""
|
||||||
|
self.is_a_year: bool = False
|
||||||
|
"""Whether the period is a whole year."""
|
||||||
|
self.is_a_day: bool = False
|
||||||
|
"""Whether the period is a single day."""
|
||||||
|
self._set_properties()
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
"""Sets the following properties.
|
||||||
|
|
||||||
|
* self.spec
|
||||||
|
* self.desc
|
||||||
|
* self.is_a_month
|
||||||
|
* self.is_type_month
|
||||||
|
* self.is_a_year
|
||||||
|
* self.is_a_day
|
||||||
|
|
||||||
|
Override this method to set the properties in the subclasses, to skip
|
||||||
|
the calculation.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.spec = get_spec(self.start, self.end)
|
||||||
|
self.desc = get_desc(self.start, self.end)
|
||||||
|
if self.start is None or self.end is None:
|
||||||
|
return
|
||||||
|
self.is_a_month = self.start.day == 1 \
|
||||||
|
and self.end == month_end(self.start)
|
||||||
|
self.is_type_month = self.is_a_month
|
||||||
|
self.is_a_year = self.start == date(self.start.year, 1, 1) \
|
||||||
|
and self.end == date(self.start.year, 12, 31)
|
||||||
|
self.is_a_day = self.start == self.end
|
||||||
|
|
||||||
|
def is_year(self, year: int) -> bool:
|
||||||
|
"""Returns whether the period is the specific year period.
|
||||||
|
|
||||||
|
:param year: The year.
|
||||||
|
:return: True if the period is the year period, or False otherwise.
|
||||||
|
"""
|
||||||
|
if not self.is_a_year:
|
||||||
|
return False
|
||||||
|
return self.start.year == year
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_type_arbitrary(self) -> bool:
|
||||||
|
"""Returns whether this period is an arbitrary period.
|
||||||
|
|
||||||
|
:return: True if this is an arbitrary period, or False otherwise.
|
||||||
|
"""
|
||||||
|
return not self.is_type_month and not self.is_a_year \
|
||||||
|
and not self.is_a_day
|
||||||
|
|
||||||
|
@property
|
||||||
|
def before(self) -> t.Self | None:
|
||||||
|
"""Returns the period before this period.
|
||||||
|
|
||||||
|
:return: The period before this period.
|
||||||
|
"""
|
||||||
|
if self.start is None:
|
||||||
|
return None
|
||||||
|
return Period(None, self.start - timedelta(days=1))
|
168
src/accounting/report/period/shortcuts.py
Normal file
168
src/accounting/report/period/shortcuts.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The named shortcut periods.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from .month_end import month_end
|
||||||
|
from .period import Period
|
||||||
|
|
||||||
|
|
||||||
|
class ThisMonth(Period):
|
||||||
|
"""The period of this month."""
|
||||||
|
def __init__(self):
|
||||||
|
today: date = date.today()
|
||||||
|
this_month_start: date = date(today.year, today.month, 1)
|
||||||
|
super().__init__(this_month_start, month_end(today))
|
||||||
|
self.is_default = True
|
||||||
|
self.is_this_month = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "this-month"
|
||||||
|
self.desc = gettext("This month")
|
||||||
|
self.is_a_month = True
|
||||||
|
self.is_type_month = True
|
||||||
|
|
||||||
|
|
||||||
|
class LastMonth(Period):
|
||||||
|
"""The period of this month."""
|
||||||
|
def __init__(self):
|
||||||
|
today: date = date.today()
|
||||||
|
year: int = today.year
|
||||||
|
month: int = today.month - 1
|
||||||
|
if month < 1:
|
||||||
|
year = year - 1
|
||||||
|
month = 12
|
||||||
|
start: date = date(year, month, 1)
|
||||||
|
super().__init__(start, month_end(start))
|
||||||
|
self.is_last_month = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "last-month"
|
||||||
|
self.desc = gettext("Last month")
|
||||||
|
self.is_a_month = True
|
||||||
|
self.is_type_month = True
|
||||||
|
|
||||||
|
|
||||||
|
class SinceLastMonth(Period):
|
||||||
|
"""The period of this month."""
|
||||||
|
def __init__(self):
|
||||||
|
today: date = date.today()
|
||||||
|
year: int = today.year
|
||||||
|
month: int = today.month - 1
|
||||||
|
if month < 1:
|
||||||
|
year = year - 1
|
||||||
|
month = 12
|
||||||
|
start: date = date(year, month, 1)
|
||||||
|
super().__init__(start, None)
|
||||||
|
self.is_since_last_month = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "since-last-month"
|
||||||
|
self.desc = gettext("Since last month")
|
||||||
|
self.is_type_month = True
|
||||||
|
|
||||||
|
|
||||||
|
class ThisYear(Period):
|
||||||
|
"""The period of this year."""
|
||||||
|
def __init__(self):
|
||||||
|
year: int = date.today().year
|
||||||
|
start: date = date(year, 1, 1)
|
||||||
|
end: date = date(year, 12, 31)
|
||||||
|
super().__init__(start, end)
|
||||||
|
self.is_this_year = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "this-year"
|
||||||
|
self.desc = gettext("This year")
|
||||||
|
self.is_a_year = True
|
||||||
|
|
||||||
|
|
||||||
|
class LastYear(Period):
|
||||||
|
"""The period of last year."""
|
||||||
|
def __init__(self):
|
||||||
|
year: int = date.today().year
|
||||||
|
start: date = date(year - 1, 1, 1)
|
||||||
|
end: date = date(year - 1, 12, 31)
|
||||||
|
super().__init__(start, end)
|
||||||
|
self.is_last_year = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "last-year"
|
||||||
|
self.desc = gettext("Last year")
|
||||||
|
self.is_a_year = True
|
||||||
|
|
||||||
|
|
||||||
|
class Today(Period):
|
||||||
|
"""The period of today."""
|
||||||
|
def __init__(self):
|
||||||
|
today: date = date.today()
|
||||||
|
super().__init__(today, today)
|
||||||
|
self.is_today = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "today"
|
||||||
|
self.desc = gettext("Today")
|
||||||
|
self.is_a_day = True
|
||||||
|
|
||||||
|
|
||||||
|
class Yesterday(Period):
|
||||||
|
"""The period of yesterday."""
|
||||||
|
def __init__(self):
|
||||||
|
yesterday: date = date.today() - timedelta(days=1)
|
||||||
|
super().__init__(yesterday, yesterday)
|
||||||
|
self.is_yesterday = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "yesterday"
|
||||||
|
self.desc = gettext("Yesterday")
|
||||||
|
self.is_a_day = True
|
||||||
|
|
||||||
|
|
||||||
|
class AllTime(Period):
|
||||||
|
"""The period of all time."""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(None, None)
|
||||||
|
self.is_all = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "all-time"
|
||||||
|
self.desc = gettext("All")
|
||||||
|
|
||||||
|
|
||||||
|
class TemplatePeriod(Period):
|
||||||
|
"""The period template."""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(None, None)
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "PERIOD"
|
||||||
|
|
||||||
|
|
||||||
|
class YearPeriod(Period):
|
||||||
|
"""A year period."""
|
||||||
|
|
||||||
|
def __init__(self, year: int):
|
||||||
|
"""Constructs a year period.
|
||||||
|
|
||||||
|
:param year: The year.
|
||||||
|
"""
|
||||||
|
start: date = date(year, 1, 1)
|
||||||
|
end: date = date(year, 12, 31)
|
||||||
|
super().__init__(start, end)
|
120
src/accounting/report/period/specification.py
Normal file
120
src/accounting/report/period/specification.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The period specification composer.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def get_spec(start: date | None, end: date | None) -> str:
|
||||||
|
"""Returns the period specification.
|
||||||
|
|
||||||
|
:param start: The start of the period.
|
||||||
|
:param end: The end of the period.
|
||||||
|
:return: The period specification.
|
||||||
|
"""
|
||||||
|
if start is None and end is None:
|
||||||
|
return "-"
|
||||||
|
if end is None:
|
||||||
|
return __get_since_spec(start)
|
||||||
|
if start is None:
|
||||||
|
return __get_until_spec(end)
|
||||||
|
try:
|
||||||
|
return __get_year_spec(start, end)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return __get_month_spec(start, end)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return __get_day_spec(start, end)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_since_spec(start: date) -> str:
|
||||||
|
"""Returns the period specification without the end day.
|
||||||
|
|
||||||
|
:param start: The start of the period.
|
||||||
|
:return: The period specification without the end day
|
||||||
|
"""
|
||||||
|
if start.month == 1 and start.day == 1:
|
||||||
|
return start.strftime("%Y-")
|
||||||
|
if start.day == 1:
|
||||||
|
return start.strftime("%Y-%m-")
|
||||||
|
return start.strftime("%Y-%m-%d-")
|
||||||
|
|
||||||
|
|
||||||
|
def __get_until_spec(end: date) -> str:
|
||||||
|
"""Returns the period specification without the start day.
|
||||||
|
|
||||||
|
:param end: The end of the period.
|
||||||
|
:return: The period specification without the start day
|
||||||
|
"""
|
||||||
|
if end.month == 12 and end.day == 31:
|
||||||
|
return end.strftime("-%Y")
|
||||||
|
if (end + timedelta(days=1)).day == 1:
|
||||||
|
return end.strftime("-%Y-%m")
|
||||||
|
return end.strftime("-%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def __get_year_spec(start: date, end: date) -> str:
|
||||||
|
"""Returns the period specification as a year range.
|
||||||
|
|
||||||
|
:param start: The start of the period.
|
||||||
|
:param end: The end of the period.
|
||||||
|
:return: The period specification as a year range.
|
||||||
|
:raise ValueError: The period is not a year range.
|
||||||
|
"""
|
||||||
|
if start.month != 1 or start.day != 1 \
|
||||||
|
or end.month != 12 or end.day != 31:
|
||||||
|
raise ValueError
|
||||||
|
start_spec: str = start.strftime("%Y")
|
||||||
|
if start.year == end.year:
|
||||||
|
return start_spec
|
||||||
|
end_spec: str = end.strftime("%Y")
|
||||||
|
return f"{start_spec}-{end_spec}"
|
||||||
|
|
||||||
|
|
||||||
|
def __get_month_spec(start: date, end: date) -> str:
|
||||||
|
"""Returns the period specification as a month range.
|
||||||
|
|
||||||
|
:param start: The start of the period.
|
||||||
|
:param end: The end of the period.
|
||||||
|
:return: The period specification as a month range.
|
||||||
|
:raise ValueError: The period is not a month range.
|
||||||
|
"""
|
||||||
|
if start.day != 1 or (end + timedelta(days=1)).day != 1:
|
||||||
|
raise ValueError
|
||||||
|
start_spec: str = start.strftime("%Y-%m")
|
||||||
|
if start.year == end.year and start.month == end.month:
|
||||||
|
return start_spec
|
||||||
|
end_spec: str = end.strftime("%Y-%m")
|
||||||
|
return f"{start_spec}-{end_spec}"
|
||||||
|
|
||||||
|
|
||||||
|
def __get_day_spec(start: date, end: date) -> str:
|
||||||
|
"""Returns the period specification as a day range.
|
||||||
|
|
||||||
|
:param start: The start of the period.
|
||||||
|
:param end: The end of the period.
|
||||||
|
:return: The period specification as a day range.
|
||||||
|
:raise ValueError: The period is a month or year range.
|
||||||
|
"""
|
||||||
|
start_spec: str = start.strftime("%Y-%m-%d")
|
||||||
|
if start == end:
|
||||||
|
return start_spec
|
||||||
|
end_spec: str = end.strftime("%Y-%m-%d")
|
||||||
|
return f"{start_spec}-{end_spec}"
|
26
src/accounting/report/reports/__init__.py
Normal file
26
src/accounting/report/reports/__init__.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The reports.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from .balance_sheet import BalanceSheet
|
||||||
|
from .income_expenses import IncomeExpenses
|
||||||
|
from .income_statement import IncomeStatement
|
||||||
|
from .journal import Journal
|
||||||
|
from .ledger import Ledger
|
||||||
|
from .search import Search
|
||||||
|
from .trial_balance import TrialBalance
|
474
src/accounting/report/reports/balance_sheet.py
Normal file
474
src/accounting/report/reports/balance_sheet.py
Normal file
@ -0,0 +1,474 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The balance sheet.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import render_template, Response
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
||||||
|
JournalEntry
|
||||||
|
from accounting.report.period import Period, PeriodChooser
|
||||||
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
|
from accounting.report.utils.base_report import BaseReport
|
||||||
|
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||||
|
period_spec
|
||||||
|
from accounting.report.utils.option_link import OptionLink
|
||||||
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
|
from accounting.report.utils.report_type import ReportType
|
||||||
|
from accounting.report.utils.urls import ledger_url, balance_sheet_url, \
|
||||||
|
income_statement_url
|
||||||
|
|
||||||
|
|
||||||
|
class ReportAccount:
|
||||||
|
"""An account in the report."""
|
||||||
|
|
||||||
|
def __init__(self, account: Account, amount: Decimal, url: str):
|
||||||
|
"""Constructs an account in the report.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:param amount: The amount.
|
||||||
|
:param url: The URL to the ledger of the account.
|
||||||
|
"""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.amount: Decimal = amount
|
||||||
|
"""The amount of the account."""
|
||||||
|
self.url: str = url
|
||||||
|
"""The URL to the ledger of the account."""
|
||||||
|
|
||||||
|
|
||||||
|
class Subsection:
|
||||||
|
"""A subsection."""
|
||||||
|
|
||||||
|
def __init__(self, title: BaseAccount):
|
||||||
|
"""Constructs a subsection.
|
||||||
|
|
||||||
|
:param title: The title account.
|
||||||
|
"""
|
||||||
|
self.title: BaseAccount = title
|
||||||
|
"""The title account."""
|
||||||
|
self.accounts: list[ReportAccount] = []
|
||||||
|
"""The accounts in the subsection."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> Decimal:
|
||||||
|
"""Returns the total of the subsection.
|
||||||
|
|
||||||
|
:return: The total of the subsection.
|
||||||
|
"""
|
||||||
|
return sum([x.amount for x in self.accounts])
|
||||||
|
|
||||||
|
|
||||||
|
class Section:
|
||||||
|
"""A section."""
|
||||||
|
|
||||||
|
def __init__(self, title: BaseAccount):
|
||||||
|
"""Constructs a section.
|
||||||
|
|
||||||
|
:param title: The title account.
|
||||||
|
"""
|
||||||
|
self.title: BaseAccount = title
|
||||||
|
"""The title account."""
|
||||||
|
self.subsections: list[Subsection] = []
|
||||||
|
"""The subsections in the section."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> Decimal:
|
||||||
|
"""Returns the total of the section.
|
||||||
|
|
||||||
|
:return: The total of the section.
|
||||||
|
"""
|
||||||
|
return sum([x.total for x in self.subsections])
|
||||||
|
|
||||||
|
|
||||||
|
class AccountCollector:
|
||||||
|
"""The balance sheet account collector."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, period: Period):
|
||||||
|
"""Constructs the balance sheet account collector.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.accounts: list[ReportAccount] = self.__query_balances()
|
||||||
|
"""The balance sheet accounts."""
|
||||||
|
|
||||||
|
def __query_balances(self) -> list[ReportAccount]:
|
||||||
|
"""Queries and returns the balances.
|
||||||
|
|
||||||
|
:return: The balances.
|
||||||
|
"""
|
||||||
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
|
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
|
sa.or_(*sub_conditions)]
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(Transaction.date <= self.__period.end)
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
|
else_=-JournalEntry.amount)).label("balance")
|
||||||
|
select_balance: sa.Select \
|
||||||
|
= sa.select(Account.id, Account.base_code, Account.no,
|
||||||
|
balance_func)\
|
||||||
|
.join(Transaction).join(Account)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.group_by(Account.id, Account.base_code, Account.no)\
|
||||||
|
.order_by(Account.base_code, Account.no)
|
||||||
|
account_balances: list[sa.Row] \
|
||||||
|
= db.session.execute(select_balance).all()
|
||||||
|
self.__all_accounts: list[Account] = Account.query\
|
||||||
|
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
|
||||||
|
Account.base_code == "3351",
|
||||||
|
Account.base_code == "3353")).all()
|
||||||
|
account_by_id: dict[int, Account] \
|
||||||
|
= {x.id: x for x in self.__all_accounts}
|
||||||
|
self.accounts: list[ReportAccount] \
|
||||||
|
= [ReportAccount(account=account_by_id[x.id],
|
||||||
|
amount=x.balance,
|
||||||
|
url=ledger_url(self.__currency,
|
||||||
|
account_by_id[x.id],
|
||||||
|
self.__period))
|
||||||
|
for x in account_balances]
|
||||||
|
self.__add_accumulated()
|
||||||
|
self.__add_current_period()
|
||||||
|
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
|
||||||
|
for balance in self.accounts:
|
||||||
|
if not balance.account.base_code.startswith("1"):
|
||||||
|
balance.amount = -balance.amount
|
||||||
|
return self.accounts
|
||||||
|
|
||||||
|
def __add_accumulated(self) -> None:
|
||||||
|
"""Adds the accumulated profit or loss to the balances.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__add_owner_s_equity(Account.ACCUMULATED_CHANGE_CODE,
|
||||||
|
self.__query_accumulated(),
|
||||||
|
self.__period)
|
||||||
|
|
||||||
|
def __query_accumulated(self) -> Decimal | None:
|
||||||
|
"""Queries and returns the accumulated profit or loss.
|
||||||
|
|
||||||
|
:return: The accumulated profit or loss.
|
||||||
|
"""
|
||||||
|
if self.__period.start is None:
|
||||||
|
return None
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
|
Transaction.date < self.__period.start]
|
||||||
|
return self.__query_balance(conditions)
|
||||||
|
|
||||||
|
def __add_current_period(self) -> None:
|
||||||
|
"""Adds the accumulated profit or loss to the balances.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__add_owner_s_equity(Account.NET_CHANGE_CODE,
|
||||||
|
self.__query_current_period(),
|
||||||
|
self.__period)
|
||||||
|
|
||||||
|
def __query_current_period(self) -> Decimal | None:
|
||||||
|
"""Queries and returns the net income or loss for current period.
|
||||||
|
|
||||||
|
:return: The net income or loss for current period.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.currency_code == self.__currency.code]
|
||||||
|
if self.__period.start is not None:
|
||||||
|
conditions.append(Transaction.date >= self.__period.start)
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(Transaction.date <= self.__period.end)
|
||||||
|
return self.__query_balance(conditions)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __query_balance(conditions: list[sa.BinaryExpression])\
|
||||||
|
-> Decimal:
|
||||||
|
"""Queries the balance.
|
||||||
|
|
||||||
|
:param conditions: The SQL conditions for the balance.
|
||||||
|
:return: The balance.
|
||||||
|
"""
|
||||||
|
conditions.extend([sa.not_(Account.base_code.startswith(x))
|
||||||
|
for x in {"1", "2", "3"}])
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
|
else_=-JournalEntry.amount))
|
||||||
|
select_balance: sa.Select = sa.select(balance_func)\
|
||||||
|
.join(Transaction).join(Account).filter(*conditions)
|
||||||
|
return db.session.scalar(select_balance)
|
||||||
|
|
||||||
|
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
|
||||||
|
period: Period) -> None:
|
||||||
|
"""Adds an owner's equity balance.
|
||||||
|
|
||||||
|
:param code: The code of the account to add.
|
||||||
|
:param amount: The amount.
|
||||||
|
:param period: The period.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if amount is None:
|
||||||
|
return
|
||||||
|
url: str = income_statement_url(self.__currency, period)
|
||||||
|
# There is an existing balance.
|
||||||
|
account_balance_by_code: dict[str, ReportAccount] \
|
||||||
|
= {x.account.code: x for x in self.accounts}
|
||||||
|
if code in account_balance_by_code:
|
||||||
|
balance: ReportAccount = account_balance_by_code[code]
|
||||||
|
balance.amount = balance.amount + amount
|
||||||
|
balance.url = url
|
||||||
|
return
|
||||||
|
# Add a new balance
|
||||||
|
account_by_code: dict[str, Account] \
|
||||||
|
= {x.code: x for x in self.__all_accounts}
|
||||||
|
self.accounts.append(ReportAccount(account=account_by_code[code],
|
||||||
|
amount=amount,
|
||||||
|
url=url))
|
||||||
|
|
||||||
|
|
||||||
|
class CSVHalfRow:
|
||||||
|
"""A half row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, title: str | None, amount: Decimal | None):
|
||||||
|
"""The constructs a half row in the CSV.
|
||||||
|
|
||||||
|
:param title: The title.
|
||||||
|
:param amount: The amount.
|
||||||
|
"""
|
||||||
|
self.title: str | None = title
|
||||||
|
"""The title."""
|
||||||
|
self.amount: Decimal | None = amount
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Constructs a row in the CSV."""
|
||||||
|
self.asset_title: str | None = None
|
||||||
|
"""The title of the asset."""
|
||||||
|
self.asset_amount: Decimal | None = None
|
||||||
|
"""The amount of the asset."""
|
||||||
|
self.liability_title: str | None = None
|
||||||
|
"""The title of the liability."""
|
||||||
|
self.liability_amount: Decimal | None = None
|
||||||
|
"""The amount of the liability."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.asset_title, self.asset_amount,
|
||||||
|
self.liability_title, self.liability_amount]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
period: Period,
|
||||||
|
has_data: bool,
|
||||||
|
assets: Section,
|
||||||
|
liabilities: Section,
|
||||||
|
owner_s_equity: Section):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:param has_data: True if there is any data, or False otherwise.
|
||||||
|
:param assets: The assets.
|
||||||
|
:param liabilities: The liabilities.
|
||||||
|
:param owner_s_equity: The owner's equity.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__has_data: bool = has_data
|
||||||
|
"""True if there is any data, or False otherwise."""
|
||||||
|
self.assets: Section = assets
|
||||||
|
"""The assets."""
|
||||||
|
self.liabilities: Section = liabilities
|
||||||
|
"""The liabilities."""
|
||||||
|
self.owner_s_equity: Section = owner_s_equity
|
||||||
|
"""The owner's equity."""
|
||||||
|
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||||
|
lambda x: balance_sheet_url(currency, x))
|
||||||
|
"""The period chooser."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
"""Returns whether there is any data on the page.
|
||||||
|
|
||||||
|
:return: True if there is any data, or False otherwise.
|
||||||
|
"""
|
||||||
|
return self.__has_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
return ReportChooser(ReportType.BALANCE_SHEET,
|
||||||
|
currency=self.currency,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(
|
||||||
|
lambda x: balance_sheet_url(x, self.period), self.currency)
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceSheet(BaseReport):
|
||||||
|
"""The balance sheet."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, period: Period):
|
||||||
|
"""Constructs a balance sheet.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__has_data: bool
|
||||||
|
"""True if there is any data, or False otherwise."""
|
||||||
|
self.__assets: Section
|
||||||
|
"""The assets."""
|
||||||
|
self.__liabilities: Section
|
||||||
|
"""The liabilities."""
|
||||||
|
self.__owner_s_equity: Section
|
||||||
|
"""The owner's equity."""
|
||||||
|
self.__set_data()
|
||||||
|
|
||||||
|
def __set_data(self) -> None:
|
||||||
|
"""Queries and sets assets, the liabilities, and the owner's equity
|
||||||
|
sections in the balance sheet.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
balances: list[ReportAccount] = AccountCollector(
|
||||||
|
self.__currency, self.__period).accounts
|
||||||
|
|
||||||
|
titles: list[BaseAccount] = BaseAccount.query\
|
||||||
|
.filter(BaseAccount.code.in_({"1", "2", "3"})).all()
|
||||||
|
subtitles: list[BaseAccount] = BaseAccount.query\
|
||||||
|
.filter(BaseAccount.code.in_({x.account.base_code[:2]
|
||||||
|
for x in balances})).all()
|
||||||
|
|
||||||
|
sections: dict[str, Section] = {x.code: Section(x) for x in titles}
|
||||||
|
subsections: dict[str, Subsection] = {x.code: Subsection(x)
|
||||||
|
for x in subtitles}
|
||||||
|
for subsection in subsections.values():
|
||||||
|
sections[subsection.title.code[0]].subsections.append(subsection)
|
||||||
|
for balance in balances:
|
||||||
|
subsections[balance.account.base_code[:2]].accounts.append(balance)
|
||||||
|
|
||||||
|
self.__has_data = len(balances) > 0
|
||||||
|
self.__assets = sections["1"]
|
||||||
|
self.__liabilities = sections["2"]
|
||||||
|
self.__owner_s_equity = sections["3"]
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "balance-sheet-{currency}-{period}.csv"\
|
||||||
|
.format(currency=self.__currency.code,
|
||||||
|
period=period_spec(self.__period))
|
||||||
|
return csv_download(filename, self.__get_csv_rows())
|
||||||
|
|
||||||
|
def __get_csv_rows(self) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows.
|
||||||
|
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
asset_rows: list[CSVHalfRow] = self.__section_csv_rows(self.__assets)
|
||||||
|
liability_rows: list[CSVHalfRow] = []
|
||||||
|
liability_rows.extend(self.__section_csv_rows(self.__liabilities))
|
||||||
|
liability_rows.append(CSVHalfRow(gettext("Total"),
|
||||||
|
self.__liabilities.total))
|
||||||
|
liability_rows.append(CSVHalfRow(None, None))
|
||||||
|
liability_rows.extend(self.__section_csv_rows(self.__owner_s_equity))
|
||||||
|
liability_rows.append(CSVHalfRow(gettext("Total"),
|
||||||
|
self.__owner_s_equity.total))
|
||||||
|
rows: list[CSVRow] = [CSVRow() for _ in
|
||||||
|
range(max(len(asset_rows), len(liability_rows)))]
|
||||||
|
for i in range(len(rows)):
|
||||||
|
if i < len(asset_rows):
|
||||||
|
rows[i].asset_title = asset_rows[i].title
|
||||||
|
rows[i].asset_amount = asset_rows[i].amount
|
||||||
|
if i < len(liability_rows) and liability_rows[i].title is not None:
|
||||||
|
rows[i].liability_title = liability_rows[i].title
|
||||||
|
rows[i].liability_amount = liability_rows[i].amount
|
||||||
|
total: CSVRow = CSVRow()
|
||||||
|
total.asset_title = gettext("Total")
|
||||||
|
total.asset_amount = self.__assets.total
|
||||||
|
total.liability_title = gettext("Total")
|
||||||
|
total.liability_amount \
|
||||||
|
= self.__liabilities.total + self.__owner_s_equity.total
|
||||||
|
rows.append(total)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __section_csv_rows(section: Section) -> list[CSVHalfRow]:
|
||||||
|
"""Gathers the CSV rows for a section.
|
||||||
|
|
||||||
|
:param section: The section.
|
||||||
|
:return: The CSV rows for the section.
|
||||||
|
"""
|
||||||
|
rows: list[CSVHalfRow] \
|
||||||
|
= [CSVHalfRow(section.title.title.title(), None)]
|
||||||
|
for subsection in section.subsections:
|
||||||
|
rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None))
|
||||||
|
for account in subsection.accounts:
|
||||||
|
rows.append(CSVHalfRow(f" {str(account.account).title()}",
|
||||||
|
account.amount))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
params: PageParams = PageParams(currency=self.__currency,
|
||||||
|
period=self.__period,
|
||||||
|
has_data=self.__has_data,
|
||||||
|
assets=self.__assets,
|
||||||
|
liabilities=self.__liabilities,
|
||||||
|
owner_s_equity=self.__owner_s_equity)
|
||||||
|
return render_template("accounting/report/balance-sheet.html",
|
||||||
|
report=params)
|
458
src/accounting/report/reports/income_expenses.py
Normal file
458
src/accounting/report/reports/income_expenses.py
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The income and expenses log.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import url_for, render_template, Response
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||||
|
from accounting.report.period import Period, PeriodChooser
|
||||||
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
|
from accounting.report.utils.base_report import BaseReport
|
||||||
|
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||||
|
period_spec
|
||||||
|
from accounting.report.utils.ie_account import IncomeExpensesAccount
|
||||||
|
from accounting.report.utils.option_link import OptionLink
|
||||||
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
|
from accounting.report.utils.report_type import ReportType
|
||||||
|
from accounting.report.utils.urls import income_expenses_url
|
||||||
|
from accounting.utils.cast import be
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class ReportEntry:
|
||||||
|
"""An entry in the report."""
|
||||||
|
|
||||||
|
def __init__(self, entry: JournalEntry | None = None):
|
||||||
|
"""Constructs the entry in the report.
|
||||||
|
|
||||||
|
:param entry: The journal entry.
|
||||||
|
"""
|
||||||
|
self.is_brought_forward: bool = False
|
||||||
|
"""Whether this is the brought-forward entry."""
|
||||||
|
self.is_total: bool = False
|
||||||
|
"""Whether this is the total entry."""
|
||||||
|
self.date: date | None = None
|
||||||
|
"""The date."""
|
||||||
|
self.account: Account | None = None
|
||||||
|
"""The account."""
|
||||||
|
self.summary: str | None = None
|
||||||
|
"""The summary."""
|
||||||
|
self.income: Decimal | None = None
|
||||||
|
"""The income amount."""
|
||||||
|
self.expense: Decimal | None = None
|
||||||
|
"""The expense amount."""
|
||||||
|
self.balance: Decimal | None = None
|
||||||
|
"""The balance."""
|
||||||
|
self.note: str | None = None
|
||||||
|
"""The note."""
|
||||||
|
self.url: str | None = None
|
||||||
|
"""The URL to the journal entry."""
|
||||||
|
if entry is not None:
|
||||||
|
self.date = entry.transaction.date
|
||||||
|
self.account = entry.account
|
||||||
|
self.summary = entry.summary
|
||||||
|
self.income = None if entry.is_debit else entry.amount
|
||||||
|
self.expense = entry.amount if entry.is_debit else None
|
||||||
|
self.note = entry.transaction.note
|
||||||
|
self.url = url_for("accounting.transaction.detail",
|
||||||
|
txn=entry.transaction)
|
||||||
|
|
||||||
|
|
||||||
|
class EntryCollector:
|
||||||
|
"""The report entry collector."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
|
||||||
|
period: Period):
|
||||||
|
"""Constructs the report entry collector.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__account: IncomeExpensesAccount = account
|
||||||
|
"""The account."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period"""
|
||||||
|
self.brought_forward: ReportEntry | None
|
||||||
|
"""The brought-forward entry."""
|
||||||
|
self.entries: list[ReportEntry]
|
||||||
|
"""The log entries."""
|
||||||
|
self.total: ReportEntry | None
|
||||||
|
"""The total entry."""
|
||||||
|
self.brought_forward = self.__get_brought_forward_entry()
|
||||||
|
self.entries = self.__query_entries()
|
||||||
|
self.total = self.__get_total_entry()
|
||||||
|
self.__populate_balance()
|
||||||
|
|
||||||
|
def __get_brought_forward_entry(self) -> ReportEntry | None:
|
||||||
|
"""Queries, composes and returns the brought-forward entry.
|
||||||
|
|
||||||
|
:return: The brought-forward entry, or None if the period starts from
|
||||||
|
the beginning.
|
||||||
|
"""
|
||||||
|
if self.__period.start is None:
|
||||||
|
return None
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
|
else_=-JournalEntry.amount))
|
||||||
|
select: sa.Select = sa.Select(balance_func)\
|
||||||
|
.join(Transaction).join(Account)\
|
||||||
|
.filter(be(JournalEntry.currency_code == self.__currency.code),
|
||||||
|
self.__account_condition,
|
||||||
|
Transaction.date < self.__period.start)
|
||||||
|
balance: int | None = db.session.scalar(select)
|
||||||
|
if balance is None:
|
||||||
|
return None
|
||||||
|
entry: ReportEntry = ReportEntry()
|
||||||
|
entry.is_brought_forward = True
|
||||||
|
entry.date = self.__period.start
|
||||||
|
entry.account = Account.accumulated_change()
|
||||||
|
entry.summary = gettext("Brought forward")
|
||||||
|
if balance > 0:
|
||||||
|
entry.income = balance
|
||||||
|
elif balance < 0:
|
||||||
|
entry.expense = -balance
|
||||||
|
entry.balance = balance
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def __query_entries(self) -> list[ReportEntry]:
|
||||||
|
"""Queries and returns the log entries.
|
||||||
|
|
||||||
|
:return: The log entries.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
|
self.__account_condition]
|
||||||
|
if self.__period.start is not None:
|
||||||
|
conditions.append(Transaction.date >= self.__period.start)
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(Transaction.date <= self.__period.end)
|
||||||
|
txn_with_account: sa.Select = sa.Select(Transaction.id).\
|
||||||
|
join(JournalEntry).join(Account).filter(*conditions)
|
||||||
|
|
||||||
|
return [ReportEntry(x)
|
||||||
|
for x in JournalEntry.query.join(Transaction).join(Account)
|
||||||
|
.filter(JournalEntry.transaction_id.in_(txn_with_account),
|
||||||
|
JournalEntry.currency_code == self.__currency.code,
|
||||||
|
sa.not_(self.__account_condition))
|
||||||
|
.order_by(Transaction.date,
|
||||||
|
Transaction.no,
|
||||||
|
JournalEntry.is_debit,
|
||||||
|
JournalEntry.no)
|
||||||
|
.options(selectinload(JournalEntry.account),
|
||||||
|
selectinload(JournalEntry.transaction))]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __account_condition(self) -> sa.BinaryExpression:
|
||||||
|
if self.__account.code == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||||
|
return sa.or_(Account.base_code.startswith("11"),
|
||||||
|
Account.base_code.startswith("12"),
|
||||||
|
Account.base_code.startswith("21"),
|
||||||
|
Account.base_code.startswith("22"))
|
||||||
|
return Account.id == self.__account.id
|
||||||
|
|
||||||
|
def __get_total_entry(self) -> ReportEntry | None:
|
||||||
|
"""Composes the total entry.
|
||||||
|
|
||||||
|
:return: The total entry, or None if there is no data.
|
||||||
|
"""
|
||||||
|
if self.brought_forward is None and len(self.entries) == 0:
|
||||||
|
return None
|
||||||
|
entry: ReportEntry = ReportEntry()
|
||||||
|
entry.is_total = True
|
||||||
|
entry.summary = gettext("Total")
|
||||||
|
entry.income = sum([x.income for x in self.entries
|
||||||
|
if x.income is not None])
|
||||||
|
entry.expense = sum([x.expense for x in self.entries
|
||||||
|
if x.expense is not None])
|
||||||
|
entry.balance = entry.income - entry.expense
|
||||||
|
if self.brought_forward is not None:
|
||||||
|
entry.balance = self.brought_forward.balance + entry.balance
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def __populate_balance(self) -> None:
|
||||||
|
"""Populates the balance of the entries.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
balance: Decimal = 0 if self.brought_forward is None \
|
||||||
|
else self.brought_forward.balance
|
||||||
|
for entry in self.entries:
|
||||||
|
if entry.income is not None:
|
||||||
|
balance = balance + entry.income
|
||||||
|
if entry.expense is not None:
|
||||||
|
balance = balance - entry.expense
|
||||||
|
entry.balance = balance
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, txn_date: date | str | None,
|
||||||
|
account: str | None,
|
||||||
|
summary: str | None,
|
||||||
|
income: str | Decimal | None,
|
||||||
|
expense: str | Decimal | None,
|
||||||
|
balance: str | Decimal | None,
|
||||||
|
note: str | None):
|
||||||
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
|
:param txn_date: The transaction date.
|
||||||
|
:param account: The account.
|
||||||
|
:param summary: The summary.
|
||||||
|
:param income: The income.
|
||||||
|
:param expense: The expense.
|
||||||
|
:param balance: The balance.
|
||||||
|
:param note: The note.
|
||||||
|
"""
|
||||||
|
self.date: date | str | None = txn_date
|
||||||
|
"""The date."""
|
||||||
|
self.account: str | None = account
|
||||||
|
"""The account."""
|
||||||
|
self.summary: str | None = summary
|
||||||
|
"""The summary."""
|
||||||
|
self.income: str | Decimal | None = income
|
||||||
|
"""The income."""
|
||||||
|
self.expense: str | Decimal | None = expense
|
||||||
|
"""The expense."""
|
||||||
|
self.balance: str | Decimal | None = balance
|
||||||
|
"""The balance."""
|
||||||
|
self.note: str | None = note
|
||||||
|
"""The note."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.date, self.account, self.summary,
|
||||||
|
self.income, self.expense, self.balance, self.note]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
account: IncomeExpensesAccount,
|
||||||
|
period: Period,
|
||||||
|
has_data: bool,
|
||||||
|
pagination: Pagination[ReportEntry],
|
||||||
|
brought_forward: ReportEntry | None,
|
||||||
|
entries: list[ReportEntry],
|
||||||
|
total: ReportEntry | None):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
:param has_data: True if there is any data, or False otherwise.
|
||||||
|
:param brought_forward: The brought-forward entry.
|
||||||
|
:param entries: The log entries.
|
||||||
|
:param total: The total entry.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.account: IncomeExpensesAccount = account
|
||||||
|
"""The account."""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__has_data: bool = has_data
|
||||||
|
"""True if there is any data, or False otherwise."""
|
||||||
|
self.pagination: Pagination[ReportEntry] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.brought_forward: ReportEntry | None = brought_forward
|
||||||
|
"""The brought-forward entry."""
|
||||||
|
self.entries: list[ReportEntry] = entries
|
||||||
|
"""The report entries."""
|
||||||
|
self.total: ReportEntry | None = total
|
||||||
|
"""The total entry."""
|
||||||
|
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||||
|
lambda x: income_expenses_url(currency, account, x))
|
||||||
|
"""The period chooser."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
"""Returns whether there is any data on the page.
|
||||||
|
|
||||||
|
:return: True if there is any data, or False otherwise.
|
||||||
|
"""
|
||||||
|
return self.__has_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
if self.account.account is None:
|
||||||
|
return ReportChooser(ReportType.INCOME_EXPENSES,
|
||||||
|
currency=self.currency,
|
||||||
|
account=Account.cash(),
|
||||||
|
period=self.period)
|
||||||
|
return ReportChooser(ReportType.INCOME_EXPENSES,
|
||||||
|
currency=self.currency,
|
||||||
|
account=self.account.account,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(
|
||||||
|
lambda x: income_expenses_url(x, self.account, self.period),
|
||||||
|
self.currency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the account options.
|
||||||
|
|
||||||
|
:return: The account options.
|
||||||
|
"""
|
||||||
|
current_al: IncomeExpensesAccount \
|
||||||
|
= IncomeExpensesAccount.current_assets_and_liabilities()
|
||||||
|
options: list[OptionLink] \
|
||||||
|
= [OptionLink(str(current_al),
|
||||||
|
income_expenses_url(self.currency, current_al,
|
||||||
|
self.period),
|
||||||
|
self.account.id == 0)]
|
||||||
|
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
||||||
|
.join(Account)\
|
||||||
|
.filter(be(JournalEntry.currency_code == self.currency.code),
|
||||||
|
sa.or_(Account.base_code.startswith("11"),
|
||||||
|
Account.base_code.startswith("12"),
|
||||||
|
Account.base_code.startswith("21"),
|
||||||
|
Account.base_code.startswith("22")))\
|
||||||
|
.group_by(JournalEntry.account_id)
|
||||||
|
options.extend([OptionLink(str(x),
|
||||||
|
income_expenses_url(
|
||||||
|
self.currency,
|
||||||
|
IncomeExpensesAccount(x),
|
||||||
|
self.period),
|
||||||
|
x.id == self.account.id)
|
||||||
|
for x in Account.query.filter(Account.id.in_(in_use))
|
||||||
|
.order_by(Account.base_code, Account.no).all()])
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeExpenses(BaseReport):
|
||||||
|
"""The income and expenses log."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
|
||||||
|
period: Period):
|
||||||
|
"""Constructs an income and expenses log.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__account: IncomeExpensesAccount = account
|
||||||
|
"""The account."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
collector: EntryCollector = EntryCollector(
|
||||||
|
self.__currency, self.__account, self.__period)
|
||||||
|
self.__brought_forward: ReportEntry | None = collector.brought_forward
|
||||||
|
"""The brought-forward entry."""
|
||||||
|
self.__entries: list[ReportEntry] = collector.entries
|
||||||
|
"""The report entries."""
|
||||||
|
self.__total: ReportEntry | None = collector.total
|
||||||
|
"""The total entry."""
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "income-expenses-{currency}-{account}-{period}.csv"\
|
||||||
|
.format(currency=self.__currency.code, account=self.__account.code,
|
||||||
|
period=period_spec(self.__period))
|
||||||
|
return csv_download(filename, self.__get_csv_rows())
|
||||||
|
|
||||||
|
def __get_csv_rows(self) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows.
|
||||||
|
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
|
||||||
|
gettext("Summary"), gettext("Income"),
|
||||||
|
gettext("Expense"), gettext("Balance"),
|
||||||
|
gettext("Note"))]
|
||||||
|
if self.__brought_forward is not None:
|
||||||
|
rows.append(CSVRow(self.__brought_forward.date,
|
||||||
|
str(self.__brought_forward.account).title(),
|
||||||
|
self.__brought_forward.summary,
|
||||||
|
self.__brought_forward.income,
|
||||||
|
self.__brought_forward.expense,
|
||||||
|
self.__brought_forward.balance,
|
||||||
|
None))
|
||||||
|
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
|
||||||
|
x.income, x.expense, x.balance, x.note)
|
||||||
|
for x in self.__entries])
|
||||||
|
if self.__total is not None:
|
||||||
|
rows.append(CSVRow(gettext("Total"), None, None,
|
||||||
|
self.__total.income, self.__total.expense,
|
||||||
|
self.__total.balance, None))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
all_entries: list[ReportEntry] = []
|
||||||
|
if self.__brought_forward is not None:
|
||||||
|
all_entries.append(self.__brought_forward)
|
||||||
|
all_entries.extend(self.__entries)
|
||||||
|
if self.__total is not None:
|
||||||
|
all_entries.append(self.__total)
|
||||||
|
pagination: Pagination[ReportEntry] \
|
||||||
|
= Pagination[ReportEntry](all_entries, is_reversed=True)
|
||||||
|
page_entries: list[ReportEntry] = pagination.list
|
||||||
|
has_data: bool = len(page_entries) > 0
|
||||||
|
brought_forward: ReportEntry | None = None
|
||||||
|
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
|
||||||
|
brought_forward = page_entries[0]
|
||||||
|
page_entries = page_entries[1:]
|
||||||
|
total: ReportEntry | None = None
|
||||||
|
if len(page_entries) > 0 and page_entries[-1].is_total:
|
||||||
|
total = page_entries[-1]
|
||||||
|
page_entries = page_entries[:-1]
|
||||||
|
params: PageParams = PageParams(currency=self.__currency,
|
||||||
|
account=self.__account,
|
||||||
|
period=self.__period,
|
||||||
|
has_data=has_data,
|
||||||
|
pagination=pagination,
|
||||||
|
brought_forward=brought_forward,
|
||||||
|
entries=page_entries,
|
||||||
|
total=total)
|
||||||
|
return render_template("accounting/report/income-expenses.html",
|
||||||
|
report=params)
|
325
src/accounting/report/reports/income_statement.py
Normal file
325
src/accounting/report/reports/income_statement.py
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The income statement.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import render_template, Response
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
||||||
|
JournalEntry
|
||||||
|
from accounting.report.period import Period, PeriodChooser
|
||||||
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
|
from accounting.report.utils.base_report import BaseReport
|
||||||
|
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||||
|
period_spec
|
||||||
|
from accounting.report.utils.option_link import OptionLink
|
||||||
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
|
from accounting.report.utils.report_type import ReportType
|
||||||
|
from accounting.report.utils.urls import ledger_url, income_statement_url
|
||||||
|
|
||||||
|
|
||||||
|
class ReportAccount:
|
||||||
|
"""An account in the report."""
|
||||||
|
|
||||||
|
def __init__(self, account: Account, amount: Decimal, url: str):
|
||||||
|
"""Constructs an account in the report.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:param amount: The amount.
|
||||||
|
:param url: The URL to the ledger of the account.
|
||||||
|
"""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.amount: Decimal = amount
|
||||||
|
"""The amount of the account."""
|
||||||
|
self.url: str = url
|
||||||
|
"""The URL to the ledger of the account."""
|
||||||
|
|
||||||
|
|
||||||
|
class AccumulatedTotal:
|
||||||
|
"""An accumulated total."""
|
||||||
|
|
||||||
|
def __init__(self, title: str):
|
||||||
|
"""Constructs an accumulated total.
|
||||||
|
|
||||||
|
:param title: The title.
|
||||||
|
"""
|
||||||
|
self.title: str = title
|
||||||
|
"""The account."""
|
||||||
|
self.amount: Decimal = Decimal("0")
|
||||||
|
"""The amount of the account."""
|
||||||
|
|
||||||
|
|
||||||
|
class Subsection:
|
||||||
|
"""A subsection."""
|
||||||
|
|
||||||
|
def __init__(self, title: BaseAccount):
|
||||||
|
"""Constructs a subsection.
|
||||||
|
|
||||||
|
:param title: The title account.
|
||||||
|
"""
|
||||||
|
self.title: BaseAccount = title
|
||||||
|
"""The title account."""
|
||||||
|
self.accounts: list[ReportAccount] = []
|
||||||
|
"""The accounts in the subsection."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> Decimal:
|
||||||
|
"""Returns the total of the subsection.
|
||||||
|
|
||||||
|
:return: The total of the subsection.
|
||||||
|
"""
|
||||||
|
return sum([x.amount for x in self.accounts])
|
||||||
|
|
||||||
|
|
||||||
|
class Section:
|
||||||
|
"""A section."""
|
||||||
|
|
||||||
|
def __init__(self, title: BaseAccount, accumulated_title: str):
|
||||||
|
"""Constructs a section.
|
||||||
|
|
||||||
|
:param title: The title account.
|
||||||
|
:param accumulated_title: The title for the accumulated total.
|
||||||
|
"""
|
||||||
|
self.title: BaseAccount = title
|
||||||
|
"""The title account."""
|
||||||
|
self.subsections: list[Subsection] = []
|
||||||
|
"""The subsections in the section."""
|
||||||
|
self.accumulated: AccumulatedTotal \
|
||||||
|
= AccumulatedTotal(accumulated_title)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> Decimal:
|
||||||
|
"""Returns the total of the section.
|
||||||
|
|
||||||
|
:return: The total of the section.
|
||||||
|
"""
|
||||||
|
return sum([x.total for x in self.subsections])
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, text: str | None, amount: str | Decimal | None):
|
||||||
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
|
:param text: The text.
|
||||||
|
:param amount: The amount.
|
||||||
|
"""
|
||||||
|
self.text: str | None = text
|
||||||
|
"""The text."""
|
||||||
|
self.amount: str | Decimal | None = amount
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.text, self.amount]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
period: Period,
|
||||||
|
has_data: bool,
|
||||||
|
sections: list[Section], ):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:param has_data: True if there is any data, or False otherwise.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__has_data: bool = has_data
|
||||||
|
"""True if there is any data, or False otherwise."""
|
||||||
|
self.sections: list[Section] = sections
|
||||||
|
"""The sections in the income statement."""
|
||||||
|
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||||
|
lambda x: income_statement_url(currency, x))
|
||||||
|
"""The period chooser."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
"""Returns whether there is any data on the page.
|
||||||
|
|
||||||
|
:return: True if there is any data, or False otherwise.
|
||||||
|
"""
|
||||||
|
return self.__has_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
return ReportChooser(ReportType.INCOME_STATEMENT,
|
||||||
|
currency=self.currency,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(
|
||||||
|
lambda x: income_statement_url(x, self.period), self.currency)
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeStatement(BaseReport):
|
||||||
|
"""The income statement."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, period: Period):
|
||||||
|
"""Constructs an income statement.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__has_data: bool
|
||||||
|
"""True if there is any data, or False otherwise."""
|
||||||
|
self.__sections: list[Section]
|
||||||
|
"""The sections."""
|
||||||
|
self.__set_data()
|
||||||
|
|
||||||
|
def __set_data(self) -> None:
|
||||||
|
"""Queries and sets data sections in the income statement.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
balances: list[ReportAccount] = self.__query_balances()
|
||||||
|
|
||||||
|
titles: list[BaseAccount] = BaseAccount.query\
|
||||||
|
.filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all()
|
||||||
|
subtitles: list[BaseAccount] = BaseAccount.query\
|
||||||
|
.filter(BaseAccount.code.in_({x.account.base_code[:2]
|
||||||
|
for x in balances})).all()
|
||||||
|
|
||||||
|
total_titles: dict[str, str] \
|
||||||
|
= {"4": gettext("total operating revenue"),
|
||||||
|
"5": gettext("gross income"),
|
||||||
|
"6": gettext("operating income"),
|
||||||
|
"7": gettext("before tax income"),
|
||||||
|
"8": gettext("after tax income"),
|
||||||
|
"9": gettext("net income or loss for current period")}
|
||||||
|
|
||||||
|
sections: dict[str, Section] \
|
||||||
|
= {x.code: Section(x, total_titles[x.code]) for x in titles}
|
||||||
|
subsections: dict[str, Subsection] \
|
||||||
|
= {x.code: Subsection(x) for x in subtitles}
|
||||||
|
for subsection in subsections.values():
|
||||||
|
sections[subsection.title.code[0]].subsections.append(subsection)
|
||||||
|
for balance in balances:
|
||||||
|
subsections[balance.account.base_code[:2]].accounts.append(balance)
|
||||||
|
|
||||||
|
self.__has_data = len(balances) > 0
|
||||||
|
self.__sections = sorted(sections.values(), key=lambda x: x.title.code)
|
||||||
|
total: Decimal = Decimal("0")
|
||||||
|
for section in self.__sections:
|
||||||
|
total = total + section.total
|
||||||
|
section.accumulated.amount = total
|
||||||
|
|
||||||
|
def __query_balances(self) -> list[ReportAccount]:
|
||||||
|
"""Queries and returns the balances.
|
||||||
|
|
||||||
|
:return: The balances.
|
||||||
|
"""
|
||||||
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
|
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
|
sa.or_(*sub_conditions)]
|
||||||
|
if self.__period.start is not None:
|
||||||
|
conditions.append(Transaction.date >= self.__period.start)
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(Transaction.date <= self.__period.end)
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntry.is_debit, -JournalEntry.amount),
|
||||||
|
else_=JournalEntry.amount)).label("balance")
|
||||||
|
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||||
|
.join(Transaction).join(Account)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.group_by(Account.id)\
|
||||||
|
.order_by(Account.base_code, Account.no)
|
||||||
|
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
||||||
|
accounts: dict[int, Account] \
|
||||||
|
= {x.id: x for x in Account.query
|
||||||
|
.filter(Account.id.in_([x.id for x in balances])).all()}
|
||||||
|
return [ReportAccount(account=accounts[x.id],
|
||||||
|
amount=x.balance,
|
||||||
|
url=ledger_url(self.__currency,
|
||||||
|
accounts[x.id],
|
||||||
|
self.__period))
|
||||||
|
for x in balances]
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "income-statement-{currency}-{period}.csv"\
|
||||||
|
.format(currency=self.__currency.code,
|
||||||
|
period=period_spec(self.__period))
|
||||||
|
return csv_download(filename, self.__get_csv_rows())
|
||||||
|
|
||||||
|
def __get_csv_rows(self) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows.
|
||||||
|
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
total_str: str = gettext("Total")
|
||||||
|
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
|
||||||
|
for section in self.__sections:
|
||||||
|
rows.append(CSVRow(str(section.title).title(), None))
|
||||||
|
for subsection in section.subsections:
|
||||||
|
rows.append(CSVRow(f" {str(subsection.title).title()}", None))
|
||||||
|
for account in subsection.accounts:
|
||||||
|
rows.append(CSVRow(f" {str(account.account).title()}",
|
||||||
|
account.amount))
|
||||||
|
rows.append(CSVRow(f" {total_str}", subsection.total))
|
||||||
|
rows.append(CSVRow(section.accumulated.title.title(),
|
||||||
|
section.accumulated.amount))
|
||||||
|
rows.append(CSVRow(None, None))
|
||||||
|
rows = rows[:-1]
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
params: PageParams = PageParams(currency=self.__currency,
|
||||||
|
period=self.__period,
|
||||||
|
has_data=self.__has_data,
|
||||||
|
sections=self.__sections)
|
||||||
|
return render_template("accounting/report/income-statement.html",
|
||||||
|
report=params)
|
217
src/accounting/report/reports/journal.py
Normal file
217
src/accounting/report/reports/journal.py
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The journal.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import render_template, Response
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||||
|
from accounting.report.period import Period, PeriodChooser
|
||||||
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
|
from accounting.report.utils.base_report import BaseReport
|
||||||
|
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||||
|
period_spec
|
||||||
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
|
from accounting.report.utils.report_type import ReportType
|
||||||
|
from accounting.report.utils.urls import journal_url
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class ReportEntry:
|
||||||
|
"""An entry in the report."""
|
||||||
|
|
||||||
|
def __init__(self, entry: JournalEntry):
|
||||||
|
"""Constructs the entry in the report.
|
||||||
|
|
||||||
|
:param entry: The journal entry.
|
||||||
|
"""
|
||||||
|
self.entry: JournalEntry = entry
|
||||||
|
"""The journal entry."""
|
||||||
|
self.transaction: Transaction = entry.transaction
|
||||||
|
"""The transaction."""
|
||||||
|
self.currency: Currency = entry.currency
|
||||||
|
"""The account."""
|
||||||
|
self.account: Account = entry.account
|
||||||
|
"""The account."""
|
||||||
|
self.summary: str | None = entry.summary
|
||||||
|
"""The summary."""
|
||||||
|
self.debit: Decimal | None = entry.debit
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: Decimal | None = entry.credit
|
||||||
|
"""The credit amount."""
|
||||||
|
self.amount: Decimal = entry.amount
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, txn_date: str | date,
|
||||||
|
currency: str,
|
||||||
|
account: str,
|
||||||
|
summary: str | None,
|
||||||
|
debit: str | Decimal | None,
|
||||||
|
credit: str | Decimal | None,
|
||||||
|
note: str | None):
|
||||||
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
|
:param txn_date: The transaction date.
|
||||||
|
:param summary: The summary.
|
||||||
|
:param debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
:param note: The note.
|
||||||
|
"""
|
||||||
|
self.date: str | date = txn_date
|
||||||
|
"""The date."""
|
||||||
|
self.currency: str = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.account: str = account
|
||||||
|
"""The account."""
|
||||||
|
self.summary: str | None = summary
|
||||||
|
"""The summary."""
|
||||||
|
self.debit: str | Decimal | None = debit
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: str | Decimal | None = credit
|
||||||
|
"""The credit amount."""
|
||||||
|
self.note: str | None = note
|
||||||
|
"""The note."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.date, self.currency, self.account, self.summary,
|
||||||
|
self.debit, self.credit, self.note]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, period: Period,
|
||||||
|
pagination: Pagination[JournalEntry],
|
||||||
|
entries: list[JournalEntry]):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
:param entries: The journal entries.
|
||||||
|
"""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.pagination: Pagination[JournalEntry] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.entries: list[JournalEntry] = entries
|
||||||
|
"""The entries."""
|
||||||
|
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||||
|
lambda x: journal_url(x))
|
||||||
|
"""The period chooser."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
"""Returns whether there is any data on the page.
|
||||||
|
|
||||||
|
:return: True if there is any data, or False otherwise.
|
||||||
|
"""
|
||||||
|
return len(self.entries) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
return ReportChooser(ReportType.JOURNAL,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
|
|
||||||
|
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows from the report entries.
|
||||||
|
|
||||||
|
:param entries: The report entries.
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
|
||||||
|
gettext("Account"), gettext("Summary"),
|
||||||
|
gettext("Debit"), gettext("Credit"),
|
||||||
|
gettext("Note"))]
|
||||||
|
rows.extend([CSVRow(x.transaction.date, x.currency.code,
|
||||||
|
str(x.account).title(), x.summary,
|
||||||
|
x.debit, x.credit, x.transaction.note)
|
||||||
|
for x in entries])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class Journal(BaseReport):
|
||||||
|
"""The journal."""
|
||||||
|
|
||||||
|
def __init__(self, period: Period):
|
||||||
|
"""Constructs a journal.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__entries: list[JournalEntry] = self.__query_entries()
|
||||||
|
"""The journal entries."""
|
||||||
|
|
||||||
|
def __query_entries(self) -> list[JournalEntry]:
|
||||||
|
"""Queries and returns the journal entries.
|
||||||
|
|
||||||
|
:return: The journal entries.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] = []
|
||||||
|
if self.__period.start is not None:
|
||||||
|
conditions.append(Transaction.date >= self.__period.start)
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(Transaction.date <= self.__period.end)
|
||||||
|
return JournalEntry.query.join(Transaction)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.order_by(Transaction.date,
|
||||||
|
Transaction.no,
|
||||||
|
JournalEntry.is_debit.desc(),
|
||||||
|
JournalEntry.no)\
|
||||||
|
.options(selectinload(JournalEntry.account),
|
||||||
|
selectinload(JournalEntry.currency),
|
||||||
|
selectinload(JournalEntry.transaction)).all()
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = f"journal-{period_spec(self.__period)}.csv"
|
||||||
|
return csv_download(filename, get_csv_rows(self.__entries))
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
pagination: Pagination[JournalEntry] \
|
||||||
|
= Pagination[JournalEntry](self.__entries, is_reversed=True)
|
||||||
|
params: PageParams = PageParams(period=self.__period,
|
||||||
|
pagination=pagination,
|
||||||
|
entries=pagination.list)
|
||||||
|
return render_template("accounting/report/journal.html",
|
||||||
|
report=params)
|
409
src/accounting/report/reports/ledger.py
Normal file
409
src/accounting/report/reports/ledger.py
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The ledger.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import url_for, render_template, Response
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||||
|
from accounting.report.period import Period, PeriodChooser
|
||||||
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
|
from accounting.report.utils.base_report import BaseReport
|
||||||
|
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||||
|
period_spec
|
||||||
|
from accounting.report.utils.option_link import OptionLink
|
||||||
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
|
from accounting.report.utils.report_type import ReportType
|
||||||
|
from accounting.report.utils.urls import ledger_url
|
||||||
|
from accounting.utils.cast import be
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class ReportEntry:
|
||||||
|
"""An entry in the report."""
|
||||||
|
|
||||||
|
def __init__(self, entry: JournalEntry | None = None):
|
||||||
|
"""Constructs the entry in the report.
|
||||||
|
|
||||||
|
:param entry: The journal entry.
|
||||||
|
"""
|
||||||
|
self.is_brought_forward: bool = False
|
||||||
|
"""Whether this is the brought-forward entry."""
|
||||||
|
self.is_total: bool = False
|
||||||
|
"""Whether this is the total entry."""
|
||||||
|
self.date: date | None = None
|
||||||
|
"""The date."""
|
||||||
|
self.summary: str | None = None
|
||||||
|
"""The summary."""
|
||||||
|
self.debit: Decimal | None = None
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: Decimal | None = None
|
||||||
|
"""The credit amount."""
|
||||||
|
self.balance: Decimal | None = None
|
||||||
|
"""The balance."""
|
||||||
|
self.note: str | None = None
|
||||||
|
"""The note."""
|
||||||
|
self.url: str | None = None
|
||||||
|
"""The URL to the journal entry."""
|
||||||
|
if entry is not None:
|
||||||
|
self.date = entry.transaction.date
|
||||||
|
self.summary = entry.summary
|
||||||
|
self.debit = entry.amount if entry.is_debit else None
|
||||||
|
self.credit = None if entry.is_debit else entry.amount
|
||||||
|
self.note = entry.transaction.note
|
||||||
|
self.url = url_for("accounting.transaction.detail",
|
||||||
|
txn=entry.transaction)
|
||||||
|
|
||||||
|
|
||||||
|
class EntryCollector:
|
||||||
|
"""The report entry collector."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||||
|
"""Constructs the report entry collector.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period"""
|
||||||
|
self.brought_forward: ReportEntry | None
|
||||||
|
"""The brought-forward entry."""
|
||||||
|
self.entries: list[ReportEntry]
|
||||||
|
"""The report entries."""
|
||||||
|
self.total: ReportEntry | None
|
||||||
|
"""The total entry."""
|
||||||
|
self.brought_forward = self.__get_brought_forward_entry()
|
||||||
|
self.entries = self.__query_entries()
|
||||||
|
self.total = self.__get_total_entry()
|
||||||
|
self.__populate_balance()
|
||||||
|
|
||||||
|
def __get_brought_forward_entry(self) -> ReportEntry | None:
|
||||||
|
"""Queries, composes and returns the brought-forward entry.
|
||||||
|
|
||||||
|
:return: The brought-forward entry, or None if the report starts from
|
||||||
|
the beginning.
|
||||||
|
"""
|
||||||
|
if self.__period.start is None:
|
||||||
|
return None
|
||||||
|
if self.__account.is_nominal:
|
||||||
|
return None
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
|
else_=-JournalEntry.amount))
|
||||||
|
select: sa.Select = sa.Select(balance_func).join(Transaction)\
|
||||||
|
.filter(be(JournalEntry.currency_code == self.__currency.code),
|
||||||
|
be(JournalEntry.account_id == self.__account.id),
|
||||||
|
Transaction.date < self.__period.start)
|
||||||
|
balance: int | None = db.session.scalar(select)
|
||||||
|
if balance is None:
|
||||||
|
return None
|
||||||
|
entry: ReportEntry = ReportEntry()
|
||||||
|
entry.is_brought_forward = True
|
||||||
|
entry.date = self.__period.start
|
||||||
|
entry.summary = gettext("Brought forward")
|
||||||
|
if balance > 0:
|
||||||
|
entry.debit = balance
|
||||||
|
elif balance < 0:
|
||||||
|
entry.credit = -balance
|
||||||
|
entry.balance = balance
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def __query_entries(self) -> list[ReportEntry]:
|
||||||
|
"""Queries and returns the report entries.
|
||||||
|
|
||||||
|
:return: The report entries.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
|
JournalEntry.account_id == self.__account.id]
|
||||||
|
if self.__period.start is not None:
|
||||||
|
conditions.append(Transaction.date >= self.__period.start)
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(Transaction.date <= self.__period.end)
|
||||||
|
return [ReportEntry(x) for x in JournalEntry.query.join(Transaction)
|
||||||
|
.filter(*conditions)
|
||||||
|
.order_by(Transaction.date,
|
||||||
|
Transaction.no,
|
||||||
|
JournalEntry.is_debit.desc(),
|
||||||
|
JournalEntry.no)
|
||||||
|
.options(selectinload(JournalEntry.transaction)).all()]
|
||||||
|
|
||||||
|
def __get_total_entry(self) -> ReportEntry | None:
|
||||||
|
"""Composes the total entry.
|
||||||
|
|
||||||
|
:return: The total entry, or None if there is no data.
|
||||||
|
"""
|
||||||
|
if self.brought_forward is None and len(self.entries) == 0:
|
||||||
|
return None
|
||||||
|
entry: ReportEntry = ReportEntry()
|
||||||
|
entry.is_total = True
|
||||||
|
entry.summary = gettext("Total")
|
||||||
|
entry.debit = sum([x.debit for x in self.entries
|
||||||
|
if x.debit is not None])
|
||||||
|
entry.credit = sum([x.credit for x in self.entries
|
||||||
|
if x.credit is not None])
|
||||||
|
entry.balance = entry.debit - entry.credit
|
||||||
|
if self.brought_forward is not None:
|
||||||
|
entry.balance = self.brought_forward.balance + entry.balance
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def __populate_balance(self) -> None:
|
||||||
|
"""Populates the balance of the entries.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if self.__account.is_nominal:
|
||||||
|
return None
|
||||||
|
balance: Decimal = 0 if self.brought_forward is None \
|
||||||
|
else self.brought_forward.balance
|
||||||
|
for entry in self.entries:
|
||||||
|
if entry.debit is not None:
|
||||||
|
balance = balance + entry.debit
|
||||||
|
if entry.credit is not None:
|
||||||
|
balance = balance - entry.credit
|
||||||
|
entry.balance = balance
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, txn_date: date | str | None,
|
||||||
|
summary: str | None,
|
||||||
|
debit: str | Decimal | None,
|
||||||
|
credit: str | Decimal | None,
|
||||||
|
balance: str | Decimal | None,
|
||||||
|
note: str | None):
|
||||||
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
|
:param txn_date: The transaction date.
|
||||||
|
:param summary: The summary.
|
||||||
|
:param debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
:param balance: The balance.
|
||||||
|
:param note: The note.
|
||||||
|
"""
|
||||||
|
self.date: date | str | None = txn_date
|
||||||
|
"""The date."""
|
||||||
|
self.summary: str | None = summary
|
||||||
|
"""The summary."""
|
||||||
|
self.debit: str | Decimal | None = debit
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: str | Decimal | None = credit
|
||||||
|
"""The credit amount."""
|
||||||
|
self.balance: str | Decimal | None = balance
|
||||||
|
"""The balance."""
|
||||||
|
self.note: str | None = note
|
||||||
|
"""The note."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.date, self.summary,
|
||||||
|
self.debit, self.credit, self.balance, self.note]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
account: Account,
|
||||||
|
period: Period,
|
||||||
|
has_data: bool,
|
||||||
|
pagination: Pagination[ReportEntry],
|
||||||
|
brought_forward: ReportEntry | None,
|
||||||
|
entries: list[ReportEntry],
|
||||||
|
total: ReportEntry | None):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
:param has_data: True if there is any data, or False otherwise.
|
||||||
|
:param brought_forward: The brought-forward entry.
|
||||||
|
:param entries: The report entries.
|
||||||
|
:param total: The total entry.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__has_data: bool = has_data
|
||||||
|
"""True if there is any data, or False otherwise."""
|
||||||
|
self.pagination: Pagination[ReportEntry] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.brought_forward: ReportEntry | None = brought_forward
|
||||||
|
"""The brought-forward entry."""
|
||||||
|
self.entries: list[ReportEntry] = entries
|
||||||
|
"""The entries."""
|
||||||
|
self.total: ReportEntry | None = total
|
||||||
|
"""The total entry."""
|
||||||
|
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||||
|
lambda x: ledger_url(currency, account, x))
|
||||||
|
"""The period chooser."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
"""Returns whether there is any data on the page.
|
||||||
|
|
||||||
|
:return: True if there is any data, or False otherwise.
|
||||||
|
"""
|
||||||
|
return self.__has_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
return ReportChooser(ReportType.LEDGER,
|
||||||
|
currency=self.currency,
|
||||||
|
account=self.account,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(
|
||||||
|
lambda x: ledger_url(x, self.account, self.period), self.currency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the account options.
|
||||||
|
|
||||||
|
:return: The account options.
|
||||||
|
"""
|
||||||
|
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
||||||
|
.filter(be(JournalEntry.currency_code == self.currency.code))\
|
||||||
|
.group_by(JournalEntry.account_id)
|
||||||
|
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
|
||||||
|
x.id == self.account.id)
|
||||||
|
for x in Account.query.filter(Account.id.in_(in_use))
|
||||||
|
.order_by(Account.base_code, Account.no).all()]
|
||||||
|
|
||||||
|
|
||||||
|
class Ledger(BaseReport):
|
||||||
|
"""The ledger."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||||
|
"""Constructs a ledger.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
collector: EntryCollector = EntryCollector(
|
||||||
|
self.__currency, self.__account, self.__period)
|
||||||
|
self.__brought_forward: ReportEntry | None = collector.brought_forward
|
||||||
|
"""The brought-forward entry."""
|
||||||
|
self.__entries: list[ReportEntry] = collector.entries
|
||||||
|
"""The report entries."""
|
||||||
|
self.__total: ReportEntry | None = collector.total
|
||||||
|
"""The total entry."""
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "ledger-{currency}-{account}-{period}.csv"\
|
||||||
|
.format(currency=self.__currency.code, account=self.__account.code,
|
||||||
|
period=period_spec(self.__period))
|
||||||
|
return csv_download(filename, self.__get_csv_rows())
|
||||||
|
|
||||||
|
def __get_csv_rows(self) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows.
|
||||||
|
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
|
||||||
|
gettext("Debit"), gettext("Credit"),
|
||||||
|
gettext("Balance"), gettext("Note"))]
|
||||||
|
if self.__brought_forward is not None:
|
||||||
|
rows.append(CSVRow(self.__brought_forward.date,
|
||||||
|
self.__brought_forward.summary,
|
||||||
|
self.__brought_forward.debit,
|
||||||
|
self.__brought_forward.credit,
|
||||||
|
self.__brought_forward.balance,
|
||||||
|
None))
|
||||||
|
rows.extend([CSVRow(x.date, x.summary,
|
||||||
|
x.debit, x.credit, x.balance, x.note)
|
||||||
|
for x in self.__entries])
|
||||||
|
if self.__total is not None:
|
||||||
|
rows.append(CSVRow(gettext("Total"), None,
|
||||||
|
self.__total.debit, self.__total.credit,
|
||||||
|
self.__total.balance, None))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
all_entries: list[ReportEntry] = []
|
||||||
|
if self.__brought_forward is not None:
|
||||||
|
all_entries.append(self.__brought_forward)
|
||||||
|
all_entries.extend(self.__entries)
|
||||||
|
if self.__total is not None:
|
||||||
|
all_entries.append(self.__total)
|
||||||
|
pagination: Pagination[ReportEntry] \
|
||||||
|
= Pagination[ReportEntry](all_entries, is_reversed=True)
|
||||||
|
page_entries: list[ReportEntry] = pagination.list
|
||||||
|
has_data: bool = len(page_entries) > 0
|
||||||
|
brought_forward: ReportEntry | None = None
|
||||||
|
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
|
||||||
|
brought_forward = page_entries[0]
|
||||||
|
page_entries = page_entries[1:]
|
||||||
|
total: ReportEntry | None = None
|
||||||
|
if len(page_entries) > 0 and page_entries[-1].is_total:
|
||||||
|
total = page_entries[-1]
|
||||||
|
page_entries = page_entries[:-1]
|
||||||
|
params: PageParams = PageParams(currency=self.__currency,
|
||||||
|
account=self.__account,
|
||||||
|
period=self.__period,
|
||||||
|
has_data=has_data,
|
||||||
|
pagination=pagination,
|
||||||
|
brought_forward=brought_forward,
|
||||||
|
entries=page_entries,
|
||||||
|
total=total)
|
||||||
|
return render_template("accounting/report/ledger.html",
|
||||||
|
report=params)
|
206
src/accounting/report/reports/search.py
Normal file
206
src/accounting/report/reports/search.py
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The search.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import Response, render_template, request
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
|
||||||
|
Transaction, JournalEntry
|
||||||
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
|
from accounting.report.utils.base_report import BaseReport
|
||||||
|
from accounting.report.utils.csv_export import csv_download
|
||||||
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
|
from accounting.report.utils.report_type import ReportType
|
||||||
|
from accounting.utils.cast import be
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
from accounting.utils.query import parse_query_keywords
|
||||||
|
from .journal import get_csv_rows
|
||||||
|
|
||||||
|
|
||||||
|
class EntryCollector:
|
||||||
|
"""The report entry collector."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Constructs the report entry collector."""
|
||||||
|
self.entries: list[JournalEntry] = self.__query_entries()
|
||||||
|
"""The report entries."""
|
||||||
|
|
||||||
|
def __query_entries(self) -> list[JournalEntry]:
|
||||||
|
"""Queries and returns the journal entries.
|
||||||
|
|
||||||
|
:return: The journal entries.
|
||||||
|
"""
|
||||||
|
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||||
|
if len(keywords) == 0:
|
||||||
|
return []
|
||||||
|
conditions: list[sa.BinaryExpression] = []
|
||||||
|
for k in keywords:
|
||||||
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.summary.contains(k),
|
||||||
|
JournalEntry.account_id.in_(
|
||||||
|
self.__get_account_condition(k)),
|
||||||
|
JournalEntry.currency_code.in_(
|
||||||
|
self.__get_currency_condition(k)),
|
||||||
|
JournalEntry.transaction_id.in_(
|
||||||
|
self.__get_transaction_condition(k))]
|
||||||
|
try:
|
||||||
|
sub_conditions.append(JournalEntry.amount == Decimal(k))
|
||||||
|
except ArithmeticError:
|
||||||
|
pass
|
||||||
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
|
return JournalEntry.query.join(Transaction).filter(*conditions)\
|
||||||
|
.order_by(Transaction.date,
|
||||||
|
Transaction.no,
|
||||||
|
JournalEntry.is_debit,
|
||||||
|
JournalEntry.no)\
|
||||||
|
.options(selectinload(JournalEntry.account),
|
||||||
|
selectinload(JournalEntry.currency),
|
||||||
|
selectinload(JournalEntry.transaction)).all()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_account_condition(k: str) -> sa.Select:
|
||||||
|
"""Composes and returns the condition to filter the account.
|
||||||
|
|
||||||
|
:param k: The keyword.
|
||||||
|
:return: The condition to filter the account.
|
||||||
|
"""
|
||||||
|
code: sa.BinaryExpression = Account.base_code + "-" \
|
||||||
|
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
|
||||||
|
sa.func.char_length(sa.cast(Account.no,
|
||||||
|
sa.String)) + 1)
|
||||||
|
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
|
||||||
|
.filter(AccountL10n.title.contains(k))
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [Account.base_code.contains(k),
|
||||||
|
Account.title_l10n.contains(k),
|
||||||
|
code.contains(k),
|
||||||
|
Account.id.in_(select_l10n)]
|
||||||
|
if k in gettext("Need offset"):
|
||||||
|
conditions.append(Account.is_need_offset)
|
||||||
|
return sa.select(Account.id).filter(sa.or_(*conditions))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_currency_condition(k: str) -> sa.Select:
|
||||||
|
"""Composes and returns the condition to filter the currency.
|
||||||
|
|
||||||
|
:param k: The keyword.
|
||||||
|
:return: The condition to filter the currency.
|
||||||
|
"""
|
||||||
|
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
|
||||||
|
.filter(CurrencyL10n.name.contains(k))
|
||||||
|
return sa.select(Currency.code).filter(
|
||||||
|
sa.or_(Currency.code.contains(k),
|
||||||
|
Currency.name_l10n.contains(k),
|
||||||
|
Currency.code.in_(select_l10n)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_transaction_condition(k: str) -> sa.Select:
|
||||||
|
"""Composes and returns the condition to filter the transaction.
|
||||||
|
|
||||||
|
:param k: The keyword.
|
||||||
|
:return: The condition to filter the transaction.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)]
|
||||||
|
txn_date: datetime
|
||||||
|
try:
|
||||||
|
txn_date = datetime.strptime(k, "%Y")
|
||||||
|
conditions.append(
|
||||||
|
be(sa.extract("year", Transaction.date) == txn_date.year))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
txn_date = datetime.strptime(k, "%Y/%m")
|
||||||
|
conditions.append(sa.and_(
|
||||||
|
sa.extract("year", Transaction.date) == txn_date.year,
|
||||||
|
sa.extract("month", Transaction.date) == txn_date.month))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||||
|
conditions.append(sa.and_(
|
||||||
|
sa.extract("month", Transaction.date) == txn_date.month,
|
||||||
|
sa.extract("day", Transaction.date) == txn_date.day))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return sa.select(Transaction.id).filter(sa.or_(*conditions))
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, pagination: Pagination[JournalEntry],
|
||||||
|
entries: list[JournalEntry]):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param entries: The search result entries.
|
||||||
|
"""
|
||||||
|
self.pagination: Pagination[JournalEntry] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.entries: list[JournalEntry] = entries
|
||||||
|
"""The entries."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
"""Returns whether there is any data on the page.
|
||||||
|
|
||||||
|
:return: True if there is any data, or False otherwise.
|
||||||
|
"""
|
||||||
|
return len(self.entries) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
return ReportChooser(ReportType.SEARCH)
|
||||||
|
|
||||||
|
|
||||||
|
class Search(BaseReport):
|
||||||
|
"""The search."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Constructs a search."""
|
||||||
|
self.__entries: list[JournalEntry] = EntryCollector().entries
|
||||||
|
"""The journal entries."""
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "search-{q}.csv".format(q=request.args["q"])
|
||||||
|
return csv_download(filename, get_csv_rows(self.__entries))
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
pagination: Pagination[JournalEntry] \
|
||||||
|
= Pagination[JournalEntry](self.__entries, is_reversed=True)
|
||||||
|
params: PageParams = PageParams(pagination=pagination,
|
||||||
|
entries=pagination.list)
|
||||||
|
return render_template("accounting/report/search.html",
|
||||||
|
report=params)
|
241
src/accounting/report/reports/trial_balance.py
Normal file
241
src/accounting/report/reports/trial_balance.py
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The trial balance.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import Response, render_template
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||||
|
from accounting.report.period import Period, PeriodChooser
|
||||||
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
|
from accounting.report.utils.base_report import BaseReport
|
||||||
|
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||||
|
period_spec
|
||||||
|
from accounting.report.utils.option_link import OptionLink
|
||||||
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
|
from accounting.report.utils.report_type import ReportType
|
||||||
|
from accounting.report.utils.urls import ledger_url, trial_balance_url
|
||||||
|
|
||||||
|
|
||||||
|
class ReportAccount:
|
||||||
|
"""An account in the report."""
|
||||||
|
|
||||||
|
def __init__(self, account: Account, amount: Decimal, url: str):
|
||||||
|
"""Constructs an account in the report.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:param amount: The amount.
|
||||||
|
:param url: The URL to the ledger of the account.
|
||||||
|
"""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.debit: Decimal | None = amount if amount > 0 else None
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: Decimal | None = -amount if amount < 0 else None
|
||||||
|
"""The credit amount."""
|
||||||
|
self.url: str = url
|
||||||
|
"""The URL to the ledger of the account."""
|
||||||
|
|
||||||
|
|
||||||
|
class Total:
|
||||||
|
"""The totals."""
|
||||||
|
|
||||||
|
def __init__(self, debit: Decimal, credit: Decimal):
|
||||||
|
"""Constructs the total in the trial balance.
|
||||||
|
|
||||||
|
:param debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
"""
|
||||||
|
self.debit: Decimal | None = debit
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: Decimal | None = credit
|
||||||
|
"""The credit amount."""
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, text: str | None,
|
||||||
|
debit: str | Decimal | None,
|
||||||
|
credit: str | Decimal | None):
|
||||||
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
|
:param text: The text.
|
||||||
|
:param debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
"""
|
||||||
|
self.text: str | None = text
|
||||||
|
"""The text."""
|
||||||
|
self.debit: str | Decimal | None = debit
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: str | Decimal | None = credit
|
||||||
|
"""The credit amount."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.text, self.debit, self.credit]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
period: Period,
|
||||||
|
accounts: list[ReportAccount],
|
||||||
|
total: Total):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:param accounts: The accounts in the trial balance.
|
||||||
|
:param total: The total of the trial balance.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.accounts: list[ReportAccount] = accounts
|
||||||
|
"""The accounts in the trial balance."""
|
||||||
|
self.total: Total = total
|
||||||
|
"""The total of the trial balance."""
|
||||||
|
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||||
|
lambda x: trial_balance_url(currency, x))
|
||||||
|
"""The period chooser."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
"""Returns whether there is any data on the page.
|
||||||
|
|
||||||
|
:return: True if there is any data, or False otherwise.
|
||||||
|
"""
|
||||||
|
return len(self.accounts) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
return ReportChooser(ReportType.TRIAL_BALANCE,
|
||||||
|
currency=self.currency,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(
|
||||||
|
lambda x: trial_balance_url(x, self.period), self.currency)
|
||||||
|
|
||||||
|
|
||||||
|
class TrialBalance(BaseReport):
|
||||||
|
"""The trial balance."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, period: Period):
|
||||||
|
"""Constructs a trial balance.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__accounts: list[ReportAccount]
|
||||||
|
"""The accounts in the trial balance."""
|
||||||
|
self.__total: Total
|
||||||
|
"""The total of the trial balance."""
|
||||||
|
self.__set_data()
|
||||||
|
|
||||||
|
def __set_data(self) -> None:
|
||||||
|
"""Queries and sets data sections in the trial balance.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.currency_code == self.__currency.code]
|
||||||
|
if self.__period.start is not None:
|
||||||
|
conditions.append(Transaction.date >= self.__period.start)
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(Transaction.date <= self.__period.end)
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
|
else_=-JournalEntry.amount)).label("balance")
|
||||||
|
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||||
|
.join(Transaction).join(Account)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.group_by(Account.id)\
|
||||||
|
.order_by(Account.base_code, Account.no)
|
||||||
|
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
||||||
|
accounts: dict[int, Account] \
|
||||||
|
= {x.id: x for x in Account.query
|
||||||
|
.filter(Account.id.in_([x.id for x in balances])).all()}
|
||||||
|
self.__accounts = [ReportAccount(account=accounts[x.id],
|
||||||
|
amount=x.balance,
|
||||||
|
url=ledger_url(self.__currency,
|
||||||
|
accounts[x.id],
|
||||||
|
self.__period))
|
||||||
|
for x in balances]
|
||||||
|
self.__total = Total(
|
||||||
|
sum([x.debit for x in self.__accounts if x.debit is not None]),
|
||||||
|
sum([x.credit for x in self.__accounts if x.credit is not None]))
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "trial-balance-{currency}-{period}.csv"\
|
||||||
|
.format(currency=self.__currency.code,
|
||||||
|
period=period_spec(self.__period))
|
||||||
|
return csv_download(filename, self.__get_csv_rows())
|
||||||
|
|
||||||
|
def __get_csv_rows(self) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows.
|
||||||
|
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
|
||||||
|
gettext("Credit"))]
|
||||||
|
rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit)
|
||||||
|
for x in self.__accounts])
|
||||||
|
rows.append(CSVRow(gettext("Total"), self.__total.debit,
|
||||||
|
self.__total.credit))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
params: PageParams = PageParams(currency=self.__currency,
|
||||||
|
period=self.__period,
|
||||||
|
accounts=self.__accounts,
|
||||||
|
total=self.__total)
|
||||||
|
return render_template("accounting/report/trial-balance.html",
|
||||||
|
report=params)
|
37
src/accounting/report/template_filters.py
Normal file
37
src/accounting/report/template_filters.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The template filters for the reports.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from accounting.template_filters import format_amount as core_format_amount
|
||||||
|
|
||||||
|
|
||||||
|
def format_amount(value: Decimal | None) -> str | None:
|
||||||
|
"""Formats an amount for the report.
|
||||||
|
|
||||||
|
:param value: The amount.
|
||||||
|
:return: The formatted amount text.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
is_negative: bool = value < 0
|
||||||
|
formatted: str = core_format_amount(abs(value))
|
||||||
|
if is_negative:
|
||||||
|
formatted = f"({formatted})"
|
||||||
|
return formatted
|
19
src/accounting/report/utils/__init__.py
Normal file
19
src/accounting/report/utils/__init__.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The utilities for the reports.
|
||||||
|
|
||||||
|
"""
|
88
src/accounting/report/utils/base_page_params.py
Normal file
88
src/accounting/report/utils/base_page_params.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The page parameters of a report.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
|
||||||
|
urlunparse
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import Currency, JournalEntry
|
||||||
|
from accounting.utils.txn_types import TransactionType
|
||||||
|
from .option_link import OptionLink
|
||||||
|
from .report_chooser import ReportChooser
|
||||||
|
|
||||||
|
|
||||||
|
class BasePageParams(ABC):
|
||||||
|
"""The base HTML page parameters class."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
"""Returns whether there is any data on the page.
|
||||||
|
|
||||||
|
:return: True if there is any data, or False otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def txn_types(self) -> t.Type[TransactionType]:
|
||||||
|
"""Returns the transaction types.
|
||||||
|
|
||||||
|
:return: The transaction types.
|
||||||
|
"""
|
||||||
|
return TransactionType
|
||||||
|
|
||||||
|
@property
|
||||||
|
def csv_uri(self) -> str:
|
||||||
|
uri: str = request.full_path if request.query_string \
|
||||||
|
else request.path
|
||||||
|
uri_p: ParseResult = urlparse(uri)
|
||||||
|
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
||||||
|
params = [x for x in params if x[0] != "as"]
|
||||||
|
params.append(("as", "csv"))
|
||||||
|
parts: list[str] = list(uri_p)
|
||||||
|
parts[4] = urlencode(params)
|
||||||
|
return urlunparse(parts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_currency_options(get_url: t.Callable[[Currency], str],
|
||||||
|
active_currency: Currency) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:param get_url: The callback to return the URL of a currency.
|
||||||
|
:param active_currency: The active currency.
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
in_use: set[str] = set(db.session.scalars(
|
||||||
|
sa.select(JournalEntry.currency_code)
|
||||||
|
.group_by(JournalEntry.currency_code)).all())
|
||||||
|
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
|
||||||
|
for x in Currency.query.filter(Currency.code.in_(in_use))
|
||||||
|
.order_by(Currency.code).all()]
|
40
src/accounting/report/utils/base_report.py
Normal file
40
src/accounting/report/utils/base_report.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The base report.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
|
|
||||||
|
class BaseReport(ABC):
|
||||||
|
"""The base report class."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
108
src/accounting/report/utils/csv_export.py
Normal file
108
src/accounting/report/utils/csv_export.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The utilities to export the report as CSV for download.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import timedelta, date
|
||||||
|
from decimal import Decimal
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
|
from accounting.report.period import Period
|
||||||
|
|
||||||
|
|
||||||
|
class BaseCSVRow(ABC):
|
||||||
|
"""The base CSV row."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def values(self) -> list[str | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
|
||||||
|
"""Exports the data rows as a CSV file for download.
|
||||||
|
|
||||||
|
:param filename: The download file name.
|
||||||
|
:param rows: The data rows.
|
||||||
|
:return: The response for download the CSV file.
|
||||||
|
"""
|
||||||
|
with StringIO() as fp:
|
||||||
|
writer = csv.writer(fp)
|
||||||
|
writer.writerows([x.values for x in rows])
|
||||||
|
fp.seek(0)
|
||||||
|
response: Response = Response(fp.read(), mimetype="text/csv")
|
||||||
|
response.headers["Content-Disposition"] \
|
||||||
|
= f"attachment; filename={filename}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def period_spec(period: Period) -> str:
|
||||||
|
"""Constructs the period specification to be used in the filename.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
:return: The period specification to be used in the filename.
|
||||||
|
"""
|
||||||
|
start: str | None = __get_start_str(period.start)
|
||||||
|
end: str | None = __get_end_str(period.end)
|
||||||
|
if period.start is None and period.end is None:
|
||||||
|
return "all-time"
|
||||||
|
if start == end:
|
||||||
|
return start
|
||||||
|
if period.start is None:
|
||||||
|
return f"until-{end}"
|
||||||
|
if period.end is None:
|
||||||
|
return f"since-{start}"
|
||||||
|
return f"{start}-{end}"
|
||||||
|
|
||||||
|
|
||||||
|
def __get_start_str(start: date | None) -> str | None:
|
||||||
|
"""Returns the string representation of the start date.
|
||||||
|
|
||||||
|
:param start: The start date.
|
||||||
|
:return: The string representation of the start date, or None if the start
|
||||||
|
date is None.
|
||||||
|
"""
|
||||||
|
if start is None:
|
||||||
|
return None
|
||||||
|
if start.month == 1 and start.day == 1:
|
||||||
|
return str(start.year)
|
||||||
|
if start.day == 1:
|
||||||
|
return start.strftime("%Y%m")
|
||||||
|
return start.strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
|
def __get_end_str(end: date | None) -> str | None:
|
||||||
|
"""Returns the string representation of the end date.
|
||||||
|
|
||||||
|
:param end: The end date.
|
||||||
|
:return: The string representation of the end date, or None if the end
|
||||||
|
date is None.
|
||||||
|
"""
|
||||||
|
if end is None:
|
||||||
|
return None
|
||||||
|
if end.month == 12 and end.day == 31:
|
||||||
|
return str(end.year)
|
||||||
|
if (end + timedelta(days=1)).day == 1:
|
||||||
|
return end.strftime("%Y%m")
|
||||||
|
return end.strftime("%Y%m%d")
|
86
src/accounting/report/utils/ie_account.py
Normal file
86
src/accounting/report/utils/ie_account.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The pseudo account for the income and expenses log.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Account
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeExpensesAccount:
|
||||||
|
"""The pseudo account for the income and expenses log."""
|
||||||
|
CURRENT_AL_CODE: str = "0000-000"
|
||||||
|
"""The account code for the current assets and liabilities."""
|
||||||
|
|
||||||
|
def __init__(self, account: Account | None = None):
|
||||||
|
"""Constructs the pseudo account for the income and expenses log.
|
||||||
|
|
||||||
|
:param account: The actual account.
|
||||||
|
"""
|
||||||
|
self.account: Account | None = account
|
||||||
|
self.id: int = -1 if account is None else account.id
|
||||||
|
"""The ID."""
|
||||||
|
self.code: str = "" if account is None else account.code
|
||||||
|
"""The code."""
|
||||||
|
self.title: str = "" if account is None else account.title
|
||||||
|
"""The title."""
|
||||||
|
self.str: str = "" if account is None else str(account)
|
||||||
|
"""The string representation of the account."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the account.
|
||||||
|
|
||||||
|
:return: The string representation of the account.
|
||||||
|
"""
|
||||||
|
return self.str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def current_assets_and_liabilities(cls) -> t.Self:
|
||||||
|
"""Returns the pseudo account for current assets and liabilities.
|
||||||
|
|
||||||
|
:return: The pseudo account for current assets and liabilities.
|
||||||
|
"""
|
||||||
|
account: cls = cls()
|
||||||
|
account.id = 0
|
||||||
|
account.code = cls.CURRENT_AL_CODE
|
||||||
|
account.title = gettext("current assets and liabilities")
|
||||||
|
account.str = account.title
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def default_ie_account_code() -> str:
|
||||||
|
"""Returns the default account code for the income and expenses log.
|
||||||
|
|
||||||
|
:return: The default account code for the income and expenses log.
|
||||||
|
"""
|
||||||
|
with current_app.app_context():
|
||||||
|
return current_app.config.get("DEFAULT_IE_ACCOUNT", Account.CASH_CODE)
|
||||||
|
|
||||||
|
|
||||||
|
def default_ie_account() -> IncomeExpensesAccount:
|
||||||
|
"""Returns the default account for the income and expenses log.
|
||||||
|
|
||||||
|
:return: The default account for the income and expenses log.
|
||||||
|
"""
|
||||||
|
code: str = default_ie_account_code()
|
||||||
|
if code == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||||
|
return IncomeExpensesAccount.current_assets_and_liabilities()
|
||||||
|
return IncomeExpensesAccount(Account.find_by_code(code))
|
41
src/accounting/report/utils/option_link.py
Normal file
41
src/accounting/report/utils/option_link.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The option link.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class OptionLink:
|
||||||
|
"""An option link."""
|
||||||
|
|
||||||
|
def __init__(self, title: str, url: str, is_active: bool,
|
||||||
|
fa_icon: str | None = None):
|
||||||
|
"""Constructs an option link.
|
||||||
|
|
||||||
|
:param title: The title.
|
||||||
|
:param url: The URL.
|
||||||
|
:param is_active: True if active, or False otherwise
|
||||||
|
:param fa_icon: The font-awesome icon, if any.
|
||||||
|
"""
|
||||||
|
self.title: str = title
|
||||||
|
"""The title."""
|
||||||
|
self.url: str = url
|
||||||
|
"""The URL."""
|
||||||
|
self.is_active: bool = is_active
|
||||||
|
"""True if active, or False otherwise."""
|
||||||
|
self.fa_icon: str | None = fa_icon
|
||||||
|
"""The font-awesome icon, if any."""
|
159
src/accounting/report/utils/report_chooser.py
Normal file
159
src/accounting/report/utils/report_chooser.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The report chooser.
|
||||||
|
|
||||||
|
This file is largely taken from the NanoParma ERP project, first written in
|
||||||
|
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||||
|
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import typing as t
|
||||||
|
|
||||||
|
from flask_babel import LazyString
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, Account
|
||||||
|
from accounting.report.period import Period, get_period
|
||||||
|
from accounting.template_globals import default_currency_code
|
||||||
|
from .ie_account import IncomeExpensesAccount
|
||||||
|
from .option_link import OptionLink
|
||||||
|
from .report_type import ReportType
|
||||||
|
from .urls import journal_url, ledger_url, income_expenses_url, \
|
||||||
|
trial_balance_url, income_statement_url, balance_sheet_url
|
||||||
|
|
||||||
|
|
||||||
|
class ReportChooser:
|
||||||
|
"""The report chooser."""
|
||||||
|
|
||||||
|
def __init__(self, active_report: ReportType,
|
||||||
|
period: Period | None = None,
|
||||||
|
currency: Currency | None = None,
|
||||||
|
account: Account | None = None):
|
||||||
|
"""Constructs the report chooser.
|
||||||
|
|
||||||
|
:param active_report: The active report.
|
||||||
|
:param period: The period.
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
"""
|
||||||
|
self.__active_report: ReportType = active_report
|
||||||
|
"""The currently active report."""
|
||||||
|
self.__period: Period = get_period() if period is None else period
|
||||||
|
"""The period."""
|
||||||
|
self.__currency: Currency = db.session.get(
|
||||||
|
Currency, default_currency_code()) \
|
||||||
|
if currency is None else currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__account: Account = Account.cash() if account is None \
|
||||||
|
else account
|
||||||
|
"""The currency."""
|
||||||
|
self.__reports: list[OptionLink] = []
|
||||||
|
"""The links to the reports."""
|
||||||
|
self.current_report: str | LazyString = ""
|
||||||
|
"""The title of the current report."""
|
||||||
|
self.is_search: bool = active_report == ReportType.SEARCH
|
||||||
|
"""Whether the current report is the search page."""
|
||||||
|
self.__reports.append(self.__journal)
|
||||||
|
self.__reports.append(self.__ledger)
|
||||||
|
self.__reports.append(self.__income_expenses)
|
||||||
|
self.__reports.append(self.__trial_balance)
|
||||||
|
self.__reports.append(self.__income_statement)
|
||||||
|
self.__reports.append(self.__balance_sheet)
|
||||||
|
for report in self.__reports:
|
||||||
|
if report.is_active:
|
||||||
|
self.current_report = report.title
|
||||||
|
if self.is_search:
|
||||||
|
self.current_report = gettext("Search")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __journal(self) -> OptionLink:
|
||||||
|
"""Returns the journal.
|
||||||
|
|
||||||
|
:return: The journal.
|
||||||
|
"""
|
||||||
|
return OptionLink(gettext("Journal"), journal_url(self.__period),
|
||||||
|
self.__active_report == ReportType.JOURNAL,
|
||||||
|
fa_icon="fa-solid fa-book")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __ledger(self) -> OptionLink:
|
||||||
|
"""Returns the ledger.
|
||||||
|
|
||||||
|
:return: The ledger.
|
||||||
|
"""
|
||||||
|
return OptionLink(gettext("Ledger"),
|
||||||
|
ledger_url(self.__currency, self.__account,
|
||||||
|
self.__period),
|
||||||
|
self.__active_report == ReportType.LEDGER,
|
||||||
|
fa_icon="fa-solid fa-clipboard")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __income_expenses(self) -> OptionLink:
|
||||||
|
"""Returns the income and expenses log.
|
||||||
|
|
||||||
|
:return: The income and expenses log.
|
||||||
|
"""
|
||||||
|
account: Account = self.__account
|
||||||
|
if not re.match(r"[12][12]", account.base_code):
|
||||||
|
account: Account = Account.cash()
|
||||||
|
return OptionLink(gettext("Income and Expenses Log"),
|
||||||
|
income_expenses_url(self.__currency,
|
||||||
|
IncomeExpensesAccount(account),
|
||||||
|
self.__period),
|
||||||
|
self.__active_report == ReportType.INCOME_EXPENSES,
|
||||||
|
fa_icon="fa-solid fa-money-bill-wave")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __trial_balance(self) -> OptionLink:
|
||||||
|
"""Returns the trial balance.
|
||||||
|
|
||||||
|
:return: The trial balance.
|
||||||
|
"""
|
||||||
|
return OptionLink(gettext("Trial Balance"),
|
||||||
|
trial_balance_url(self.__currency, self.__period),
|
||||||
|
self.__active_report == ReportType.TRIAL_BALANCE,
|
||||||
|
fa_icon="fa-solid fa-scale-unbalanced")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __income_statement(self) -> OptionLink:
|
||||||
|
"""Returns the income statement.
|
||||||
|
|
||||||
|
:return: The income statement.
|
||||||
|
"""
|
||||||
|
return OptionLink(gettext("Income Statement"),
|
||||||
|
income_statement_url(self.__currency, self.__period),
|
||||||
|
self.__active_report == ReportType.INCOME_STATEMENT,
|
||||||
|
fa_icon="fa-solid fa-file-invoice-dollar")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __balance_sheet(self) -> OptionLink:
|
||||||
|
"""Returns the balance sheet.
|
||||||
|
|
||||||
|
:return: The balance sheet.
|
||||||
|
"""
|
||||||
|
return OptionLink(gettext("Balance Sheet"),
|
||||||
|
balance_sheet_url(self.__currency, self.__period),
|
||||||
|
self.__active_report == ReportType.BALANCE_SHEET,
|
||||||
|
fa_icon="fa-solid fa-scale-balanced")
|
||||||
|
|
||||||
|
def __iter__(self) -> t.Iterator[OptionLink]:
|
||||||
|
"""Returns the iteration of the reports.
|
||||||
|
|
||||||
|
:return: The iteration of the reports.
|
||||||
|
"""
|
||||||
|
return iter(self.__reports)
|
38
src/accounting/report/utils/report_type.py
Normal file
38
src/accounting/report/utils/report_type.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The report types.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ReportType(Enum):
|
||||||
|
"""The report types."""
|
||||||
|
JOURNAL: str = "journal"
|
||||||
|
"""The journal."""
|
||||||
|
LEDGER: str = "ledger"
|
||||||
|
"""The ledger."""
|
||||||
|
INCOME_EXPENSES: str = "income-expenses"
|
||||||
|
"""The income and expenses log."""
|
||||||
|
TRIAL_BALANCE: str = "trial-balance"
|
||||||
|
"""The trial balance."""
|
||||||
|
INCOME_STATEMENT: str = "income-statement"
|
||||||
|
"""The income statement."""
|
||||||
|
BALANCE_SHEET: str = "balance-sheet"
|
||||||
|
"""The balance sheet."""
|
||||||
|
SEARCH: str = "search"
|
||||||
|
"""The search."""
|
117
src/accounting/report/utils/urls.py
Normal file
117
src/accounting/report/utils/urls.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The utilities to get the ledger URL.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
|
from accounting.models import Currency, Account
|
||||||
|
from accounting.report.period import Period
|
||||||
|
from accounting.template_globals import default_currency_code
|
||||||
|
from .ie_account import IncomeExpensesAccount, default_ie_account_code
|
||||||
|
|
||||||
|
|
||||||
|
def journal_url(period: Period) \
|
||||||
|
-> str:
|
||||||
|
"""Returns the URL of a journal.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
:return: The URL of the journal.
|
||||||
|
"""
|
||||||
|
if period.is_default:
|
||||||
|
return url_for("accounting.report.journal-default")
|
||||||
|
return url_for("accounting.report.journal", period=period)
|
||||||
|
|
||||||
|
|
||||||
|
def ledger_url(currency: Currency, account: Account, period: Period) \
|
||||||
|
-> str:
|
||||||
|
"""Returns the URL of a ledger.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The URL of the ledger.
|
||||||
|
"""
|
||||||
|
if period.is_default:
|
||||||
|
return url_for("accounting.report.ledger-default",
|
||||||
|
currency=currency, account=account)
|
||||||
|
return url_for("accounting.report.ledger",
|
||||||
|
currency=currency, account=account,
|
||||||
|
period=period)
|
||||||
|
|
||||||
|
|
||||||
|
def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
|
||||||
|
period: Period) -> str:
|
||||||
|
"""Returns the URL of an income and expenses log.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The URL of the income and expenses log.
|
||||||
|
"""
|
||||||
|
if currency.code == default_currency_code() \
|
||||||
|
and account.code == default_ie_account_code() \
|
||||||
|
and period.is_default:
|
||||||
|
return url_for("accounting.report.default")
|
||||||
|
if period.is_default:
|
||||||
|
return url_for("accounting.report.income-expenses-default",
|
||||||
|
currency=currency, account=account)
|
||||||
|
return url_for("accounting.report.income-expenses",
|
||||||
|
currency=currency, account=account,
|
||||||
|
period=period)
|
||||||
|
|
||||||
|
|
||||||
|
def trial_balance_url(currency: Currency, period: Period) -> str:
|
||||||
|
"""Returns the URL of a trial balance.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The URL of the trial balance.
|
||||||
|
"""
|
||||||
|
if period.is_default:
|
||||||
|
return url_for("accounting.report.trial-balance-default",
|
||||||
|
currency=currency)
|
||||||
|
return url_for("accounting.report.trial-balance",
|
||||||
|
currency=currency, period=period)
|
||||||
|
|
||||||
|
|
||||||
|
def income_statement_url(currency: Currency, period: Period) -> str:
|
||||||
|
"""Returns the URL of an income statement.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The URL of the income statement.
|
||||||
|
"""
|
||||||
|
if period.is_default:
|
||||||
|
return url_for("accounting.report.income-statement-default",
|
||||||
|
currency=currency)
|
||||||
|
return url_for("accounting.report.income-statement",
|
||||||
|
currency=currency, period=period)
|
||||||
|
|
||||||
|
|
||||||
|
def balance_sheet_url(currency: Currency, period: Period) -> str:
|
||||||
|
"""Returns the URL of a balance sheet.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The URL of the balance sheet.
|
||||||
|
"""
|
||||||
|
if period.is_default:
|
||||||
|
return url_for("accounting.report.balance-sheet-default",
|
||||||
|
currency=currency)
|
||||||
|
return url_for("accounting.report.balance-sheet",
|
||||||
|
currency=currency, period=period)
|
299
src/accounting/report/views.py
Normal file
299
src/accounting/report/views.py
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The views for the report management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, Response
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import Currency, Account
|
||||||
|
from accounting.report.period import Period, get_period
|
||||||
|
from accounting.template_globals import default_currency_code
|
||||||
|
from accounting.utils.permission import has_permission, can_view
|
||||||
|
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
|
||||||
|
IncomeStatement, BalanceSheet, Search
|
||||||
|
from .template_filters import format_amount
|
||||||
|
from .utils.ie_account import IncomeExpensesAccount, default_ie_account
|
||||||
|
|
||||||
|
bp: Blueprint = Blueprint("report", __name__)
|
||||||
|
"""The view blueprint for the reports."""
|
||||||
|
bp.add_app_template_filter(format_amount, "accounting_report_format_amount")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("", endpoint="default")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_default_report() -> str | Response:
|
||||||
|
"""Returns the income and expenses log in the default period.
|
||||||
|
|
||||||
|
:return: The income and expenses log in the default period.
|
||||||
|
"""
|
||||||
|
return __get_income_expenses(
|
||||||
|
db.session.get(Currency, default_currency_code()),
|
||||||
|
default_ie_account(),
|
||||||
|
get_period())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("journal", endpoint="journal-default")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_default_journal() -> str | Response:
|
||||||
|
"""Returns the journal in the default period.
|
||||||
|
|
||||||
|
:return: The journal in the default period.
|
||||||
|
"""
|
||||||
|
return __get_journal(get_period())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("journal/<period:period>", endpoint="journal")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_journal(period: Period) -> str | Response:
|
||||||
|
"""Returns the journal.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
:return: The journal in the period.
|
||||||
|
"""
|
||||||
|
return __get_journal(period)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_journal(period: Period) -> str | Response:
|
||||||
|
"""Returns the journal.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
:return: The journal in the period.
|
||||||
|
"""
|
||||||
|
report: Journal = Journal(period)
|
||||||
|
if "as" in request.args and request.args["as"] == "csv":
|
||||||
|
return report.csv()
|
||||||
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("ledger/<currency:currency>/<account:account>",
|
||||||
|
endpoint="ledger-default")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_default_ledger(currency: Currency, account: Account) -> str | Response:
|
||||||
|
"""Returns the ledger in the default period.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:return: The ledger in the default period.
|
||||||
|
"""
|
||||||
|
return __get_ledger(currency, account, get_period())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
|
||||||
|
endpoint="ledger")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_ledger(currency: Currency, account: Account, period: Period) \
|
||||||
|
-> str | Response:
|
||||||
|
"""Returns the ledger.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The ledger in the period.
|
||||||
|
"""
|
||||||
|
return __get_ledger(currency, account, period)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_ledger(currency: Currency, account: Account, period: Period) \
|
||||||
|
-> str | Response:
|
||||||
|
"""Returns the ledger.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The ledger in the period.
|
||||||
|
"""
|
||||||
|
report: Ledger = Ledger(currency, account, period)
|
||||||
|
if "as" in request.args and request.args["as"] == "csv":
|
||||||
|
return report.csv()
|
||||||
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
|
||||||
|
endpoint="income-expenses-default")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_default_income_expenses(currency: Currency,
|
||||||
|
account: IncomeExpensesAccount) \
|
||||||
|
-> str | Response:
|
||||||
|
"""Returns the income and expenses log in the default period.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:return: The income and expenses log in the default period.
|
||||||
|
"""
|
||||||
|
return __get_income_expenses(currency, account, get_period())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get(
|
||||||
|
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
|
||||||
|
endpoint="income-expenses")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
|
||||||
|
period: Period) -> str | Response:
|
||||||
|
"""Returns the income and expenses log.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The income and expenses log in the period.
|
||||||
|
"""
|
||||||
|
return __get_income_expenses(currency, account, period)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
|
||||||
|
period: Period) -> str | Response:
|
||||||
|
"""Returns the income and expenses log.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The income and expenses log in the period.
|
||||||
|
"""
|
||||||
|
report: IncomeExpenses = IncomeExpenses(currency, account, period)
|
||||||
|
if "as" in request.args and request.args["as"] == "csv":
|
||||||
|
return report.csv()
|
||||||
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("trial-balance/<currency:currency>",
|
||||||
|
endpoint="trial-balance-default")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_default_trial_balance(currency: Currency) -> str | Response:
|
||||||
|
"""Returns the trial balance in the default period.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The trial balance in the default period.
|
||||||
|
"""
|
||||||
|
return __get_trial_balance(currency, get_period())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("trial-balance/<currency:currency>/<period:period>",
|
||||||
|
endpoint="trial-balance")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_trial_balance(currency: Currency, period: Period) -> str | Response:
|
||||||
|
"""Returns the trial balance.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The trial balance in the period.
|
||||||
|
"""
|
||||||
|
return __get_trial_balance(currency, period)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
|
||||||
|
"""Returns the trial balance.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The trial balance in the period.
|
||||||
|
"""
|
||||||
|
report: TrialBalance = TrialBalance(currency, period)
|
||||||
|
if "as" in request.args and request.args["as"] == "csv":
|
||||||
|
return report.csv()
|
||||||
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("income-statement/<currency:currency>",
|
||||||
|
endpoint="income-statement-default")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_default_income_statement(currency: Currency) -> str | Response:
|
||||||
|
"""Returns the income statement in the default period.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The income statement in the default period.
|
||||||
|
"""
|
||||||
|
return __get_income_statement(currency, get_period())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("income-statement/<currency:currency>/<period:period>",
|
||||||
|
endpoint="income-statement")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_income_statement(currency: Currency, period: Period) -> str | Response:
|
||||||
|
"""Returns the income statement.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The income statement in the period.
|
||||||
|
"""
|
||||||
|
return __get_income_statement(currency, period)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_income_statement(currency: Currency, period: Period) \
|
||||||
|
-> str | Response:
|
||||||
|
"""Returns the income statement.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The income statement in the period.
|
||||||
|
"""
|
||||||
|
report: IncomeStatement = IncomeStatement(currency, period)
|
||||||
|
if "as" in request.args and request.args["as"] == "csv":
|
||||||
|
return report.csv()
|
||||||
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("balance-sheet/<currency:currency>",
|
||||||
|
endpoint="balance-sheet-default")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_default_balance_sheet(currency: Currency) -> str | Response:
|
||||||
|
"""Returns the balance sheet in the default period.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The balance sheet in the default period.
|
||||||
|
"""
|
||||||
|
return __get_balance_sheet(currency, get_period())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("balance-sheet/<currency:currency>/<period:period>",
|
||||||
|
endpoint="balance-sheet")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_balance_sheet(currency: Currency, period: Period) \
|
||||||
|
-> str | Response:
|
||||||
|
"""Returns the balance sheet.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The balance sheet in the period.
|
||||||
|
"""
|
||||||
|
return __get_balance_sheet(currency, period)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_balance_sheet(currency: Currency, period: Period) \
|
||||||
|
-> str | Response:
|
||||||
|
"""Returns the balance sheet.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:return: The balance sheet in the period.
|
||||||
|
"""
|
||||||
|
report: BalanceSheet = BalanceSheet(currency, period)
|
||||||
|
if "as" in request.args and request.args["as"] == "csv":
|
||||||
|
return report.csv()
|
||||||
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("search", endpoint="search")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def search() -> str | Response:
|
||||||
|
"""Returns the search result.
|
||||||
|
|
||||||
|
:return: The search result.
|
||||||
|
"""
|
||||||
|
report: Search = Search()
|
||||||
|
if "as" in request.args and request.args["as"] == "csv":
|
||||||
|
return report.csv()
|
||||||
|
return report.html()
|
@ -24,16 +24,6 @@
|
|||||||
.accounting-clickable {
|
.accounting-clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-group .btn .accounting-search-input {
|
|
||||||
min-height: calc(1em + .5rem + 2px);
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
.btn-group .btn .accounting-search-label button {
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
color: inherit;
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
.form-floating > textarea.form-control {
|
.form-floating > textarea.form-control {
|
||||||
height: 6rem;
|
height: 6rem;
|
||||||
}
|
}
|
||||||
@ -41,6 +31,64 @@
|
|||||||
color: #141619;
|
color: #141619;
|
||||||
background-color: #D3D3D4;
|
background-color: #D3D3D4;
|
||||||
}
|
}
|
||||||
|
.form-control.accounting-disabled {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The toolbar */
|
||||||
|
.accounting-toolbar {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.accounting-toolbar .input-group > .input-group-text {
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.accounting-toolbar .input-group > .input-group-text > button {
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.accounting-toolbar form.btn > .form-control {
|
||||||
|
min-height: calc(1.5em + 2px);
|
||||||
|
padding-top: 0.1rem;
|
||||||
|
padding-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
.accounting-toolbar > .btn, .accounting-toolbar > .btn-group > .btn {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.accounting-toolbar > .btn:first-child, .accounting-toolbar > .btn-group:first-child > .btn {
|
||||||
|
border-top-left-radius: 0.375rem;
|
||||||
|
border-bottom-left-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
.accounting-toolbar > .btn:last-child, .accounting-toolbar > .btn-group:last-child > .btn {
|
||||||
|
border-top-right-radius: 0.375rem;
|
||||||
|
border-bottom-right-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
.accounting-toolbar .btn.input-group {
|
||||||
|
width: 16rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media(max-width:767px) {
|
||||||
|
.accounting-toolbar > .btn:not(form), .accounting-toolbar > .btn-group > .btn {
|
||||||
|
height: 3.2rem;
|
||||||
|
width: 3.2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
|
||||||
|
padding-top: 0.7rem;
|
||||||
|
}
|
||||||
|
.accounting-toolbar > form.btn {
|
||||||
|
width: 12rem;
|
||||||
|
height: 2.6rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The card layout */
|
/** The card layout */
|
||||||
.accounting-card {
|
.accounting-card {
|
||||||
@ -58,6 +106,42 @@
|
|||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
color: #373b3e;
|
color: #373b3e;
|
||||||
}
|
}
|
||||||
|
.accounting-sheet {
|
||||||
|
padding: 2em 1.5em;
|
||||||
|
margin: 1em;
|
||||||
|
background-color: #F8F9FA;
|
||||||
|
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||||
|
}
|
||||||
|
.accounting-sheet h2 {
|
||||||
|
border-bottom: thick double slategray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links between objects */
|
||||||
|
.accounting-original-entry {
|
||||||
|
border-top: thin solid darkslategray;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
.accounting-original-entry a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.accounting-original-entry a:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.accounting-offset-entries {
|
||||||
|
border-top: thin solid darkslategray;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
.accounting-offset-entries ul li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.accounting-offset-entries ul li a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.accounting-offset-entries ul li a:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
/** The option selector */
|
/** The option selector */
|
||||||
.accounting-selector-list {
|
.accounting-selector-list {
|
||||||
@ -79,21 +163,9 @@
|
|||||||
.accounting-entry-control {
|
.accounting-entry-control {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
.accounting-transaction-card {
|
|
||||||
padding: 2em 1.5em;
|
|
||||||
margin: 1em;
|
|
||||||
background-color: #F8F9FA;
|
|
||||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
|
||||||
}
|
|
||||||
.accounting-transaction-card h2 {
|
|
||||||
border-bottom: thick double slategray;
|
|
||||||
}
|
|
||||||
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
|
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
|
||||||
background-color: #f2f2f2;
|
background-color: #f2f2f2;
|
||||||
}
|
}
|
||||||
.accounting-list-group-stripped .list-group-item-success:nth-child(2n+1) {
|
|
||||||
background-color: #c7dbd2;
|
|
||||||
}
|
|
||||||
.accounting-list-group-hover .list-group-item:hover {
|
.accounting-list-group-hover .list-group-item:hover {
|
||||||
background-color: #ececec;
|
background-color: #ececec;
|
||||||
}
|
}
|
||||||
@ -108,6 +180,143 @@
|
|||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
border-top: thick double slategray;
|
border-top: thick double slategray;
|
||||||
}
|
}
|
||||||
|
.accounting-entry-editor-original-entry-content {
|
||||||
|
width: calc(100% - 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The report table */
|
||||||
|
.accounting-report-table-header, .accounting-report-table-footer {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
.accounting-report-table-header {
|
||||||
|
border-bottom: thin solid slategray;
|
||||||
|
}
|
||||||
|
.accounting-report-table-footer {
|
||||||
|
font-style: italic;
|
||||||
|
border-top: thin solid slategray;
|
||||||
|
}
|
||||||
|
.accounting-report-table-row {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
a.accounting-report-table-row {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.accounting-report-table-row > div {
|
||||||
|
padding: .5rem;
|
||||||
|
}
|
||||||
|
.accounting-report-table .accounting-amount {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.accounting-report-table-body .accounting-amount {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
.accounting-report-table-body .accounting-report-table-row:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.075);
|
||||||
|
}
|
||||||
|
.accounting-journal-table .accounting-report-table-row {
|
||||||
|
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
.accounting-ledger-real-table .accounting-report-table-row {
|
||||||
|
grid-template-columns: 1fr 4fr 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
.accounting-ledger-real-table .accounting-report-table-footer .accounting-report-table-row {
|
||||||
|
grid-template-columns: 5fr 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
.accounting-ledger-nominal-table .accounting-report-table-row {
|
||||||
|
grid-template-columns: 1fr 4fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
.accounting-ledger-nominal-table .accounting-report-table-footer .accounting-report-table-row {
|
||||||
|
grid-template-columns: 5fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
.accounting-income-expenses-table .accounting-report-table-row {
|
||||||
|
grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
.accounting-income-expenses-table .accounting-report-table-footer .accounting-report-table-row {
|
||||||
|
grid-template-columns: 7fr 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
.accounting-trial-balance-table .accounting-report-table-header {
|
||||||
|
border-bottom: thick double slategray;
|
||||||
|
}
|
||||||
|
.accounting-trial-balance-table .accounting-report-table-footer {
|
||||||
|
border-top: thick double slategray;
|
||||||
|
}
|
||||||
|
.accounting-trial-balance-table .accounting-report-table-row {
|
||||||
|
grid-template-columns: 3fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
.accounting-income-statement-table .accounting-report-table-body {
|
||||||
|
border-top: thick double slategray;
|
||||||
|
border-bottom: thick double slategray;
|
||||||
|
}
|
||||||
|
.accounting-income-statement-table .accounting-report-table-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.accounting-income-statement-table .accounting-report-table-header .accounting-report-table-row {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.accounting-income-statement-section, .accounting-income-statement-total {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
.accounting-income-statement-subsection, .accounting-income-statement-subtotal {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.accounting-income-statement-subtotal {
|
||||||
|
border-top: thin solid darkslategray;
|
||||||
|
}
|
||||||
|
/* Indents */
|
||||||
|
.accounting-income-statement-subsection {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.accounting-income-statement-account, .accounting-income-statement-subtotal {
|
||||||
|
margin-left: 1rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
/* A visual blank line between categories */
|
||||||
|
.accounting-income-statement-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.accounting-income-statement-section:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.accounting-income-statement-total {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.accounting-income-statement-total:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.accounting-balance-sheet-section, .accounting-balance-sheet-total {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
.accounting-balance-sheet-section {
|
||||||
|
border-bottom: thick double darkslategray;
|
||||||
|
}
|
||||||
|
.accounting-balance-sheet-total {
|
||||||
|
border-top: thick double darkslategray;
|
||||||
|
}
|
||||||
|
.accounting-balance-sheet-subtotal {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bolder;
|
||||||
|
border-top: thick double darkslategray;
|
||||||
|
}
|
||||||
|
.accounting-balance-sheet-account {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The accounting report */
|
||||||
|
.accounting-mobile-journal-credit {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* The Material Design text field (floating form control in Bootstrap) */
|
/* The Material Design text field (floating form control in Bootstrap) */
|
||||||
.accounting-material-text-field {
|
.accounting-material-text-field {
|
||||||
|
@ -20,164 +20,339 @@
|
|||||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
* First written: 2023/2/1
|
* First written: 2023/2/1
|
||||||
*/
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
// Initializes the page JavaScript.
|
// Initializes the page JavaScript.
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
initializeBaseAccountSelector();
|
AccountForm.initialize();
|
||||||
document.getElementById("accounting-base-code")
|
|
||||||
.onchange = validateBase;
|
|
||||||
document.getElementById("accounting-title")
|
|
||||||
.onchange = validateTitle;
|
|
||||||
document.getElementById("accounting-form")
|
|
||||||
.onsubmit = validateForm;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the base account selector.
|
* The account form.
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
function initializeBaseAccountSelector() {
|
class AccountForm {
|
||||||
const selector = document.getElementById("accounting-base-selector-modal");
|
|
||||||
const base = document.getElementById("accounting-base");
|
/**
|
||||||
const baseCode = document.getElementById("accounting-base-code");
|
* The base account selector
|
||||||
const baseContent = document.getElementById("accounting-base-content");
|
* @type {BaseAccountSelector}
|
||||||
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
*/
|
||||||
const btnClear = document.getElementById("accounting-btn-clear-base");
|
#baseAccountSelector;
|
||||||
selector.addEventListener("show.bs.modal", function () {
|
|
||||||
base.classList.add("accounting-not-empty");
|
/**
|
||||||
options.forEach(function (item) {
|
* The form element
|
||||||
item.classList.remove("active");
|
* @type {HTMLFormElement}
|
||||||
});
|
*/
|
||||||
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
|
#formElement;
|
||||||
if (selected !== null) {
|
|
||||||
selected.classList.add("active");
|
/**
|
||||||
}
|
* The control of the base account
|
||||||
});
|
* @type {HTMLDivElement}
|
||||||
selector.addEventListener("hidden.bs.modal", function () {
|
*/
|
||||||
if (baseCode.value === "") {
|
#baseControl;
|
||||||
base.classList.remove("accounting-not-empty");
|
|
||||||
}
|
/**
|
||||||
});
|
* The input of the base account
|
||||||
options.forEach(function (option) {
|
* @type {HTMLInputElement}
|
||||||
option.onclick = function () {
|
*/
|
||||||
baseCode.value = option.dataset.code;
|
#baseCode;
|
||||||
baseContent.innerText = option.dataset.content;
|
|
||||||
btnClear.classList.add("btn-danger");
|
/**
|
||||||
btnClear.classList.remove("btn-secondary")
|
* The base account
|
||||||
btnClear.disabled = false;
|
* @type {HTMLDivElement}
|
||||||
validateBase();
|
*/
|
||||||
bootstrap.Modal.getInstance(selector).hide();
|
#base;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message for the base account
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#baseError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#title;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message of the title
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#titleError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The control of the is-need-offset option
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#isNeedOffsetControl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The is-need-offset option
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#isNeedOffset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the account form.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
this.#baseAccountSelector = new BaseAccountSelector(this);
|
||||||
|
this.#formElement = document.getElementById("accounting-form");
|
||||||
|
this.#baseControl = document.getElementById("accounting-base-control");
|
||||||
|
this.#baseCode = document.getElementById("accounting-base-code");
|
||||||
|
this.#base = document.getElementById("accounting-base");
|
||||||
|
this.#baseError = document.getElementById("accounting-base-error");
|
||||||
|
this.#title = document.getElementById("accounting-title");
|
||||||
|
this.#titleError = document.getElementById("accounting-title-error");
|
||||||
|
this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
|
||||||
|
this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
|
||||||
|
this.#formElement.onsubmit = () => {
|
||||||
|
return this.#validateForm();
|
||||||
|
};
|
||||||
|
this.#baseControl.onclick = () => {
|
||||||
|
this.#baseControl.classList.add("accounting-not-empty");
|
||||||
|
this.#baseAccountSelector.onOpen(this.#baseCode.value);
|
||||||
};
|
};
|
||||||
});
|
|
||||||
btnClear.onclick = function () {
|
|
||||||
baseCode.value = "";
|
|
||||||
baseContent.innerText = "";
|
|
||||||
btnClear.classList.add("btn-secondary")
|
|
||||||
btnClear.classList.remove("btn-danger");
|
|
||||||
btnClear.disabled = true;
|
|
||||||
validateBase();
|
|
||||||
bootstrap.Modal.getInstance(selector).hide();
|
|
||||||
}
|
}
|
||||||
initializeBaseAccountQuery();
|
|
||||||
|
/**
|
||||||
|
* The callback when the base account selector is closed.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
onBaseAccountSelectorClosed() {
|
||||||
|
if (this.#baseCode.value === "") {
|
||||||
|
this.#baseControl.classList.remove("accounting-not-empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the base account.
|
||||||
|
*
|
||||||
|
* @param code {string} the base account code
|
||||||
|
* @param text {string} the text for the base account
|
||||||
|
*/
|
||||||
|
setBaseAccount(code, text) {
|
||||||
|
this.#baseCode.value = code;
|
||||||
|
this.#base.innerText = text;
|
||||||
|
if (["1", "2", "3"].includes(code.substring(0, 1))) {
|
||||||
|
this.#isNeedOffsetControl.classList.remove("d-none");
|
||||||
|
this.#isNeedOffset.disabled = false;
|
||||||
|
} else {
|
||||||
|
this.#isNeedOffsetControl.classList.add("d-none");
|
||||||
|
this.#isNeedOffset.disabled = true;
|
||||||
|
this.#isNeedOffset.checked = false;
|
||||||
|
}
|
||||||
|
this.#validateBase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the base account.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
clearBaseAccount() {
|
||||||
|
this.#baseCode.value = "";
|
||||||
|
this.#base.innerText = "";
|
||||||
|
this.#validateBase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the form.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateForm() {
|
||||||
|
let isValid = true;
|
||||||
|
isValid = this.#validateBase() && isValid;
|
||||||
|
isValid = this.#validateTitle() && isValid;
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the base account.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateBase() {
|
||||||
|
if (this.#baseCode.value === "") {
|
||||||
|
this.#baseControl.classList.add("is-invalid");
|
||||||
|
this.#baseError.innerText = A_("Please select the base account.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#baseControl.classList.remove("is-invalid");
|
||||||
|
this.#baseError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the title.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateTitle() {
|
||||||
|
this.#title.value = this.#title.value.trim();
|
||||||
|
if (this.#title.value === "") {
|
||||||
|
this.#title.classList.add("is-invalid");
|
||||||
|
this.#titleError.innerText = A_("Please fill in the title.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#title.classList.remove("is-invalid");
|
||||||
|
this.#titleError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account form
|
||||||
|
* @type {AccountForm} the form
|
||||||
|
*/
|
||||||
|
static #form;
|
||||||
|
|
||||||
|
static initialize() {
|
||||||
|
this.#form = new AccountForm();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the query on the base account options.
|
* The base account selector.
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
function initializeBaseAccountQuery() {
|
class BaseAccountSelector {
|
||||||
const query = document.getElementById("accounting-base-selector-query");
|
|
||||||
const optionList = document.getElementById("accounting-base-option-list");
|
/**
|
||||||
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
* The account form
|
||||||
const queryNoResult = document.getElementById("accounting-base-option-no-result");
|
* @type {AccountForm}
|
||||||
query.addEventListener("input", function () {
|
*/
|
||||||
if (query.value === "") {
|
#form;
|
||||||
options.forEach(function (option) {
|
|
||||||
option.classList.remove("d-none");
|
/**
|
||||||
});
|
* The selector modal
|
||||||
optionList.classList.remove("d-none");
|
* @type {HTMLDivElement}
|
||||||
queryNoResult.classList.add("d-none");
|
*/
|
||||||
return
|
#modal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query input
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message when the query has no result
|
||||||
|
* @type {HTMLParagraphElement}
|
||||||
|
*/
|
||||||
|
#queryNoResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The option list
|
||||||
|
* @type {HTMLUListElement}
|
||||||
|
*/
|
||||||
|
#optionList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options
|
||||||
|
* @type {HTMLLIElement[]}
|
||||||
|
*/
|
||||||
|
#options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The button to clear the base account value
|
||||||
|
* @type {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#clearButton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the base account selector.
|
||||||
|
*
|
||||||
|
* @param form {AccountForm} the form
|
||||||
|
*/
|
||||||
|
constructor(form) {
|
||||||
|
this.#form = form;
|
||||||
|
this.#modal = document.getElementById("accounting-base-selector-modal");
|
||||||
|
this.#query = document.getElementById("accounting-base-selector-query");
|
||||||
|
this.#optionList = document.getElementById("accounting-base-selector-option-list");
|
||||||
|
// noinspection JSValidateTypes
|
||||||
|
this.#options = Array.from(document.getElementsByClassName("accounting-base-selector-option"));
|
||||||
|
this.#clearButton = document.getElementById("accounting-base-selector-clear");
|
||||||
|
this.#queryNoResult = document.getElementById("accounting-base-selector-option-no-result");
|
||||||
|
this.#modal.addEventListener("hidden.bs.modal", () => {
|
||||||
|
this.#form.onBaseAccountSelectorClosed();
|
||||||
|
});
|
||||||
|
for (const option of this.#options) {
|
||||||
|
option.onclick = () => {
|
||||||
|
this.#form.setBaseAccount(option.dataset.code, option.dataset.content);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
let hasAnyMatched = false;
|
this.#clearButton.onclick = () => {
|
||||||
options.forEach(function (option) {
|
this.#form.clearBaseAccount();
|
||||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
};
|
||||||
let isMatched = false;
|
this.#initializeBaseAccountQuery();
|
||||||
for (const queryValue of queryValues) {
|
}
|
||||||
if (queryValue.includes(query.value)) {
|
|
||||||
isMatched = true;
|
/**
|
||||||
break;
|
* Initializes the query.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#initializeBaseAccountQuery() {
|
||||||
|
this.#query.addEventListener("input", () => {
|
||||||
|
if (this.#query.value === "") {
|
||||||
|
for (const option of this.#options) {
|
||||||
|
option.classList.remove("d-none");
|
||||||
|
}
|
||||||
|
this.#optionList.classList.remove("d-none");
|
||||||
|
this.#queryNoResult.classList.add("d-none");
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let hasAnyMatched = false;
|
||||||
|
for (const option of this.#options) {
|
||||||
|
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||||
|
let isMatched = false;
|
||||||
|
for (const queryValue of queryValues) {
|
||||||
|
if (queryValue.includes(this.#query.value)) {
|
||||||
|
isMatched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isMatched) {
|
||||||
|
option.classList.remove("d-none");
|
||||||
|
hasAnyMatched = true;
|
||||||
|
} else {
|
||||||
|
option.classList.add("d-none");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isMatched) {
|
if (!hasAnyMatched) {
|
||||||
option.classList.remove("d-none");
|
this.#optionList.classList.add("d-none");
|
||||||
hasAnyMatched = true;
|
this.#queryNoResult.classList.remove("d-none");
|
||||||
} else {
|
} else {
|
||||||
option.classList.add("d-none");
|
this.#optionList.classList.remove("d-none");
|
||||||
|
this.#queryNoResult.classList.add("d-none");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!hasAnyMatched) {
|
}
|
||||||
optionList.classList.add("d-none");
|
|
||||||
queryNoResult.classList.remove("d-none");
|
/**
|
||||||
} else {
|
* The callback when the base account selector is shown.
|
||||||
optionList.classList.remove("d-none");
|
*
|
||||||
queryNoResult.classList.add("d-none");
|
* @param baseCode {string} the active base code
|
||||||
|
*/
|
||||||
|
onOpen(baseCode) {
|
||||||
|
for (const option of this.#options) {
|
||||||
|
if (option.dataset.code === baseCode) {
|
||||||
|
option.classList.add("active");
|
||||||
|
} else {
|
||||||
|
option.classList.remove("active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (baseCode === "") {
|
||||||
|
this.#clearButton.classList.add("btn-secondary")
|
||||||
|
this.#clearButton.classList.remove("btn-danger");
|
||||||
|
this.#clearButton.disabled = true;
|
||||||
|
} else {
|
||||||
|
this.#clearButton.classList.add("btn-danger");
|
||||||
|
this.#clearButton.classList.remove("btn-secondary")
|
||||||
|
this.#clearButton.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the form.
|
|
||||||
*
|
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateForm() {
|
|
||||||
let isValid = true;
|
|
||||||
isValid = validateBase() && isValid;
|
|
||||||
isValid = validateTitle() && isValid;
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the base account.
|
|
||||||
*
|
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateBase() {
|
|
||||||
const field = document.getElementById("accounting-base-code");
|
|
||||||
const error = document.getElementById("accounting-base-code-error");
|
|
||||||
const displayField = document.getElementById("accounting-base");
|
|
||||||
field.value = field.value.trim();
|
|
||||||
if (field.value === "") {
|
|
||||||
displayField.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please select the base account.");
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
displayField.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the title.
|
|
||||||
*
|
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateTitle() {
|
|
||||||
const field = document.getElementById("accounting-title");
|
|
||||||
const error = document.getElementById("accounting-title-error");
|
|
||||||
field.value = field.value.trim();
|
|
||||||
if (field.value === "") {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please fill in the title.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
@ -20,12 +20,13 @@
|
|||||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
* First written: 2023/2/2
|
* First written: 2023/2/2
|
||||||
*/
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
// Initializes the page JavaScript.
|
// Initializes the page JavaScript.
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const list = document.getElementById("accounting-order-list");
|
const list = document.getElementById("accounting-order-list");
|
||||||
if (list !== null) {
|
if (list !== null) {
|
||||||
const onReorder = function () {
|
const onReorder = () => {
|
||||||
const accounts = Array.from(list.children);
|
const accounts = Array.from(list.children);
|
||||||
for (let i = 0; i < accounts.length; i++) {
|
for (let i = 0; i < accounts.length; i++) {
|
||||||
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
||||||
|
224
src/accounting/static/js/account-selector.js
Normal file
224
src/accounting/static/js/account-selector.js
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/2/28
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account selector.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class AccountSelector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journal entry editor
|
||||||
|
* @type {JournalEntryEditor}
|
||||||
|
*/
|
||||||
|
#entryEditor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry type
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
entryType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix of the HTML ID and class
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#prefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The button to clear the account
|
||||||
|
* @type {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#clearButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query input
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message when the query has no result
|
||||||
|
* @type {HTMLParagraphElement}
|
||||||
|
*/
|
||||||
|
#queryNoResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The option list
|
||||||
|
* @type {HTMLUListElement}
|
||||||
|
*/
|
||||||
|
#optionList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options
|
||||||
|
* @type {HTMLLIElement[]}
|
||||||
|
*/
|
||||||
|
#options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The more item to show all accounts
|
||||||
|
* @type {HTMLLIElement}
|
||||||
|
*/
|
||||||
|
#more;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an account selector.
|
||||||
|
*
|
||||||
|
* @param entryEditor {JournalEntryEditor} the journal entry editor
|
||||||
|
* @param entryType {string} the entry type, either "debit" or "credit"
|
||||||
|
*/
|
||||||
|
constructor(entryEditor, entryType) {
|
||||||
|
this.#entryEditor = entryEditor
|
||||||
|
this.entryType = entryType;
|
||||||
|
this.#prefix = "accounting-account-selector-" + entryType;
|
||||||
|
this.#query = document.getElementById(this.#prefix + "-query");
|
||||||
|
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
||||||
|
this.#optionList = document.getElementById(this.#prefix + "-option-list");
|
||||||
|
// noinspection JSValidateTypes
|
||||||
|
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||||
|
this.#more = document.getElementById(this.#prefix + "-more");
|
||||||
|
this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
|
||||||
|
this.#more.onclick = () => {
|
||||||
|
this.#more.classList.add("d-none");
|
||||||
|
this.#filterOptions();
|
||||||
|
};
|
||||||
|
this.#clearButton.onclick = () => this.#entryEditor.clearAccount();
|
||||||
|
for (const option of this.#options) {
|
||||||
|
option.onclick = () => this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
|
||||||
|
}
|
||||||
|
this.#query.addEventListener("input", () => {
|
||||||
|
this.#filterOptions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the options.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#filterOptions() {
|
||||||
|
const codesInUse = this.#getCodesUsedInForm();
|
||||||
|
let shouldAnyShow = false;
|
||||||
|
for (const option of this.#options) {
|
||||||
|
const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
|
||||||
|
if (shouldShow) {
|
||||||
|
option.classList.remove("d-none");
|
||||||
|
shouldAnyShow = true;
|
||||||
|
} else {
|
||||||
|
option.classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
|
||||||
|
this.#optionList.classList.add("d-none");
|
||||||
|
this.#queryNoResult.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
this.#optionList.classList.remove("d-none");
|
||||||
|
this.#queryNoResult.classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the account codes that are used in the form.
|
||||||
|
*
|
||||||
|
* @return {string[]} the account codes that are used in the form
|
||||||
|
*/
|
||||||
|
#getCodesUsedInForm() {
|
||||||
|
const inUse = this.#entryEditor.form.getAccountCodesUsed(this.entryType);
|
||||||
|
if (this.#entryEditor.accountCode !== null) {
|
||||||
|
inUse.push(this.#entryEditor.accountCode);
|
||||||
|
}
|
||||||
|
return inUse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether an option should show.
|
||||||
|
*
|
||||||
|
* @param option {HTMLLIElement} the option
|
||||||
|
* @param more {HTMLLIElement} the more element
|
||||||
|
* @param inUse {string[]} the account codes that are used in the form
|
||||||
|
* @param query {HTMLInputElement} the query element, if any
|
||||||
|
* @return {boolean} true if the option should show, or false otherwise
|
||||||
|
*/
|
||||||
|
#shouldOptionShow(option, more, inUse, query) {
|
||||||
|
const isQueryMatched = () => {
|
||||||
|
if (query.value === "") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||||
|
for (const queryValue of queryValues) {
|
||||||
|
if (queryValue.includes(query.value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const isMoreMatched = () => {
|
||||||
|
if (more.classList.contains("d-none")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
|
||||||
|
};
|
||||||
|
return isMoreMatched() && isQueryMatched();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback when the account selector is shown.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
onOpen() {
|
||||||
|
this.#query.value = "";
|
||||||
|
this.#more.classList.remove("d-none");
|
||||||
|
this.#filterOptions();
|
||||||
|
for (const option of this.#options) {
|
||||||
|
if (option.dataset.code === this.#entryEditor.accountCode) {
|
||||||
|
option.classList.add("active");
|
||||||
|
} else {
|
||||||
|
option.classList.remove("active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.#entryEditor.accountCode === null) {
|
||||||
|
this.#clearButton.classList.add("btn-secondary");
|
||||||
|
this.#clearButton.classList.remove("btn-danger");
|
||||||
|
this.#clearButton.disabled = true;
|
||||||
|
} else {
|
||||||
|
this.#clearButton.classList.add("btn-danger");
|
||||||
|
this.#clearButton.classList.remove("btn-secondary");
|
||||||
|
this.#clearButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the account selector instances.
|
||||||
|
*
|
||||||
|
* @param entryEditor {JournalEntryEditor} the journal entry editor
|
||||||
|
* @return {{debit: AccountSelector, credit: AccountSelector}}
|
||||||
|
*/
|
||||||
|
static getInstances(entryEditor) {
|
||||||
|
const selectors = {}
|
||||||
|
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
|
||||||
|
for (const modal of modals) {
|
||||||
|
selectors[modal.dataset.entryType] = new AccountSelector(entryEditor, modal.dataset.entryType);
|
||||||
|
}
|
||||||
|
return selectors;
|
||||||
|
}
|
||||||
|
}
|
@ -20,155 +20,155 @@
|
|||||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
* First written: 2023/2/6
|
* First written: 2023/2/6
|
||||||
*/
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
// Initializes the page JavaScript.
|
// Initializes the page JavaScript.
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
document.getElementById("accounting-code")
|
CurrencyForm.initialize();
|
||||||
.onchange = validateCode;
|
|
||||||
document.getElementById("accounting-name")
|
|
||||||
.onchange = validateName;
|
|
||||||
document.getElementById("accounting-form")
|
|
||||||
.onsubmit = validateForm;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The asynchronous validation result
|
* The currency form.
|
||||||
* @type {object}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
let isAsyncValid = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the form.
|
|
||||||
*
|
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateForm() {
|
|
||||||
isAsyncValid = {
|
|
||||||
"code": false,
|
|
||||||
"_sync": false,
|
|
||||||
};
|
|
||||||
let isValid = true;
|
|
||||||
isValid = validateCode() && isValid;
|
|
||||||
isValid = validateName() && isValid;
|
|
||||||
isAsyncValid["_sync"] = isValid;
|
|
||||||
submitFormIfAllAsyncValid();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submits the form if the whole form passed the asynchronous
|
|
||||||
* validations.
|
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
function submitFormIfAllAsyncValid() {
|
class CurrencyForm {
|
||||||
let isValid = true;
|
|
||||||
Object.keys(isAsyncValid).forEach(function (key) {
|
|
||||||
isValid = isAsyncValid[key] && isValid;
|
|
||||||
});
|
|
||||||
if (isValid) {
|
|
||||||
document.getElementById("accounting-form").submit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the code.
|
* The form.
|
||||||
*
|
* @type {HTMLFormElement}
|
||||||
* @param changeEvent {Event} the change event, if invoked from onchange
|
*/
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
#formElement;
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateCode(changeEvent = null) {
|
|
||||||
const key = "code";
|
|
||||||
const isSubmission = changeEvent === null;
|
|
||||||
let hasAsyncValidation = false;
|
|
||||||
const field = document.getElementById("accounting-code");
|
|
||||||
const error = document.getElementById("accounting-code-error");
|
|
||||||
field.value = field.value.trim();
|
|
||||||
if (field.value === "") {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please fill in the code.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const blocklist = JSON.parse(field.dataset.blocklist);
|
|
||||||
if (blocklist.includes(field.value)) {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("This code is not available.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!field.value.match(/^[A-Z]{3}$/)) {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const original = field.dataset.original;
|
|
||||||
if (original === "" || field.value !== original) {
|
|
||||||
hasAsyncValidation = true;
|
|
||||||
validateAsyncCodeIsDuplicated(isSubmission, key);
|
|
||||||
}
|
|
||||||
if (!hasAsyncValidation) {
|
|
||||||
isAsyncValid[key] = true;
|
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates asynchronously whether the code is duplicated.
|
* The code
|
||||||
* The boolean validation result is stored in isAsyncValid[key].
|
* @type {HTMLInputElement}
|
||||||
*
|
*/
|
||||||
* @param isSubmission {boolean} whether this is invoked from a form submission
|
#code;
|
||||||
* @param key {string} the key to store the result in isAsyncValid
|
|
||||||
* @private
|
/**
|
||||||
*/
|
* The error message of the code
|
||||||
function validateAsyncCodeIsDuplicated(isSubmission, key) {
|
* @type {HTMLDivElement}
|
||||||
const field = document.getElementById("accounting-code");
|
*/
|
||||||
const error = document.getElementById("accounting-code-error");
|
#codeError;
|
||||||
const url = field.dataset.existsUrl;
|
|
||||||
const onLoad = function () {
|
/**
|
||||||
if (this.status === 200) {
|
* The name
|
||||||
const result = JSON.parse(this.responseText);
|
* @type {HTMLInputElement}
|
||||||
if (result["exists"]) {
|
*/
|
||||||
field.classList.add("is-invalid");
|
#name;
|
||||||
error.innerText = A_("Code conflicts with another currency.");
|
|
||||||
if (isSubmission) {
|
/**
|
||||||
isAsyncValid[key] = false;
|
* The error message of the name
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#nameError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the currency form.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
this.#formElement = document.getElementById("accounting-form");
|
||||||
|
this.#code = document.getElementById("accounting-code");
|
||||||
|
this.#codeError = document.getElementById("accounting-code-error");
|
||||||
|
this.#name = document.getElementById("accounting-name");
|
||||||
|
this.#nameError = document.getElementById("accounting-name-error");
|
||||||
|
this.#code.onchange = () => {
|
||||||
|
this.#validateCode().then();
|
||||||
|
};
|
||||||
|
this.#name.onchange = () => {
|
||||||
|
this.#validateName();
|
||||||
|
};
|
||||||
|
this.#formElement.onsubmit = () => {
|
||||||
|
this.#validateForm().then((isValid) => {
|
||||||
|
if (isValid) {
|
||||||
|
this.#formElement.submit();
|
||||||
}
|
}
|
||||||
return;
|
});
|
||||||
}
|
return false;
|
||||||
field.classList.remove("is-invalid");
|
};
|
||||||
error.innerText = "";
|
}
|
||||||
if (isSubmission) {
|
|
||||||
isAsyncValid[key] = true;
|
/**
|
||||||
submitFormIfAllAsyncValid();
|
* Validates the form.
|
||||||
|
*
|
||||||
|
* @returns {Promise<boolean>} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
async #validateForm() {
|
||||||
|
let isValid = true;
|
||||||
|
isValid = await this.#validateCode() && isValid;
|
||||||
|
isValid = this.#validateName() && isValid;
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the code.
|
||||||
|
*
|
||||||
|
* @param changeEvent {Event} the change event, if invoked from onchange
|
||||||
|
* @returns {Promise<boolean>} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
async #validateCode(changeEvent = null) {
|
||||||
|
this.#code.value = this.#code.value.trim();
|
||||||
|
if (this.#code.value === "") {
|
||||||
|
this.#code.classList.add("is-invalid");
|
||||||
|
this.#codeError.innerText = A_("Please fill in the code.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const blocklist = JSON.parse(this.#code.dataset.blocklist);
|
||||||
|
if (blocklist.includes(this.#code.value)) {
|
||||||
|
this.#code.classList.add("is-invalid");
|
||||||
|
this.#codeError.innerText = A_("This code is not available.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!this.#code.value.match(/^[A-Z]{3}$/)) {
|
||||||
|
this.#code.classList.add("is-invalid");
|
||||||
|
this.#codeError.innerText = A_("Code can only be composed of 3 upper-cased letters.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const original = this.#code.dataset.original;
|
||||||
|
if (original === "" || this.#code.value !== original) {
|
||||||
|
const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value));
|
||||||
|
const data = await response.json();
|
||||||
|
if (data["exists"]) {
|
||||||
|
this.#code.classList.add("is-invalid");
|
||||||
|
this.#codeError.innerText = A_("Code conflicts with another currency.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
this.#code.classList.remove("is-invalid");
|
||||||
const request = new XMLHttpRequest();
|
this.#codeError.innerText = "";
|
||||||
request.onload = onLoad;
|
return true;
|
||||||
request.open("GET", url + "?q=" + encodeURIComponent(field.value));
|
}
|
||||||
request.send();
|
|
||||||
}
|
/**
|
||||||
|
* Validates the name.
|
||||||
/**
|
*
|
||||||
* Validates the name.
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
*
|
*/
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
#validateName() {
|
||||||
* @private
|
this.#name.value = this.#name.value.trim();
|
||||||
*/
|
if (this.#name.value === "") {
|
||||||
function validateName() {
|
this.#name.classList.add("is-invalid");
|
||||||
const field = document.getElementById("accounting-name");
|
this.#nameError.innerText = A_("Please fill in the name.");
|
||||||
const error = document.getElementById("accounting-name-error");
|
return false;
|
||||||
field.value = field.value.trim();
|
}
|
||||||
if (field.value === "") {
|
this.#name.classList.remove("is-invalid");
|
||||||
field.classList.add("is-invalid");
|
this.#nameError.innerText = "";
|
||||||
error.innerText = A_("Please fill in the name.");
|
return true;
|
||||||
return false;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form
|
||||||
|
* @type {CurrencyForm}
|
||||||
|
*/
|
||||||
|
static #form;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the currency form.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
static initialize() {
|
||||||
|
this.#form = new CurrencyForm();
|
||||||
}
|
}
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
* First written: 2023/2/3
|
* First written: 2023/2/3
|
||||||
*/
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the drag-and-drop reordering on a list.
|
* Initializes the drag-and-drop reordering on a list.
|
||||||
@ -42,21 +43,21 @@ function initializeDragAndDropReordering(list, onReorder) {
|
|||||||
function initializeMouseDragAndDropReordering(list, onReorder) {
|
function initializeMouseDragAndDropReordering(list, onReorder) {
|
||||||
const items = Array.from(list.children);
|
const items = Array.from(list.children);
|
||||||
let dragged = null;
|
let dragged = null;
|
||||||
items.forEach(function (item) {
|
for (const item of items) {
|
||||||
item.draggable = true;
|
item.draggable = true;
|
||||||
item.addEventListener("dragstart", function () {
|
item.addEventListener("dragstart", () => {
|
||||||
dragged = item;
|
dragged = item;
|
||||||
dragged.classList.add("accounting-dragged");
|
dragged.classList.add("accounting-dragged");
|
||||||
});
|
});
|
||||||
item.addEventListener("dragover", function () {
|
item.addEventListener("dragover", () => {
|
||||||
onDragOver(dragged, item);
|
onDragOver(dragged, item);
|
||||||
onReorder();
|
onReorder();
|
||||||
});
|
});
|
||||||
item.addEventListener("dragend", function () {
|
item.addEventListener("dragend", () => {
|
||||||
dragged.classList.remove("accounting-dragged");
|
dragged.classList.remove("accounting-dragged");
|
||||||
dragged = null;
|
dragged = null;
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -68,20 +69,20 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
|
|||||||
*/
|
*/
|
||||||
function initializeTouchDragAndDropReordering(list, onReorder) {
|
function initializeTouchDragAndDropReordering(list, onReorder) {
|
||||||
const items = Array.from(list.children);
|
const items = Array.from(list.children);
|
||||||
items.forEach(function (item) {
|
for (const item of items) {
|
||||||
item.addEventListener("touchstart", function () {
|
item.addEventListener("touchstart", () => {
|
||||||
item.classList.add("accounting-dragged");
|
item.classList.add("accounting-dragged");
|
||||||
});
|
});
|
||||||
item.addEventListener("touchmove", function (event) {
|
item.addEventListener("touchmove", (event) => {
|
||||||
const touch = event.targetTouches[0];
|
const touch = event.targetTouches[0];
|
||||||
const target = document.elementFromPoint(touch.pageX, touch.pageY);
|
const target = document.elementFromPoint(touch.pageX, touch.pageY);
|
||||||
onDragOver(item, target);
|
onDragOver(item, target);
|
||||||
onReorder();
|
onReorder();
|
||||||
});
|
});
|
||||||
item.addEventListener("touchend", function () {
|
item.addEventListener("touchend", () => {
|
||||||
item.classList.remove("accounting-dragged");
|
item.classList.remove("accounting-dragged");
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
596
src/accounting/static/js/journal-entry-editor.js
Normal file
596
src/accounting/static/js/journal-entry-editor.js
Normal file
@ -0,0 +1,596 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* journal-entry-editor.js: The JavaScript for the journal entry editor
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/2/25
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journal entry editor.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class JournalEntryEditor {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transaction form
|
||||||
|
* @type {TransactionForm}
|
||||||
|
*/
|
||||||
|
form;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journal entry editor
|
||||||
|
* @type {HTMLFormElement}
|
||||||
|
*/
|
||||||
|
#element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bootstrap modal
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#modal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry type, either "debit" or "credit"
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
entryType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix of the HTML ID and class
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#prefix = "accounting-entry-editor"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The container of the original entry
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#originalEntryContainer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The control of the original entry
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#originalEntryControl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The original entry
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#originalEntry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message of the original entry
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#originalEntryError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The delete button of the original entry
|
||||||
|
* @type {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#originalEntryDelete;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The control of the summary
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#summaryControl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The summary
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#summary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message of the summary
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#summaryError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The control of the account
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#accountControl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#account;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message of the account
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#accountError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#amount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message of the amount
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#amountError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journal entry to edit
|
||||||
|
* @type {JournalEntrySubForm|null}
|
||||||
|
*/
|
||||||
|
entry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The debit or credit entry side sub-form
|
||||||
|
* @type {DebitCreditSideSubForm}
|
||||||
|
*/
|
||||||
|
#side;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the journal entry needs offset
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isNeedOffset = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the original entry
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
originalEntryId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date of the original entry
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
originalEntryDate = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text of the original entry
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
originalEntryText = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account code
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
accountCode = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account text
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
accountText = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The summary
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
summary = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
amount = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The summary editors
|
||||||
|
* @type {{debit: SummaryEditor, credit: SummaryEditor}}
|
||||||
|
*/
|
||||||
|
#summaryEditors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account selectors
|
||||||
|
* @type {{debit: AccountSelector, credit: AccountSelector}}
|
||||||
|
*/
|
||||||
|
#accountSelectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The original entry selector
|
||||||
|
* @type {OriginalEntrySelector}
|
||||||
|
*/
|
||||||
|
originalEntrySelector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new journal entry editor.
|
||||||
|
*
|
||||||
|
* @param form {TransactionForm} the transaction form
|
||||||
|
*/
|
||||||
|
constructor(form) {
|
||||||
|
this.form = form;
|
||||||
|
this.#element = document.getElementById(this.#prefix);
|
||||||
|
this.#modal = document.getElementById(this.#prefix + "-modal");
|
||||||
|
this.#originalEntryContainer = document.getElementById(this.#prefix + "-original-entry-container");
|
||||||
|
this.#originalEntryControl = document.getElementById(this.#prefix + "-original-entry-control");
|
||||||
|
this.#originalEntry = document.getElementById(this.#prefix + "-original-entry");
|
||||||
|
this.#originalEntryError = document.getElementById(this.#prefix + "-original-entry-error");
|
||||||
|
this.#originalEntryDelete = document.getElementById(this.#prefix + "-original-entry-delete");
|
||||||
|
this.#summaryControl = document.getElementById(this.#prefix + "-summary-control");
|
||||||
|
this.#summary = document.getElementById(this.#prefix + "-summary");
|
||||||
|
this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
|
||||||
|
this.#accountControl = document.getElementById(this.#prefix + "-account-control");
|
||||||
|
this.#account = document.getElementById(this.#prefix + "-account");
|
||||||
|
this.#accountError = document.getElementById(this.#prefix + "-account-error")
|
||||||
|
this.#amount = document.getElementById(this.#prefix + "-amount");
|
||||||
|
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
|
||||||
|
this.#summaryEditors = SummaryEditor.getInstances(this);
|
||||||
|
this.#accountSelectors = AccountSelector.getInstances(this);
|
||||||
|
this.originalEntrySelector = new OriginalEntrySelector();
|
||||||
|
this.#originalEntryControl.onclick = () => this.originalEntrySelector.onOpen(this, this.originalEntryId)
|
||||||
|
this.#originalEntryDelete.onclick = () => this.clearOriginalEntry();
|
||||||
|
this.#summaryControl.onclick = () => this.#summaryEditors[this.entryType].onOpen();
|
||||||
|
this.#accountControl.onclick = () => this.#accountSelectors[this.entryType].onOpen();
|
||||||
|
this.#amount.onchange = () => this.#validateAmount();
|
||||||
|
this.#element.onsubmit = () => {
|
||||||
|
if (this.#validate()) {
|
||||||
|
if (this.entry === null) {
|
||||||
|
this.entry = this.#side.addJournalEntry();
|
||||||
|
}
|
||||||
|
this.amount = this.#amount.value;
|
||||||
|
this.entry.save(this);
|
||||||
|
bootstrap.Modal.getInstance(this.#modal).hide();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the original entry from the original entry selector.
|
||||||
|
*
|
||||||
|
* @param originalEntry {OriginalEntry} the original entry
|
||||||
|
*/
|
||||||
|
saveOriginalEntry(originalEntry) {
|
||||||
|
this.isNeedOffset = false;
|
||||||
|
this.#originalEntryContainer.classList.remove("d-none");
|
||||||
|
this.#originalEntryControl.classList.add("accounting-not-empty");
|
||||||
|
this.originalEntryId = originalEntry.id;
|
||||||
|
this.originalEntryDate = originalEntry.date;
|
||||||
|
this.originalEntryText = originalEntry.text;
|
||||||
|
this.#originalEntry.innerText = originalEntry.text;
|
||||||
|
this.#setEnableSummaryAccount(false);
|
||||||
|
if (originalEntry.summary === "") {
|
||||||
|
this.#summaryControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
this.#summaryControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
this.summary = originalEntry.summary === ""? null: originalEntry.summary;
|
||||||
|
this.#summary.innerText = originalEntry.summary;
|
||||||
|
this.#accountControl.classList.add("accounting-not-empty");
|
||||||
|
this.accountCode = originalEntry.accountCode;
|
||||||
|
this.accountText = originalEntry.accountText;
|
||||||
|
this.#account.innerText = originalEntry.accountText;
|
||||||
|
this.#amount.value = String(originalEntry.netBalance);
|
||||||
|
this.#amount.max = String(originalEntry.netBalance);
|
||||||
|
this.#amount.min = "0";
|
||||||
|
this.#validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the original entry.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
clearOriginalEntry() {
|
||||||
|
this.isNeedOffset = false;
|
||||||
|
this.#originalEntryContainer.classList.add("d-none");
|
||||||
|
this.#originalEntryControl.classList.remove("accounting-not-empty");
|
||||||
|
this.originalEntryId = null;
|
||||||
|
this.originalEntryDate = null;
|
||||||
|
this.originalEntryText = null;
|
||||||
|
this.#originalEntry.innerText = "";
|
||||||
|
this.#setEnableSummaryAccount(true);
|
||||||
|
this.#accountControl.classList.remove("accounting-not-empty");
|
||||||
|
this.accountCode = null;
|
||||||
|
this.accountText = null;
|
||||||
|
this.#account.innerText = "";
|
||||||
|
this.#amount.max = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currency code.
|
||||||
|
*
|
||||||
|
* @return {string} the currency code
|
||||||
|
*/
|
||||||
|
getCurrencyCode() {
|
||||||
|
return this.#side.currency.getCurrencyCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the summary from the summary editor.
|
||||||
|
*
|
||||||
|
* @param summary {string} the summary
|
||||||
|
*/
|
||||||
|
saveSummary(summary) {
|
||||||
|
if (summary === "") {
|
||||||
|
this.#summaryControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
this.#summaryControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
this.summary = summary === ""? null: summary;
|
||||||
|
this.#summary.innerText = summary;
|
||||||
|
this.#validateSummary();
|
||||||
|
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the summary with the suggested account from the summary editor.
|
||||||
|
*
|
||||||
|
* @param summary {string} the summary
|
||||||
|
* @param accountCode {string} the account code
|
||||||
|
* @param accountText {string} the account text
|
||||||
|
* @param isAccountNeedOffset {boolean} true if the journal entries in the account need offset, or false otherwise
|
||||||
|
*/
|
||||||
|
saveSummaryWithAccount(summary, accountCode, accountText, isAccountNeedOffset) {
|
||||||
|
this.isNeedOffset = isAccountNeedOffset;
|
||||||
|
this.#accountControl.classList.add("accounting-not-empty");
|
||||||
|
this.accountCode = accountCode;
|
||||||
|
this.accountText = accountText;
|
||||||
|
this.#account.innerText = accountText;
|
||||||
|
this.#validateAccount();
|
||||||
|
this.saveSummary(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the account.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
clearAccount() {
|
||||||
|
this.isNeedOffset = false;
|
||||||
|
this.#accountControl.classList.remove("accounting-not-empty");
|
||||||
|
this.accountCode = null;
|
||||||
|
this.accountText = null;
|
||||||
|
this.#account.innerText = "";
|
||||||
|
this.#validateAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the account.
|
||||||
|
*
|
||||||
|
* @param code {string} the account code
|
||||||
|
* @param text {string} the account text
|
||||||
|
* @param isNeedOffset {boolean} true if the journal entries in the account need offset or false otherwise
|
||||||
|
*/
|
||||||
|
saveAccount(code, text, isNeedOffset) {
|
||||||
|
this.isNeedOffset = isNeedOffset;
|
||||||
|
this.#accountControl.classList.add("accounting-not-empty");
|
||||||
|
this.accountCode = code;
|
||||||
|
this.accountText = text;
|
||||||
|
this.#account.innerText = text;
|
||||||
|
this.#validateAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the form.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validate() {
|
||||||
|
let isValid = true;
|
||||||
|
isValid = this.#validateOriginalEntry() && isValid;
|
||||||
|
isValid = this.#validateSummary() && isValid;
|
||||||
|
isValid = this.#validateAccount() && isValid;
|
||||||
|
isValid = this.#validateAmount() && isValid
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the original entry.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#validateOriginalEntry() {
|
||||||
|
this.#originalEntryControl.classList.remove("is-invalid");
|
||||||
|
this.#originalEntryError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the summary.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#validateSummary() {
|
||||||
|
this.#summary.classList.remove("is-invalid");
|
||||||
|
this.#summaryError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the account.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateAccount() {
|
||||||
|
if (this.accountCode === null) {
|
||||||
|
this.#accountControl.classList.add("is-invalid");
|
||||||
|
this.#accountError.innerText = A_("Please select the account.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#accountControl.classList.remove("is-invalid");
|
||||||
|
this.#accountError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the amount.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#validateAmount() {
|
||||||
|
this.#amount.value = this.#amount.value.trim();
|
||||||
|
this.#amount.classList.remove("is-invalid");
|
||||||
|
if (this.#amount.value === "") {
|
||||||
|
this.#amount.classList.add("is-invalid");
|
||||||
|
this.#amountError.innerText = A_("Please fill in the amount.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const amount =new Decimal(this.#amount.value);
|
||||||
|
if (amount.lessThanOrEqualTo(0)) {
|
||||||
|
this.#amount.classList.add("is-invalid");
|
||||||
|
this.#amountError.innerText = A_("Please fill in a positive amount.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.#amount.max !== "") {
|
||||||
|
if (amount.greaterThan(new Decimal(this.#amount.max))) {
|
||||||
|
this.#amount.classList.add("is-invalid");
|
||||||
|
this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original entry.", {balance: new Decimal(this.#amount.max)});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.#amount.min !== "") {
|
||||||
|
const min = new Decimal(this.#amount.min);
|
||||||
|
if (amount.lessThan(min)) {
|
||||||
|
this.#amount.classList.add("is-invalid");
|
||||||
|
this.#amountError.innerText = A_("The amount must not be less than the offset total %(total)s.", {total: formatDecimal(min)});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#amount.classList.remove("is-invalid");
|
||||||
|
this.#amountError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback when adding a new journal entry.
|
||||||
|
*
|
||||||
|
* @param side {DebitCreditSideSubForm} the debit or credit side sub-form
|
||||||
|
*/
|
||||||
|
onAddNew(side) {
|
||||||
|
this.entry = null;
|
||||||
|
this.#side = side;
|
||||||
|
this.entryType = this.#side.entryType;
|
||||||
|
this.isNeedOffset = false;
|
||||||
|
this.#originalEntryContainer.classList.add("d-none");
|
||||||
|
this.#originalEntryControl.classList.remove("accounting-not-empty");
|
||||||
|
this.#originalEntryControl.classList.remove("is-invalid");
|
||||||
|
this.originalEntryId = null;
|
||||||
|
this.originalEntryDate = null;
|
||||||
|
this.originalEntryText = null;
|
||||||
|
this.#originalEntry.innerText = "";
|
||||||
|
this.#setEnableSummaryAccount(true);
|
||||||
|
this.#summaryControl.classList.remove("accounting-not-empty");
|
||||||
|
this.#summaryControl.classList.remove("is-invalid");
|
||||||
|
this.summary = null;
|
||||||
|
this.#summary.innerText = ""
|
||||||
|
this.#summaryError.innerText = ""
|
||||||
|
this.#accountControl.classList.remove("accounting-not-empty");
|
||||||
|
this.#accountControl.classList.remove("is-invalid");
|
||||||
|
this.accountCode = null;
|
||||||
|
this.accountText = null;
|
||||||
|
this.#account.innerText = "";
|
||||||
|
this.#accountError.innerText = "";
|
||||||
|
this.#amount.value = "";
|
||||||
|
this.#amount.max = "";
|
||||||
|
this.#amount.min = "0";
|
||||||
|
this.#amount.classList.remove("is-invalid");
|
||||||
|
this.#amountError.innerText = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback when editing a journal entry.
|
||||||
|
*
|
||||||
|
* @param entry {JournalEntrySubForm} the journal entry sub-form
|
||||||
|
*/
|
||||||
|
onEdit(entry) {
|
||||||
|
this.entry = entry;
|
||||||
|
this.#side = entry.side;
|
||||||
|
this.entryType = this.#side.entryType;
|
||||||
|
this.isNeedOffset = entry.isNeedOffset();
|
||||||
|
this.originalEntryId = entry.getOriginalEntryId();
|
||||||
|
this.originalEntryDate = entry.getOriginalEntryDate();
|
||||||
|
this.originalEntryText = entry.getOriginalEntryText();
|
||||||
|
this.#originalEntry.innerText = this.originalEntryText;
|
||||||
|
if (this.originalEntryId === null) {
|
||||||
|
this.#originalEntryContainer.classList.add("d-none");
|
||||||
|
this.#originalEntryControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
this.#originalEntryContainer.classList.remove("d-none");
|
||||||
|
this.#originalEntryControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
this.#setEnableSummaryAccount(!entry.isMatched && this.originalEntryId === null);
|
||||||
|
this.summary = entry.getSummary();
|
||||||
|
if (this.summary === null) {
|
||||||
|
this.#summaryControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
this.#summaryControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
this.#summary.innerText = this.summary === null? "": this.summary;
|
||||||
|
if (entry.getAccountCode() === null) {
|
||||||
|
this.#accountControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
this.#accountControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
this.accountCode = entry.getAccountCode();
|
||||||
|
this.accountText = entry.getAccountText();
|
||||||
|
this.#account.innerText = this.accountText;
|
||||||
|
this.#amount.value = entry.getAmount() === null? "": String(entry.getAmount());
|
||||||
|
const maxAmount = this.#getMaxAmount();
|
||||||
|
this.#amount.max = maxAmount === null? "": maxAmount;
|
||||||
|
this.#amount.min = entry.getAmountMin() === null? "": String(entry.getAmountMin());
|
||||||
|
this.#validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds out the max amount.
|
||||||
|
*
|
||||||
|
* @return {Decimal|null} the max amount
|
||||||
|
*/
|
||||||
|
#getMaxAmount() {
|
||||||
|
if (this.originalEntryId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.originalEntrySelector.getNetBalance(this.entry, this.form, this.originalEntryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the enable status of the summary and account.
|
||||||
|
*
|
||||||
|
* @param isEnabled {boolean} true to enable, or false otherwise
|
||||||
|
*/
|
||||||
|
#setEnableSummaryAccount(isEnabled) {
|
||||||
|
if (isEnabled) {
|
||||||
|
this.#summaryControl.dataset.bsToggle = "modal";
|
||||||
|
this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + this.#side.entryType + "-modal";
|
||||||
|
this.#summaryControl.classList.remove("accounting-disabled");
|
||||||
|
this.#summaryControl.classList.add("accounting-clickable");
|
||||||
|
this.#accountControl.dataset.bsToggle = "modal";
|
||||||
|
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#side.entryType + "-modal";
|
||||||
|
this.#accountControl.classList.remove("accounting-disabled");
|
||||||
|
this.#accountControl.classList.add("accounting-clickable");
|
||||||
|
} else {
|
||||||
|
this.#summaryControl.dataset.bsToggle = "";
|
||||||
|
this.#summaryControl.dataset.bsTarget = "";
|
||||||
|
this.#summaryControl.classList.add("accounting-disabled");
|
||||||
|
this.#summaryControl.classList.remove("accounting-clickable");
|
||||||
|
this.#accountControl.dataset.bsToggle = "";
|
||||||
|
this.#accountControl.dataset.bsTarget = "";
|
||||||
|
this.#accountControl.classList.add("accounting-disabled");
|
||||||
|
this.#accountControl.classList.remove("accounting-clickable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -20,9 +20,10 @@
|
|||||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
* First written: 2023/2/25
|
* First written: 2023/2/25
|
||||||
*/
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
// Initializes the page JavaScript.
|
// Initializes the page JavaScript.
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
initializeMaterialFabSpeedDial();
|
initializeMaterialFabSpeedDial();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -34,7 +35,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
function initializeMaterialFabSpeedDial() {
|
function initializeMaterialFabSpeedDial() {
|
||||||
const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial");
|
const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial");
|
||||||
const fab = document.getElementById(btnFab.dataset.target);
|
const fab = document.getElementById(btnFab.dataset.target);
|
||||||
btnFab.onclick = function () {
|
btnFab.onclick = () => {
|
||||||
if (fab.classList.contains("show")) {
|
if (fab.classList.contains("show")) {
|
||||||
fab.classList.remove("show");
|
fab.classList.remove("show");
|
||||||
} else {
|
} else {
|
||||||
|
417
src/accounting/static/js/original-entry-selector.js
Normal file
417
src/accounting/static/js/original-entry-selector.js
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* original-entry-selector.js: The JavaScript for the original entry selector
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/3/10
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The original entry selector.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class OriginalEntrySelector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix of the HTML ID and class
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#prefix = "accounting-original-entry-selector";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The modal of the original entry editor
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#modal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query input
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message when the query has no result
|
||||||
|
* @type {HTMLParagraphElement}
|
||||||
|
*/
|
||||||
|
#queryNoResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The option list
|
||||||
|
* @type {HTMLUListElement}
|
||||||
|
*/
|
||||||
|
#optionList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options
|
||||||
|
* @type {OriginalEntry[]}
|
||||||
|
*/
|
||||||
|
#options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options by their ID
|
||||||
|
* @type {Object.<string, OriginalEntry>}
|
||||||
|
*/
|
||||||
|
#optionById;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journal entry editor
|
||||||
|
* @type {JournalEntryEditor}
|
||||||
|
*/
|
||||||
|
entryEditor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currency code
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#currencyCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry
|
||||||
|
*/
|
||||||
|
#entryType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an original entry selector.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
this.#modal = document.getElementById(this.#prefix + "-modal");
|
||||||
|
this.#query = document.getElementById(this.#prefix + "-query");
|
||||||
|
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
||||||
|
this.#optionList = document.getElementById(this.#prefix + "-option-list");
|
||||||
|
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalEntry(this, element));
|
||||||
|
this.#optionById = {};
|
||||||
|
for (const option of this.#options) {
|
||||||
|
this.#optionById[option.id] = option;
|
||||||
|
}
|
||||||
|
this.#query.addEventListener("input", () => {
|
||||||
|
this.#filterOptions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the net balance for an original entry.
|
||||||
|
*
|
||||||
|
* @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing
|
||||||
|
* @param form {TransactionForm} the transaction form
|
||||||
|
* @param originalEntryId {string} the ID of the original entry
|
||||||
|
* @return {Decimal} the net balance of the original entry
|
||||||
|
*/
|
||||||
|
getNetBalance(currentEntry, form, originalEntryId) {
|
||||||
|
const otherEntries = form.getEntries().filter((entry) => entry !== currentEntry);
|
||||||
|
let otherOffset = new Decimal(0);
|
||||||
|
for (const otherEntry of otherEntries) {
|
||||||
|
if (otherEntry.getOriginalEntryId() === originalEntryId) {
|
||||||
|
const amount = otherEntry.getAmount();
|
||||||
|
if (amount !== null) {
|
||||||
|
otherOffset = otherOffset.plus(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.#optionById[originalEntryId].bareNetBalance.minus(otherOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the net balances, subtracting the offset amounts on the form but the currently editing journal entry
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#updateNetBalances() {
|
||||||
|
const otherEntries = this.entryEditor.form.getEntries().filter((entry) => entry !== this.entryEditor.entry);
|
||||||
|
const otherOffsets = {}
|
||||||
|
for (const otherEntry of otherEntries) {
|
||||||
|
const otherOriginalEntryId = otherEntry.getOriginalEntryId();
|
||||||
|
const amount = otherEntry.getAmount();
|
||||||
|
if (otherOriginalEntryId === null || amount === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(otherOriginalEntryId in otherOffsets)) {
|
||||||
|
otherOffsets[otherOriginalEntryId] = new Decimal("0");
|
||||||
|
}
|
||||||
|
otherOffsets[otherOriginalEntryId] = otherOffsets[otherOriginalEntryId].plus(amount);
|
||||||
|
}
|
||||||
|
for (const option of this.#options) {
|
||||||
|
if (option.id in otherOffsets) {
|
||||||
|
option.updateNetBalance(otherOffsets[option.id]);
|
||||||
|
} else {
|
||||||
|
option.resetNetBalance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the options.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#filterOptions() {
|
||||||
|
let hasAnyMatched = false;
|
||||||
|
for (const option of this.#options) {
|
||||||
|
if (option.isMatched(this.#entryType, this.#currencyCode, this.#query.value)) {
|
||||||
|
option.setShown(true);
|
||||||
|
hasAnyMatched = true;
|
||||||
|
} else {
|
||||||
|
option.setShown(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasAnyMatched) {
|
||||||
|
this.#optionList.classList.add("d-none");
|
||||||
|
this.#queryNoResult.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
this.#optionList.classList.remove("d-none");
|
||||||
|
this.#queryNoResult.classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback when the original entry selector is shown.
|
||||||
|
*
|
||||||
|
* @param entryEditor {JournalEntryEditor} the journal entry editor
|
||||||
|
* @param originalEntryId {string|null} the ID of the original entry
|
||||||
|
*/
|
||||||
|
onOpen(entryEditor, originalEntryId = null) {
|
||||||
|
this.entryEditor = entryEditor
|
||||||
|
this.#currencyCode = entryEditor.getCurrencyCode();
|
||||||
|
this.#entryType = entryEditor.entryType;
|
||||||
|
for (const option of this.#options) {
|
||||||
|
option.setActive(option.id === originalEntryId);
|
||||||
|
}
|
||||||
|
this.#query.value = "";
|
||||||
|
this.#updateNetBalances();
|
||||||
|
this.#filterOptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An original entry.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class OriginalEntry {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The original entry selector
|
||||||
|
* @type {OriginalEntrySelector}
|
||||||
|
*/
|
||||||
|
#selector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The element
|
||||||
|
* @type {HTMLLIElement}
|
||||||
|
*/
|
||||||
|
#element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry type, either "debit" or "credit"
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#entryType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currency code
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#currencyCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account code
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
accountCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account text
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
accountText;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The summary
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
summary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The net balance, without the offset amounts on the form
|
||||||
|
* @type {Decimal}
|
||||||
|
*/
|
||||||
|
bareNetBalance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The net balance
|
||||||
|
* @type {Decimal}
|
||||||
|
*/
|
||||||
|
netBalance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text of the net balance
|
||||||
|
* @type {HTMLSpanElement}
|
||||||
|
*/
|
||||||
|
netBalanceText;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text representation of the original entry
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
text;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The values to query against
|
||||||
|
* @type {string[][]}
|
||||||
|
*/
|
||||||
|
#queryValues;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an original entry.
|
||||||
|
*
|
||||||
|
* @param selector {OriginalEntrySelector} the original entry selector
|
||||||
|
* @param element {HTMLLIElement} the element
|
||||||
|
*/
|
||||||
|
constructor(selector, element) {
|
||||||
|
this.#selector = selector;
|
||||||
|
this.#element = element;
|
||||||
|
this.id = element.dataset.id;
|
||||||
|
this.date = element.dataset.date;
|
||||||
|
this.#entryType = element.dataset.entryType;
|
||||||
|
this.#currencyCode = element.dataset.currencyCode;
|
||||||
|
this.accountCode = element.dataset.accountCode;
|
||||||
|
this.accountText = element.dataset.accountText;
|
||||||
|
this.summary = element.dataset.summary;
|
||||||
|
this.bareNetBalance = new Decimal(element.dataset.netBalance);
|
||||||
|
this.netBalance = this.bareNetBalance;
|
||||||
|
this.netBalanceText = document.getElementById("accounting-original-entry-selector-option-" + this.id + "-net-balance");
|
||||||
|
this.text = element.dataset.text;
|
||||||
|
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
||||||
|
this.#element.onclick = () => this.#selector.entryEditor.saveOriginalEntry(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the net balance to its initial value, without the offset amounts on the form.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
resetNetBalance() {
|
||||||
|
if (this.netBalance !== this.bareNetBalance) {
|
||||||
|
this.netBalance = this.bareNetBalance;
|
||||||
|
this.#updateNetBalanceText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the net balance with an offset.
|
||||||
|
*
|
||||||
|
* @param offset {Decimal} the offset to be added to the net balance
|
||||||
|
*/
|
||||||
|
updateNetBalance(offset) {
|
||||||
|
this.netBalance = this.bareNetBalance.minus(offset);
|
||||||
|
this.#updateNetBalanceText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the text display of the net balance.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#updateNetBalanceText() {
|
||||||
|
this.netBalanceText.innerText = formatDecimal(this.netBalance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the original matches.
|
||||||
|
*
|
||||||
|
* @param entryType {string} the entry type, either "debit" or "credit"
|
||||||
|
* @param currencyCode {string} the currency code
|
||||||
|
* @param query {string|null} the query term
|
||||||
|
*/
|
||||||
|
isMatched(entryType, currencyCode, query = null) {
|
||||||
|
return this.netBalance.greaterThan(0)
|
||||||
|
&& this.date <= this.#selector.entryEditor.form.getDate()
|
||||||
|
&& this.#isEntryTypeMatches(entryType)
|
||||||
|
&& this.#currencyCode === currencyCode
|
||||||
|
&& this.#isQueryMatches(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the original entry matches the entry type.
|
||||||
|
*
|
||||||
|
* @param entryType {string} the entry type, either "debit" or credit
|
||||||
|
* @return {boolean} true if the option matches, or false otherwise
|
||||||
|
*/
|
||||||
|
#isEntryTypeMatches(entryType) {
|
||||||
|
return (entryType === "debit" && this.#entryType === "credit")
|
||||||
|
|| (entryType === "credit" && this.#entryType === "debit");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the original entry matches the query.
|
||||||
|
*
|
||||||
|
* @param query {string|null} the query term
|
||||||
|
* @return {boolean} true if the option matches, or false otherwise
|
||||||
|
*/
|
||||||
|
#isQueryMatches(query) {
|
||||||
|
if (query === "") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const queryValue of this.#queryValues[0]) {
|
||||||
|
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const queryValue of this.#queryValues[1]) {
|
||||||
|
if (queryValue === query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether the option is shown.
|
||||||
|
*
|
||||||
|
* @param isShown {boolean} true to show, or false otherwise
|
||||||
|
*/
|
||||||
|
setShown(isShown) {
|
||||||
|
if (isShown) {
|
||||||
|
this.#element.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
this.#element.classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether the option is active.
|
||||||
|
*
|
||||||
|
* @param isActive {boolean} true if active, or false otherwise
|
||||||
|
*/
|
||||||
|
setActive(isActive) {
|
||||||
|
if (isActive) {
|
||||||
|
this.#element.classList.add("active");
|
||||||
|
} else {
|
||||||
|
this.#element.classList.remove("active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
426
src/accounting/static/js/period-chooser.js
Normal file
426
src/accounting/static/js/period-chooser.js
Normal file
@ -0,0 +1,426 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* period-chooser.js: The JavaScript for the period chooser
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/3/4
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Initializes the page JavaScript.
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
PeriodChooser.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The period chooser.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class PeriodChooser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix of the HTML ID and class
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
prefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The modal of the period chooser
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
modal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab planes
|
||||||
|
* @type {{month: MonthTab, year: YearTab, day: DayTab, custom: CustomTab}}
|
||||||
|
*/
|
||||||
|
tabPlanes = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the period chooser.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
this.prefix = "accounting-period-chooser";
|
||||||
|
this.modal = document.getElementById(this.prefix + "-modal");
|
||||||
|
for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) {
|
||||||
|
const tab = new cls(this);
|
||||||
|
this.tabPlanes[tab.tabId()] = tab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The period chooser.
|
||||||
|
* @type {PeriodChooser}
|
||||||
|
*/
|
||||||
|
static #chooser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the period chooser.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
static initialize() {
|
||||||
|
this.#chooser = new PeriodChooser();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tab plane.
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class TabPlane {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The period chooser
|
||||||
|
* @type {PeriodChooser}
|
||||||
|
*/
|
||||||
|
chooser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix of the HTML ID and class
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
prefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab
|
||||||
|
* @type {HTMLSpanElement}
|
||||||
|
*/
|
||||||
|
#tab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#page;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a tab plane.
|
||||||
|
*
|
||||||
|
* @param chooser {PeriodChooser} the period chooser
|
||||||
|
*/
|
||||||
|
constructor(chooser) {
|
||||||
|
this.chooser = chooser;
|
||||||
|
this.prefix = "accounting-period-chooser-" + this.tabId();
|
||||||
|
this.#tab = document.getElementById(this.prefix + "-tab");
|
||||||
|
this.#page = document.getElementById(this.prefix + "-page");
|
||||||
|
this.#tab.onclick = () => this.#switchToMe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab ID
|
||||||
|
*
|
||||||
|
* @return {string}
|
||||||
|
* @abstract
|
||||||
|
*/
|
||||||
|
tabId() { throw new Error("Method not implemented.") };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches to the tab plane.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#switchToMe() {
|
||||||
|
for (const tabPlane of Object.values(this.chooser.tabPlanes)) {
|
||||||
|
tabPlane.#tab.classList.remove("active")
|
||||||
|
tabPlane.#tab.ariaCurrent = "false";
|
||||||
|
tabPlane.#page.classList.add("d-none");
|
||||||
|
tabPlane.#page.ariaCurrent = "false";
|
||||||
|
}
|
||||||
|
this.#tab.classList.add("active");
|
||||||
|
this.#tab.ariaCurrent = "page";
|
||||||
|
this.#page.classList.remove("d-none");
|
||||||
|
this.#page.ariaCurrent = "page";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The month tab plane.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class MonthTab extends TabPlane {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The month chooser.
|
||||||
|
* @type {tempusDominus.TempusDominus}
|
||||||
|
*/
|
||||||
|
#monthChooser
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a tab plane.
|
||||||
|
*
|
||||||
|
* @param chooser {PeriodChooser} the period chooser
|
||||||
|
*/
|
||||||
|
constructor(chooser) {
|
||||||
|
super(chooser);
|
||||||
|
const monthChooser = document.getElementById(this.prefix + "-chooser");
|
||||||
|
if (monthChooser !== null) {
|
||||||
|
let start = monthChooser.dataset.start;
|
||||||
|
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
|
||||||
|
restrictions: {
|
||||||
|
minDate: start,
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
inline: true,
|
||||||
|
components: {
|
||||||
|
date: false,
|
||||||
|
clock: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultDate: monthChooser.dataset.default,
|
||||||
|
});
|
||||||
|
monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
|
||||||
|
const date = e.detail.date;
|
||||||
|
const year = date.year;
|
||||||
|
const month = date.month + 1;
|
||||||
|
const period = month < 10? year + "-0" + month: year + "-" + month;
|
||||||
|
window.location = chooser.modal.dataset.urlTemplate
|
||||||
|
.replaceAll("PERIOD", period);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab ID
|
||||||
|
*
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
tabId() {
|
||||||
|
return "month";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The year tab plane.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class YearTab extends TabPlane {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab ID
|
||||||
|
*
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
tabId() {
|
||||||
|
return "year";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day tab plane.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class DayTab extends TabPlane {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day input
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error of the date input
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#dateError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a tab plane.
|
||||||
|
*
|
||||||
|
* @param chooser {PeriodChooser} the period chooser
|
||||||
|
*/
|
||||||
|
constructor(chooser) {
|
||||||
|
super(chooser);
|
||||||
|
this.#date = document.getElementById(this.prefix + "-date");
|
||||||
|
this.#dateError = document.getElementById(this.prefix + "-date-error");
|
||||||
|
if (this.#date !== null) {
|
||||||
|
this.#date.onchange = () => {
|
||||||
|
if (this.#validateDate()) {
|
||||||
|
window.location = chooser.modal.dataset.urlTemplate
|
||||||
|
.replaceAll("PERIOD", this.#date.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the date.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateDate() {
|
||||||
|
if (this.#date.value === "") {
|
||||||
|
this.#date.classList.add("is-invalid");
|
||||||
|
this.#dateError.innerText = A_("Please fill in the date.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.#date.value < this.#date.min) {
|
||||||
|
this.#date.classList.add("is-invalid");
|
||||||
|
this.#dateError.innerText = A_("The date is too early.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#date.classList.remove("is-invalid");
|
||||||
|
this.#dateError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab ID
|
||||||
|
*
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
tabId() {
|
||||||
|
return "day";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The custom tab plane.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class CustomTab extends TabPlane {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The start of the period
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#start;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error of the start
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#startError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The end of the period
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#end;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error of the end
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#endError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The confirm button
|
||||||
|
* @type {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#conform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a tab plane.
|
||||||
|
*
|
||||||
|
* @param chooser {PeriodChooser} the period chooser
|
||||||
|
*/
|
||||||
|
constructor(chooser) {
|
||||||
|
super(chooser);
|
||||||
|
this.#start = document.getElementById(this.prefix + "-start");
|
||||||
|
this.#startError = document.getElementById(this.prefix + "-start-error");
|
||||||
|
this.#end = document.getElementById(this.prefix + "-end");
|
||||||
|
this.#endError = document.getElementById(this.prefix + "-end-error");
|
||||||
|
this.#conform = document.getElementById(this.prefix + "-confirm");
|
||||||
|
if (this.#start !== null) {
|
||||||
|
this.#start.onchange = () => {
|
||||||
|
if (this.#validateStart()) {
|
||||||
|
this.#end.min = this.#start.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.#end.onchange = () => {
|
||||||
|
if (this.#validateEnd()) {
|
||||||
|
this.#start.max = this.#end.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.#conform.onclick = () => {
|
||||||
|
let isValid = true;
|
||||||
|
isValid = this.#validateStart() && isValid;
|
||||||
|
isValid = this.#validateEnd() && isValid;
|
||||||
|
if (isValid) {
|
||||||
|
window.location = chooser.modal.dataset.urlTemplate
|
||||||
|
.replaceAll("PERIOD", this.#start.value + "-" + this.#end.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the start of the period.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#validateStart() {
|
||||||
|
if (this.#start.value === "") {
|
||||||
|
this.#start.classList.add("is-invalid");
|
||||||
|
this.#startError.innerText = A_("Please fill in the start date.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.#start.value < this.#start.min) {
|
||||||
|
this.#start.classList.add("is-invalid");
|
||||||
|
this.#startError.innerText = A_("The start date is too early.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.#start.value > this.#start.max) {
|
||||||
|
this.#start.classList.add("is-invalid");
|
||||||
|
this.#startError.innerText = A_("The start date cannot be beyond the end date.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#start.classList.remove("is-invalid");
|
||||||
|
this.#startError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the end of the period.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#validateEnd() {
|
||||||
|
this.#end.value = this.#end.value.trim();
|
||||||
|
if (this.#end.value === "") {
|
||||||
|
this.#end.classList.add("is-invalid");
|
||||||
|
this.#endError.innerText = A_("Please fill in the end date.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.#end.value < this.#end.min) {
|
||||||
|
this.#end.classList.add("is-invalid");
|
||||||
|
this.#endError.innerText = A_("The end date cannot be beyond the start date.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#end.classList.remove("is-invalid");
|
||||||
|
this.#endError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab ID
|
||||||
|
*
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
tabId() {
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
}
|
1149
src/accounting/static/js/summary-editor.js
Normal file
1149
src/accounting/static/js/summary-editor.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -20,12 +20,13 @@
|
|||||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
* First written: 2023/2/26
|
* First written: 2023/2/26
|
||||||
*/
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
// Initializes the page JavaScript.
|
// Initializes the page JavaScript.
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const list = document.getElementById("accounting-order-list");
|
const list = document.getElementById("accounting-order-list");
|
||||||
if (list !== null) {
|
if (list !== null) {
|
||||||
const onReorder = function () {
|
const onReorder = () => {
|
||||||
const accounts = Array.from(list.children);
|
const accounts = Array.from(list.children);
|
||||||
for (let i = 0; i < accounts.length; i++) {
|
for (let i = 0; i < accounts.length; i++) {
|
||||||
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
||||||
|
@ -14,50 +14,31 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
"""The template filters and globals for the transaction management.
|
"""The template filters.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
import typing as t
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from html import escape
|
|
||||||
from urllib.parse import ParseResult, urlparse, parse_qsl, urlencode, \
|
|
||||||
urlunparse
|
|
||||||
|
|
||||||
from flask import request, current_app
|
|
||||||
from flask_babel import get_locale
|
from flask_babel import get_locale
|
||||||
|
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency
|
|
||||||
|
|
||||||
|
|
||||||
def with_type(uri: str) -> str:
|
def format_amount(value: Decimal | None) -> str | None:
|
||||||
"""Adds the transaction type to the URI, if it is specified.
|
|
||||||
|
|
||||||
:param uri: The URI.
|
|
||||||
:return: The result URL, optionally with the transaction type added.
|
|
||||||
"""
|
|
||||||
if "as" not in request.args:
|
|
||||||
return uri
|
|
||||||
uri_p: ParseResult = urlparse(uri)
|
|
||||||
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
|
||||||
params = [x for x in params if x[0] != "next"]
|
|
||||||
params.append(("as", request.args["as"]))
|
|
||||||
parts: list[str] = list(uri_p)
|
|
||||||
parts[4] = urlencode(params)
|
|
||||||
return urlunparse(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def format_amount(value: Decimal | None) -> str:
|
|
||||||
"""Formats an amount for readability.
|
"""Formats an amount for readability.
|
||||||
|
|
||||||
:param value: The amount.
|
:param value: The amount.
|
||||||
:return: The formatted amount text.
|
:return: The formatted amount text.
|
||||||
"""
|
"""
|
||||||
if value is None or value == 0:
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value == 0:
|
||||||
return "-"
|
return "-"
|
||||||
whole: int = int(value)
|
whole: int = int(value)
|
||||||
frac: Decimal = (value - whole).normalize()
|
frac: Decimal = (value - whole).normalize()
|
||||||
return "{:,}".format(whole) + str(frac)[1:]
|
return "{:,}".format(whole) + str(abs(frac))[1:]
|
||||||
|
|
||||||
|
|
||||||
def format_date(value: date) -> str:
|
def format_date(value: date) -> str:
|
||||||
@ -90,30 +71,11 @@ def format_date(value: date) -> str:
|
|||||||
return "{}/{}({})".format(value.month, value.day, weekday)
|
return "{}/{}({})".format(value.month, value.day, weekday)
|
||||||
|
|
||||||
|
|
||||||
def text2html(value: str) -> str:
|
def default(value: t.Any, default_value: t.Any = "") -> t.Any:
|
||||||
"""Converts plain text into HTML.
|
"""Returns the default value if the given value is None.
|
||||||
|
|
||||||
:param value: The plain text.
|
:param value: The value.
|
||||||
:return: The HTML.
|
:param default_value: The default value when the given value is None.
|
||||||
|
:return: The value, or the default value if the given value is None.
|
||||||
"""
|
"""
|
||||||
s: str = escape(value)
|
return default_value if value is None else value
|
||||||
s = s.replace("\n", "<br>")
|
|
||||||
s = s.replace(" ", " ")
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def currency_options() -> str:
|
|
||||||
"""Returns the currency options.
|
|
||||||
|
|
||||||
:return: The currency options.
|
|
||||||
"""
|
|
||||||
return Currency.query.order_by(Currency.code).all()
|
|
||||||
|
|
||||||
|
|
||||||
def default_currency_code() -> str:
|
|
||||||
"""Returns the default currency code.
|
|
||||||
|
|
||||||
:return: The default currency code.
|
|
||||||
"""
|
|
||||||
with current_app.app_context():
|
|
||||||
return current_app.config.get("DEFAULT_CURRENCY", "USD")
|
|
39
src/accounting/template_globals.py
Normal file
39
src/accounting/template_globals.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The template globals for the transaction management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from accounting.models import Currency
|
||||||
|
|
||||||
|
|
||||||
|
def currency_options() -> str:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return Currency.query.order_by(Currency.code).all()
|
||||||
|
|
||||||
|
|
||||||
|
def default_currency_code() -> str:
|
||||||
|
"""Returns the default currency code.
|
||||||
|
|
||||||
|
:return: The default currency code.
|
||||||
|
"""
|
||||||
|
with current_app.app_context():
|
||||||
|
return current_app.config.get("DEFAULT_CURRENCY", "USD")
|
@ -59,8 +59,8 @@ First written: 2023/1/31
|
|||||||
{% if accounting_can_edit() %}
|
{% if accounting_can_edit() %}
|
||||||
<form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
|
<form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
{% if "next" in request.args %}
|
{% if request.args.next %}
|
||||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
|
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
@ -85,9 +85,9 @@ First written: 2023/1/31
|
|||||||
<div class="accounting-card col-sm-6">
|
<div class="accounting-card col-sm-6">
|
||||||
<div class="accounting-card-title">{{ obj.title }}</div>
|
<div class="accounting-card-title">{{ obj.title }}</div>
|
||||||
<div class="accounting-card-code">{{ obj.code }}</div>
|
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||||
{% if obj.is_pay_off_needed %}
|
{% if obj.is_need_offset %}
|
||||||
<div>
|
<div>
|
||||||
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
|
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="small text-secondary fst-italic">
|
<div class="small text-secondary fst-italic">
|
||||||
|
@ -27,7 +27,7 @@ First written: 2023/2/1
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="btn-group btn-actions mb-3">
|
<div class="btn-group mb-3">
|
||||||
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
|
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
|
||||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||||
{{ A_("Back") }}
|
{{ A_("Back") }}
|
||||||
@ -36,14 +36,14 @@ First written: 2023/2/1
|
|||||||
|
|
||||||
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
|
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
|
||||||
{{ form.csrf_token }}
|
{{ form.csrf_token }}
|
||||||
{% if "next" in request.args %}
|
{% if request.args.next %}
|
||||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-floating mb-3">
|
<div class="form-floating mb-3">
|
||||||
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
|
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
|
||||||
<div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
|
<div id="accounting-base-control" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
|
||||||
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
|
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
|
||||||
<div id="accounting-base-content">
|
<div id="accounting-base">
|
||||||
{% if form.base_code.data %}
|
{% if form.base_code.data %}
|
||||||
{% if form.base_code.errors %}
|
{% if form.base_code.errors %}
|
||||||
{{ A_("(Unknown)") }}
|
{{ A_("(Unknown)") }}
|
||||||
@ -53,19 +53,19 @@ First written: 2023/2/1
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
|
<div id="accounting-base-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-floating mb-3">
|
<div class="form-floating mb-3">
|
||||||
<input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
|
<input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ form.title.data|accounting_default }}" placeholder=" " required="required">
|
||||||
<label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
|
<label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
|
||||||
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
|
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-check form-switch mb-3">
|
<div id="accounting-is-need-offset-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2", "3"] %} d-none {% endif %}">
|
||||||
<input id="accounting-is-pay-off-needed" class="form-check-input" type="checkbox" name="is_pay_off_needed" value="1" {% if form.is_pay_off_needed.data %} checked="checked" {% endif %}>
|
<input id="accounting-is-need-offset" class="form-check-input" type="checkbox" name="is_need_offset" value="1" {% if form.is_need_offset.data %} checked="checked" {% endif %}>
|
||||||
<label class="form-check-label" for="accounting-is-pay-off-needed">
|
<label class="form-check-label" for="accounting-is-need-offset">
|
||||||
{{ A_("The entries in the account need pay-off.") }}
|
{{ A_("The entries in the account need offset.") }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -99,21 +99,21 @@ First written: 2023/2/1
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul id="accounting-base-option-list" class="list-group accounting-selector-list">
|
<ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list">
|
||||||
{% for base in form.base_options %}
|
{% for base in form.base_options %}
|
||||||
<li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
|
<li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
|
||||||
{{ base }}
|
{{ base }}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
|
<p id="accounting-base-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
||||||
{% if form.base_code.data %}
|
{% if form.base_code.data %}
|
||||||
<button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
|
<button id="accounting-base-selector-clear" type="button" class="btn btn-danger" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
|
<button id="accounting-base-selector-clear" type="button" class="btn btn-secondary" disabled="disabled" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -21,35 +21,23 @@ First written: 2023/1/30
|
|||||||
#}
|
#}
|
||||||
{% extends "accounting/base.html" %}
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{% if request.args.q %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="btn-group mb-2 d-none d-md-inline-flex">
|
<div class="mb-2 accounting-toolbar">
|
||||||
{% if accounting_can_edit() %}
|
{% if accounting_can_edit() %}
|
||||||
<a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
|
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
|
||||||
<i class="fa-solid fa-plus"></i>
|
<i class="fa-solid fa-plus"></i>
|
||||||
{{ A_("New") }}
|
{{ A_("New") }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
|
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
|
||||||
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
|
||||||
<label for="accounting-search-desktop" class="accounting-search-label">
|
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
|
||||||
<button type="submit">
|
<button type="submit">
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
{{ A_("Search") }}
|
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group mb-2 d-md-none">
|
|
||||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
|
|
||||||
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
|
||||||
<label for="accounting-search-mobile" class="accounting-search-label">
|
|
||||||
<button type="submit">
|
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
|
||||||
{{ A_("Search") }}
|
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
@ -70,8 +58,8 @@ First written: 2023/1/30
|
|||||||
{% for item in list %}
|
{% for item in list %}
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
|
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
|
||||||
{{ item }}
|
{{ item }}
|
||||||
{% if item.is_pay_off_needed %}
|
{% if item.is_need_offset %}
|
||||||
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
|
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -40,8 +40,8 @@ First written: 2023/2/2
|
|||||||
{% if base.accounts|length > 1 and accounting_can_edit() %}
|
{% if base.accounts|length > 1 and accounting_can_edit() %}
|
||||||
<form action="{{ url_for("accounting.account.sort", base=base) }}" method="post">
|
<form action="{{ url_for("accounting.account.sort", base=base) }}" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
{% if "next" in request.args %}
|
{% if request.args.next %}
|
||||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
|
<ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
|
||||||
{% for account in base.accounts|sort(attribute="no") %}
|
{% for account in base.accounts|sort(attribute="no") %}
|
||||||
|
@ -21,17 +21,17 @@ First written: 2023/1/26
|
|||||||
#}
|
#}
|
||||||
{% extends "accounting/base.html" %}
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{% if request.args.q %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="btn-group mb-2">
|
<div class="mb-2 accounting-toolbar">
|
||||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-label="{{ A_("Search") }}">
|
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
|
||||||
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
|
||||||
<label for="accounting-search" class="accounting-search-label">
|
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
|
||||||
<button type="submit">
|
<button type="submit">
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
{{ A_("Search") }}
|
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
|
@ -55,8 +55,8 @@ First written: 2023/2/6
|
|||||||
{% if accounting_can_edit() %}
|
{% if accounting_can_edit() %}
|
||||||
<form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
|
<form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
{% if "next" in request.args %}
|
{% if request.args.next %}
|
||||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
|
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
|
@ -27,7 +27,7 @@ First written: 2023/2/6
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="btn-group btn-actions mb-3">
|
<div class="btn-group mb-3">
|
||||||
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
|
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
|
||||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||||
{{ A_("Back") }}
|
{{ A_("Back") }}
|
||||||
@ -36,17 +36,17 @@ First written: 2023/2/6
|
|||||||
|
|
||||||
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
|
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
|
||||||
{{ form.csrf_token }}
|
{{ form.csrf_token }}
|
||||||
{% if "next" in request.args %}
|
{% if request.args.next %}
|
||||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-floating mb-3">
|
<div class="form-floating mb-3">
|
||||||
<input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ "" if form.code.data is none else form.code.data }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
|
<input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ form.code.data|accounting_default }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
|
||||||
<label class="form-label" for="accounting-code">{{ A_("Code") }}</label>
|
<label class="form-label" for="accounting-code">{{ A_("Code") }}</label>
|
||||||
<div id="accounting-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
|
<div id="accounting-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-floating mb-3">
|
<div class="form-floating mb-3">
|
||||||
<input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ "" if form.name.data is none else form.name.data }}" placeholder=" " required="required">
|
<input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ form.name.data|accounting_default }}" placeholder=" " required="required">
|
||||||
<label class="form-label" for="accounting-name">{{ A_("Name") }}</label>
|
<label class="form-label" for="accounting-name">{{ A_("Name") }}</label>
|
||||||
<div id="accounting-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
|
<div id="accounting-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -21,35 +21,23 @@ First written: 2023/2/6
|
|||||||
#}
|
#}
|
||||||
{% extends "accounting/base.html" %}
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{% if request.args.q %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="btn-group mb-2 d-none d-md-inline-flex">
|
<div class="mb-2 accounting-toolbar">
|
||||||
{% if accounting_can_edit() %}
|
{% if accounting_can_edit() %}
|
||||||
<a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
|
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
|
||||||
<i class="fa-solid fa-plus"></i>
|
<i class="fa-solid fa-plus"></i>
|
||||||
{{ A_("New") }}
|
{{ A_("New") }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
|
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
|
||||||
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
|
||||||
<label for="accounting-search-desktop" class="accounting-search-label">
|
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
|
||||||
<button type="submit">
|
<button type="submit">
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
{{ A_("Search") }}
|
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group mb-2 d-md-none">
|
|
||||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
|
|
||||||
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
|
||||||
<label for="accounting-search-mobile" class="accounting-search-label">
|
|
||||||
<button type="submit">
|
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
|
||||||
{{ A_("Search") }}
|
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
|
@ -28,14 +28,14 @@ First written: 2023/1/26
|
|||||||
</span>
|
</span>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.transaction.") %} active {% endif %}" href="{{ url_for("accounting.transaction.list") }}">
|
<a class="dropdown-item {% if request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}">
|
||||||
<i class="fa-solid fa-receipt"></i>
|
<i class="fa-solid fa-book"></i>
|
||||||
{{ A_("Transactions") }}
|
{{ A_("Reports") }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
|
<a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
|
||||||
<i class="fa-solid fa-list"></i>
|
<i class="fa-solid fa-clipboard"></i>
|
||||||
{{ A_("Accounts") }}
|
{{ A_("Accounts") }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
113
src/accounting/templates/accounting/report/balance-sheet.html
Normal file
113
src/accounting/templates/accounting/report/balance-sheet.html
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
income-statement.html: The income statement
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/7
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block accounting_scripts %}
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="mb-3 accounting-toolbar">
|
||||||
|
{% with use_currency_chooser = true,
|
||||||
|
use_period_chooser = true %}
|
||||||
|
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/period-chooser.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/search-modal.html" %}
|
||||||
|
|
||||||
|
{% if report.has_data %}
|
||||||
|
<div class="accounting-sheet">
|
||||||
|
<div class="d-none d-sm-flex justify-content-center mb-3">
|
||||||
|
<h2 class="text-center">{{ A_("Balance Sheet of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row accounting-report-table accounting-balance-sheet-table">
|
||||||
|
<div class="col-sm-6">
|
||||||
|
{% if report.assets.subsections %}
|
||||||
|
{% with section = report.assets %}
|
||||||
|
{% include "accounting/report/include/balance-sheet-section.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
<div class="d-md-none d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-total">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div class="accounting-amount {% if report.assets.total < 0 %} text-danger {% endif %}">{{ report.assets.total|accounting_report_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-sm-6">
|
||||||
|
{% if report.liabilities.subsections %}
|
||||||
|
{% with section = report.liabilities %}
|
||||||
|
{% include "accounting/report/include/balance-sheet-section.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
<div class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-subtotal">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div class="accounting-amount {% if report.liabilities.total < 0 %} text-danger {% endif %}">{{ report.liabilities.total|accounting_report_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if report.owner_s_equity.subsections %}
|
||||||
|
{% with section = report.owner_s_equity %}
|
||||||
|
{% include "accounting/report/include/balance-sheet-section.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
<div class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-subtotal">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div class="accounting-amount {% if report.owner_s_equity.total < 0 %} text-danger {% endif %}">{{ report.owner_s_equity.total|accounting_report_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-md-none d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-total">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div class="accounting-amount {% if report.liabilities.total < 0 %} text-danger {% endif %}">{{ (report.liabilities.total + report.owner_s_equity.total)|accounting_report_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row d-none d-md-flex">
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<div class="d-flex justify-content-between accounting-balance-sheet-total">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div class="accounting-amount {% if report.assets.total < 0 %} text-danger {% endif %}">{{ report.assets.total|accounting_report_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<div class="d-flex justify-content-between accounting-balance-sheet-total">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div class="accounting-amount {% if report.liabilities.total + report.owner_s_equity.total < 0 %} text-danger {% endif %}">{{ (report.liabilities.total + report.owner_s_equity.total)|accounting_report_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ A_("There is no data.") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
add-new-material-fab.html: The material floating action buttons to add a new transaction
|
add-txn-material-fab.html: The material floating action buttons to add a new transaction
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -22,13 +22,13 @@ First written: 2023/2/25
|
|||||||
{% if accounting_can_edit() %}
|
{% if accounting_can_edit() %}
|
||||||
<div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
|
<div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
|
||||||
<div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
|
<div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
|
||||||
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.expense)|accounting_append_next }}">
|
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
|
||||||
{{ A_("Cash expense") }}
|
{{ A_("Cash expense") }}
|
||||||
</a>
|
</a>
|
||||||
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.income)|accounting_append_next }}">
|
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
|
||||||
{{ A_("Cash income") }}
|
{{ A_("Cash income") }}
|
||||||
</a>
|
</a>
|
||||||
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.transfer)|accounting_append_next }}">
|
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
|
||||||
{{ A_("Transfer") }}
|
{{ A_("Transfer") }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
@ -0,0 +1,43 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
balance-sheet-section.html: A section in the balance sheet.
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/8
|
||||||
|
#}
|
||||||
|
<div class="accounting-report-table-row accounting-balance-sheet-section">
|
||||||
|
<div>{{ section.title.title|title }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="accounting-report-table-body">
|
||||||
|
{% for subsection in section.subsections %}
|
||||||
|
<div class="accounting-report-table-row accounting-balance-sheet-subsection">
|
||||||
|
<div>
|
||||||
|
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
|
||||||
|
{{ subsection.title.title|title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for account in subsection.accounts %}
|
||||||
|
<a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}">
|
||||||
|
<div>
|
||||||
|
<span class="d-none d-md-inline">{{ account.account.code }}</span>
|
||||||
|
{{ account.account.title|title }}
|
||||||
|
</div>
|
||||||
|
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
@ -0,0 +1,27 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
income-expenses-row-desktop.html: The row in the income and expenses log for the desktop computers
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/8
|
||||||
|
#}
|
||||||
|
<div>{{ entry.date|accounting_format_date }}</div>
|
||||||
|
<div>{{ entry.account.title|title }}</div>
|
||||||
|
<div>{{ entry.summary|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
|
@ -0,0 +1,50 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
income-expenses-row-mobile.html: The row in the income and expenses log for the mobile devices
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/5
|
||||||
|
#}
|
||||||
|
<div>
|
||||||
|
{% if entry.date or entry.account %}
|
||||||
|
<div class="text-muted small">
|
||||||
|
{% if entry.date %}
|
||||||
|
{{ entry.date|accounting_format_date }}
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.account %}
|
||||||
|
{{ entry.account.title|title }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.summary %}
|
||||||
|
<div>{{ entry.summary }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-nowrap">
|
||||||
|
{% if entry.income %}
|
||||||
|
<span class="badge rounded-pill bg-success">+{{ entry.income|accounting_format_amount }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.expense %}
|
||||||
|
<span class="badge rounded-pill bg-warning">-{{ entry.expense|accounting_format_amount }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.balance < 0 %}
|
||||||
|
<span class="badge rounded-pill bg-danger">{{ entry.balance|accounting_format_amount }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
@ -0,0 +1,28 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
ledger-row-desktop.html: The row in the ledger for the desktop computers
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/8
|
||||||
|
#}
|
||||||
|
<div>{{ entry.date|accounting_format_date }}</div>
|
||||||
|
<div>{{ entry.summary|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
||||||
|
{% if report.account.is_real %}
|
||||||
|
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
|
||||||
|
{% endif %}
|
@ -0,0 +1,43 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
ledger-row-mobile.html: The row in the ledger for the mobile devices
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/5
|
||||||
|
#}
|
||||||
|
<div>
|
||||||
|
{% if entry.date %}
|
||||||
|
<div class="text-muted small">
|
||||||
|
{{ entry.date|accounting_format_date }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.summary %}
|
||||||
|
<div>{{ entry.summary }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% if entry.debit %}
|
||||||
|
<span class="badge rounded-pill bg-success">+{{ entry.debit|accounting_format_amount }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.credit %}
|
||||||
|
<span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if report.account.is_real %}
|
||||||
|
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
@ -0,0 +1,150 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
period-chooser.html: The period chooser
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/4
|
||||||
|
#}
|
||||||
|
<div id="accounting-period-chooser-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-period-chooser-modal-label" aria-hidden="true" data-url-template="{{ report.period_chooser.url_template }}">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="accounting-period-chooser-modal-label">{{ A_("Period Chooser") }}</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{# Tab navigation #}
|
||||||
|
<ul class="nav nav-tabs mb-2">
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-period-chooser-month-tab" class="nav-link {% if report.period.is_type_month %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_type_month %} page {% else %} false {% endif %}" data-tab-id="month">
|
||||||
|
{{ A_("Month") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-period-chooser-year-tab" class="nav-link {% if report.period.is_a_year %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_a_year %} page {% else %} false {% endif %}" data-tab-id="year">
|
||||||
|
{{ A_("Year") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-period-chooser-day-tab" class="nav-link {% if report.period.is_a_day %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_a_day %} page {% else %} false {% endif %}" data-tab-id="day">
|
||||||
|
{{ A_("Day") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-period-chooser-custom-tab" class="nav-link {% if report.period.is_type_arbitrary %} active {% endif %} accounting-clickable" aria-current="{% if report.period.is_type_arbitrary %} page {% else %} false {% endif %}" data-tab-id="custom">
|
||||||
|
{{ A_("Custom") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{# The month periods #}
|
||||||
|
<div id="accounting-period-chooser-month-page" {% if report.period.is_type_month %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-month-tab">
|
||||||
|
<div>
|
||||||
|
<a class="btn {% if report.period.is_this_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_month_url }}">
|
||||||
|
{{ A_("This month") }}
|
||||||
|
</a>
|
||||||
|
{% if report.period_chooser.has_last_month %}
|
||||||
|
<a class="btn {% if report.period.is_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_month_url }}">
|
||||||
|
{{ A_("Last month") }}
|
||||||
|
</a>
|
||||||
|
<a class="btn {% if report.period.is_since_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.since_last_month_url }}">
|
||||||
|
{{ A_("Since last month") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if report.period_chooser.has_data %}
|
||||||
|
<div id="accounting-period-chooser-month-chooser" class="mt-3" data-start="{{ report.period_chooser.data_start }}" data-default="{{ report.period.start|accounting_default(report.period_chooser.data_start) }}"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The year periods #}
|
||||||
|
<div id="accounting-period-chooser-year-page" {% if report.period.is_a_year %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-year-tab">
|
||||||
|
<a class="btn {% if report.period.is_this_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_year_url }}">
|
||||||
|
{{ A_("This year") }}
|
||||||
|
</a>
|
||||||
|
{% if report.period_chooser.has_last_year %}
|
||||||
|
<a class="btn {% if report.period.is_last_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_year_url }}">
|
||||||
|
{{ A_("Last year") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if report.period_chooser.available_years %}
|
||||||
|
<ul class="nav nav-pills mt-3">
|
||||||
|
{% for year in report.period_chooser.available_years %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if report.period.is_year(year) %} active {% endif %}" href="{{ report.period_chooser.year_url(year) }}">{{ year }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The day periods #}
|
||||||
|
<div id="accounting-period-chooser-day-page" {% if report.period.is_a_day %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-day-tab">
|
||||||
|
<div>
|
||||||
|
<a class="btn {% if report.period.is_today %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.today_url }}">
|
||||||
|
{{ A_("Today") }}
|
||||||
|
</a>
|
||||||
|
{% if report.period_chooser.has_yesterday %}
|
||||||
|
<a class="btn {% if report.period.is_yesterday %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.yesterday_url }}">
|
||||||
|
{{ A_("Yesterday") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if report.period_chooser.has_data %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input id="accounting-period-chooser-day-date" class="form-control" type="date" value="{{ report.period.start|accounting_default }}" min="{{ report.period_chooser.data_start }}" required="required">
|
||||||
|
<label for="accounting-period-chooser-day-date" class="form-label">{{ A_("Date") }}</label>
|
||||||
|
<div id="accounting-period-chooser-day-date-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The custom periods #}
|
||||||
|
<div id="accounting-period-chooser-custom-page" {% if report.period.is_type_arbitrary %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-custom-tab">
|
||||||
|
<div>
|
||||||
|
<a class="btn {% if report.period.is_all %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.all_url }}">
|
||||||
|
{{ A_("All") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% if report.period_chooser.has_data %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input id="accounting-period-chooser-custom-start" class="form-control" type="date" value="{{ report.period.start|accounting_default }}" min="{{ report.period_chooser.data_start }}" max="{{ report.period.end }}" required="required">
|
||||||
|
<label for="accounting-period-chooser-custom-start" class="form-label">{{ A_("From") }}</label>
|
||||||
|
<div id="accounting-period-chooser-custom-start-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input id="accounting-period-chooser-custom-end" class="form-control" type="date" value="{{ report.period.end|accounting_default }}" min="{{ report.period.start }}" required="required">
|
||||||
|
<label for="accounting-period-chooser-custom-end" class="form-label">{{ A_("To") }}</label>
|
||||||
|
<div id="accounting-period-chooser-custom-end-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button id="accounting-period-chooser-custom-confirm" class="btn btn-primary" type="submit">
|
||||||
|
{{ A_("Confirm") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,43 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
search-modal.html: The search modal
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/8
|
||||||
|
#}
|
||||||
|
<form action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-labelledby="accounting-search-modal-label">
|
||||||
|
<div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-modal-label" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="accounting-search-modal-label">{{ A_("Search") }}</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input id="accounting-search-modal-search" class="form-control" type="text" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
|
||||||
|
<label for="accounting-search-modal-search">{{ A_("Search") }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
||||||
|
<button type="submit" class="btn btn-primary">{{ A_("Search") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,130 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
toolbar-buttons.html: The toolbar buttons on the report
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/8
|
||||||
|
#}
|
||||||
|
{% if accounting_can_edit() %}
|
||||||
|
<div class="btn-group d-none d-md-flex" role="group">
|
||||||
|
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
<span class="d-none d-md-inline">{{ A_("New") }}</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
|
||||||
|
{{ A_("Cash Expense") }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
|
||||||
|
{{ A_("Cash Income") }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
|
||||||
|
{{ A_("Transfer") }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fa-solid fa-book"></i>
|
||||||
|
<span class="d-none d-md-inline">{{ report.report_chooser.current_report }}</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu" aria-label="{{ A_("Report") }}">
|
||||||
|
{% for report in report.report_chooser %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">
|
||||||
|
<i class="{{ report.fa_icon }}"></i>
|
||||||
|
{{ report.title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li>
|
||||||
|
<span class="dropdown-item {% if report.report_chooser.is_search %} active {% endif %} accounting-clickable" data-bs-toggle="modal" data-bs-target="#accounting-search-modal">
|
||||||
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
|
{{ A_("Search") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% if use_currency_chooser %}
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fa-solid fa-money-bill-wave"></i>
|
||||||
|
<span class="d-none d-md-inline">{{ report.currency.name|title }}</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu" aria-label="{{ A_("Currency") }}">
|
||||||
|
{% for currency in report.currency_options %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
|
||||||
|
{{ currency.title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if use_account_chooser %}
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fa-solid fa-clipboard"></i>
|
||||||
|
<span class="d-none d-md-inline">{{ report.account.title|title }}</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu" aria-label="{{ A_("Account") }}">
|
||||||
|
{% for account in report.account_options %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
|
||||||
|
{{ account.title|title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if use_period_chooser %}
|
||||||
|
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
|
||||||
|
<i class="fa-solid fa-calendar-day"></i>
|
||||||
|
<span class="d-none d-md-inline">{{ report.period.desc|title }}</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if report.has_data %}
|
||||||
|
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
|
||||||
|
<i class="fa-solid fa-download"></i>
|
||||||
|
<span class="d-none d-md-inline">{{ A_("Download") }}</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-secondary" type="button" disabled="disabled">
|
||||||
|
<i class="fa-solid fa-download"></i>
|
||||||
|
<span class="d-none d-md-inline">{{ A_("Download") }}</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if use_search %}
|
||||||
|
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
|
||||||
|
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
|
||||||
|
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
|
||||||
|
<button type="submit">
|
||||||
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
|
<span class="d-none d-md-inline">{{ A_("Search") }}</span>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
116
src/accounting/templates/accounting/report/income-expenses.html
Normal file
116
src/accounting/templates/accounting/report/income-expenses.html
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
income-expenses.html: The income and expenses log
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/5
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block accounting_scripts %}
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="mb-3 accounting-toolbar">
|
||||||
|
{% with use_currency_chooser = true,
|
||||||
|
use_account_chooser = true,
|
||||||
|
use_period_chooser = true %}
|
||||||
|
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/period-chooser.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/search-modal.html" %}
|
||||||
|
|
||||||
|
{% if report.has_data %}
|
||||||
|
{% with pagination = report.pagination %}
|
||||||
|
{% include "accounting/include/pagination.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="d-none d-md-block accounting-report-table accounting-income-expenses-table">
|
||||||
|
<div class="accounting-report-table-header">
|
||||||
|
<div class="accounting-report-table-row">
|
||||||
|
<div>{{ A_("Date") }}</div>
|
||||||
|
<div>{{ A_("Account") }}</div>
|
||||||
|
<div>{{ A_("Summary") }}</div>
|
||||||
|
<div class="accounting-amount">{{ A_("Income") }}</div>
|
||||||
|
<div class="accounting-amount">{{ A_("Expense") }}</div>
|
||||||
|
<div class="accounting-amount">{{ A_("Balance") }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accounting-report-table-body">
|
||||||
|
{% if report.brought_forward %}
|
||||||
|
{% with entry = report.brought_forward %}
|
||||||
|
<div class="accounting-report-table-row">
|
||||||
|
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
{% for entry in report.entries %}
|
||||||
|
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
|
||||||
|
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if report.total %}
|
||||||
|
{% with entry = report.total %}
|
||||||
|
<div class="accounting-report-table-footer">
|
||||||
|
<div class="accounting-report-table-row">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.income|accounting_format_amount }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.expense|accounting_format_amount }}</div>
|
||||||
|
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-group d-md-none">
|
||||||
|
{% if report.brought_forward %}
|
||||||
|
{% with entry = report.brought_forward %}
|
||||||
|
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||||
|
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
{% for entry in report.entries %}
|
||||||
|
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}">
|
||||||
|
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% if report.total %}
|
||||||
|
{% with entry = report.total %}
|
||||||
|
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||||
|
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ A_("There is no data.") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -0,0 +1,99 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
income-statement.html: The income statement
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/7
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block accounting_scripts %}
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="mb-3 accounting-toolbar">
|
||||||
|
{% with use_currency_chooser = true,
|
||||||
|
use_period_chooser = true %}
|
||||||
|
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/period-chooser.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/search-modal.html" %}
|
||||||
|
|
||||||
|
{% if report.has_data %}
|
||||||
|
<div class="accounting-sheet">
|
||||||
|
<div class="d-none d-sm-flex justify-content-center mb-3">
|
||||||
|
<h2 class="text-center">{{ A_("Income Statement of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="accounting-report-table accounting-income-statement-table">
|
||||||
|
<div class="accounting-report-table-header">
|
||||||
|
<div class="accounting-report-table-row">
|
||||||
|
<div class="accounting-amount">{{ A_("Amount") }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accounting-report-table-body">
|
||||||
|
{% for section in report.sections %}
|
||||||
|
<div class="accounting-report-table-row accounting-income-statement-section">
|
||||||
|
<div>
|
||||||
|
<span class="d-none d-md-inline">{{ section.title.code }}</span>
|
||||||
|
{{ section.title.title|title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for subsection in section.subsections %}
|
||||||
|
<div class="accounting-report-table-row accounting-income-statement-subsection">
|
||||||
|
<div>
|
||||||
|
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
|
||||||
|
{{ subsection.title.title|title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for account in subsection.accounts %}
|
||||||
|
<a class="accounting-report-table-row accounting-income-statement-account" href="{{ account.url }}">
|
||||||
|
<div>
|
||||||
|
<span class="d-none d-md-inline">{{ account.account.code }}</span>
|
||||||
|
{{ account.account.title|title }}
|
||||||
|
</div>
|
||||||
|
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="accounting-report-table-row accounting-income-statement-subtotal">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div class="accounting-amount {% if subsection.total < 0 %} text-danger {% endif %}">{{ subsection.total|accounting_report_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="accounting-report-table-row accounting-income-statement-total">
|
||||||
|
<div>{{ section.accumulated.title|title }}</div>
|
||||||
|
<div class="accounting-amount {% if section.accumulated.amount < 0 %} text-danger {% endif %}">{{ section.accumulated.amount|accounting_report_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ A_("There is no data.") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
106
src/accounting/templates/accounting/report/journal.html
Normal file
106
src/accounting/templates/accounting/report/journal.html
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
journal.html: The journal
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/4
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block accounting_scripts %}
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Journal %(period)s", period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="mb-3 accounting-toolbar">
|
||||||
|
{% with use_period_chooser = true %}
|
||||||
|
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/period-chooser.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/search-modal.html" %}
|
||||||
|
|
||||||
|
{% if report.has_data %}
|
||||||
|
{% with pagination = report.pagination %}
|
||||||
|
{% include "accounting/include/pagination.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="d-none d-md-block accounting-report-table accounting-journal-table">
|
||||||
|
<div class="accounting-report-table-header">
|
||||||
|
<div class="accounting-report-table-row">
|
||||||
|
<div>{{ A_("Date") }}</div>
|
||||||
|
<div>{{ A_("Currency") }}</div>
|
||||||
|
<div>{{ A_("Account") }}</div>
|
||||||
|
<div>{{ A_("Summary") }}</div>
|
||||||
|
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
||||||
|
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accounting-report-table-body">
|
||||||
|
{% for entry in report.entries %}
|
||||||
|
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||||
|
<div>{{ entry.transaction.date|accounting_format_date }}</div>
|
||||||
|
<div>{{ entry.currency.name }}</div>
|
||||||
|
<div>
|
||||||
|
<span class="d-none d-md-inline">{{ entry.account.code }}</span>
|
||||||
|
{{ entry.account.title|title }}
|
||||||
|
</div>
|
||||||
|
<div>{{ entry.summary|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-group d-md-none">
|
||||||
|
{% for entry in report.entries %}
|
||||||
|
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
|
||||||
|
<div class="text-muted small">
|
||||||
|
{{ entry.transaction.date|accounting_format_date }}
|
||||||
|
{{ entry.account.title|title }}
|
||||||
|
{% if entry.currency.code != accounting_default_currency_code() %}
|
||||||
|
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if entry.summary is not none %}
|
||||||
|
<div>{{ entry.summary }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ A_("There is no data.") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
119
src/accounting/templates/accounting/report/ledger.html
Normal file
119
src/accounting/templates/accounting/report/ledger.html
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
ledger.html: The ledger
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/5
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block accounting_scripts %}
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="mb-3 accounting-toolbar">
|
||||||
|
{% with use_currency_chooser = true,
|
||||||
|
use_account_chooser = true,
|
||||||
|
use_period_chooser = true %}
|
||||||
|
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/period-chooser.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/search-modal.html" %}
|
||||||
|
|
||||||
|
{% if report.has_data %}
|
||||||
|
{% with pagination = report.pagination %}
|
||||||
|
{% include "accounting/include/pagination.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="d-none d-md-block accounting-report-table {% if report.account.is_real %} accounting-ledger-real-table {% else %} accounting-ledger-nominal-table {% endif %}">
|
||||||
|
<div class="accounting-report-table-header">
|
||||||
|
<div class="accounting-report-table-row">
|
||||||
|
<div>{{ A_("Date") }}</div>
|
||||||
|
<div>{{ A_("Summary") }}</div>
|
||||||
|
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
||||||
|
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
||||||
|
{% if report.account.is_real %}
|
||||||
|
<div class="accounting-amount">{{ A_("Balance") }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accounting-report-table-body">
|
||||||
|
{% if report.brought_forward %}
|
||||||
|
{% with entry = report.brought_forward %}
|
||||||
|
<div class="accounting-report-table-row">
|
||||||
|
{% include "accounting/report/include/ledger-row-desktop.html" %}
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
{% for entry in report.entries %}
|
||||||
|
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
|
||||||
|
{% include "accounting/report/include/ledger-row-desktop.html" %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if report.total %}
|
||||||
|
{% with entry = report.total %}
|
||||||
|
<div class="accounting-report-table-footer">
|
||||||
|
<div class="accounting-report-table-row">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
||||||
|
{% if report.account.is_real %}
|
||||||
|
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-group d-md-none">
|
||||||
|
{% if report.brought_forward %}
|
||||||
|
{% with entry = report.brought_forward %}
|
||||||
|
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||||
|
{% include "accounting/report/include/ledger-row-mobile.html" %}
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
{% for entry in report.entries %}
|
||||||
|
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}">
|
||||||
|
{% include "accounting/report/include/ledger-row-mobile.html" %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% if report.total %}
|
||||||
|
{% with entry = report.total %}
|
||||||
|
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||||
|
{% include "accounting/report/include/ledger-row-mobile.html" %}
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ A_("There is no data.") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
103
src/accounting/templates/accounting/report/search.html
Normal file
103
src/accounting/templates/accounting/report/search.html
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
search.html: The search result
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/8
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block accounting_scripts %}
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="mb-3 accounting-toolbar">
|
||||||
|
{% with use_search = true %}
|
||||||
|
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/search-modal.html" %}
|
||||||
|
|
||||||
|
{% if report.has_data %}
|
||||||
|
{% with pagination = report.pagination %}
|
||||||
|
{% include "accounting/include/pagination.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="d-none d-md-block accounting-report-table accounting-journal-table">
|
||||||
|
<div class="accounting-report-table-header">
|
||||||
|
<div class="accounting-report-table-row">
|
||||||
|
<div>{{ A_("Date") }}</div>
|
||||||
|
<div>{{ A_("Currency") }}</div>
|
||||||
|
<div>{{ A_("Account") }}</div>
|
||||||
|
<div>{{ A_("Summary") }}</div>
|
||||||
|
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
||||||
|
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accounting-report-table-body">
|
||||||
|
{% for entry in report.entries %}
|
||||||
|
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||||
|
<div>{{ entry.transaction.date|accounting_format_date }}</div>
|
||||||
|
<div>{{ entry.currency.name }}</div>
|
||||||
|
<div>
|
||||||
|
<span class="d-none d-md-inline">{{ entry.account.code }}</span>
|
||||||
|
{{ entry.account.title|title }}
|
||||||
|
</div>
|
||||||
|
<div>{{ entry.summary|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-group d-md-none">
|
||||||
|
{% for entry in report.entries %}
|
||||||
|
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
|
||||||
|
<div class="text-muted small">
|
||||||
|
{{ entry.transaction.date|accounting_format_date }}
|
||||||
|
{{ entry.account.title|title }}
|
||||||
|
{% if entry.currency.code != accounting_default_currency_code() %}
|
||||||
|
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if entry.summary is not none %}
|
||||||
|
<div>{{ entry.summary }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ A_("There is no data.") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -0,0 +1,85 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
trial-balance.html: The trial balance
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/5
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block accounting_scripts %}
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Trial Balance of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="mb-3 accounting-toolbar">
|
||||||
|
{% with use_currency_chooser = true,
|
||||||
|
use_period_chooser = true %}
|
||||||
|
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/period-chooser.html" %}
|
||||||
|
|
||||||
|
{% include "accounting/report/include/search-modal.html" %}
|
||||||
|
|
||||||
|
{% if report.has_data %}
|
||||||
|
<div class="accounting-sheet">
|
||||||
|
<div class="d-none d-sm-flex justify-content-center mb-3">
|
||||||
|
<h2 class="text-center">{{ A_("Trial Balance of %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="accounting-report-table accounting-trial-balance-table">
|
||||||
|
<div class="accounting-report-table-header">
|
||||||
|
<div class="accounting-report-table-row">
|
||||||
|
<div>{{ A_("Account") }}</div>
|
||||||
|
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
||||||
|
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="accounting-report-table-body">
|
||||||
|
{% for account in report.accounts %}
|
||||||
|
<a class="accounting-report-table-row" href="{{ account.url }}">
|
||||||
|
<div>
|
||||||
|
<span class="d-none d-md-inline">{{ account.account.code }}</span>
|
||||||
|
{{ account.account.title|title }}
|
||||||
|
</div>
|
||||||
|
<div class="accounting-amount">{{ account.debit|accounting_format_amount|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount">{{ account.credit|accounting_format_amount|accounting_default }}</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="accounting-report-table-footer">
|
||||||
|
<div class="accounting-report-table-row">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div class="accounting-amount">{{ report.total.debit|accounting_format_amount }}</div>
|
||||||
|
<div class="accounting-amount">{{ report.total.credit|accounting_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ A_("There is no data.") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -23,6 +23,6 @@ First written: 2023/2/25
|
|||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
|
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
|
||||||
|
|
||||||
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
|
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
|
||||||
|
@ -21,6 +21,13 @@ First written: 2023/2/26
|
|||||||
#}
|
#}
|
||||||
{% extends "accounting/transaction/include/detail.html" %}
|
{% extends "accounting/transaction/include/detail.html" %}
|
||||||
|
|
||||||
|
{% block to_transfer %}
|
||||||
|
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
|
||||||
|
<i class="fa-solid fa-bars-staggered"></i>
|
||||||
|
{{ A_("To Transfer") }}
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block transaction_currencies %}
|
{% block transaction_currencies %}
|
||||||
{% for currency in obj.currencies %}
|
{% for currency in obj.currencies %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -28,23 +35,13 @@ First written: 2023/2/26
|
|||||||
|
|
||||||
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
|
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
|
||||||
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
|
||||||
{% for entry in currency.debit %}
|
{% with entries = currency.debit %}
|
||||||
<li class="list-group-item accounting-transaction-entry">
|
{% include "accounting/transaction/include/detail-entries.html" %}
|
||||||
<div class="d-flex justify-content-between">
|
{% endwith %}
|
||||||
<div>
|
|
||||||
<div class="small">{{ entry.account }}</div>
|
|
||||||
{% if entry.summary is not none %}
|
|
||||||
<div>{{ entry.summary }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>{{ _("Total") }}</div>
|
<div>{{ A_("Total") }}</div>
|
||||||
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
|
<div>{{ currency.debit_total|accounting_format_amount }}</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -19,22 +19,23 @@ currency-sub-form.html: The currency sub-form in the cash expense transaction fo
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
First written: 2023/2/25
|
||||||
#}
|
#}
|
||||||
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
|
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
|
||||||
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
|
||||||
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
|
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
|
||||||
<div class="d-flex justify-content-between mt-2 mb-3">
|
<div class="d-flex justify-content-between mt-2 mb-3">
|
||||||
<div class="form-floating accounting-currency-content">
|
<div class="form-floating accounting-currency-content">
|
||||||
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
|
<select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
|
||||||
{% for currency in accounting_txn_currency_options() %}
|
{% for currency in accounting_currency_options() %}
|
||||||
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
|
<label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
|
||||||
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
|
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
|
||||||
<i class="fas fa-minus"></i>
|
<i class="fas fa-minus"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -50,14 +51,22 @@ First written: 2023/2/25
|
|||||||
entry_index = loop.index,
|
entry_index = loop.index,
|
||||||
entry_id = entry_form.eid.data,
|
entry_id = entry_form.eid.data,
|
||||||
only_one_entry_form = debit_forms|length == 1,
|
only_one_entry_form = debit_forms|length == 1,
|
||||||
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
|
account_code_data = entry_form.account_code.data|accounting_default,
|
||||||
account_code_error = entry_form.account_code.errors,
|
account_code_error = entry_form.account_code.errors,
|
||||||
account_text = entry_form.account_text,
|
account_text = entry_form.account_text,
|
||||||
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
|
summary_data = entry_form.summary.data|accounting_default,
|
||||||
summary_errors = entry_form.summary.errors,
|
summary_errors = entry_form.summary.errors,
|
||||||
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
|
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
|
||||||
|
original_entry_date = entry_form.original_entry_date|accounting_default,
|
||||||
|
original_entry_text = entry_form.original_entry_text|accounting_default,
|
||||||
|
is_need_offset = entry_form.is_need_offset,
|
||||||
|
offset_entries = entry_form.offsets,
|
||||||
|
offset_total = entry_form.offset_total|accounting_default("0"),
|
||||||
|
net_balance_data = entry_form.net_balance,
|
||||||
|
net_balance_text = entry_form.net_balance|accounting_format_amount,
|
||||||
|
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
|
||||||
amount_errors = entry_form.amount.errors,
|
amount_errors = entry_form.amount.errors,
|
||||||
amount_text = entry_form.amount.data|accounting_txn_format_amount,
|
amount_text = entry_form.amount.data|accounting_format_amount,
|
||||||
entry_errors = entry_form.all_errors %}
|
entry_errors = entry_form.all_errors %}
|
||||||
{% include "accounting/transaction/include/form-entry-item.html" %}
|
{% include "accounting/transaction/include/form-entry-item.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
@ -70,7 +79,7 @@ First written: 2023/2/25
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
|
<button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
{{ A_("New") }}
|
{{ A_("New") }}
|
||||||
</button>
|
</button>
|
||||||
|
@ -29,22 +29,29 @@ First written: 2023/2/25
|
|||||||
currency_errors = currency_form.whole_form.errors,
|
currency_errors = currency_form.whole_form.errors,
|
||||||
currency_code_data = currency_form.code.data,
|
currency_code_data = currency_form.code.data,
|
||||||
currency_code_errors = currency_form.code.errors,
|
currency_code_errors = currency_form.code.errors,
|
||||||
|
currency_code_is_locked = currency_form.is_code_locked,
|
||||||
debit_forms = currency_form.debit,
|
debit_forms = currency_form.debit,
|
||||||
debit_errors = currency_form.debit_errors,
|
debit_errors = currency_form.debit_errors,
|
||||||
debit_total = currency_form.form.debit_total|accounting_txn_format_amount %}
|
debit_total = currency_form.form.debit_total|accounting_format_amount %}
|
||||||
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
|
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% with currency_index = 1,
|
{% with currency_index = 1,
|
||||||
only_one_currency_form = True,
|
only_one_currency_form = True,
|
||||||
currency_code_data = accounting_txn_default_currency_code(),
|
currency_code_data = accounting_default_currency_code(),
|
||||||
debit_total = "-" %}
|
debit_total = "-" %}
|
||||||
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
|
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block account_selector_modals %}
|
{% block form_modals %}
|
||||||
{% include "accounting/transaction/include/debit-account-modal.html" %}
|
{% with summary_editor = form.summary_editor.debit %}
|
||||||
|
{% include "accounting/transaction/include/summary-editor-modal.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% with entry_type = "debit",
|
||||||
|
account_options = form.debit_account_options %}
|
||||||
|
{% include "accounting/transaction/include/account-selector-modal.html" %}
|
||||||
|
{% endwith %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user