Compare commits
694 Commits
Author | SHA1 | Date | |
---|---|---|---|
cb1d254cf0 | |||
eb9ad57e72 | |||
ec26f8ef4d | |||
7ed29115ed | |||
95955197ac | |||
d5a0f79e4b | |||
6aa655aa64 | |||
6e532af26e | |||
fa1818d124 | |||
f21ecc2aa9 | |||
5ae1ab95ae | |||
7a5b3b78fc | |||
7df4051452 | |||
85084c68fd | |||
0185c16654 | |||
7dd007f3cf | |||
38b8a028d5 | |||
213981a8b2 | |||
a4d1789b58 | |||
91620d7db2 | |||
02fcabb0ce | |||
4c2dcc5070 | |||
c9166fda4d | |||
3a0f0873e2 | |||
a17395b43e | |||
17c8d9d1a9 | |||
fa94cd407e | |||
9a704c8185 | |||
8286c0c6d8 | |||
f7efacad75 | |||
9263ae0274 | |||
78a9d7794c | |||
f3ae37a409 | |||
ddc1081252 | |||
202d51a032 | |||
562bc47be7 | |||
f3d43a66cc | |||
c3fc6d9a87 | |||
e1a0380628 | |||
f2a2fcdd32 | |||
ab29166f1e | |||
8033921181 | |||
08732c1e66 | |||
4adc464d3d | |||
2f9d2e36cb | |||
5bb10bf6ba | |||
06e7b6ddff | |||
20e1982984 | |||
a70720be50 | |||
cb6de08152 | |||
211821b4d7 | |||
0faca49540 | |||
14e79df571 | |||
04fbb725d2 | |||
a1d6844e52 | |||
94391b02a6 | |||
1cb8a7563e | |||
63f0f28948 | |||
3431922f12 | |||
d5a9e1af18 | |||
73f5d63f44 | |||
bf2c7bb785 | |||
93ba086548 | |||
5c4f6017b8 | |||
cb16b2f0ff | |||
d2f11e8779 | |||
4ccaf01b3c | |||
7c512b1c15 | |||
dc432da398 | |||
c8504bcbf5 | |||
c865141583 | |||
8c1ecd6eac | |||
e8e4100677 | |||
6a8773c531 | |||
30e0c7682c | |||
eb5a7bef7e | |||
8a174d8847 | |||
7459afd63a | |||
a9afc385e9 | |||
a8be739ec7 | |||
0130bc58a9 | |||
821059fa80 | |||
5b4f57d0b3 | |||
4bfac2d545 | |||
f105f0cf7b | |||
5e320729d7 | |||
7515032082 | |||
361b18e411 | |||
7d084e570e | |||
cb397910f8 | |||
5f8b0dec98 | |||
8398d1e8bb | |||
562801692a | |||
faee1e61c6 | |||
57a4177037 | |||
fa1dedf207 | |||
7ed13dc0af | |||
52807c5322 | |||
231a71feea | |||
4902eecae0 | |||
889e4c058e | |||
7262a6cb42 | |||
c4ff4ecb3d | |||
2859f628ea | |||
e0355b2af1 | |||
b4d390c33a | |||
a4ab8a761c | |||
907ce6d06e | |||
7e1388735e | |||
6f773dd837 | |||
87fa5aa6bc | |||
35e05b3708 | |||
7ccc96bda0 | |||
283758ebe9 | |||
b673c7aeaf | |||
0ad2ac53dd | |||
7e90ec5a8f | |||
7755365467 | |||
979eea606a | |||
5a9e08f2c4 | |||
68c810d492 | |||
5f88260507 | |||
779d89f8c4 | |||
5d4bf4361b | |||
10170d613d | |||
c885c08c37 | |||
e2a4340f2a | |||
9728ff30e0 | |||
a4644ede5f | |||
8f477dd6f1 | |||
44ac53f15c | |||
5edb5465c5 | |||
067afdb165 | |||
37a4c26f86 | |||
89e43830b4 | |||
671dbfb692 | |||
2014344d25 | |||
f9c39709c8 | |||
b394c58ec6 | |||
0af3e2785b | |||
7066f75e72 | |||
619540da49 | |||
567004f7d9 | |||
761d5a5824 | |||
fa3cdace7f | |||
656762850c | |||
e2325f08d0 | |||
855356084e | |||
7aaeb32a3d | |||
b376cf1580 | |||
ccbdc779ac | |||
61ee08fda2 | |||
d8afadda02 | |||
c8e1270d8f | |||
2a78799404 | |||
863d7a9368 | |||
6fd37b21d9 | |||
bbf3ee3320 | |||
b60cc7902d | |||
623313b58a | |||
d0d2d77a2e | |||
494faeffea | |||
871a5fd1d8 | |||
e615ad2690 | |||
da92a0b42c | |||
678d0aa773 | |||
9248ba7e3b | |||
446087b212 | |||
a42e7d13a2 | |||
a82f5091f1 | |||
3455827c09 | |||
5dccf99a55 | |||
8818b46e01 | |||
2f3ad99467 | |||
592910187b | |||
cb7a0d377f | |||
79175285f8 | |||
fef474977c | |||
fa1a55cd3d | |||
2253ec7e6d | |||
32aa532548 | |||
56138f7de3 | |||
21ef944259 | |||
760f1c2877 | |||
e377eac407 | |||
77787eee9f | |||
03265a1232 | |||
079dc1ab6d | |||
d4fe91ec4a | |||
acc5b4d6ea | |||
19a93cb4c3 | |||
116089d1d2 | |||
50dd6078c7 | |||
9a4531b26c | |||
b1af1d7425 | |||
8f909965a9 | |||
e26af6f3fc | |||
02fffc3400 | |||
d7d6929bf2 | |||
e4cc61552e | |||
d18dd7d4d2 | |||
3251660092 | |||
c1235608d8 | |||
25c45b16ae | |||
78f570b81b | |||
5db13393cc | |||
1e286fbeba | |||
d4b3fe67b9 | |||
5d0757c845 | |||
b69a519904 | |||
122b7b059c | |||
4977847dd8 | |||
b9b197ea27 | |||
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 | |||
329027969a | |||
9f7a8c9540 | |||
384bb2c46d | |||
cabfe268ce | |||
26df71014b | |||
3126ee8153 | |||
cb622f4bad | |||
515d39e61c | |||
952061c4bb | |||
788225826d | |||
c52081e528 | |||
1f235acdf9 | |||
0f6c23e1f3 | |||
488e72679e | |||
6d43b14862 | |||
685213cdbb | |||
05fde3a742 | |||
9383f5484f | |||
88314e1e45 | |||
83b5761bca | |||
f25c993b75 | |||
6d02f8033d | |||
2c367703e4 | |||
284b5be128 | |||
a672a13789 | |||
9af9afd14d | |||
d98e9f8f05 | |||
652bddc07a | |||
5a6e4f5b5e | |||
f878ba5535 | |||
e7c36ba13a | |||
4cfe7c7c59 | |||
b0b30a8ae6 | |||
2e3633b205 | |||
d68aa91c33 | |||
3f63fb0bda | |||
d5af5de3c1 | |||
d9c08568cf | |||
a4c89f1494 | |||
a73e3204b9 | |||
330a71ebf2 | |||
36b0bb3a0e | |||
2ab60b2224 | |||
36f55900c7 | |||
d99f592cff | |||
e24ed61b99 | |||
354f1ff3d8 | |||
d8e0e30c41 | |||
d58859bcf3 | |||
40e64c4d2e | |||
2aacb67988 | |||
a839c5a41a | |||
356d10eb6e |
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
|
||||||
|
|
||||||
# Copyright (c) 2022 imacat.
|
# Copyright (c) 2022 imacat.
|
||||||
@ -38,3 +38,4 @@ excludes
|
|||||||
*.mo
|
*.mo
|
||||||
zh_Hans
|
zh_Hans
|
||||||
test_temp.py
|
test_temp.py
|
||||||
|
dummy.js
|
||||||
|
40
.readthedocs.yaml
Normal file
40
.readthedocs.yaml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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.
|
||||||
|
|
||||||
|
# .readthedocs.yaml
|
||||||
|
# Read the Docs configuration file
|
||||||
|
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||||
|
|
||||||
|
# Required
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
# Set the version of Python and other tools you might need
|
||||||
|
build:
|
||||||
|
os: ubuntu-22.04
|
||||||
|
tools:
|
||||||
|
python: "3.11"
|
||||||
|
|
||||||
|
# Build documentation in the docs/ directory with Sphinx
|
||||||
|
|
||||||
|
# If using Sphinx, optionally build your docs in additional formats such as PDF
|
||||||
|
formats: all
|
||||||
|
|
||||||
|
# Optionally declare the Python requirements required to build your docs
|
||||||
|
python:
|
||||||
|
install:
|
||||||
|
- method: pip
|
||||||
|
path: .
|
24
MANIFEST.in
24
MANIFEST.in
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
||||||
|
|
||||||
# Copyright (c) 2022-2023 imacat.
|
# Copyright (c) 2022-2023 imacat.
|
||||||
@ -15,14 +15,14 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
include src/accounting/translations/*
|
recursive-include src/accounting/static *
|
||||||
include src/accounting/translations/*/LC_MESSAGES/*
|
exclude src/accounting/static/js/dummy.js
|
||||||
include docs/*
|
recursive-include src/accounting/templates *
|
||||||
include docs/source/*
|
recursive-include src/accounting/translations *
|
||||||
include docs/source/_static/*
|
recursive-include src/accounting/data *
|
||||||
include docs/source/_templates/*
|
recursive-include docs *
|
||||||
include tests/*
|
recursive-exclude docs/build *
|
||||||
include tests/testsite/*
|
recursive-include tests *
|
||||||
include tests/testsite/templates/*
|
exclude tests/test_temp.py
|
||||||
include tests/testsite/translations/*
|
recursive-exclude tests *.pyc
|
||||||
include tests/testsite/translations/*/LC_MESSAGES/*
|
recursive-exclude tests/instance *
|
||||||
|
240
README.rst
240
README.rst
@ -1,24 +1,217 @@
|
|||||||
=====================
|
===============
|
||||||
Mia! Accounting Flask
|
Mia! Accounting
|
||||||
=====================
|
===============
|
||||||
|
|
||||||
|
|
||||||
Description
|
Description
|
||||||
===========
|
===========
|
||||||
|
|
||||||
This is the Mia! Accounting Flask project. It is an accounting
|
*Mia! Accounting* is an accounting module for Flask_ applications.
|
||||||
module for the Flask_ applications.
|
It implements `double-entry bookkeeping`_, and generates the following
|
||||||
|
accounting reports:
|
||||||
|
|
||||||
|
* Trial balance
|
||||||
|
* Income statement
|
||||||
|
* Balance sheet
|
||||||
|
|
||||||
|
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
|
||||||
|
receivables.
|
||||||
|
|
||||||
|
You may try the `Mia! Accounting live demonstration`_.
|
||||||
|
|
||||||
|
|
||||||
Install
|
History
|
||||||
=======
|
=======
|
||||||
|
|
||||||
Install the latest source from the
|
I created my own private accounting application in Perl_/mod_perl_ in
|
||||||
`Mia! Accounting Flask repository`_.
|
2007, as part of my personal website. The first revision was made
|
||||||
|
using Perl/Mojolicious_ in 2019, with the aim of making it
|
||||||
|
mobile-friendly using Bootstrap_, and with modern back-end and
|
||||||
|
front-end technologies such as jQuery.
|
||||||
|
|
||||||
|
The second revision was done in Python_/Django_ in 2020, as I was
|
||||||
|
looking to change my career from PHP_/Laravel_ to Python, but lacked
|
||||||
|
experience with large Python projects. I wanted to add something new
|
||||||
|
to my portfolio and decided to work on the somewhat outdated
|
||||||
|
Mojolicious project.
|
||||||
|
|
||||||
|
Despite having no prior experience with Django, I spent two months
|
||||||
|
working late nights to create the `Mia! Account Django application`_.
|
||||||
|
It took me another 1.5 months to make it an independent module, which
|
||||||
|
I later released as an open source project.
|
||||||
|
|
||||||
|
The application worked nicely for my household bookkeeping for two
|
||||||
|
years. However, new demands arose over time, especially with tracking
|
||||||
|
payables and receivables, which became difficult with credit card
|
||||||
|
payments. This was critical `during the pandemic`_ as more payments
|
||||||
|
were made online with credit cards.
|
||||||
|
|
||||||
|
The biggest issue I encountered was with Django's MVT framework. Due
|
||||||
|
to my lack of experience with Django during development, I ended up
|
||||||
|
with mixed function-based view controllers and class-based views. It
|
||||||
|
became very difficult to track whether problems originated from my
|
||||||
|
overridden methods or not-overridden methods, or from the Django base
|
||||||
|
views themselves. I did not fully understand how everything worked.
|
||||||
|
|
||||||
|
Therefore, I decided to turn to microframeworks like Flask. After
|
||||||
|
working with modularized Flask and FastAPI_ applications for two
|
||||||
|
years, I returned to the project and wrote its third revision using
|
||||||
|
Flask in 2023.
|
||||||
|
|
||||||
|
|
||||||
|
Installation
|
||||||
|
============
|
||||||
|
|
||||||
|
Install *Mia! Accounting* with ``pip``:
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
pip install git+https://gitea.imacat.idv.tw/imacat/mia-accounting-flask.git
|
pip install mia-accounting
|
||||||
|
|
||||||
|
You may also download the from the `PyPI project page`_ or the
|
||||||
|
`release page`_ on the `Git repository`_.
|
||||||
|
|
||||||
|
|
||||||
|
Prerequisites
|
||||||
|
=============
|
||||||
|
|
||||||
|
You need a running Flask application with database user login.
|
||||||
|
The primary key of the user data model must be integer.
|
||||||
|
|
||||||
|
The following front-end JavaScript libraries must be loaded. You may
|
||||||
|
download it locally or use CDN_.
|
||||||
|
|
||||||
|
* Bootstrap_ 5.2.3 or above
|
||||||
|
* FontAwesome_ 6.2.1 or above
|
||||||
|
* `Decimal.js`_ 6.4.3 or above
|
||||||
|
* `Tempus-Dominus`_ 6.4.3 or above
|
||||||
|
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
=============
|
||||||
|
|
||||||
|
You need to pass the Flask *app* and an implementation of
|
||||||
|
``UserUtilityInterface`` to the ``init_app`` function.
|
||||||
|
``UserUtilityInterface`` contains everything *Mia! Accounting* needs.
|
||||||
|
|
||||||
|
The following is an example configuration for *Mia! Accounting*.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
from flask import Response, redirect
|
||||||
|
from .auth import current_user()
|
||||||
|
from .modules import User
|
||||||
|
|
||||||
|
def create_app(test_config=None) -> Flask:
|
||||||
|
app: Flask = Flask(__name__)
|
||||||
|
|
||||||
|
... (Configuration of SQLAlchemy, CSRF, Babel_JS, ... etc) ...
|
||||||
|
|
||||||
|
import accounting
|
||||||
|
|
||||||
|
class UserUtilities(accounting.UserUtilityInterface[User]):
|
||||||
|
|
||||||
|
def can_view(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def can_edit(self) -> bool:
|
||||||
|
return "editor" in current_user().roles
|
||||||
|
|
||||||
|
def can_admin(self) -> bool:
|
||||||
|
return current_user().is_admin
|
||||||
|
|
||||||
|
def unauthorized(self) -> Response:
|
||||||
|
return redirect("/login")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cls(self) -> t.Type[User]:
|
||||||
|
return User
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pk_column(self) -> Column:
|
||||||
|
return User.id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_user(self) -> User | None:
|
||||||
|
return current_user()
|
||||||
|
|
||||||
|
def get_by_username(self, username: str) -> User | None:
|
||||||
|
return User.query.filter(User.username == username).first()
|
||||||
|
|
||||||
|
def get_pk(self, user: User) -> int:
|
||||||
|
return user.id
|
||||||
|
|
||||||
|
accounting.init_app(app, UserUtils())
|
||||||
|
|
||||||
|
... (Any other configuration) ...
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
Database Initialization
|
||||||
|
=======================
|
||||||
|
|
||||||
|
After the configuration, you need to run
|
||||||
|
`flask_sqlalchemy.SQLAlchemy.create_all`_ to create the
|
||||||
|
database tables that *Mia! Accounting* uses.
|
||||||
|
|
||||||
|
*Mia! Accounting* adds three console commands:
|
||||||
|
|
||||||
|
* ``accounting-init-base``
|
||||||
|
* ``accounting-init-accounts``
|
||||||
|
* ``accounting-init-currencies``
|
||||||
|
|
||||||
|
You need to run ``accounting-init-base`` first, and then the other
|
||||||
|
two commands.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
% flask --app myapp accounting-init-base
|
||||||
|
% flask --app myapp accounting-init-accounts
|
||||||
|
% flask --app myapp accounting-init-currencies
|
||||||
|
|
||||||
|
|
||||||
|
Navigation Menu
|
||||||
|
===============
|
||||||
|
|
||||||
|
Include the navigation menu in the `Bootstrap navigation bar`_ in your
|
||||||
|
base template:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
...
|
||||||
|
<div id="collapsible-navbar" class="collapse navbar-collapse">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
...
|
||||||
|
{% include "accounting/include/nav.html" %}
|
||||||
|
...
|
||||||
|
</ul>
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
Check your Flask application and see how it works.
|
||||||
|
|
||||||
|
|
||||||
|
Test Site and Live Demonstration
|
||||||
|
================================
|
||||||
|
|
||||||
|
You may find a working example in the `test site`_ in the
|
||||||
|
`source distribution`_. It is the simplest website that works with
|
||||||
|
*Mia! Accounting*. It is used in the automatic tests. It is the same
|
||||||
|
code run for `live demonstration`_.
|
||||||
|
|
||||||
|
If you do not have a running Flask application, you may start with the
|
||||||
|
test site.
|
||||||
|
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
=============
|
||||||
|
|
||||||
|
Refer to the `documentation on Read the Docs`_.
|
||||||
|
|
||||||
|
|
||||||
Copyright
|
Copyright
|
||||||
@ -46,5 +239,32 @@ Authors
|
|||||||
| imacat@mail.imacat.idv.tw
|
| imacat@mail.imacat.idv.tw
|
||||||
| 2023/1/27
|
| 2023/1/27
|
||||||
|
|
||||||
|
|
||||||
.. _Flask: https://flask.palletsprojects.com
|
.. _Flask: https://flask.palletsprojects.com
|
||||||
.. _Mia! Accounting Flask repository: https://gitea.imacat.idv.tw/imacat/mia-accounting-flask
|
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
|
||||||
|
.. _Mia! Accounting live demonstration: https://accounting.imacat.idv.tw/
|
||||||
|
.. _Perl: https://www.perl.org
|
||||||
|
.. _mod_perl: https://perl.apache.org
|
||||||
|
.. _Mojolicious: https://mojolicious.org
|
||||||
|
.. _Bootstrap: https://getbootstrap.com
|
||||||
|
.. _jQuery: https://jquery.com
|
||||||
|
.. _Python: https://www.python.org
|
||||||
|
.. _Django: https://www.djangoproject.com
|
||||||
|
.. _PHP: https://www.php.net
|
||||||
|
.. _Laravel: https://laravel.com
|
||||||
|
.. _Mia! Account Django application: https://github.com/imacat/mia-accounting-django
|
||||||
|
.. _during the pandemic: https://en.wikipedia.org/wiki/COVID-19_pandemic
|
||||||
|
.. _FastAPI: https://fastapi.tiangolo.com
|
||||||
|
.. _FontAwesome: https://fontawesome.com
|
||||||
|
.. _Decimal.js: https://mikemcl.github.io/decimal.js
|
||||||
|
.. _Tempus-Dominus: https://getdatepicker.com
|
||||||
|
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
|
||||||
|
.. _PyPI project page: https://pypi.org/project/mia-accounting
|
||||||
|
.. _release page: https://github.com/imacat/mia-accounting/releases
|
||||||
|
.. _Git repository: https://github.com/imacat/mia-accounting
|
||||||
|
.. _flask_sqlalchemy.SQLAlchemy.create_all: https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/api/#flask_sqlalchemy.SQLAlchemy.create_all
|
||||||
|
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
|
||||||
|
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
|
||||||
|
.. _source distribution: https://pypi.org/project/mia-accounting/#files
|
||||||
|
.. _live demonstration: https://accounting.imacat.idv.tw
|
||||||
|
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io
|
||||||
|
@ -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:
|
||||||
|
45
docs/source/accounting.journal_entry.forms.rst
Normal file
45
docs/source/accounting.journal_entry.forms.rst
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
accounting.journal\_entry.forms package
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.journal\_entry.forms.currency module
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.forms.currency
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.forms.journal\_entry module
|
||||||
|
-----------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.forms.journal_entry
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.forms.line\_item module
|
||||||
|
-------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.forms.line_item
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.forms.reorder module
|
||||||
|
----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.forms.reorder
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.forms
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
46
docs/source/accounting.journal_entry.rst
Normal file
46
docs/source/accounting.journal_entry.rst
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
accounting.journal\_entry package
|
||||||
|
=================================
|
||||||
|
|
||||||
|
Subpackages
|
||||||
|
-----------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 4
|
||||||
|
|
||||||
|
accounting.journal_entry.forms
|
||||||
|
accounting.journal_entry.utils
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.journal\_entry.converters module
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.converters
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.template\_filters module
|
||||||
|
--------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.template_filters
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.views module
|
||||||
|
--------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.views
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
53
docs/source/accounting.journal_entry.utils.rst
Normal file
53
docs/source/accounting.journal_entry.utils.rst
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
accounting.journal\_entry.utils package
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.journal\_entry.utils.account\_option module
|
||||||
|
------------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.utils.account_option
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.utils.description\_editor module
|
||||||
|
----------------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.utils.description_editor
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.utils.offset\_alias module
|
||||||
|
----------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.utils.offset_alias
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.utils.operators module
|
||||||
|
------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.utils.operators
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.utils.original\_line\_items module
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.utils.original_line_items
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.utils
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
29
docs/source/accounting.option.rst
Normal file
29
docs/source/accounting.option.rst
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
accounting.option package
|
||||||
|
=========================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.option.forms module
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.option.forms
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.option.views module
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.option.views
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.option
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
: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:
|
69
docs/source/accounting.report.utils.rst
Normal file
69
docs/source/accounting.report.utils.rst
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
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.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,15 +10,18 @@ Subpackages
|
|||||||
accounting.account
|
accounting.account
|
||||||
accounting.base_account
|
accounting.base_account
|
||||||
accounting.currency
|
accounting.currency
|
||||||
|
accounting.journal_entry
|
||||||
|
accounting.option
|
||||||
|
accounting.report
|
||||||
accounting.utils
|
accounting.utils
|
||||||
|
|
||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
accounting.database module
|
accounting.forms module
|
||||||
--------------------------
|
-----------------------
|
||||||
|
|
||||||
.. automodule:: accounting.database
|
.. automodule:: accounting.forms
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
@ -39,6 +42,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
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
@ -4,10 +4,50 @@ accounting.utils package
|
|||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
accounting.utils.next\_url module
|
accounting.utils.cast module
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.cast
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.current\_account module
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.current_account
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.flash\_errors module
|
||||||
|
-------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.flash_errors
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.journal\_entry\_types module
|
||||||
|
---------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.journal_entry_types
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.next\_uri module
|
||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
.. automodule:: accounting.utils.next_url
|
.. automodule:: accounting.utils.next_uri
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.options module
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.options
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
@ -10,10 +10,10 @@ sys.path.insert(0, os.path.abspath('../../src/'))
|
|||||||
# -- Project information -----------------------------------------------------
|
# -- Project information -----------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||||
|
|
||||||
project = 'Mia! Accounting Flask'
|
project = 'Mia! Accounting'
|
||||||
copyright = '2023, imacat'
|
copyright = '2023, imacat'
|
||||||
author = 'imacat'
|
author = 'imacat'
|
||||||
release = '0.0.0'
|
release = '1.0.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']
|
||||||
|
61
docs/source/examples.rst
Normal file
61
docs/source/examples.rst
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
Examples
|
||||||
|
========
|
||||||
|
|
||||||
|
|
||||||
|
.. _example-userutils:
|
||||||
|
|
||||||
|
An Example Configuration
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
The following is an example configuration for *Mia! Accounting*.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
from flask import Response, redirect
|
||||||
|
from .auth import current_user()
|
||||||
|
from .modules import User
|
||||||
|
|
||||||
|
def create_app(test_config=None) -> Flask:
|
||||||
|
app: Flask = Flask(__name__)
|
||||||
|
|
||||||
|
... (Configuration of SQLAlchemy, CSRF, Babel_JS, ... etc) ...
|
||||||
|
|
||||||
|
import accounting
|
||||||
|
|
||||||
|
class UserUtilities(accounting.UserUtilityInterface[User]):
|
||||||
|
|
||||||
|
def can_view(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def can_edit(self) -> bool:
|
||||||
|
return "editor" in current_user().roles
|
||||||
|
|
||||||
|
def can_admin(self) -> bool:
|
||||||
|
return current_user().is_admin
|
||||||
|
|
||||||
|
def unauthorized(self) -> Response:
|
||||||
|
return redirect("/login")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cls(self) -> t.Type[User]:
|
||||||
|
return User
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pk_column(self) -> Column:
|
||||||
|
return User.id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_user(self) -> User | None:
|
||||||
|
return current_user()
|
||||||
|
|
||||||
|
def get_by_username(self, username: str) -> User | None:
|
||||||
|
return User.query.filter(User.username == username).first()
|
||||||
|
|
||||||
|
def get_pk(self, user: User) -> int:
|
||||||
|
return user.id
|
||||||
|
|
||||||
|
accounting.init_app(app, UserUtils())
|
||||||
|
|
||||||
|
... (Any other configuration) ...
|
||||||
|
|
||||||
|
return app
|
@ -1,15 +1,19 @@
|
|||||||
.. Mia! Accounting Flask documentation master file, created by
|
.. Mia! Accounting documentation master file, created by
|
||||||
sphinx-quickstart on Fri Jan 27 12:20:04 2023.
|
sphinx-quickstart on Fri Jan 27 12:20:04 2023.
|
||||||
You can adapt this file completely to your liking, but it should at least
|
You can adapt this file completely to your liking, but it should at least
|
||||||
contain the root `toctree` directive.
|
contain the root `toctree` directive.
|
||||||
|
|
||||||
Welcome to Mia! Accounting Flask's documentation!
|
Welcome to Mia! Accounting's documentation!
|
||||||
=================================================
|
===========================================
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
:caption: Contents:
|
:caption: Contents:
|
||||||
|
|
||||||
|
intro
|
||||||
|
accounting
|
||||||
|
examples
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Indices and tables
|
Indices and tables
|
||||||
|
188
docs/source/intro.rst
Normal file
188
docs/source/intro.rst
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
Introduction
|
||||||
|
============
|
||||||
|
|
||||||
|
*Mia! Accounting* is an accounting module for Flask_ applications.
|
||||||
|
It implements `double-entry bookkeeping`_, and generates the following
|
||||||
|
accounting reports:
|
||||||
|
|
||||||
|
* Trial balance
|
||||||
|
* Income statement
|
||||||
|
* Balance sheet
|
||||||
|
|
||||||
|
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
|
||||||
|
receivables.
|
||||||
|
|
||||||
|
You may try the `Mia! Accounting live demonstration`_.
|
||||||
|
|
||||||
|
|
||||||
|
History
|
||||||
|
-------
|
||||||
|
|
||||||
|
I created my own private accounting application in Perl_/mod_perl_ in
|
||||||
|
2007, as part of my personal website. The first revision was made
|
||||||
|
using Perl/Mojolicious_ in 2019, with the aim of making it
|
||||||
|
mobile-friendly using Bootstrap_, and with modern back-end and
|
||||||
|
front-end technologies such as jQuery.
|
||||||
|
|
||||||
|
The second revision was done in Python_/Django_ in 2020, as I was
|
||||||
|
looking to change my career from PHP_/Laravel_ to Python, but lacked
|
||||||
|
experience with large Python projects. I wanted to add something new
|
||||||
|
to my portfolio and decided to work on the somewhat outdated
|
||||||
|
Mojolicious project.
|
||||||
|
|
||||||
|
Despite having no prior experience with Django, I spent two months
|
||||||
|
working late nights to create the `Mia! Account Django application`_.
|
||||||
|
It took me another 1.5 months to make it an independent module, which
|
||||||
|
I later released as an open source project.
|
||||||
|
|
||||||
|
The application worked nicely for my household bookkeeping for two
|
||||||
|
years. However, new demands arose over time, especially with tracking
|
||||||
|
payables and receivables, which became difficult with credit card
|
||||||
|
payments. This was critical `during the pandemic`_ as more payments
|
||||||
|
were made online with credit cards.
|
||||||
|
|
||||||
|
The biggest issue I encountered was with Django's MVT framework. Due
|
||||||
|
to my lack of experience with Django during development, I ended up
|
||||||
|
with mixed function-based view controllers and class-based views. It
|
||||||
|
became very difficult to track whether problems originated from my
|
||||||
|
overridden methods or not-overridden methods, or from the Django base
|
||||||
|
views themselves. I did not fully understand how everything worked.
|
||||||
|
|
||||||
|
Therefore, I decided to turn to microframeworks like Flask. After
|
||||||
|
working with modularized Flask and FastAPI_ applications for two
|
||||||
|
years, I returned to the project and wrote its third revision using
|
||||||
|
Flask in 2023.
|
||||||
|
|
||||||
|
|
||||||
|
Installation
|
||||||
|
------------
|
||||||
|
|
||||||
|
Install *Mia! Accounting* with ``pip``:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pip install mia-accounting
|
||||||
|
|
||||||
|
You may also download the from the `PyPI project page`_ or the
|
||||||
|
`release page`_ on the `Git repository`_.
|
||||||
|
|
||||||
|
|
||||||
|
Prerequisites
|
||||||
|
-------------
|
||||||
|
|
||||||
|
You need a running Flask application with database user login.
|
||||||
|
The primary key of the user data model must be integer.
|
||||||
|
|
||||||
|
The following front-end JavaScript libraries must be loaded. You may
|
||||||
|
download it locally or use CDN_.
|
||||||
|
|
||||||
|
* Bootstrap_ 5.2.3 or above
|
||||||
|
* FontAwesome_ 6.2.1 or above
|
||||||
|
* `Decimal.js`_ 6.4.3 or above
|
||||||
|
* `Tempus-Dominus`_ 6.4.3 or above
|
||||||
|
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
-------------
|
||||||
|
|
||||||
|
You need to pass the Flask *app* and an implementation of
|
||||||
|
:py:class:`accounting.utils.user.UserUtilityInterface` to the
|
||||||
|
:py:func:`accounting.init_app` function. ``UserUtilityInterface``
|
||||||
|
contains everything *Mia! Accounting* needs.
|
||||||
|
|
||||||
|
See an example in :ref:`example-userutils`.
|
||||||
|
|
||||||
|
|
||||||
|
Database Initialization
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
After the configuration, you need to run
|
||||||
|
:py:meth:`flask_sqlalchemy.SQLAlchemy.create_all` to create the
|
||||||
|
database tables that *Mia! Accounting* uses.
|
||||||
|
|
||||||
|
*Mia! Accounting* adds three console commands:
|
||||||
|
|
||||||
|
* ``accounting-init-base``
|
||||||
|
* ``accounting-init-accounts``
|
||||||
|
* ``accounting-init-currencies``
|
||||||
|
|
||||||
|
You need to run ``accounting-init-base`` first, and then the other
|
||||||
|
two commands.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
% flask --app myapp accounting-init-base
|
||||||
|
% flask --app myapp accounting-init-accounts
|
||||||
|
% flask --app myapp accounting-init-currencies
|
||||||
|
|
||||||
|
|
||||||
|
Navigation Menu
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Include the navigation menu in the `Bootstrap navigation bar`_ in your
|
||||||
|
base template:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
...
|
||||||
|
<div id="collapsible-navbar" class="collapse navbar-collapse">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
...
|
||||||
|
{% include "accounting/include/nav.html" %}
|
||||||
|
...
|
||||||
|
</ul>
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
Check your Flask application and see how it works.
|
||||||
|
|
||||||
|
|
||||||
|
Test Site and Live Demonstration
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
You may find a working example in the `test site`_ in the
|
||||||
|
`source distribution`_. It is the simplest website that works with
|
||||||
|
*Mia! Accounting*. It is used in the automatic tests. It is the same
|
||||||
|
code run for `live demonstration`_.
|
||||||
|
|
||||||
|
If you do not have a running Flask application, you may start with the
|
||||||
|
test site.
|
||||||
|
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Refer to the `documentation on Read the Docs`_.
|
||||||
|
|
||||||
|
|
||||||
|
.. _Flask: https://flask.palletsprojects.com
|
||||||
|
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
|
||||||
|
.. _Mia! Accounting live demonstration: https://accounting.imacat.idv.tw/
|
||||||
|
.. _Perl: https://www.perl.org
|
||||||
|
.. _mod_perl: https://perl.apache.org
|
||||||
|
.. _Mojolicious: https://mojolicious.org
|
||||||
|
.. _Bootstrap: https://getbootstrap.com
|
||||||
|
.. _jQuery: https://jquery.com
|
||||||
|
.. _Python: https://www.python.org
|
||||||
|
.. _Django: https://www.djangoproject.com
|
||||||
|
.. _PHP: https://www.php.net
|
||||||
|
.. _Laravel: https://laravel.com
|
||||||
|
.. _Mia! Account Django application: https://github.com/imacat/mia-accounting-django
|
||||||
|
.. _during the pandemic: https://en.wikipedia.org/wiki/COVID-19_pandemic
|
||||||
|
.. _FastAPI: https://fastapi.tiangolo.com
|
||||||
|
.. _FontAwesome: https://fontawesome.com
|
||||||
|
.. _Decimal.js: https://mikemcl.github.io/decimal.js
|
||||||
|
.. _Tempus-Dominus: https://getdatepicker.com
|
||||||
|
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
|
||||||
|
.. _PyPI project page: https://pypi.org/project/mia-accounting
|
||||||
|
.. _release page: https://github.com/imacat/mia-accounting/releases
|
||||||
|
.. _Git repository: https://github.com/imacat/mia-accounting
|
||||||
|
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
|
||||||
|
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
|
||||||
|
.. _source distribution: https://pypi.org/project/mia-accounting/#files
|
||||||
|
.. _live demonstration: https://accounting.imacat.idv.tw
|
||||||
|
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io
|
@ -1,7 +1,7 @@
|
|||||||
# The Mia! Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
||||||
|
|
||||||
# Copyright (c) 2022 imacat.
|
# Copyright (c) 2022-2023 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@ -15,6 +15,51 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "mia-accounting"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "A Flask accounting module."
|
||||||
|
readme = "README.rst"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
authors = [
|
||||||
|
{name = "imacat", email = "imacat@mail.imacat.idv.tw"},
|
||||||
|
]
|
||||||
|
keywords = ["mia", "accounting", "flask"]
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Framework :: Flask",
|
||||||
|
"Topic :: Office/Business :: Financial :: Accounting",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"flask",
|
||||||
|
"Flask-SQLAlchemy",
|
||||||
|
"Flask-WTF",
|
||||||
|
"Flask-Babel >= 3",
|
||||||
|
"Flask-Babel-JS",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = [
|
||||||
|
"unittest",
|
||||||
|
"httpx",
|
||||||
|
"OpenCC",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
"Documentation" = "https://mia-accounting.readthedocs.io"
|
||||||
|
"Repository" = "https://github.com/imacat/mia-accounting"
|
||||||
|
"Bug Tracker" = "https://github.com/imacat/mia-accounting/issues"
|
||||||
|
"Demonstration" = "https://accounting.imacat.idv.tw"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=42"]
|
requires = ["setuptools>=42"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.exclude-package-data]
|
||||||
|
"*" = [
|
||||||
|
"babel.cfg",
|
||||||
|
"*.pot",
|
||||||
|
"*.po",
|
||||||
|
]
|
||||||
|
55
setup.cfg
55
setup.cfg
@ -1,55 +0,0 @@
|
|||||||
# The Mia! Flask Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
|
||||||
|
|
||||||
# Copyright (c) 2022-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.
|
|
||||||
|
|
||||||
[metadata]
|
|
||||||
name = mia-accounting-flask
|
|
||||||
version = 0.2.0
|
|
||||||
author = imacat
|
|
||||||
author_email = imacat@mail.imacat.idv.tw
|
|
||||||
description = The Mia! Accounting Flask project.
|
|
||||||
long_description = file: README.rst
|
|
||||||
long_description_content_type = text/x-rst
|
|
||||||
url = https://github.com/imacat/mia-accounting-flask
|
|
||||||
project_urls =
|
|
||||||
Bug Tracker = https://github.com/imacat/mia-accounting-flask/issues
|
|
||||||
classifiers =
|
|
||||||
Programming Language :: Python :: 3
|
|
||||||
License :: OSI Approved :: Apache Software License
|
|
||||||
Operating System :: OS Independent
|
|
||||||
Framework :: Flask
|
|
||||||
Topic :: Office/Business :: Financial :: Accounting
|
|
||||||
|
|
||||||
[options]
|
|
||||||
package_dir =
|
|
||||||
= src
|
|
||||||
python_requires = >=3.11
|
|
||||||
install_requires =
|
|
||||||
flask
|
|
||||||
Flask-SQLAlchemy
|
|
||||||
Flask-WTF
|
|
||||||
Flask-Babel >= 3
|
|
||||||
Flask-Babel-JS
|
|
||||||
tests_require =
|
|
||||||
unittest
|
|
||||||
httpx
|
|
||||||
OpenCC
|
|
||||||
|
|
||||||
[options.package_data]
|
|
||||||
accounting =
|
|
||||||
static/**
|
|
||||||
templates/**
|
|
||||||
translations/*/LC_MESSAGES/*.mo
|
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -17,45 +17,58 @@
|
|||||||
"""The accounting application.
|
"""The accounting application.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, Blueprint
|
from flask import Flask, Blueprint
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
from accounting.utils.user import AbstractUserUtils
|
from accounting.utils.user import UserUtilityInterface
|
||||||
|
|
||||||
|
db: SQLAlchemy = SQLAlchemy()
|
||||||
|
"""The database instance."""
|
||||||
|
data_dir: Path = Path(__file__).parent / "data"
|
||||||
|
"""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
|
||||||
# in the application.
|
# in the application.
|
||||||
from .database import set_db
|
global db
|
||||||
set_db(app.extensions["sqlalchemy"])
|
db = app.extensions["sqlalchemy"]
|
||||||
from .utils.user import init_user_utils
|
from .utils.user import init_user_utils
|
||||||
init_user_utils(user_utils)
|
init_user_utils(user_utils)
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("accounting", __name__,
|
bp: Blueprint = Blueprint("accounting", __name__,
|
||||||
url_prefix=url_prefix,
|
|
||||||
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)
|
||||||
@ -66,7 +79,13 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
|||||||
from . import currency
|
from . import currency
|
||||||
currency.init_app(app, bp)
|
currency.init_app(app, bp)
|
||||||
|
|
||||||
from .utils import next_url
|
from . import journal_entry
|
||||||
next_url.init_app(bp)
|
journal_entry.init_app(app, bp)
|
||||||
|
|
||||||
app.register_blueprint(bp)
|
from . import report
|
||||||
|
report.init_app(app, url_prefix)
|
||||||
|
|
||||||
|
from . import option
|
||||||
|
option.init_app(bp)
|
||||||
|
|
||||||
|
app.register_blueprint(bp, url_prefix=url_prefix)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -18,19 +18,18 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
from secrets import randbelow
|
from secrets import randbelow
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from flask.cli import with_appcontext
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
from accounting.database import db
|
from accounting import db
|
||||||
from accounting.models import BaseAccount, Account, AccountL10n
|
from accounting.models import BaseAccount, Account, AccountL10n
|
||||||
from accounting.utils.user import has_user, get_user_pk
|
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-offset-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,37 @@ 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_offset_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_offset_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 journal entry line items in the account need offset.
|
||||||
|
|
||||||
|
:param base_code: The code of the base account.
|
||||||
|
:return: True if journal entry line items 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 +135,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_offset_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]
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -23,7 +23,7 @@ from flask_wtf import FlaskForm
|
|||||||
from wtforms import StringField, BooleanField
|
from wtforms import StringField, BooleanField
|
||||||
from wtforms.validators import DataRequired, ValidationError
|
from wtforms.validators import DataRequired, ValidationError
|
||||||
|
|
||||||
from accounting.database import db
|
from accounting import db
|
||||||
from accounting.locale import lazy_gettext
|
from accounting.locale import lazy_gettext
|
||||||
from accounting.models import BaseAccount, Account
|
from accounting.models import BaseAccount, Account
|
||||||
from accounting.utils.random_id import new_id
|
from accounting.utils.random_id import new_id
|
||||||
@ -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_offset_needed = BooleanField()
|
is_need_offset = BooleanField(
|
||||||
"""Whether the the entries of this account need offsets."""
|
validators=[NoOffsetNominalAccount()])
|
||||||
|
"""Whether the the journal entry line items 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.
|
||||||
@ -76,29 +91,30 @@ class AccountForm(FlaskForm):
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
is_new: bool = obj.id is None
|
is_new: bool = obj.id is None
|
||||||
prev_base_code: str | None = obj.base_code
|
|
||||||
if is_new:
|
if is_new:
|
||||||
obj.id = new_id(Account)
|
obj.id = new_id(Account)
|
||||||
obj.base_code = self.base_code.data
|
if obj.base_code != self.base_code.data:
|
||||||
if prev_base_code != self.base_code.data:
|
if obj.base_code is not None:
|
||||||
max_no: int = db.session.scalars(
|
sort_accounts_in(obj.base_code, obj.id)
|
||||||
sa.select(sa.func.max(Account.no))
|
sort_accounts_in(self.base_code.data, obj.id)
|
||||||
.filter(Account.base_code == self.base_code.data)).one()
|
count: int = Account.query\
|
||||||
obj.no = 1 if max_no is None else max_no + 1
|
.filter(Account.base_code == self.base_code.data).count()
|
||||||
|
obj.base_code = self.base_code.data
|
||||||
|
obj.no = count + 1
|
||||||
obj.title = self.title.data
|
obj.title = self.title.data
|
||||||
obj.is_offset_needed = self.is_offset_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
|
||||||
obj.updated_by_id = current_user_pk
|
obj.updated_by_id = current_user_pk
|
||||||
if prev_base_code is not None \
|
|
||||||
and prev_base_code != self.base_code.data:
|
|
||||||
setattr(self, "__post_update",
|
|
||||||
lambda: sort_accounts_in(prev_base_code, obj.id))
|
|
||||||
|
|
||||||
def post_update(self, obj) -> None:
|
def post_update(self, obj: Account) -> None:
|
||||||
"""The post-processing after the update.
|
"""The post-processing after the update.
|
||||||
|
|
||||||
|
:param obj: The account object.
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
current_user_pk: int = get_current_user_pk()
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -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
|
||||||
@ -40,15 +40,15 @@ def get_account_query() -> list[Account]:
|
|||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
l10n: list[AccountL10n] = AccountL10n.query\
|
l10n: list[AccountL10n] = AccountL10n.query\
|
||||||
.filter(AccountL10n.title.contains(k)).all()
|
.filter(AccountL10n.title.icontains(k)).all()
|
||||||
l10n_matches: set[str] = {x.account_id for x in l10n}
|
l10n_matches: set[str] = {x.account_id for x in l10n}
|
||||||
sub_conditions: list[sa.BinaryExpression] \
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
= [Account.base_code.contains(k),
|
= [Account.base_code.contains(k),
|
||||||
Account.title_l10n.contains(k),
|
Account.title_l10n.icontains(k),
|
||||||
code.contains(k),
|
code.contains(k),
|
||||||
Account.id.in_(l10n_matches)]
|
Account.id.in_(l10n_matches)]
|
||||||
if k in gettext("Offset needed"):
|
if k in gettext("Needs Offset"):
|
||||||
sub_conditions.append(Account.is_offset_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)\
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -19,17 +19,22 @@
|
|||||||
"""
|
"""
|
||||||
from urllib.parse import parse_qsl, urlencode
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from flask import Blueprint, render_template, session, redirect, flash, \
|
from flask import Blueprint, render_template, session, redirect, flash, \
|
||||||
url_for, request
|
url_for, request
|
||||||
from werkzeug.datastructures import ImmutableMultiDict
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
|
||||||
from accounting.database 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.next_url import inherit_next, or_next
|
from accounting.utils.cast import s
|
||||||
|
from accounting.utils.flash_errors import flash_form_errors
|
||||||
|
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 .forms import AccountForm, sort_accounts_in, AccountReorderForm
|
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
|
||||||
|
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."""
|
||||||
@ -42,7 +47,6 @@ def list_accounts() -> str:
|
|||||||
|
|
||||||
:return: The account list.
|
:return: The account list.
|
||||||
"""
|
"""
|
||||||
from .query import get_account_query
|
|
||||||
accounts: list[BaseAccount] = get_account_query()
|
accounts: list[BaseAccount] = get_account_query()
|
||||||
pagination: Pagination = Pagination[BaseAccount](accounts)
|
pagination: Pagination = Pagination[BaseAccount](accounts)
|
||||||
return render_template("accounting/account/list.html",
|
return render_template("accounting/account/list.html",
|
||||||
@ -76,18 +80,15 @@ def add_account() -> redirect:
|
|||||||
"""
|
"""
|
||||||
form = AccountForm(request.form)
|
form = AccountForm(request.form)
|
||||||
if not form.validate():
|
if not form.validate():
|
||||||
for key in form.errors:
|
flash_form_errors(form)
|
||||||
for error in form.errors[key]:
|
|
||||||
flash(error, "error")
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
return redirect(inherit_next(url_for("accounting.account.create")))
|
return redirect(inherit_next(url_for("accounting.account.create")))
|
||||||
account: Account = Account()
|
account: Account = Account()
|
||||||
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(url_for("accounting.account.detail",
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
account=account)))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<account:account>", endpoint="detail")
|
@bp.get("/<account:account>", endpoint="detail")
|
||||||
@ -131,23 +132,20 @@ def update_account(account: Account) -> redirect:
|
|||||||
"""
|
"""
|
||||||
form = AccountForm(request.form)
|
form = AccountForm(request.form)
|
||||||
if not form.validate():
|
if not form.validate():
|
||||||
for key in form.errors:
|
flash_form_errors(form)
|
||||||
for error in form.errors[key]:
|
|
||||||
flash(error, "error")
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
return redirect(inherit_next(url_for("accounting.account.edit",
|
return redirect(inherit_next(url_for("accounting.account.edit",
|
||||||
account=account)))
|
account=account)))
|
||||||
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(url_for("accounting.account.detail",
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
account=account)))
|
account.updated_by_id = get_current_user_pk()
|
||||||
form.post_update(account)
|
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(url_for("accounting.account.detail",
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
account=account)))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<account:account>/delete", endpoint="delete")
|
@bp.post("/<account:account>/delete", endpoint="delete")
|
||||||
@ -159,11 +157,14 @@ def delete_account(account: Account) -> redirect:
|
|||||||
:return: The redirection to the account list on success, or the account
|
:return: The redirection to the account list on success, or the account
|
||||||
detail on error.
|
detail on error.
|
||||||
"""
|
"""
|
||||||
|
if not account.can_delete:
|
||||||
|
flash(s(lazy_gettext("The account cannot be deleted.")), "error")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
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(url_for("accounting.account.list")))
|
return redirect(or_next(__get_list_uri()))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/bases/<baseAccount:base>", endpoint="order")
|
@bp.get("/bases/<baseAccount:base>", endpoint="order")
|
||||||
@ -189,8 +190,25 @@ 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(url_for("accounting.account.list")))
|
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(url_for("accounting.account.list")))
|
return redirect(or_next(__get_list_uri()))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_detail_uri(account: Account) -> str:
|
||||||
|
"""Returns the detail URI of an account.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:return: The detail URI of the account.
|
||||||
|
"""
|
||||||
|
return url_for("accounting.account.detail", account=account)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_list_uri() -> str:
|
||||||
|
"""Returns the account list URI.
|
||||||
|
|
||||||
|
:return: The account list URI.
|
||||||
|
"""
|
||||||
|
return url_for("accounting.account.list")
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -17,16 +17,15 @@
|
|||||||
"""The console commands for the base account management.
|
"""The console commands for the base account management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
import csv
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from flask.cli import with_appcontext
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
from accounting.database import db
|
from accounting import data_dir
|
||||||
|
from accounting import db
|
||||||
from accounting.models import BaseAccount, BaseAccountL10n
|
from accounting.models import BaseAccount, BaseAccountL10n
|
||||||
|
|
||||||
BaseAccountData = tuple[int, str, str, str]
|
|
||||||
"""The format of the base account data, as a list of (code, English,
|
|
||||||
Traditional Chinese, Simplified Chinese) tuples."""
|
|
||||||
|
|
||||||
|
|
||||||
@click.command("accounting-init-base")
|
@click.command("accounting-init-base")
|
||||||
@with_appcontext
|
@with_appcontext
|
||||||
@ -36,674 +35,17 @@ def init_base_accounts_command() -> None:
|
|||||||
click.echo("Base accounts already exist.")
|
click.echo("Base accounts already exist.")
|
||||||
raise click.Abort
|
raise click.Abort
|
||||||
|
|
||||||
db.session.bulk_save_objects(
|
with open(data_dir / "base_accounts.csv") as fp:
|
||||||
[BaseAccount(code=str(x[0]), title_l10n=x[1]) for x in DATA])
|
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
||||||
db.session.bulk_save_objects(
|
account_data: list[dict[str, str]] = [{"code": x["code"],
|
||||||
[BaseAccountL10n(account_code=x[0], locale=y[0], title=y[1])
|
"title_l10n": x["title"]}
|
||||||
for x in DATA for y in (("zh_Hant", x[2]), ("zh_Hans", x[3]))])
|
for x in data]
|
||||||
|
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
|
||||||
|
l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
|
||||||
|
"locale": y,
|
||||||
|
"title": x[f"l10n-{y}"]}
|
||||||
|
for x in data for y in locales]
|
||||||
|
db.session.bulk_insert_mappings(BaseAccount, account_data)
|
||||||
|
db.session.bulk_insert_mappings(BaseAccountL10n, l10n_data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
click.echo("Base accounts initialized.")
|
click.echo("Base accounts initialized.")
|
||||||
|
|
||||||
|
|
||||||
DATA: list[BaseAccountData] = [
|
|
||||||
(1, "assets", "資產", "资产"),
|
|
||||||
(2, "liabilities", "負債", "负债"),
|
|
||||||
(3, "owners’ equity", "業主權益", "业主权益"),
|
|
||||||
(4, "operating revenue", "營業收入", "营业收入"),
|
|
||||||
(5, "operating costs", "營業成本", "营业成本"),
|
|
||||||
(6, "operating expenses", "營業費用", "营业费用"),
|
|
||||||
(7, "non-operating revenue and expenses, other income (expense)",
|
|
||||||
"營業外收入及費用", "营业外收入及费用"),
|
|
||||||
(8, "income tax expense (or benefit)", "所得稅費用(或利益)",
|
|
||||||
"所得税费用(或利益)"),
|
|
||||||
(9, "nonrecurring gain or loss", "非經常營業損益", "非经常营业损益"),
|
|
||||||
(11, "current assets", "流動資產", "流动资产"),
|
|
||||||
(12, "current assets", "流動資產", "流动资产"),
|
|
||||||
(13, "funds and long-term investments", "基金及長期投資", "基金及长期投资"),
|
|
||||||
(14, "property , plant, and equipment", "固定資產", "固定资产"),
|
|
||||||
(15, "property , plant, and equipment", "固定資產", "固定资产"),
|
|
||||||
(16, "depletable assets", "遞耗資產", "递耗资产"),
|
|
||||||
(17, "intangible assets", "無形資產", "无形资产"),
|
|
||||||
(18, "other assets", "其他資產", "其他资产"),
|
|
||||||
(21, "current liabilities", "流動負債", "流动负债"),
|
|
||||||
(22, "current liabilities", "流動負債", "流动负债"),
|
|
||||||
(23, "long-term liabilities", "長期負債", "长期负债"),
|
|
||||||
(28, "other liabilities", "其他負債", "其他负债"),
|
|
||||||
(31, "capital", "資本", "资本"),
|
|
||||||
(32, "additional paid-in capital", "資本公積", "资本公积"),
|
|
||||||
(33, "retained earnings (accumulated deficit)", "保留盈餘(或累積虧損)",
|
|
||||||
"保留盈余(或累积亏损)"),
|
|
||||||
(34, "equity adjustments", "權益調整", "权益调整"),
|
|
||||||
(35, "treasury stock", "庫藏股", "库藏股"),
|
|
||||||
(36, "minority interest", "少數股權", "少数股权"),
|
|
||||||
(41, "sales revenue", "銷貨收入", "销货收入"),
|
|
||||||
(46, "service revenue", "勞務收入", "劳务收入"),
|
|
||||||
(47, "agency revenue", "業務收入", "业务收入"),
|
|
||||||
(48, "other operating revenue", "其他營業收入", "其他营业收入"),
|
|
||||||
(51, "cost of goods sold", "銷貨成本", "销货成本"),
|
|
||||||
(56, "service costs", "勞務成本", "劳务成本"),
|
|
||||||
(57, "agency costs", "業務成本", "业务成本"),
|
|
||||||
(58, "other operating costs", "其他營業成本", "其他营业成本"),
|
|
||||||
(61, "selling expenses", "推銷費用", "推销费用"),
|
|
||||||
(62, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
|
|
||||||
(63, "research and development expenses", "研究發展費用", "研究发展费用"),
|
|
||||||
(71, "non-operating revenue", "營業外收入", "营业外收入"),
|
|
||||||
(72, "non-operating revenue", "營業外收入", "营业外收入"),
|
|
||||||
(73, "non-operating revenue", "營業外收入", "营业外收入"),
|
|
||||||
(74, "non-operating revenue", "營業外收入", "营业外收入"),
|
|
||||||
(75, "non-operating expenses", "營業外費用", "营业外费用"),
|
|
||||||
(76, "non-operating expenses", "營業外費用", "营业外费用"),
|
|
||||||
(77, "non-operating expenses", "營業外費用", "营业外费用"),
|
|
||||||
(78, "non-operating expenses", "營業外費用", "营业外费用"),
|
|
||||||
(81, "income tax expense (or benefit)", "所得稅費用(或利益)",
|
|
||||||
"所得税费用(或利益)"),
|
|
||||||
(91, "gain (loss) from discontinued operations", "停業部門損益",
|
|
||||||
"停业部门损益"),
|
|
||||||
(92, "extraordinary gain or loss", "非常損益", "非常损益"),
|
|
||||||
(93, "cumulative effect of changes in accounting principles",
|
|
||||||
"會計原則變動累積影響數", "会计原则变动累积影响数"),
|
|
||||||
(94, "minority interest income", "少數股權淨利", "少数股权净利"),
|
|
||||||
(111, "cash and cash equivalents", "現金及約當現金", "现金及约当现金"),
|
|
||||||
(112, "short-term investments", "短期投資", "短期投资"),
|
|
||||||
(113, "notes receivable", "應收票據", "应收票据"),
|
|
||||||
(114, "accounts receivable", "應收帳款", "应收帐款"),
|
|
||||||
(118, "other receivables", "其他應收款", "其他应收款"),
|
|
||||||
(121, "inventories", "存貨", "存货"),
|
|
||||||
(122, "inventories", "存貨", "存货"),
|
|
||||||
(125, "prepaid expenses", "預付費用", "预付费用"),
|
|
||||||
(126, "prepayments", "預付款項", "预付款项"),
|
|
||||||
(128, "other current assets", "其他流動資產", "其他流动资产"),
|
|
||||||
(129, "other current assets", "其他流動資產", "其他流动资产"),
|
|
||||||
(131, "funds", "基金", "基金"),
|
|
||||||
(132, "long-term investments", "長期投資", "长期投资"),
|
|
||||||
(141, "land", "土地", "土地"),
|
|
||||||
(142, "land improvements", "土地改良物", "土地改良物"),
|
|
||||||
(143, "buildings", "房屋及建物", "房屋及建物"),
|
|
||||||
(144, "machinery and equipment", "機(器)具及設備", "机(器)具及设备"),
|
|
||||||
(145, "machinery and equipment", "機(器)具及設備", "机(器)具及设备"),
|
|
||||||
(146, "machinery and equipment", "機(器)具及設備", "机(器)具及设备"),
|
|
||||||
(151, "leased assets", "租賃資產", "租赁资产"),
|
|
||||||
(152, "leasehold improvements", "租賃權益改良", "租赁权益改良"),
|
|
||||||
(156, "construction in progress and prepayments for equipment",
|
|
||||||
"未完工程及預付購置設備款", "未完工程及预付购置设备款"),
|
|
||||||
(158, "miscellaneous property, plant, and equipment", "雜項固定資產",
|
|
||||||
"杂项固定资产"),
|
|
||||||
(161, "depletable assets", "遞耗資產", "递耗资产"),
|
|
||||||
(171, "trademarks", "商標權", "商标权"),
|
|
||||||
(172, "patents", "專利權", "专利权"),
|
|
||||||
(173, "franchise", "特許權", "特许权"),
|
|
||||||
(174, "copyright", "著作權", "著作权"),
|
|
||||||
(175, "computer software", "電腦軟體", "电脑软体"),
|
|
||||||
(176, "goodwill", "商譽", "商誉"),
|
|
||||||
(177, "organization costs", "開辦費", "开办费"),
|
|
||||||
(178, "other intangibles", "其他無形資產", "其他无形资产"),
|
|
||||||
(181, "deferred assets", "遞延資產", "递延资产"),
|
|
||||||
(182, "idle assets", "閒置資產", "闲置资产"),
|
|
||||||
(184, "long-term notes , accounts and overdue receivables",
|
|
||||||
"長期應收票據及款項與催收帳款", "长期应收票据及款项与催收帐款"),
|
|
||||||
(185, "assets leased to others", "出租資產", "出租资产"),
|
|
||||||
(186, "refundable deposit", "存出保證金", "存出保证金"),
|
|
||||||
(188, "miscellaneous assets", "雜項資產", "杂项资产"),
|
|
||||||
(211, "short-term borrowings (debt)", "短期借款", "短期借款"),
|
|
||||||
(212, "short-term notes and bills payable", "應付短期票券", "应付短期票券"),
|
|
||||||
(213, "notes payable", "應付票據", "应付票据"),
|
|
||||||
(214, "accounts pay able", "應付帳款", "应付帐款"),
|
|
||||||
(216, "income taxes payable", "應付所得稅", "应付所得税"),
|
|
||||||
(217, "accrued expenses", "應付費用", "应付费用"),
|
|
||||||
(218, "other payables", "其他應付款", "其他应付款"),
|
|
||||||
(219, "other payables", "其他應付款", "其他应付款"),
|
|
||||||
(226, "advance receipts", "預收款項", "预收款项"),
|
|
||||||
(227, "long-term liabilities -current portion",
|
|
||||||
"一年或一營業週期內到期長期負債", "一年或一营业周期内到期长期负债"),
|
|
||||||
(228, "other current liabilities", "其他流動負債",
|
|
||||||
"其他流动负债"),
|
|
||||||
(229, "other current liabilities", "其他流動負債",
|
|
||||||
"其他流动负债"),
|
|
||||||
(231, "corporate bonds payable", "應付公司債", "应付公司债"),
|
|
||||||
(232, "long-term loans payable", "長期借款", "长期借款"),
|
|
||||||
(233, "long-term notes and accounts payable", "長期應付票據及款項",
|
|
||||||
"长期应付票据及款项"),
|
|
||||||
(234, "accrued liabilities for land value increment tax",
|
|
||||||
"估計應付土地增值稅", "估计应付土地增值税"),
|
|
||||||
(235, "accrued pension liabilities", "應計退休金負債", "应计退休金负债"),
|
|
||||||
(238, "other long-term liabilities", "其他長期負債", "其他长期负债"),
|
|
||||||
(281, "deferred liabilities", "遞延負債", "递延负债"),
|
|
||||||
(286, "deposits received", "存入保證金", "存入保证金"),
|
|
||||||
(288, "miscellaneous liabilities", "雜項負債", "杂项负债"),
|
|
||||||
(311, "capital", "資本(或股本)", "资本(或股本)"),
|
|
||||||
(321, "paid-in capital in excess of par", "股票溢價", "股票溢价"),
|
|
||||||
(323, "capital surplus from assets revaluation", "資產重估增值準備",
|
|
||||||
"资产重估增值准备"),
|
|
||||||
(324, "capital surplus from gain on disposal of assets", "處分資產溢價公積",
|
|
||||||
"处分资产溢价公积"),
|
|
||||||
(325, "capital surplus from business combination", "合併公積", "合并公积"),
|
|
||||||
(326, "donated surplus", "受贈公積", "受赠公积"),
|
|
||||||
(328, "other additional paid-in capital", "其他資本公積", "其他资本公积"),
|
|
||||||
(331, "legal reserve", "法定盈餘公積", "法定盈余公积"),
|
|
||||||
(332, "special reserve", "特別盈餘公積", "特别盈余公积"),
|
|
||||||
(335, "retained earnings-unappropriated (or accumulated deficit)",
|
|
||||||
"未分配盈餘(或累積虧損)", "未分配盈余(或累积亏损)"),
|
|
||||||
(341,
|
|
||||||
"unrealized loss on market value decline of long-term equity investments",
|
|
||||||
"長期股權投資未實現跌價損失", "长期股权投资未实现跌价损失"),
|
|
||||||
(342, "cumulative translation adjustment", "累積換算調整數", "累积换算调整数"),
|
|
||||||
(343, "net loss not recognized as pension cost", "未認列為退休金成本之淨損失",
|
|
||||||
"未认列为退休金成本之净损失"),
|
|
||||||
(351, "treasury stock", "庫藏股", "库藏股"),
|
|
||||||
(361, "minority interest", "少數股權", "少数股权"),
|
|
||||||
(411, "sales revenue", "銷貨收入", "销货收入"),
|
|
||||||
(417, "sales return", "銷貨退回", "销货退回"),
|
|
||||||
(419, "sales allowances", "銷貨折讓", "销货折让"),
|
|
||||||
(461, "service revenue", "勞務收入", "劳务收入"),
|
|
||||||
(471, "agency revenue", "業務收入", "业务收入"),
|
|
||||||
(488, "other operating revenue", "其他營業收入—其他", "其他营业收入—其他"),
|
|
||||||
(511, "cost of goods sold", "銷貨成本", "销货成本"),
|
|
||||||
(512, "purchases", "進貨", "进货"),
|
|
||||||
(513, "materials purchased", "進料", "进料"),
|
|
||||||
(514, "direct labor", "直接人工", "直接人工"),
|
|
||||||
(515, "manufacturing overhead", "製造費用", "制造费用"),
|
|
||||||
(516, "manufacturing overhead", "製造費用", "制造费用"),
|
|
||||||
(517, "manufacturing overhead", "製造費用", "制造费用"),
|
|
||||||
(518, "manufacturing overhead", "製造費用", "制造费用"),
|
|
||||||
(561, "service costs", "勞務成本", "劳务成本"),
|
|
||||||
(571, "agency costs", "業務成本", "业务成本"),
|
|
||||||
(588, "other operating costs-other", "其他營業成本—其他", "其他营业成本—其他"),
|
|
||||||
(615, "selling expenses", "推銷費用", "推销费用"),
|
|
||||||
(616, "selling expenses", "推銷費用", "推销费用"),
|
|
||||||
(617, "selling expenses", "推銷費用", "推销费用"),
|
|
||||||
(618, "selling expenses", "推銷費用", "推销费用"),
|
|
||||||
(625, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
|
|
||||||
(626, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
|
|
||||||
(627, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
|
|
||||||
(628, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
|
|
||||||
(635, "research and development expenses", "研究發展費用", "研究发展费用"),
|
|
||||||
(636, "research and development expenses", "研究發展費用", "研究发展费用"),
|
|
||||||
(637, "research and development expenses", "研究發展費用", "研究发展费用"),
|
|
||||||
(638, "research and development expenses", "研究發展費用", "研究发展费用"),
|
|
||||||
(711, "interest revenue", "利息收入", "利息收入"),
|
|
||||||
(712, "investment income", "投資收益", "投资收益"),
|
|
||||||
(713, "foreign exchange gain", "兌換利益", "兑换利益"),
|
|
||||||
(714, "gain on disposal of investments", "處分投資收益", "处分投资收益"),
|
|
||||||
(715, "gain on disposal of assets", "處分資產溢價收入", "处分资产溢价收入"),
|
|
||||||
(748, "other non-operating revenue", "其他營業外收入", "其他营业外收入"),
|
|
||||||
(751, "interest expense", "利息費用", "利息费用"),
|
|
||||||
(752, "investment loss", "投資損失", "投资损失"),
|
|
||||||
(753, "foreign exchange loss", "兌換損失", "兑换损失"),
|
|
||||||
(754, "loss on disposal of investments", "處分投資損失", "处分投资损失"),
|
|
||||||
(755, "loss on disposal of assets", "處分資產損失", "处分资产损失"),
|
|
||||||
(788, "other non-operating expenses", "其他營業外費用", "其他营业外费用"),
|
|
||||||
(811, "income tax expense (or benefit)", "所得稅費用(或利益)",
|
|
||||||
"所得税费用(或利益)"),
|
|
||||||
(911, "income (loss) from operations of discontinued segments",
|
|
||||||
"停業部門損益—停業前營業損益", "停业部门损益—停业前营业损益"),
|
|
||||||
(912, "gain (loss) from disposal of discontinued segments",
|
|
||||||
"停業部門損益—處分損益", "停业部门损益—处分损益"),
|
|
||||||
(921, "extraordinary gain or loss", "非常損益", "非常损益"),
|
|
||||||
(931, "cumulative effect of changes in accounting principles",
|
|
||||||
"會計原則變動累積影響數", "会计原则变动累积影响数"),
|
|
||||||
(941, "minority interest income", "少數股權淨利", "少数股权净利"),
|
|
||||||
(1111, "cash on hand", "庫存現金", "库存现金"),
|
|
||||||
(1112, "petty cash/revolving funds", "零用金/週轉金", "零用金/周转金"),
|
|
||||||
(1113, "cash in banks", "銀行存款", "银行存款"),
|
|
||||||
(1116, "cash in transit", "在途現金", "在途现金"),
|
|
||||||
(1117, "cash equivalents", "約當現金", "约当现金"),
|
|
||||||
(1118, "other cash and cash equivalents", "其他現金及約當現金",
|
|
||||||
"其他现金及约当现金"),
|
|
||||||
(1121, "short-term investments – stock", "短期投資—股票", "短期投资—股票"),
|
|
||||||
(1122, "short-term investments – short-term notes and bills",
|
|
||||||
"短期投資—短期票券", "短期投资—短期票券"),
|
|
||||||
(1123, "short-term investments – government bonds", "短期投資—政府債券",
|
|
||||||
"短期投资—政府债券"),
|
|
||||||
(1124, "short-term investments – beneficiary certificates",
|
|
||||||
"短期投資—受益憑證", "短期投资—受益凭证"),
|
|
||||||
(1125, "short-term investments – corporate bonds", "短期投資—公司債",
|
|
||||||
"短期投资—公司债"),
|
|
||||||
(1128, "short-term investments – other", "短期投資—其他", "短期投资—其他"),
|
|
||||||
(1129, "allowance for reduction of short-term investment to market",
|
|
||||||
"備抵短期投資跌價損失", "备抵短期投资跌价损失"),
|
|
||||||
(1131, "notes receivable", "應收票據", "应收票据"),
|
|
||||||
(1132, "discounted notes receivable", "應收票據貼現", "应收票据贴现"),
|
|
||||||
(1137, "notes receivable – related parties", "應收票據—關係人",
|
|
||||||
"应收票据—关系人"),
|
|
||||||
(1138, "other notes receivable", "其他應收票據", "其他应收票据"),
|
|
||||||
(1139, "allowance for uncollectible accounts – notes receivable",
|
|
||||||
"備抵呆帳-應收票據", "备抵呆帐-应收票据"),
|
|
||||||
(1141, "accounts receivable", "應收帳款", "应收帐款"),
|
|
||||||
(1142, "installment accounts receivable", "應收分期帳款",
|
|
||||||
"应收分期帐款"),
|
|
||||||
(1147, "accounts receivable – related parties", "應收帳款—關係人",
|
|
||||||
"应收帐款—关系人"),
|
|
||||||
(1149, "allowance for uncollectible accounts – accounts receivable",
|
|
||||||
"備抵呆帳-應收帳款", "备抵呆帐-应收帐款"),
|
|
||||||
(1181, "forward exchange contract receivable", "應收出售遠匯款",
|
|
||||||
"应收出售远汇款"),
|
|
||||||
(1182, "forward exchange contract receivable – foreign currencies",
|
|
||||||
"應收遠匯款—外幣", "应收远汇款—外币"),
|
|
||||||
(1183, "discount on forward ex-change contract", "買賣遠匯折價",
|
|
||||||
"买卖远汇折价"),
|
|
||||||
(1184, "earned revenue receivable", "應收收益", "应收收益"),
|
|
||||||
(1185, "income tax refund receivable", "應收退稅款", "应收退税款"),
|
|
||||||
(1187, "other receivables – related parties", "其他應收款—關係人",
|
|
||||||
"其他应收款—关系人"),
|
|
||||||
(1188, "other receivables – other", "其他應收款—其他", "其他应收款—其他"),
|
|
||||||
(1189, "allowance for uncollectible accounts – other receivables",
|
|
||||||
"備抵呆帳—其他應收款", "备抵呆帐—其他应收款"),
|
|
||||||
(1211, "merchandise inventory", "商品存貨", "商品存货"),
|
|
||||||
(1212, "consigned goods", "寄銷商品", "寄销商品"),
|
|
||||||
(1213, "goods in transit", "在途商品", "在途商品"),
|
|
||||||
(1219, "allowance for reduction of inventory to market", "備抵存貨跌價損失",
|
|
||||||
"备抵存货跌价损失"),
|
|
||||||
(1221, "finished goods", "製成品", "制成品"),
|
|
||||||
(1222, "consigned finished goods", "寄銷製成品", "寄销制成品"),
|
|
||||||
(1223, "by-products", "副產品", "副产品"),
|
|
||||||
(1224, "work in process", "在製品", "在制品"),
|
|
||||||
(1225, "work in process – outsourced", "委外加工", "委外加工"),
|
|
||||||
(1226, "raw materials", "原料", "原料"),
|
|
||||||
(1227, "supplies", "物料", "物料"),
|
|
||||||
(1228, "materials and supplies in transit", "在途原物料", "在途原物料"),
|
|
||||||
(1229, "allowance for reduction of inventory to market", "備抵存貨跌價損失",
|
|
||||||
"备抵存货跌价损失"),
|
|
||||||
(1251, "prepaid payroll", "預付薪資", "预付薪资"),
|
|
||||||
(1252, "prepaid rents", "預付租金", "预付租金"),
|
|
||||||
(1253, "prepaid insurance", "預付保險費", "预付保险费"),
|
|
||||||
(1254, "office supplies", "用品盤存", "用品盘存"),
|
|
||||||
(1255, "prepaid income tax", "預付所得稅", "预付所得税"),
|
|
||||||
(1258, "other prepaid expenses", "其他預付費用", "其他预付费用"),
|
|
||||||
(1261, "prepayment for purchases", "預付貨款", "预付货款"),
|
|
||||||
(1268, "other prepayments", "其他預付款項", "其他预付款项"),
|
|
||||||
(1281, "VAT paid ( or input tax)", "進項稅額", "进项税额"),
|
|
||||||
(1282, "excess VAT paid (or overpaid VAT)", "留抵稅額", "留抵税额"),
|
|
||||||
(1283, "temporary payments", "暫付款", "暂付款"),
|
|
||||||
(1284, "payment on behalf of others", "代付款", "代付款"),
|
|
||||||
(1285, "advances to employees", "員工借支", "员工借支"),
|
|
||||||
(1286, "refundable deposits", "存出保證金", "存出保证金"),
|
|
||||||
(1287, "certificate of deposit-restricted", "受限制存款", "受限制存款"),
|
|
||||||
(1291, "deferred income tax assets", "遞延所得稅資產", "递延所得税资产"),
|
|
||||||
(1292, "deferred foreign exchange losses", "遞延兌換損失", "递延兑换损失"),
|
|
||||||
(1293, "owners’ (stockholders’) current account", "業主(股東)往來",
|
|
||||||
"业主(股东)往来"),
|
|
||||||
(1294, "current account with others", "同業往來", "同业往来"),
|
|
||||||
(1298, "other current assets – other", "其他流動資產—其他",
|
|
||||||
"其他流动资产—其他"),
|
|
||||||
(1311, "redemption fund (or sinking fund)", "償債基金", "偿债基金"),
|
|
||||||
(1312, "fund for improvement and expansion", "改良及擴充基金",
|
|
||||||
"改良及扩充基金"),
|
|
||||||
(1313, "contingency fund", "意外損失準備基金", "意外损失准备基金"),
|
|
||||||
(1314, "pension fund", "退休基金", "退休基金"),
|
|
||||||
(1318, "other funds", "其他基金", "其他基金"),
|
|
||||||
(1321, "long-term equity investments", "長期股權投資", "长期股权投资"),
|
|
||||||
(1322, "long-term bond investments", "長期債券投資", "长期债券投资"),
|
|
||||||
(1323, "long-term real estate in-vestments", "長期不動產投資",
|
|
||||||
"长期不动产投资"),
|
|
||||||
(1324, "cash surrender value of life insurance", "人壽保險現金解約價值",
|
|
||||||
"人寿保险现金解约价值"),
|
|
||||||
(1328, "other long-term investments", "其他長期投資", "其他长期投资"),
|
|
||||||
(1329,
|
|
||||||
"allowance for excess of cost over market value of long-term investments",
|
|
||||||
"備抵長期投資跌價損失", "备抵长期投资跌价损失"),
|
|
||||||
(1411, "land", "土地", "土地"),
|
|
||||||
(1418, "land – revaluation increments", "土地—重估增值", "土地—重估增值"),
|
|
||||||
(1421, "land improvements", "土地改良物", "土地改良物"),
|
|
||||||
(1428, "land improvements – revaluation increments", "土地改良物—重估增值",
|
|
||||||
"土地改良物—重估增值"),
|
|
||||||
(1429, "accumulated depreciation – land improvements", "累積折舊—土地改良物",
|
|
||||||
"累积折旧—土地改良物"),
|
|
||||||
(1431, "buildings", "房屋及建物", "房屋及建物"),
|
|
||||||
(1438, "buildings –revaluation increments", "房屋及建物—重估增值",
|
|
||||||
"房屋及建物—重估增值"),
|
|
||||||
(1439, "accumulated depreciation – buildings", "累積折舊—房屋及建物",
|
|
||||||
"累积折旧—房屋及建物"),
|
|
||||||
(1441, "machinery", "機(器)具", "机(器)具"),
|
|
||||||
(1448, "machinery – revaluation increments", "機(器)具—重估增值",
|
|
||||||
"机(器)具—重估增值"),
|
|
||||||
(1449, "accumulated depreciation – machinery", "累積折舊—機(器)具",
|
|
||||||
"累积折旧—机(器)具"),
|
|
||||||
(1511, "leased assets", "租賃資產", "租赁资产"),
|
|
||||||
(1519, "accumulated depreciation – leased assets", "累積折舊—租賃資產",
|
|
||||||
"累积折旧—租赁资产"),
|
|
||||||
(1521, "leasehold improvements", "租賃權益改良", "租赁权益改良"),
|
|
||||||
(1529, "accumulated depreciation – leasehold improvements",
|
|
||||||
"累積折舊—租賃權益改良", "累积折旧—租赁权益改良"),
|
|
||||||
(1561, "construction in progress", "未完工程", "未完工程"),
|
|
||||||
(1562, "prepayment for equipment", "預付購置設備款", "预付购置设备款"),
|
|
||||||
(1581, "miscellaneous property, plant, and equipment", "雜項固定資產",
|
|
||||||
"杂项固定资产"),
|
|
||||||
(1588,
|
|
||||||
"miscellaneous property, plant, and equipment – revaluation increments",
|
|
||||||
"雜項固定資產—重估增值", "杂项固定资产—重估增值"),
|
|
||||||
(1589,
|
|
||||||
"accumulated depreciation – miscellaneous property, plant, and equipment",
|
|
||||||
"累積折舊—雜項固定資產", "累积折旧—杂项固定资产"),
|
|
||||||
(1611, "natural resources", "天然資源", "天然资源"),
|
|
||||||
(1618, "natural resources –revaluation increments", "天然資源—重估增值",
|
|
||||||
"天然资源—重估增值"),
|
|
||||||
(1619, "accumulated depletion – natural resources", "累積折耗—天然資源",
|
|
||||||
"累积折耗—天然资源"),
|
|
||||||
(1711, "trademarks", "商標權", "商标权"),
|
|
||||||
(1721, "patents", "專利權", "专利权"),
|
|
||||||
(1731, "franchise", "特許權", "特许权"),
|
|
||||||
(1741, "copyright", "著作權", "著作权"),
|
|
||||||
(1751, "computer software cost", "電腦軟體", "电脑软体"),
|
|
||||||
(1761, "goodwill", "商譽", "商誉"),
|
|
||||||
(1771, "organization costs", "開辦費", "开办费"),
|
|
||||||
(1781, "deferred pension costs", "遞延退休金成本", "递延退休金成本"),
|
|
||||||
(1782, "leasehold improvements", "租賃權益改良", "租赁权益改良"),
|
|
||||||
(1788, "other intangible assets – other", "其他無形資產—其他",
|
|
||||||
"其他无形资产—其他"),
|
|
||||||
(1811, "deferred bond issuance costs", "債券發行成本", "债券发行成本"),
|
|
||||||
(1812, "long-term prepaid rent", "長期預付租金", "长期预付租金"),
|
|
||||||
(1813, "long-term prepaid insurance", "長期預付保險費", "长期预付保险费"),
|
|
||||||
(1814, "deferred income tax assets", "遞延所得稅資產", "递延所得税资产"),
|
|
||||||
(1815, "prepaid pension cost", "預付退休金", "预付退休金"),
|
|
||||||
(1818, "other deferred assets", "其他遞延資產", "其他递延资产"),
|
|
||||||
(1821, "idle assets", "閒置資產", "闲置资产"),
|
|
||||||
(1841, "long-term notes receivable", "長期應收票據", "长期应收票据"),
|
|
||||||
(1842, "long-term accounts receivable", "長期應收帳款", "长期应收帐款"),
|
|
||||||
(1843, "overdue receivables", "催收帳款", "催收帐款"),
|
|
||||||
(1847,
|
|
||||||
"long-term notes, accounts and overdue receivables – related parties",
|
|
||||||
"長期應收票據及款項與催收帳款—關係人", "长期应收票据及款项与催收帐款—关系人"),
|
|
||||||
(1848, "other long-term receivables", "其他長期應收款項", "其他长期应收款项"),
|
|
||||||
(1849,
|
|
||||||
"allowance for uncollectible accounts – long-term notes, accounts and"
|
|
||||||
" overdue receivables",
|
|
||||||
"備抵呆帳—長期應收票據及款項與催收帳款", "备抵呆帐—长期应收票据及款项与催收帐款"),
|
|
||||||
(1851, "assets leased to others", "出租資產", "出租资产"),
|
|
||||||
(1858, "assets leased to others – incremental value from revaluation",
|
|
||||||
"出租資產—重估增值", "出租资产—重估增值"),
|
|
||||||
(1859, "accumulated depreciation – assets leased to others",
|
|
||||||
"累積折舊—出租資產", "累积折旧—出租资产"),
|
|
||||||
(1861, "refundable deposits", "存出保證金", "存出保证金"),
|
|
||||||
(1881, "certificate of deposit – restricted", "受限制存款", "受限制存款"),
|
|
||||||
(1888, "miscellaneous assets – other", "雜項資產—其他", "杂项资产—其他"),
|
|
||||||
(2111, "bank overdraft", "銀行透支", "银行透支"),
|
|
||||||
(2112, "bank loan", "銀行借款", "银行借款"),
|
|
||||||
(2114, "short-term borrowings – owners", "短期借款—業主", "短期借款—业主"),
|
|
||||||
(2115, "short-term borrowings – employees", "短期借款—員工", "短期借款—员工"),
|
|
||||||
(2117, "short-term borrowings – related parties", "短期借款—關係人",
|
|
||||||
"短期借款—关系人"),
|
|
||||||
(2118, "short-term borrowings – other", "短期借款—其他", "短期借款—其他"),
|
|
||||||
(2121, "commercial paper payable", "應付商業本票", "应付商业本票"),
|
|
||||||
(2122, "bank acceptance", "銀行承兌匯票", "银行承兑汇票"),
|
|
||||||
(2128, "other short-term notes and bills payable", "其他應付短期票券",
|
|
||||||
"其他应付短期票券"),
|
|
||||||
(2129, "discount on short-term notes and bills payable", "應付短期票券折價",
|
|
||||||
"应付短期票券折价"),
|
|
||||||
(2131, "notes payable", "應付票據", "应付票据"),
|
|
||||||
(2137, "notes payable – related parties", "應付票據—關係人",
|
|
||||||
"应付票据—关系人"),
|
|
||||||
(2138, "other notes payable", "其他應付票據", "其他应付票据"),
|
|
||||||
(2141, "accounts payable", "應付帳款", "应付帐款"),
|
|
||||||
(2147, "accounts payable – related parties", "應付帳款—關係人",
|
|
||||||
"应付帐款—关系人"),
|
|
||||||
(2161, "income tax payable", "應付所得稅", "应付所得税"),
|
|
||||||
(2171, "accrued payroll", "應付薪工", "应付薪工"),
|
|
||||||
(2172, "accrued rent payable", "應付租金", "应付租金"),
|
|
||||||
(2173, "accrued interest payable", "應付利息", "应付利息"),
|
|
||||||
(2174, "accrued VAT payable", "應付營業稅", "应付营业税"),
|
|
||||||
(2175, "accrued taxes payable – other", "應付稅捐—其他", "应付税捐—其他"),
|
|
||||||
(2178, "other accrued expenses payable", "其他應付費用", "其他应付费用"),
|
|
||||||
(2181, "forward exchange contract payable", "應付購入遠匯款", "应付购入远汇款"),
|
|
||||||
(2182, "forward exchange contract payable – foreign currencies",
|
|
||||||
"應付遠匯款—外幣", "应付远汇款—外币"),
|
|
||||||
(2183, "premium on forward exchange contract", "買賣遠匯溢價", "买卖远汇溢价"),
|
|
||||||
(2184, "payables on land and building purchased", "應付土地房屋款",
|
|
||||||
"应付土地房屋款"),
|
|
||||||
(2185, "Payables on equipment", "應付設備款", "应付设备款"),
|
|
||||||
(2187, "other payables – related parties", "其他應付款—關係人",
|
|
||||||
"其他应付款—关系人"),
|
|
||||||
(2191, "dividend payable", "應付股利", "应付股利"),
|
|
||||||
(2192, "bonus payable", "應付紅利", "应付红利"),
|
|
||||||
(2193, "compensation payable to directors and supervisors", "應付董監事酬勞",
|
|
||||||
"应付董监事酬劳"),
|
|
||||||
(2198, "other payables – other", "其他應付款—其他", "其他应付款—其他"),
|
|
||||||
(2261, "sales revenue received in advance", "預收貨款", "预收货款"),
|
|
||||||
(2262, "revenue received in advance", "預收收入", "预收收入"),
|
|
||||||
(2268, "other advance receipts", "其他預收款", "其他预收款"),
|
|
||||||
(2271, "corporate bonds payable – current portion",
|
|
||||||
"一年或一營業週期內到期公司債", "一年或一营业周期内到期公司债"),
|
|
||||||
(2272, "long-term loans payable – current portion",
|
|
||||||
"一年或一營業週期內到期長期借款", "一年或一营业周期内到期长期借款"),
|
|
||||||
(2273,
|
|
||||||
"long-term notes and accounts payable due within one year or one"
|
|
||||||
" operating cycle",
|
|
||||||
"一年或一營業週期內到期長期應付票據及款項",
|
|
||||||
"一年或一营业周期内到期长期应付票据及款项"),
|
|
||||||
(2277,
|
|
||||||
"long-term notes and accounts payables to related parties – current"
|
|
||||||
" portion",
|
|
||||||
"一年或一營業週期內到期長期應付票據及款項—關係人",
|
|
||||||
"一年或一营业周期内到期长期应付票据及款项—关系人"),
|
|
||||||
(2278, "other long-term liabilities – current portion",
|
|
||||||
"其他一年或一營業週期內到期長期負債", "其他一年或一营业周期内到期长期负债"),
|
|
||||||
(2281, "VAT received (or output tax)", "銷項稅額", "销项税额"),
|
|
||||||
(2283, "temporary receipts", "暫收款", "暂收款"),
|
|
||||||
(2284, "receipts under custody", "代收款", "代收款"),
|
|
||||||
(2285, "estimated warranty liabilities", "估計售後服務/保固負債",
|
|
||||||
"估计售后服务/保固负债"),
|
|
||||||
(2291, "deferred income tax liabilities", "遞延所得稅負債", "递延所得税负债"),
|
|
||||||
(2292, "deferred foreign exchange gain", "遞延兌換利益", "递延兑换利益"),
|
|
||||||
(2293, "owners’ current account", "業主(股東)往來", "业主(股东)往来"),
|
|
||||||
(2294, "current account with others", "同業往來", "同业往来"),
|
|
||||||
(2298, "other current liabilities – others", "其他流動負債—其他",
|
|
||||||
"其他流动负债—其他"),
|
|
||||||
(2311, "corporate bonds payable", "應付公司債", "应付公司债"),
|
|
||||||
(2319, "premium (discount) on corporate bonds payable",
|
|
||||||
"應付公司債溢(折)價", "应付公司债溢(折)价"),
|
|
||||||
(2321, "long-term loans payable – bank", "長期銀行借款", "长期银行借款"),
|
|
||||||
(2324, "long-term loans payable – owners", "長期借款—業主", "长期借款—业主"),
|
|
||||||
(2325, "long-term loans payable – employees", "長期借款—員工",
|
|
||||||
"长期借款—员工"),
|
|
||||||
(2327, "long-term loans payable – related parties", "長期借款—關係人",
|
|
||||||
"长期借款—关系人"),
|
|
||||||
(2328, "long-term loans payable – other", "長期借款—其他", "长期借款—其他"),
|
|
||||||
(2331, "long-term notes payable", "長期應付票據", "长期应付票据"),
|
|
||||||
(2332, "long-term accounts pay-able", "長期應付帳款", "长期应付帐款"),
|
|
||||||
(2333, "long-term capital lease liabilities", "長期應付租賃負債",
|
|
||||||
"长期应付租赁负债"),
|
|
||||||
(2337, "Long-term notes and accounts payable – related parties",
|
|
||||||
"長期應付票據及款項—關係人", "长期应付票据及款项—关系人"),
|
|
||||||
(2338, "other long-term payables", "其他長期應付款項", "其他长期应付款项"),
|
|
||||||
(2341, "estimated accrued land value incremental tax pay-able",
|
|
||||||
"估計應付土地增值稅", "估计应付土地增值税"),
|
|
||||||
(2351, "accrued pension liabilities", "應計退休金負債", "应计退休金负债"),
|
|
||||||
(2388, "other long-term liabilities – other", "其他長期負債—其他",
|
|
||||||
"其他长期负债—其他"),
|
|
||||||
(2811, "deferred revenue", "遞延收入", "递延收入"),
|
|
||||||
(2814, "deferred income tax liabilities", "遞延所得稅負債", "递延所得税负债"),
|
|
||||||
(2818, "other deferred liabilities", "其他遞延負債", "其他递延负债"),
|
|
||||||
(2861, "guarantee deposit received", "存入保證金", "存入保证金"),
|
|
||||||
(2888, "miscellaneous liabilities – other", "雜項負債—其他", "杂项负债—其他"),
|
|
||||||
(3111, "capital – common stock", "普通股股本", "普通股股本"),
|
|
||||||
(3112, "capital – preferred stock", "特別股股本", "特别股股本"),
|
|
||||||
(3113, "capital collected in advance", "預收股本", "预收股本"),
|
|
||||||
(3114, "stock dividends to be distributed", "待分配股票股利",
|
|
||||||
"待分配股票股利"),
|
|
||||||
(3115, "capital", "資本", "资本"),
|
|
||||||
(3211, "paid-in capital in excess of par- common stock", "普通股股票溢價",
|
|
||||||
"普通股股票溢价"),
|
|
||||||
(3212, "paid-in capital in excess of par- preferred stock", "特別股股票溢價",
|
|
||||||
"特别股股票溢价"),
|
|
||||||
(3231, "capital surplus from assets revaluation", "資產重估增值準備",
|
|
||||||
"资产重估增值准备"),
|
|
||||||
(3241, "capital surplus from gain on disposal of assets", "處分資產溢價公積",
|
|
||||||
"处分资产溢价公积"),
|
|
||||||
(3251, "capital surplus from business combination", "合併公積", "合并公积"),
|
|
||||||
(3261, "donated surplus", "受贈公積", "受赠公积"),
|
|
||||||
(3281, "additional paid-in capital from investee under equity method",
|
|
||||||
"權益法長期股權投資資本公積", "权益法长期股权投资资本公积"),
|
|
||||||
(3282, "additional paid-in capital – treasury stock trans-actions",
|
|
||||||
"資本公積—庫藏股票交易", "资本公积—库藏股票交易"),
|
|
||||||
(3311, "legal reserve", "法定盈餘公積", "法定盈余公积"),
|
|
||||||
(3321, "contingency reserve", "意外損失準備", "意外损失准备"),
|
|
||||||
(3322, "improvement and expansion reserve", "改良擴充準備", "改良扩充准备"),
|
|
||||||
(3323, "special reserve for redemption of liabilities", "償債準備",
|
|
||||||
"偿债准备"),
|
|
||||||
(3328, "other special reserve", "其他特別盈餘公積", "其他特别盈余公积"),
|
|
||||||
(3351, "accumulated profit or loss", "累積盈虧", "累积盈亏"),
|
|
||||||
(3352, "prior period adjustments", "前期損益調整", "前期损益调整"),
|
|
||||||
(3353, "net income or loss for current period", "本期損益", "本期损益"),
|
|
||||||
(3411,
|
|
||||||
"unrealized loss on market value decline of long-term equity investments",
|
|
||||||
"長期股權投資未實現跌價損失", "长期股权投资未实现跌价损失"),
|
|
||||||
(3421, "cumulative translation adjustments", "累積換算調整數",
|
|
||||||
"累积换算调整数"),
|
|
||||||
(3431, "net loss not recognized as pension costs",
|
|
||||||
"未認列為退休金成本之淨損失", "未认列为退休金成本之净损失"),
|
|
||||||
(3511, "treasury stock", "庫藏股", "库藏股"),
|
|
||||||
(3611, "minority interest", "少數股權", "少数股权"),
|
|
||||||
(4111, "sales revenue", "銷貨收入", "销货收入"),
|
|
||||||
(4112, "installment sales revenue", "分期付款銷貨收入", "分期付款销货收入"),
|
|
||||||
(4171, "sales return", "銷貨退回", "销货退回"),
|
|
||||||
(4191, "sales discounts and allowances", "銷貨折讓", "销货折让"),
|
|
||||||
(4611, "service revenue", "勞務收入", "劳务收入"),
|
|
||||||
(4711, "agency revenue", "業務收入", "业务收入"),
|
|
||||||
(4888, "other operating revenue – other", "其他營業收入—其他",
|
|
||||||
"其他营业收入—其他"),
|
|
||||||
(5111, "cost of goods sold", "銷貨成本", "销货成本"),
|
|
||||||
(5112, "installment cost of goods sold", "分期付款銷貨成本",
|
|
||||||
"分期付款销货成本"),
|
|
||||||
(5121, "purchases", "進貨", "进货"),
|
|
||||||
(5122, "purchase expenses", "進貨費用", "进货费用"),
|
|
||||||
(5123, "purchase returns", "進貨退出", "进货退出"),
|
|
||||||
(5124, "charges on purchased merchandise", "進貨折讓", "进货折让"),
|
|
||||||
(5131, "material purchased", "進料", "进料"),
|
|
||||||
(5132, "charges on purchased material", "進料費用", "进料费用"),
|
|
||||||
(5133, "material purchase returns", "進料退出", "进料退出"),
|
|
||||||
(5134, "material purchase allowances", "進料折讓", "进料折让"),
|
|
||||||
(5141, "direct labor", "直接人工", "直接人工"),
|
|
||||||
(5151, "indirect labor", "間接人工", "间接人工"),
|
|
||||||
(5152, "rent expense, rent", "租金支出", "租金支出"),
|
|
||||||
(5153, "office supplies (expense)", "文具用品", "文具用品"),
|
|
||||||
(5154, "travelling expense, travel", "旅費", "旅费"),
|
|
||||||
(5155, "shipping expenses, freight", "運費", "运费"),
|
|
||||||
(5156, "postage (expenses)", "郵電費", "邮电费"),
|
|
||||||
(5157, "repair (s) and maintenance (expense )", "修繕費", "修缮费"),
|
|
||||||
(5158, "packing expenses", "包裝費", "包装费"),
|
|
||||||
(5161, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"),
|
|
||||||
(5162, "insurance (expense)", "保險費", "保险费"),
|
|
||||||
(5163, "manufacturing overhead – outsourced", "加工費", "加工费"),
|
|
||||||
(5166, "taxes", "稅捐", "税捐"),
|
|
||||||
(5168, "depreciation expense", "折舊", "折旧"),
|
|
||||||
(5169, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"),
|
|
||||||
(5172, "meal (expenses)", "伙食費", "伙食费"),
|
|
||||||
(5173, "employee benefits/welfare", "職工福利", "职工福利"),
|
|
||||||
(5176, "training (expense)", "訓練費", "训练费"),
|
|
||||||
(5177, "indirect materials", "間接材料", "间接材料"),
|
|
||||||
(5188, "other manufacturing expenses", "其他製造費用", "其他制造费用"),
|
|
||||||
(5611, "service costs", "勞務成本", "劳务成本"),
|
|
||||||
(5711, "agency costs", "業務成本", "业务成本"),
|
|
||||||
(5888, "other operating costs – other", "其他營業成本—其他",
|
|
||||||
"其他营业成本—其他"),
|
|
||||||
(6151, "payroll expense", "薪資支出", "薪资支出"),
|
|
||||||
(6152, "rent expense, rent", "租金支出", "租金支出"),
|
|
||||||
(6153, "office supplies (expense)", "文具用品", "文具用品"),
|
|
||||||
(6154, "travelling expense, travel", "旅費", "旅费"),
|
|
||||||
(6155, "shipping expenses, freight", "運費", "运费"),
|
|
||||||
(6156, "postage (expenses)", "郵電費", "邮电费"),
|
|
||||||
(6157, "repair (s) and maintenance (expense)", "修繕費", "修缮费"),
|
|
||||||
(6159, "advertisement expense, advertisement", "廣告費", "广告费"),
|
|
||||||
(6161, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"),
|
|
||||||
(6162, "insurance (expense)", "保險費", "保险费"),
|
|
||||||
(6164, "entertainment (expense)", "交際費", "交际费"),
|
|
||||||
(6165, "donation (expense)", "捐贈", "捐赠"),
|
|
||||||
(6166, "taxes", "稅捐", "税捐"),
|
|
||||||
(6167, "loss on uncollectible accounts", "呆帳損失", "呆帐损失"),
|
|
||||||
(6168, "depreciation expense", "折舊", "折旧"),
|
|
||||||
(6169, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"),
|
|
||||||
(6172, "meal (expenses)", "伙食費", "伙食费"),
|
|
||||||
(6173, "employee benefits/welfare", "職工福利", "职工福利"),
|
|
||||||
(6175, "commission (expense)", "佣金支出", "佣金支出"),
|
|
||||||
(6176, "training (expense)", "訓練費", "训练费"),
|
|
||||||
(6188, "other selling expenses", "其他推銷費用", "其他推销费用"),
|
|
||||||
(6251, "payroll expense", "薪資支出", "薪资支出"),
|
|
||||||
(6252, "rent expense, rent", "租金支出", "租金支出"),
|
|
||||||
(6253, "office supplies", "文具用品", "文具用品"),
|
|
||||||
(6254, "travelling expense, travel", "旅費", "旅费"),
|
|
||||||
(6255, "shipping expenses,freight", "運費", "运费"),
|
|
||||||
(6256, "postage (expenses)", "郵電費", "邮电费"),
|
|
||||||
(6257, "repair (s) and maintenance (expense)", "修繕費", "修缮费"),
|
|
||||||
(6259, "advertisement expense, advertisement", "廣告費", "广告费"),
|
|
||||||
(6261, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"),
|
|
||||||
(6262, "insurance (expense)", "保險費", "保险费"),
|
|
||||||
(6264, "entertainment (expense)", "交際費", "交际费"),
|
|
||||||
(6265, "donation (expense)", "捐贈", "捐赠"),
|
|
||||||
(6266, "taxes", "稅捐", "税捐"),
|
|
||||||
(6267, "loss on uncollectible accounts", "呆帳損失", "呆帐损失"),
|
|
||||||
(6268, "depreciation expense", "折舊", "折旧"),
|
|
||||||
(6269, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"),
|
|
||||||
(6271, "loss on export sales", "外銷損失", "外销损失"),
|
|
||||||
(6272, "meal (expenses)", "伙食費", "伙食费"),
|
|
||||||
(6273, "employee benefits/welfare", "職工福利", "职工福利"),
|
|
||||||
(6274, "research and development expense", "研究發展費用", "研究发展费用"),
|
|
||||||
(6275, "commission (expense)", "佣金支出", "佣金支出"),
|
|
||||||
(6276, "training (expense)", "訓練費", "训练费"),
|
|
||||||
(6278, "professional service fees", "勞務費", "劳务费"),
|
|
||||||
(6288, "other general and administrative expenses", "其他管理及總務費用",
|
|
||||||
"其他管理及总务费用"),
|
|
||||||
(6351, "payroll expense", "薪資支出", "薪资支出"),
|
|
||||||
(6352, "rent expense, rent", "租金支出", "租金支出"),
|
|
||||||
(6353, "office supplies", "文具用品", "文具用品"),
|
|
||||||
(6354, "travelling expense, travel", "旅費", "旅费"),
|
|
||||||
(6355, "shipping expenses, freight", "運費", "运费"),
|
|
||||||
(6356, "postage (expenses)", "郵電費", "邮电费"),
|
|
||||||
(6357, "repair (s) and maintenance (expense)", "修繕費", "修缮费"),
|
|
||||||
(6361, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"),
|
|
||||||
(6362, "insurance (expense)", "保險費", "保险费"),
|
|
||||||
(6364, "entertainment (expense)", "交際費", "交际费"),
|
|
||||||
(6366, "taxes", "稅捐", "税捐"),
|
|
||||||
(6368, "depreciation expense", "折舊", "折旧"),
|
|
||||||
(6369, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"),
|
|
||||||
(6372, "meal (expenses)", "伙食費", "伙食费"),
|
|
||||||
(6373, "employee benefits/welfare", "職工福利", "职工福利"),
|
|
||||||
(6376, "training (expense)", "訓練費", "训练费"),
|
|
||||||
(6378, "other research and development expenses", "其他研究發展費用",
|
|
||||||
"其他研究发展费用"),
|
|
||||||
(7111, "interest revenue/income", "利息收入", "利息收入"),
|
|
||||||
(7121, "investment income recognized under equity method",
|
|
||||||
"權益法認列之投資收益", "权益法认列之投资收益"),
|
|
||||||
(7122, "dividends income", "股利收入", "股利收入"),
|
|
||||||
(7123, "gain on market price recovery of short-term investment",
|
|
||||||
"短期投資市價回升利益", "短期投资市价回升利益"),
|
|
||||||
(7131, "foreign exchange gain", "兌換利益", "兑换利益"),
|
|
||||||
(7141, "gain on disposal of investments", "處分投資收益", "处分投资收益"),
|
|
||||||
(7151, "gain on disposal of assets", "處分資產溢價收入", "处分资产溢价收入"),
|
|
||||||
(7481, "donation income", "捐贈收入", "捐赠收入"),
|
|
||||||
(7482, "rent revenue/income", "租金收入", "租金收入"),
|
|
||||||
(7483, "commission revenue/income", "佣金收入", "佣金收入"),
|
|
||||||
(7484, "revenue from sale of scraps", "出售下腳及廢料收入",
|
|
||||||
"出售下脚及废料收入"),
|
|
||||||
(7485, "gain on physical inventory", "存貨盤盈", "存货盘盈"),
|
|
||||||
(7486, "gain from price recovery of inventory", "存貨跌價回升利益",
|
|
||||||
"存货跌价回升利益"),
|
|
||||||
(7487, "gain on reversal of bad debts", "壞帳轉回利益", "坏帐转回利益"),
|
|
||||||
(7488, "other non-operating revenue – other items", "其他營業外收入—其他",
|
|
||||||
"其他营业外收入—其他"),
|
|
||||||
(7511, "interest expense", "利息費用", "利息费用"),
|
|
||||||
(7521, "investment loss recognized under equity method",
|
|
||||||
"權益法認列之投資損失", "权益法认列之投资损失"),
|
|
||||||
(7523, "unrealized loss on reduction of short-term investments to market",
|
|
||||||
"短期投資未實現跌價損失", "短期投资未实现跌价损失"),
|
|
||||||
(7531, "foreign exchange loss", "兌換損失", "兑换损失"),
|
|
||||||
(7541, "loss on disposal of investments", "處分投資損失", "处分投资损失"),
|
|
||||||
(7551, "loss on disposal of assets", "處分資產損失", "处分资产损失"),
|
|
||||||
(7881, "loss on work stoppages", "停工損失", "停工损失"),
|
|
||||||
(7882, "casualty loss", "災害損失", "灾害损失"),
|
|
||||||
(7885, "loss on physical inventory", "存貨盤損", "存货盘损"),
|
|
||||||
(7886,
|
|
||||||
"loss for market price decline and obsolete and slow-moving inventories",
|
|
||||||
"存貨跌價及呆滯損失", "存货跌价及呆滞损失"),
|
|
||||||
(7888, "other non-operating expenses – other", "其他營業外費用—其他",
|
|
||||||
"其他营业外费用—其他"),
|
|
||||||
(8111, "income tax expense ( or benefit)", "所得稅費用(或利益)",
|
|
||||||
"所得税费用(或利益)"),
|
|
||||||
(9111, "income (loss) from operations of discontinued segment",
|
|
||||||
"停業部門損益—停業前營業損益", "停业部门损益—停业前营业损益"),
|
|
||||||
(9121, "gain (loss) from disposal of discontinued segment",
|
|
||||||
"停業部門損益—處分損益", "停业部门损益—处分损益"),
|
|
||||||
(9211, "extraordinary gain or loss", "非常損益", "非常损益"),
|
|
||||||
(9311, "cumulative effect of changes in accounting principles",
|
|
||||||
"會計原則變動累積影響數", "会计原则变动累积影响数"),
|
|
||||||
(9411, "minority interest income", "少數股權淨利", "少数股权净利"),
|
|
||||||
]
|
|
||||||
"""The base account data."""
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -20,7 +20,7 @@
|
|||||||
from flask import abort
|
from flask import abort
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from accounting.database import db
|
from accounting import db
|
||||||
from accounting.models import BaseAccount
|
from accounting.models import BaseAccount
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -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
|
||||||
@ -35,10 +35,10 @@ def get_base_account_query() -> list[BaseAccount]:
|
|||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
|
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
|
||||||
.filter(BaseAccountL10n.title.contains(k)).all()
|
.filter(BaseAccountL10n.title.icontains(k)).all()
|
||||||
l10n_matches: set[str] = {x.account_code for x in l10n}
|
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||||
conditions.append(sa.or_(BaseAccount.code.contains(k),
|
conditions.append(sa.or_(BaseAccount.code.contains(k),
|
||||||
BaseAccount.title_l10n.contains(k),
|
BaseAccount.title_l10n.icontains(k),
|
||||||
BaseAccount.code.in_(l10n_matches)))
|
BaseAccount.code.in_(l10n_matches)))
|
||||||
return BaseAccount.query.filter(*conditions)\
|
return BaseAccount.query.filter(*conditions)\
|
||||||
.order_by(BaseAccount.code).all()
|
.order_by(BaseAccount.code).all()
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -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",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -17,12 +17,14 @@
|
|||||||
"""The console commands for the currency management.
|
"""The console commands for the currency management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
import csv
|
||||||
import os
|
import os
|
||||||
|
import typing as t
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from flask.cli import with_appcontext
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
from accounting.database import db
|
from accounting import db, data_dir
|
||||||
from accounting.models import Currency, CurrencyL10n
|
from accounting.models import Currency, CurrencyL10n
|
||||||
from accounting.utils.user import has_user, get_user_pk
|
from accounting.utils.user import has_user, get_user_pk
|
||||||
|
|
||||||
@ -54,25 +56,29 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
|||||||
@with_appcontext
|
@with_appcontext
|
||||||
def init_currencies_command(username: str) -> None:
|
def init_currencies_command(username: str) -> None:
|
||||||
"""Initializes the currencies."""
|
"""Initializes the currencies."""
|
||||||
data: list[CurrencyData] = [
|
existing_codes: set[str] = {x.code for x in Currency.query.all()}
|
||||||
("TWD", "New Taiwan dollar", "新臺幣", "新台币"),
|
|
||||||
("USD", "United States dollar", "美元", "美元"),
|
with open(data_dir / "currencies.csv") as fp:
|
||||||
]
|
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
||||||
creator_pk: int = get_user_pk(username)
|
to_add: list[dict[str, str]] = [x for x in data
|
||||||
existing: list[Currency] = Currency.query.all()
|
if x["code"] not in existing_codes]
|
||||||
existing_code: set[str] = {x.code for x in existing}
|
|
||||||
to_add: list[CurrencyData] = [x for x in data if x[0] not in existing_code]
|
|
||||||
if len(to_add) == 0:
|
if len(to_add) == 0:
|
||||||
click.echo("No more currency to add.")
|
click.echo("No more currency to add.")
|
||||||
return
|
return
|
||||||
|
|
||||||
db.session.bulk_save_objects(
|
creator_pk: int = get_user_pk(username)
|
||||||
[Currency(code=x[0], name_l10n=x[1],
|
currency_data: list[dict[str, t.Any]] = [{"code": x["code"],
|
||||||
created_by_id=creator_pk, updated_by_id=creator_pk)
|
"name_l10n": x["name"],
|
||||||
for x in data])
|
"created_by_id": creator_pk,
|
||||||
db.session.bulk_save_objects(
|
"updated_by_id": creator_pk}
|
||||||
[CurrencyL10n(currency_code=x[0], locale=y[0], name=y[1])
|
for x in to_add]
|
||||||
for x in data for y in (("zh_Hant", x[2]), ("zh_Hans", x[3]))])
|
locales: list[str] = [x[5:] for x in to_add[0] if x.startswith("l10n-")]
|
||||||
|
l10n_data: list[dict[str, str]] = [{"currency_code": x["code"],
|
||||||
|
"locale": y,
|
||||||
|
"name": x[f"l10n-{y}"]}
|
||||||
|
for x in to_add for y in locales]
|
||||||
|
db.session.bulk_insert_mappings(Currency, currency_data)
|
||||||
|
db.session.bulk_insert_mappings(CurrencyL10n, l10n_data)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
click.echo(F"{len(to_add)} added. Currencies initialized.")
|
click.echo(F"{len(to_add)} added. Currencies initialized.")
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -20,7 +20,7 @@
|
|||||||
from flask import abort
|
from flask import abort
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from accounting.database import db
|
from accounting import db
|
||||||
from accounting.models import Currency
|
from accounting.models import Currency
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -17,36 +17,35 @@
|
|||||||
"""The forms for the currency management.
|
"""The forms for the currency management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
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
|
||||||
|
|
||||||
from accounting.database 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.strip_text import strip_text
|
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.")),
|
||||||
@ -82,12 +81,3 @@ class CurrencyForm(FlaskForm):
|
|||||||
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
|
||||||
obj.updated_by_id = current_user_pk
|
obj.updated_by_id = current_user_pk
|
||||||
|
|
||||||
def post_update(self, obj) -> None:
|
|
||||||
"""The post-processing after the update.
|
|
||||||
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
current_user_pk: int = get_current_user_pk()
|
|
||||||
obj.updated_by_id = current_user_pk
|
|
||||||
obj.updated_at = sa.func.now()
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -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
|
||||||
@ -35,10 +35,10 @@ def get_currency_query() -> list[Currency]:
|
|||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
||||||
.filter(CurrencyL10n.name.contains(k)).all()
|
.filter(CurrencyL10n.name.icontains(k)).all()
|
||||||
l10n_matches: set[str] = {x.account_code for x in l10n}
|
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||||
conditions.append(sa.or_(Currency.code.contains(k),
|
conditions.append(sa.or_(Currency.code.icontains(k),
|
||||||
Currency.name_l10n.contains(k),
|
Currency.name_l10n.icontains(k),
|
||||||
Currency.code.in_(l10n_matches)))
|
Currency.code.in_(l10n_matches)))
|
||||||
return Currency.query.filter(*conditions)\
|
return Currency.query.filter(*conditions)\
|
||||||
.order_by(Currency.code).all()
|
.order_by(Currency.code).all()
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -19,16 +19,20 @@
|
|||||||
"""
|
"""
|
||||||
from urllib.parse import urlencode, parse_qsl
|
from urllib.parse import urlencode, parse_qsl
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from flask import Blueprint, render_template, redirect, session, request, \
|
from flask import Blueprint, render_template, redirect, session, request, \
|
||||||
flash, url_for
|
flash, url_for
|
||||||
from werkzeug.datastructures import ImmutableMultiDict
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
|
||||||
from accounting.database 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.next_url import inherit_next, or_next
|
from accounting.utils.cast import s
|
||||||
|
from accounting.utils.flash_errors import flash_form_errors
|
||||||
|
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 has_permission, can_view, can_edit
|
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
from .forms import CurrencyForm
|
from .forms import CurrencyForm
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("currency", __name__)
|
bp: Blueprint = Blueprint("currency", __name__)
|
||||||
@ -44,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",
|
||||||
@ -78,18 +82,15 @@ def add_currency() -> redirect:
|
|||||||
"""
|
"""
|
||||||
form = CurrencyForm(request.form)
|
form = CurrencyForm(request.form)
|
||||||
if not form.validate():
|
if not form.validate():
|
||||||
for key in form.errors:
|
flash_form_errors(form)
|
||||||
for error in form.errors[key]:
|
|
||||||
flash(error, "error")
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
return redirect(inherit_next(url_for("accounting.currency.create")))
|
return redirect(inherit_next(url_for("accounting.currency.create")))
|
||||||
currency: Currency = Currency()
|
currency: Currency = Currency()
|
||||||
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(url_for("accounting.currency.detail",
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
currency=currency)))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<currency:currency>", endpoint="detail")
|
@bp.get("/<currency:currency>", endpoint="detail")
|
||||||
@ -134,23 +135,20 @@ def update_currency(currency: Currency) -> redirect:
|
|||||||
form = CurrencyForm(request.form)
|
form = CurrencyForm(request.form)
|
||||||
form.obj_code = currency.code
|
form.obj_code = currency.code
|
||||||
if not form.validate():
|
if not form.validate():
|
||||||
for key in form.errors:
|
flash_form_errors(form)
|
||||||
for error in form.errors[key]:
|
|
||||||
flash(error, "error")
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
return redirect(inherit_next(url_for("accounting.currency.edit",
|
return redirect(inherit_next(url_for("accounting.currency.edit",
|
||||||
currency=currency)))
|
currency=currency)))
|
||||||
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(url_for("accounting.currency.detail",
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
currency=currency)))
|
currency.updated_by_id = get_current_user_pk()
|
||||||
form.post_update(currency)
|
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(url_for("accounting.currency.detail",
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
currency=currency)))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<currency:currency>/delete", endpoint="delete")
|
@bp.post("/<currency:currency>/delete", endpoint="delete")
|
||||||
@ -162,9 +160,12 @@ def delete_currency(currency: Currency) -> redirect:
|
|||||||
:return: The redirection to the currency list on success, or the currency
|
:return: The redirection to the currency list on success, or the currency
|
||||||
detail on error.
|
detail on error.
|
||||||
"""
|
"""
|
||||||
|
if not currency.can_delete:
|
||||||
|
flash(s(lazy_gettext("The currency cannot be deleted.")), "error")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
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")))
|
||||||
|
|
||||||
|
|
||||||
@ -176,3 +177,12 @@ def exists_code() -> dict[str, bool]:
|
|||||||
:return: Whether the currency code exists.
|
:return: Whether the currency code exists.
|
||||||
"""
|
"""
|
||||||
return {"exists": db.session.get(Currency, request.args["q"]) is not None}
|
return {"exists": db.session.get(Currency, request.args["q"]) is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def __get_detail_uri(currency: Currency) -> str:
|
||||||
|
"""Returns the detail URI of a currency.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The detail URI of the currency.
|
||||||
|
"""
|
||||||
|
return url_for("accounting.currency.detail", currency=currency)
|
||||||
|
528
src/accounting/data/base_accounts.csv
Normal file
528
src/accounting/data/base_accounts.csv
Normal file
@ -0,0 +1,528 @@
|
|||||||
|
code,title,l10n-zh_Hant,l10n-zh_Hans
|
||||||
|
1,assets,資產,资产
|
||||||
|
2,liabilities,負債,负债
|
||||||
|
3,owners’ equity,業主權益,业主权益
|
||||||
|
4,operating revenue,營業收入,营业收入
|
||||||
|
5,operating costs,營業成本,营业成本
|
||||||
|
6,operating expenses,營業費用,营业费用
|
||||||
|
7,"non-operating revenue and expenses, other income (expense)",營業外收入及費用,营业外收入及费用
|
||||||
|
8,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益)
|
||||||
|
9,nonrecurring gain or loss,非經常營業損益,非经常营业损益
|
||||||
|
11,current assets,流動資產,流动资产
|
||||||
|
12,current assets,流動資產,流动资产
|
||||||
|
13,funds and long-term investments,基金及長期投資,基金及长期投资
|
||||||
|
14,"property , plant, and equipment",固定資產,固定资产
|
||||||
|
15,"property , plant, and equipment",固定資產,固定资产
|
||||||
|
16,depletable assets,遞耗資產,递耗资产
|
||||||
|
17,intangible assets,無形資產,无形资产
|
||||||
|
18,other assets,其他資產,其他资产
|
||||||
|
21,current liabilities,流動負債,流动负债
|
||||||
|
22,current liabilities,流動負債,流动负债
|
||||||
|
23,long-term liabilities,長期負債,长期负债
|
||||||
|
28,other liabilities,其他負債,其他负债
|
||||||
|
31,capital,資本,资本
|
||||||
|
32,additional paid-in capital,資本公積,资本公积
|
||||||
|
33,retained earnings (accumulated deficit),保留盈餘(或累積虧損),保留盈余(或累积亏损)
|
||||||
|
34,equity adjustments,權益調整,权益调整
|
||||||
|
35,treasury stock,庫藏股,库藏股
|
||||||
|
36,minority interest,少數股權,少数股权
|
||||||
|
41,sales revenue,銷貨收入,销货收入
|
||||||
|
46,service revenue,勞務收入,劳务收入
|
||||||
|
47,agency revenue,業務收入,业务收入
|
||||||
|
48,other operating revenue,其他營業收入,其他营业收入
|
||||||
|
51,cost of goods sold,銷貨成本,销货成本
|
||||||
|
56,service costs,勞務成本,劳务成本
|
||||||
|
57,agency costs,業務成本,业务成本
|
||||||
|
58,other operating costs,其他營業成本,其他营业成本
|
||||||
|
61,selling expenses,推銷費用,推销费用
|
||||||
|
62,general & administrative expenses,管理及總務費用,管理及总务费用
|
||||||
|
63,research and development expenses,研究發展費用,研究发展费用
|
||||||
|
71,non-operating revenue,營業外收入,营业外收入
|
||||||
|
72,non-operating revenue,營業外收入,营业外收入
|
||||||
|
73,non-operating revenue,營業外收入,营业外收入
|
||||||
|
74,non-operating revenue,營業外收入,营业外收入
|
||||||
|
75,non-operating expenses,營業外費用,营业外费用
|
||||||
|
76,non-operating expenses,營業外費用,营业外费用
|
||||||
|
77,non-operating expenses,營業外費用,营业外费用
|
||||||
|
78,non-operating expenses,營業外費用,营业外费用
|
||||||
|
81,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益)
|
||||||
|
91,gain (loss) from discontinued operations,停業部門損益,停业部门损益
|
||||||
|
92,extraordinary gain or loss,非常損益,非常损益
|
||||||
|
93,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数
|
||||||
|
94,minority interest income,少數股權淨利,少数股权净利
|
||||||
|
111,cash and cash equivalents,現金及約當現金,现金及约当现金
|
||||||
|
112,short-term investments,短期投資,短期投资
|
||||||
|
113,notes receivable,應收票據,应收票据
|
||||||
|
114,accounts receivable,應收帳款,应收帐款
|
||||||
|
118,other receivables,其他應收款,其他应收款
|
||||||
|
121,inventories,存貨,存货
|
||||||
|
122,inventories,存貨,存货
|
||||||
|
125,prepaid expenses,預付費用,预付费用
|
||||||
|
126,prepayments,預付款項,预付款项
|
||||||
|
128,other current assets,其他流動資產,其他流动资产
|
||||||
|
129,other current assets,其他流動資產,其他流动资产
|
||||||
|
131,funds,基金,基金
|
||||||
|
132,long-term investments,長期投資,长期投资
|
||||||
|
141,land,土地,土地
|
||||||
|
142,land improvements,土地改良物,土地改良物
|
||||||
|
143,buildings,房屋及建物,房屋及建物
|
||||||
|
144,machinery and equipment,機(器)具及設備,机(器)具及设备
|
||||||
|
145,machinery and equipment,機(器)具及設備,机(器)具及设备
|
||||||
|
146,machinery and equipment,機(器)具及設備,机(器)具及设备
|
||||||
|
151,leased assets,租賃資產,租赁资产
|
||||||
|
152,leasehold improvements,租賃權益改良,租赁权益改良
|
||||||
|
156,construction in progress and prepayments for equipment,未完工程及預付購置設備款,未完工程及预付购置设备款
|
||||||
|
158,"miscellaneous property, plant, and equipment",雜項固定資產,杂项固定资产
|
||||||
|
161,depletable assets,遞耗資產,递耗资产
|
||||||
|
171,trademarks,商標權,商标权
|
||||||
|
172,patents,專利權,专利权
|
||||||
|
173,franchise,特許權,特许权
|
||||||
|
174,copyright,著作權,著作权
|
||||||
|
175,computer software,電腦軟體,电脑软体
|
||||||
|
176,goodwill,商譽,商誉
|
||||||
|
177,organization costs,開辦費,开办费
|
||||||
|
178,other intangibles,其他無形資產,其他无形资产
|
||||||
|
181,deferred assets,遞延資產,递延资产
|
||||||
|
182,idle assets,閒置資產,闲置资产
|
||||||
|
184,"long-term notes , accounts and overdue receivables",長期應收票據及款項與催收帳款,长期应收票据及款项与催收帐款
|
||||||
|
185,assets leased to others,出租資產,出租资产
|
||||||
|
186,refundable deposit,存出保證金,存出保证金
|
||||||
|
188,miscellaneous assets,雜項資產,杂项资产
|
||||||
|
211,short-term borrowings (debt),短期借款,短期借款
|
||||||
|
212,short-term notes and bills payable,應付短期票券,应付短期票券
|
||||||
|
213,notes payable,應付票據,应付票据
|
||||||
|
214,accounts pay able,應付帳款,应付帐款
|
||||||
|
216,income taxes payable,應付所得稅,应付所得税
|
||||||
|
217,accrued expenses,應付費用,应付费用
|
||||||
|
218,other payables,其他應付款,其他应付款
|
||||||
|
219,other payables,其他應付款,其他应付款
|
||||||
|
226,advance receipts,預收款項,预收款项
|
||||||
|
227,long-term liabilities -current portion,一年或一營業週期內到期長期負債,一年或一营业周期内到期长期负债
|
||||||
|
228,other current liabilities,其他流動負債,其他流动负债
|
||||||
|
229,other current liabilities,其他流動負債,其他流动负债
|
||||||
|
231,corporate bonds payable,應付公司債,应付公司债
|
||||||
|
232,long-term loans payable,長期借款,长期借款
|
||||||
|
233,long-term notes and accounts payable,長期應付票據及款項,长期应付票据及款项
|
||||||
|
234,accrued liabilities for land value increment tax,估計應付土地增值稅,估计应付土地增值税
|
||||||
|
235,accrued pension liabilities,應計退休金負債,应计退休金负债
|
||||||
|
238,other long-term liabilities,其他長期負債,其他长期负债
|
||||||
|
281,deferred liabilities,遞延負債,递延负债
|
||||||
|
286,deposits received,存入保證金,存入保证金
|
||||||
|
288,miscellaneous liabilities,雜項負債,杂项负债
|
||||||
|
311,capital,資本(或股本),资本(或股本)
|
||||||
|
321,paid-in capital in excess of par,股票溢價,股票溢价
|
||||||
|
323,capital surplus from assets revaluation,資產重估增值準備,资产重估增值准备
|
||||||
|
324,capital surplus from gain on disposal of assets,處分資產溢價公積,处分资产溢价公积
|
||||||
|
325,capital surplus from business combination,合併公積,合并公积
|
||||||
|
326,donated surplus,受贈公積,受赠公积
|
||||||
|
328,other additional paid-in capital,其他資本公積,其他资本公积
|
||||||
|
331,legal reserve,法定盈餘公積,法定盈余公积
|
||||||
|
332,special reserve,特別盈餘公積,特别盈余公积
|
||||||
|
335,retained earnings-unappropriated (or accumulated deficit),未分配盈餘(或累積虧損),未分配盈余(或累积亏损)
|
||||||
|
341,unrealized loss on market value decline of long-term equity investments,長期股權投資未實現跌價損失,长期股权投资未实现跌价损失
|
||||||
|
342,cumulative translation adjustment,累積換算調整數,累积换算调整数
|
||||||
|
343,net loss not recognized as pension cost,未認列為退休金成本之淨損失,未认列为退休金成本之净损失
|
||||||
|
351,treasury stock,庫藏股,库藏股
|
||||||
|
361,minority interest,少數股權,少数股权
|
||||||
|
411,sales revenue,銷貨收入,销货收入
|
||||||
|
417,sales return,銷貨退回,销货退回
|
||||||
|
419,sales allowances,銷貨折讓,销货折让
|
||||||
|
461,service revenue,勞務收入,劳务收入
|
||||||
|
471,agency revenue,業務收入,业务收入
|
||||||
|
488,other operating revenue,其他營業收入—其他,其他营业收入—其他
|
||||||
|
511,cost of goods sold,銷貨成本,销货成本
|
||||||
|
512,purchases,進貨,进货
|
||||||
|
513,materials purchased,進料,进料
|
||||||
|
514,direct labor,直接人工,直接人工
|
||||||
|
515,manufacturing overhead,製造費用,制造费用
|
||||||
|
516,manufacturing overhead,製造費用,制造费用
|
||||||
|
517,manufacturing overhead,製造費用,制造费用
|
||||||
|
518,manufacturing overhead,製造費用,制造费用
|
||||||
|
561,service costs,勞務成本,劳务成本
|
||||||
|
571,agency costs,業務成本,业务成本
|
||||||
|
588,other operating costs-other,其他營業成本—其他,其他营业成本—其他
|
||||||
|
615,selling expenses,推銷費用,推销费用
|
||||||
|
616,selling expenses,推銷費用,推销费用
|
||||||
|
617,selling expenses,推銷費用,推销费用
|
||||||
|
618,selling expenses,推銷費用,推销费用
|
||||||
|
625,general & administrative expenses,管理及總務費用,管理及总务费用
|
||||||
|
626,general & administrative expenses,管理及總務費用,管理及总务费用
|
||||||
|
627,general & administrative expenses,管理及總務費用,管理及总务费用
|
||||||
|
628,general & administrative expenses,管理及總務費用,管理及总务费用
|
||||||
|
635,research and development expenses,研究發展費用,研究发展费用
|
||||||
|
636,research and development expenses,研究發展費用,研究发展费用
|
||||||
|
637,research and development expenses,研究發展費用,研究发展费用
|
||||||
|
638,research and development expenses,研究發展費用,研究发展费用
|
||||||
|
711,interest revenue,利息收入,利息收入
|
||||||
|
712,investment income,投資收益,投资收益
|
||||||
|
713,foreign exchange gain,兌換利益,兑换利益
|
||||||
|
714,gain on disposal of investments,處分投資收益,处分投资收益
|
||||||
|
715,gain on disposal of assets,處分資產溢價收入,处分资产溢价收入
|
||||||
|
748,other non-operating revenue,其他營業外收入,其他营业外收入
|
||||||
|
751,interest expense,利息費用,利息费用
|
||||||
|
752,investment loss,投資損失,投资损失
|
||||||
|
753,foreign exchange loss,兌換損失,兑换损失
|
||||||
|
754,loss on disposal of investments,處分投資損失,处分投资损失
|
||||||
|
755,loss on disposal of assets,處分資產損失,处分资产损失
|
||||||
|
788,other non-operating expenses,其他營業外費用,其他营业外费用
|
||||||
|
811,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益)
|
||||||
|
911,income (loss) from operations of discontinued segments,停業部門損益—停業前營業損益,停业部门损益—停业前营业损益
|
||||||
|
912,gain (loss) from disposal of discontinued segments,停業部門損益—處分損益,停业部门损益—处分损益
|
||||||
|
921,extraordinary gain or loss,非常損益,非常损益
|
||||||
|
931,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数
|
||||||
|
941,minority interest income,少數股權淨利,少数股权净利
|
||||||
|
1111,cash on hand,庫存現金,库存现金
|
||||||
|
1112,petty cash/revolving funds,零用金/週轉金,零用金/周转金
|
||||||
|
1113,cash in banks,銀行存款,银行存款
|
||||||
|
1116,cash in transit,在途現金,在途现金
|
||||||
|
1117,cash equivalents,約當現金,约当现金
|
||||||
|
1118,other cash and cash equivalents,其他現金及約當現金,其他现金及约当现金
|
||||||
|
1121,short-term investments – stock,短期投資—股票,短期投资—股票
|
||||||
|
1122,short-term investments – short-term notes and bills,短期投資—短期票券,短期投资—短期票券
|
||||||
|
1123,short-term investments – government bonds,短期投資—政府債券,短期投资—政府债券
|
||||||
|
1124,short-term investments – beneficiary certificates,短期投資—受益憑證,短期投资—受益凭证
|
||||||
|
1125,short-term investments – corporate bonds,短期投資—公司債,短期投资—公司债
|
||||||
|
1128,short-term investments – other,短期投資—其他,短期投资—其他
|
||||||
|
1129,allowance for reduction of short-term investment to market,備抵短期投資跌價損失,备抵短期投资跌价损失
|
||||||
|
1131,notes receivable,應收票據,应收票据
|
||||||
|
1132,discounted notes receivable,應收票據貼現,应收票据贴现
|
||||||
|
1137,notes receivable – related parties,應收票據—關係人,应收票据—关系人
|
||||||
|
1138,other notes receivable,其他應收票據,其他应收票据
|
||||||
|
1139,allowance for uncollectible accounts – notes receivable,備抵呆帳-應收票據,备抵呆帐-应收票据
|
||||||
|
1141,accounts receivable,應收帳款,应收帐款
|
||||||
|
1142,installment accounts receivable,應收分期帳款,应收分期帐款
|
||||||
|
1147,accounts receivable – related parties,應收帳款—關係人,应收帐款—关系人
|
||||||
|
1149,allowance for uncollectible accounts – accounts receivable,備抵呆帳-應收帳款,备抵呆帐-应收帐款
|
||||||
|
1181,forward exchange contract receivable,應收出售遠匯款,应收出售远汇款
|
||||||
|
1182,forward exchange contract receivable – foreign currencies,應收遠匯款—外幣,应收远汇款—外币
|
||||||
|
1183,discount on forward ex-change contract,買賣遠匯折價,买卖远汇折价
|
||||||
|
1184,earned revenue receivable,應收收益,应收收益
|
||||||
|
1185,income tax refund receivable,應收退稅款,应收退税款
|
||||||
|
1187,other receivables – related parties,其他應收款—關係人,其他应收款—关系人
|
||||||
|
1188,other receivables – other,其他應收款—其他,其他应收款—其他
|
||||||
|
1189,allowance for uncollectible accounts – other receivables,備抵呆帳—其他應收款,备抵呆帐—其他应收款
|
||||||
|
1211,merchandise inventory,商品存貨,商品存货
|
||||||
|
1212,consigned goods,寄銷商品,寄销商品
|
||||||
|
1213,goods in transit,在途商品,在途商品
|
||||||
|
1219,allowance for reduction of inventory to market,備抵存貨跌價損失,备抵存货跌价损失
|
||||||
|
1221,finished goods,製成品,制成品
|
||||||
|
1222,consigned finished goods,寄銷製成品,寄销制成品
|
||||||
|
1223,by-products,副產品,副产品
|
||||||
|
1224,work in process,在製品,在制品
|
||||||
|
1225,work in process – outsourced,委外加工,委外加工
|
||||||
|
1226,raw materials,原料,原料
|
||||||
|
1227,supplies,物料,物料
|
||||||
|
1228,materials and supplies in transit,在途原物料,在途原物料
|
||||||
|
1229,allowance for reduction of inventory to market,備抵存貨跌價損失,备抵存货跌价损失
|
||||||
|
1251,prepaid payroll,預付薪資,预付薪资
|
||||||
|
1252,prepaid rents,預付租金,预付租金
|
||||||
|
1253,prepaid insurance,預付保險費,预付保险费
|
||||||
|
1254,office supplies,用品盤存,用品盘存
|
||||||
|
1255,prepaid income tax,預付所得稅,预付所得税
|
||||||
|
1258,other prepaid expenses,其他預付費用,其他预付费用
|
||||||
|
1261,prepayment for purchases,預付貨款,预付货款
|
||||||
|
1268,other prepayments,其他預付款項,其他预付款项
|
||||||
|
1281,VAT paid ( or input tax),進項稅額,进项税额
|
||||||
|
1282,excess VAT paid (or overpaid VAT),留抵稅額,留抵税额
|
||||||
|
1283,temporary payments,暫付款,暂付款
|
||||||
|
1284,payment on behalf of others,代付款,代付款
|
||||||
|
1285,advances to employees,員工借支,员工借支
|
||||||
|
1286,refundable deposits,存出保證金,存出保证金
|
||||||
|
1287,certificate of deposit-restricted,受限制存款,受限制存款
|
||||||
|
1291,deferred income tax assets,遞延所得稅資產,递延所得税资产
|
||||||
|
1292,deferred foreign exchange losses,遞延兌換損失,递延兑换损失
|
||||||
|
1293,owners’ (stockholders’) current account,業主(股東)往來,业主(股东)往来
|
||||||
|
1294,current account with others,同業往來,同业往来
|
||||||
|
1298,other current assets – other,其他流動資產—其他,其他流动资产—其他
|
||||||
|
1311,redemption fund (or sinking fund),償債基金,偿债基金
|
||||||
|
1312,fund for improvement and expansion,改良及擴充基金,改良及扩充基金
|
||||||
|
1313,contingency fund,意外損失準備基金,意外损失准备基金
|
||||||
|
1314,pension fund,退休基金,退休基金
|
||||||
|
1318,other funds,其他基金,其他基金
|
||||||
|
1321,long-term equity investments,長期股權投資,长期股权投资
|
||||||
|
1322,long-term bond investments,長期債券投資,长期债券投资
|
||||||
|
1323,long-term real estate in-vestments,長期不動產投資,长期不动产投资
|
||||||
|
1324,cash surrender value of life insurance,人壽保險現金解約價值,人寿保险现金解约价值
|
||||||
|
1328,other long-term investments,其他長期投資,其他长期投资
|
||||||
|
1329,allowance for excess of cost over market value of long-term investments,備抵長期投資跌價損失,备抵长期投资跌价损失
|
||||||
|
1411,land,土地,土地
|
||||||
|
1418,land – revaluation increments,土地—重估增值,土地—重估增值
|
||||||
|
1421,land improvements,土地改良物,土地改良物
|
||||||
|
1428,land improvements – revaluation increments,土地改良物—重估增值,土地改良物—重估增值
|
||||||
|
1429,accumulated depreciation – land improvements,累積折舊—土地改良物,累积折旧—土地改良物
|
||||||
|
1431,buildings,房屋及建物,房屋及建物
|
||||||
|
1438,buildings –revaluation increments,房屋及建物—重估增值,房屋及建物—重估增值
|
||||||
|
1439,accumulated depreciation – buildings,累積折舊—房屋及建物,累积折旧—房屋及建物
|
||||||
|
1441,machinery,機(器)具,机(器)具
|
||||||
|
1448,machinery – revaluation increments,機(器)具—重估增值,机(器)具—重估增值
|
||||||
|
1449,accumulated depreciation – machinery,累積折舊—機(器)具,累积折旧—机(器)具
|
||||||
|
1511,leased assets,租賃資產,租赁资产
|
||||||
|
1519,accumulated depreciation – leased assets,累積折舊—租賃資產,累积折旧—租赁资产
|
||||||
|
1521,leasehold improvements,租賃權益改良,租赁权益改良
|
||||||
|
1529,accumulated depreciation – leasehold improvements,累積折舊—租賃權益改良,累积折旧—租赁权益改良
|
||||||
|
1561,construction in progress,未完工程,未完工程
|
||||||
|
1562,prepayment for equipment,預付購置設備款,预付购置设备款
|
||||||
|
1581,"miscellaneous property, plant, and equipment",雜項固定資產,杂项固定资产
|
||||||
|
1588,"miscellaneous property, plant, and equipment – revaluation increments",雜項固定資產—重估增值,杂项固定资产—重估增值
|
||||||
|
1589,"accumulated depreciation – miscellaneous property, plant, and equipment",累積折舊—雜項固定資產,累积折旧—杂项固定资产
|
||||||
|
1611,natural resources,天然資源,天然资源
|
||||||
|
1618,natural resources –revaluation increments,天然資源—重估增值,天然资源—重估增值
|
||||||
|
1619,accumulated depletion – natural resources,累積折耗—天然資源,累积折耗—天然资源
|
||||||
|
1711,trademarks,商標權,商标权
|
||||||
|
1721,patents,專利權,专利权
|
||||||
|
1731,franchise,特許權,特许权
|
||||||
|
1741,copyright,著作權,著作权
|
||||||
|
1751,computer software cost,電腦軟體,电脑软体
|
||||||
|
1761,goodwill,商譽,商誉
|
||||||
|
1771,organization costs,開辦費,开办费
|
||||||
|
1781,deferred pension costs,遞延退休金成本,递延退休金成本
|
||||||
|
1782,leasehold improvements,租賃權益改良,租赁权益改良
|
||||||
|
1788,other intangible assets – other,其他無形資產—其他,其他无形资产—其他
|
||||||
|
1811,deferred bond issuance costs,債券發行成本,债券发行成本
|
||||||
|
1812,long-term prepaid rent,長期預付租金,长期预付租金
|
||||||
|
1813,long-term prepaid insurance,長期預付保險費,长期预付保险费
|
||||||
|
1814,deferred income tax assets,遞延所得稅資產,递延所得税资产
|
||||||
|
1815,prepaid pension cost,預付退休金,预付退休金
|
||||||
|
1818,other deferred assets,其他遞延資產,其他递延资产
|
||||||
|
1821,idle assets,閒置資產,闲置资产
|
||||||
|
1841,long-term notes receivable,長期應收票據,长期应收票据
|
||||||
|
1842,long-term accounts receivable,長期應收帳款,长期应收帐款
|
||||||
|
1843,overdue receivables,催收帳款,催收帐款
|
||||||
|
1847,"long-term notes, accounts and overdue receivables – related parties",長期應收票據及款項與催收帳款—關係人,长期应收票据及款项与催收帐款—关系人
|
||||||
|
1848,other long-term receivables,其他長期應收款項,其他长期应收款项
|
||||||
|
1849,"allowance for uncollectible accounts – long-term notes, accounts and overdue receivables",備抵呆帳—長期應收票據及款項與催收帳款,备抵呆帐—长期应收票据及款项与催收帐款
|
||||||
|
1851,assets leased to others,出租資產,出租资产
|
||||||
|
1858,assets leased to others – incremental value from revaluation,出租資產—重估增值,出租资产—重估增值
|
||||||
|
1859,accumulated depreciation – assets leased to others,累積折舊—出租資產,累积折旧—出租资产
|
||||||
|
1861,refundable deposits,存出保證金,存出保证金
|
||||||
|
1881,certificate of deposit – restricted,受限制存款,受限制存款
|
||||||
|
1888,miscellaneous assets – other,雜項資產—其他,杂项资产—其他
|
||||||
|
2111,bank overdraft,銀行透支,银行透支
|
||||||
|
2112,bank loan,銀行借款,银行借款
|
||||||
|
2114,short-term borrowings – owners,短期借款—業主,短期借款—业主
|
||||||
|
2115,short-term borrowings – employees,短期借款—員工,短期借款—员工
|
||||||
|
2117,short-term borrowings – related parties,短期借款—關係人,短期借款—关系人
|
||||||
|
2118,short-term borrowings – other,短期借款—其他,短期借款—其他
|
||||||
|
2121,commercial paper payable,應付商業本票,应付商业本票
|
||||||
|
2122,bank acceptance,銀行承兌匯票,银行承兑汇票
|
||||||
|
2128,other short-term notes and bills payable,其他應付短期票券,其他应付短期票券
|
||||||
|
2129,discount on short-term notes and bills payable,應付短期票券折價,应付短期票券折价
|
||||||
|
2131,notes payable,應付票據,应付票据
|
||||||
|
2137,notes payable – related parties,應付票據—關係人,应付票据—关系人
|
||||||
|
2138,other notes payable,其他應付票據,其他应付票据
|
||||||
|
2141,accounts payable,應付帳款,应付帐款
|
||||||
|
2147,accounts payable – related parties,應付帳款—關係人,应付帐款—关系人
|
||||||
|
2161,income tax payable,應付所得稅,应付所得税
|
||||||
|
2171,accrued payroll,應付薪工,应付薪工
|
||||||
|
2172,accrued rent payable,應付租金,应付租金
|
||||||
|
2173,accrued interest payable,應付利息,应付利息
|
||||||
|
2174,accrued VAT payable,應付營業稅,应付营业税
|
||||||
|
2175,accrued taxes payable – other,應付稅捐—其他,应付税捐—其他
|
||||||
|
2178,other accrued expenses payable,其他應付費用,其他应付费用
|
||||||
|
2181,forward exchange contract payable,應付購入遠匯款,应付购入远汇款
|
||||||
|
2182,forward exchange contract payable – foreign currencies,應付遠匯款—外幣,应付远汇款—外币
|
||||||
|
2183,premium on forward exchange contract,買賣遠匯溢價,买卖远汇溢价
|
||||||
|
2184,payables on land and building purchased,應付土地房屋款,应付土地房屋款
|
||||||
|
2185,Payables on equipment,應付設備款,应付设备款
|
||||||
|
2187,other payables – related parties,其他應付款—關係人,其他应付款—关系人
|
||||||
|
2191,dividend payable,應付股利,应付股利
|
||||||
|
2192,bonus payable,應付紅利,应付红利
|
||||||
|
2193,compensation payable to directors and supervisors,應付董監事酬勞,应付董监事酬劳
|
||||||
|
2198,other payables – other,其他應付款—其他,其他应付款—其他
|
||||||
|
2261,sales revenue received in advance,預收貨款,预收货款
|
||||||
|
2262,revenue received in advance,預收收入,预收收入
|
||||||
|
2268,other advance receipts,其他預收款,其他预收款
|
||||||
|
2271,corporate bonds payable – current portion,一年或一營業週期內到期公司債,一年或一营业周期内到期公司债
|
||||||
|
2272,long-term loans payable – current portion,一年或一營業週期內到期長期借款,一年或一营业周期内到期长期借款
|
||||||
|
2273,long-term notes and accounts payable due within one year or one operating cycle,一年或一營業週期內到期長期應付票據及款項,一年或一营业周期内到期长期应付票据及款项
|
||||||
|
2277,long-term notes and accounts payables to related parties – current portion,一年或一營業週期內到期長期應付票據及款項—關係人,一年或一营业周期内到期长期应付票据及款项—关系人
|
||||||
|
2278,other long-term liabilities – current portion,其他一年或一營業週期內到期長期負債,其他一年或一营业周期内到期长期负债
|
||||||
|
2281,VAT received (or output tax),銷項稅額,销项税额
|
||||||
|
2283,temporary receipts,暫收款,暂收款
|
||||||
|
2284,receipts under custody,代收款,代收款
|
||||||
|
2285,estimated warranty liabilities,估計售後服務/保固負債,估计售后服务/保固负债
|
||||||
|
2291,deferred income tax liabilities,遞延所得稅負債,递延所得税负债
|
||||||
|
2292,deferred foreign exchange gain,遞延兌換利益,递延兑换利益
|
||||||
|
2293,owners’ current account,業主(股東)往來,业主(股东)往来
|
||||||
|
2294,current account with others,同業往來,同业往来
|
||||||
|
2298,other current liabilities – others,其他流動負債—其他,其他流动负债—其他
|
||||||
|
2311,corporate bonds payable,應付公司債,应付公司债
|
||||||
|
2319,premium (discount) on corporate bonds payable,應付公司債溢(折)價,应付公司债溢(折)价
|
||||||
|
2321,long-term loans payable – bank,長期銀行借款,长期银行借款
|
||||||
|
2324,long-term loans payable – owners,長期借款—業主,长期借款—业主
|
||||||
|
2325,long-term loans payable – employees,長期借款—員工,长期借款—员工
|
||||||
|
2327,long-term loans payable – related parties,長期借款—關係人,长期借款—关系人
|
||||||
|
2328,long-term loans payable – other,長期借款—其他,长期借款—其他
|
||||||
|
2331,long-term notes payable,長期應付票據,长期应付票据
|
||||||
|
2332,long-term accounts pay-able,長期應付帳款,长期应付帐款
|
||||||
|
2333,long-term capital lease liabilities,長期應付租賃負債,长期应付租赁负债
|
||||||
|
2337,Long-term notes and accounts payable – related parties,長期應付票據及款項—關係人,长期应付票据及款项—关系人
|
||||||
|
2338,other long-term payables,其他長期應付款項,其他长期应付款项
|
||||||
|
2341,estimated accrued land value incremental tax pay-able,估計應付土地增值稅,估计应付土地增值税
|
||||||
|
2351,accrued pension liabilities,應計退休金負債,应计退休金负债
|
||||||
|
2388,other long-term liabilities – other,其他長期負債—其他,其他长期负债—其他
|
||||||
|
2811,deferred revenue,遞延收入,递延收入
|
||||||
|
2814,deferred income tax liabilities,遞延所得稅負債,递延所得税负债
|
||||||
|
2818,other deferred liabilities,其他遞延負債,其他递延负债
|
||||||
|
2861,guarantee deposit received,存入保證金,存入保证金
|
||||||
|
2888,miscellaneous liabilities – other,雜項負債—其他,杂项负债—其他
|
||||||
|
3111,capital – common stock,普通股股本,普通股股本
|
||||||
|
3112,capital – preferred stock,特別股股本,特别股股本
|
||||||
|
3113,capital collected in advance,預收股本,预收股本
|
||||||
|
3114,stock dividends to be distributed,待分配股票股利,待分配股票股利
|
||||||
|
3115,capital,資本,资本
|
||||||
|
3211,paid-in capital in excess of par- common stock,普通股股票溢價,普通股股票溢价
|
||||||
|
3212,paid-in capital in excess of par- preferred stock,特別股股票溢價,特别股股票溢价
|
||||||
|
3231,capital surplus from assets revaluation,資產重估增值準備,资产重估增值准备
|
||||||
|
3241,capital surplus from gain on disposal of assets,處分資產溢價公積,处分资产溢价公积
|
||||||
|
3251,capital surplus from business combination,合併公積,合并公积
|
||||||
|
3261,donated surplus,受贈公積,受赠公积
|
||||||
|
3281,additional paid-in capital from investee under equity method,權益法長期股權投資資本公積,权益法长期股权投资资本公积
|
||||||
|
3282,additional paid-in capital – treasury stock trans-actions,資本公積—庫藏股票交易,资本公积—库藏股票交易
|
||||||
|
3311,legal reserve,法定盈餘公積,法定盈余公积
|
||||||
|
3321,contingency reserve,意外損失準備,意外损失准备
|
||||||
|
3322,improvement and expansion reserve,改良擴充準備,改良扩充准备
|
||||||
|
3323,special reserve for redemption of liabilities,償債準備,偿债准备
|
||||||
|
3328,other special reserve,其他特別盈餘公積,其他特别盈余公积
|
||||||
|
3351,accumulated profit or loss,累積盈虧,累积盈亏
|
||||||
|
3352,prior period adjustments,前期損益調整,前期损益调整
|
||||||
|
3353,net income or loss for current period,本期損益,本期损益
|
||||||
|
3411,unrealized loss on market value decline of long-term equity investments,長期股權投資未實現跌價損失,长期股权投资未实现跌价损失
|
||||||
|
3421,cumulative translation adjustments,累積換算調整數,累积换算调整数
|
||||||
|
3431,net loss not recognized as pension costs,未認列為退休金成本之淨損失,未认列为退休金成本之净损失
|
||||||
|
3511,treasury stock,庫藏股,库藏股
|
||||||
|
3611,minority interest,少數股權,少数股权
|
||||||
|
4111,sales revenue,銷貨收入,销货收入
|
||||||
|
4112,installment sales revenue,分期付款銷貨收入,分期付款销货收入
|
||||||
|
4171,sales return,銷貨退回,销货退回
|
||||||
|
4191,sales discounts and allowances,銷貨折讓,销货折让
|
||||||
|
4611,service revenue,勞務收入,劳务收入
|
||||||
|
4711,agency revenue,業務收入,业务收入
|
||||||
|
4888,other operating revenue – other,其他營業收入—其他,其他营业收入—其他
|
||||||
|
5111,cost of goods sold,銷貨成本,销货成本
|
||||||
|
5112,installment cost of goods sold,分期付款銷貨成本,分期付款销货成本
|
||||||
|
5121,purchases,進貨,进货
|
||||||
|
5122,purchase expenses,進貨費用,进货费用
|
||||||
|
5123,purchase returns,進貨退出,进货退出
|
||||||
|
5124,charges on purchased merchandise,進貨折讓,进货折让
|
||||||
|
5131,material purchased,進料,进料
|
||||||
|
5132,charges on purchased material,進料費用,进料费用
|
||||||
|
5133,material purchase returns,進料退出,进料退出
|
||||||
|
5134,material purchase allowances,進料折讓,进料折让
|
||||||
|
5141,direct labor,直接人工,直接人工
|
||||||
|
5151,indirect labor,間接人工,间接人工
|
||||||
|
5152,"rent expense, rent",租金支出,租金支出
|
||||||
|
5153,office supplies (expense),文具用品,文具用品
|
||||||
|
5154,"travelling expense, travel",旅費,旅费
|
||||||
|
5155,"shipping expenses, freight",運費,运费
|
||||||
|
5156,postage (expenses),郵電費,邮电费
|
||||||
|
5157,repair (s) and maintenance (expense ),修繕費,修缮费
|
||||||
|
5158,packing expenses,包裝費,包装费
|
||||||
|
5161,utilities (expense),水電瓦斯費,水电瓦斯费
|
||||||
|
5162,insurance (expense),保險費,保险费
|
||||||
|
5163,manufacturing overhead – outsourced,加工費,加工费
|
||||||
|
5166,taxes,稅捐,税捐
|
||||||
|
5168,depreciation expense,折舊,折旧
|
||||||
|
5169,various amortization,各項耗竭及攤提,各项耗竭及摊提
|
||||||
|
5172,meal (expenses),伙食費,伙食费
|
||||||
|
5173,employee benefits/welfare,職工福利,职工福利
|
||||||
|
5176,training (expense),訓練費,训练费
|
||||||
|
5177,indirect materials,間接材料,间接材料
|
||||||
|
5188,other manufacturing expenses,其他製造費用,其他制造费用
|
||||||
|
5611,service costs,勞務成本,劳务成本
|
||||||
|
5711,agency costs,業務成本,业务成本
|
||||||
|
5888,other operating costs – other,其他營業成本—其他,其他营业成本—其他
|
||||||
|
6151,payroll expense,薪資支出,薪资支出
|
||||||
|
6152,"rent expense, rent",租金支出,租金支出
|
||||||
|
6153,office supplies (expense),文具用品,文具用品
|
||||||
|
6154,"travelling expense, travel",旅費,旅费
|
||||||
|
6155,"shipping expenses, freight",運費,运费
|
||||||
|
6156,postage (expenses),郵電費,邮电费
|
||||||
|
6157,repair (s) and maintenance (expense),修繕費,修缮费
|
||||||
|
6159,"advertisement expense, advertisement",廣告費,广告费
|
||||||
|
6161,utilities (expense),水電瓦斯費,水电瓦斯费
|
||||||
|
6162,insurance (expense),保險費,保险费
|
||||||
|
6164,entertainment (expense),交際費,交际费
|
||||||
|
6165,donation (expense),捐贈,捐赠
|
||||||
|
6166,taxes,稅捐,税捐
|
||||||
|
6167,loss on uncollectible accounts,呆帳損失,呆帐损失
|
||||||
|
6168,depreciation expense,折舊,折旧
|
||||||
|
6169,various amortization,各項耗竭及攤提,各项耗竭及摊提
|
||||||
|
6172,meal (expenses),伙食費,伙食费
|
||||||
|
6173,employee benefits/welfare,職工福利,职工福利
|
||||||
|
6175,commission (expense),佣金支出,佣金支出
|
||||||
|
6176,training (expense),訓練費,训练费
|
||||||
|
6188,other selling expenses,其他推銷費用,其他推销费用
|
||||||
|
6251,payroll expense,薪資支出,薪资支出
|
||||||
|
6252,"rent expense, rent",租金支出,租金支出
|
||||||
|
6253,office supplies,文具用品,文具用品
|
||||||
|
6254,"travelling expense, travel",旅費,旅费
|
||||||
|
6255,"shipping expenses,freight",運費,运费
|
||||||
|
6256,postage (expenses),郵電費,邮电费
|
||||||
|
6257,repair (s) and maintenance (expense),修繕費,修缮费
|
||||||
|
6259,"advertisement expense, advertisement",廣告費,广告费
|
||||||
|
6261,utilities (expense),水電瓦斯費,水电瓦斯费
|
||||||
|
6262,insurance (expense),保險費,保险费
|
||||||
|
6264,entertainment (expense),交際費,交际费
|
||||||
|
6265,donation (expense),捐贈,捐赠
|
||||||
|
6266,taxes,稅捐,税捐
|
||||||
|
6267,loss on uncollectible accounts,呆帳損失,呆帐损失
|
||||||
|
6268,depreciation expense,折舊,折旧
|
||||||
|
6269,various amortization,各項耗竭及攤提,各项耗竭及摊提
|
||||||
|
6271,loss on export sales,外銷損失,外销损失
|
||||||
|
6272,meal (expenses),伙食費,伙食费
|
||||||
|
6273,employee benefits/welfare,職工福利,职工福利
|
||||||
|
6274,research and development expense,研究發展費用,研究发展费用
|
||||||
|
6275,commission (expense),佣金支出,佣金支出
|
||||||
|
6276,training (expense),訓練費,训练费
|
||||||
|
6278,professional service fees,勞務費,劳务费
|
||||||
|
6288,other general and administrative expenses,其他管理及總務費用,其他管理及总务费用
|
||||||
|
6351,payroll expense,薪資支出,薪资支出
|
||||||
|
6352,"rent expense, rent",租金支出,租金支出
|
||||||
|
6353,office supplies,文具用品,文具用品
|
||||||
|
6354,"travelling expense, travel",旅費,旅费
|
||||||
|
6355,"shipping expenses, freight",運費,运费
|
||||||
|
6356,postage (expenses),郵電費,邮电费
|
||||||
|
6357,repair (s) and maintenance (expense),修繕費,修缮费
|
||||||
|
6361,utilities (expense),水電瓦斯費,水电瓦斯费
|
||||||
|
6362,insurance (expense),保險費,保险费
|
||||||
|
6364,entertainment (expense),交際費,交际费
|
||||||
|
6366,taxes,稅捐,税捐
|
||||||
|
6368,depreciation expense,折舊,折旧
|
||||||
|
6369,various amortization,各項耗竭及攤提,各项耗竭及摊提
|
||||||
|
6372,meal (expenses),伙食費,伙食费
|
||||||
|
6373,employee benefits/welfare,職工福利,职工福利
|
||||||
|
6376,training (expense),訓練費,训练费
|
||||||
|
6378,other research and development expenses,其他研究發展費用,其他研究发展费用
|
||||||
|
7111,interest revenue/income,利息收入,利息收入
|
||||||
|
7121,investment income recognized under equity method,權益法認列之投資收益,权益法认列之投资收益
|
||||||
|
7122,dividends income,股利收入,股利收入
|
||||||
|
7123,gain on market price recovery of short-term investment,短期投資市價回升利益,短期投资市价回升利益
|
||||||
|
7131,foreign exchange gain,兌換利益,兑换利益
|
||||||
|
7141,gain on disposal of investments,處分投資收益,处分投资收益
|
||||||
|
7151,gain on disposal of assets,處分資產溢價收入,处分资产溢价收入
|
||||||
|
7481,donation income,捐贈收入,捐赠收入
|
||||||
|
7482,rent revenue/income,租金收入,租金收入
|
||||||
|
7483,commission revenue/income,佣金收入,佣金收入
|
||||||
|
7484,revenue from sale of scraps,出售下腳及廢料收入,出售下脚及废料收入
|
||||||
|
7485,gain on physical inventory,存貨盤盈,存货盘盈
|
||||||
|
7486,gain from price recovery of inventory,存貨跌價回升利益,存货跌价回升利益
|
||||||
|
7487,gain on reversal of bad debts,壞帳轉回利益,坏帐转回利益
|
||||||
|
7488,other non-operating revenue – other items,其他營業外收入—其他,其他营业外收入—其他
|
||||||
|
7511,interest expense,利息費用,利息费用
|
||||||
|
7521,investment loss recognized under equity method,權益法認列之投資損失,权益法认列之投资损失
|
||||||
|
7523,unrealized loss on reduction of short-term investments to market,短期投資未實現跌價損失,短期投资未实现跌价损失
|
||||||
|
7531,foreign exchange loss,兌換損失,兑换损失
|
||||||
|
7541,loss on disposal of investments,處分投資損失,处分投资损失
|
||||||
|
7551,loss on disposal of assets,處分資產損失,处分资产损失
|
||||||
|
7881,loss on work stoppages,停工損失,停工损失
|
||||||
|
7882,casualty loss,災害損失,灾害损失
|
||||||
|
7885,loss on physical inventory,存貨盤損,存货盘损
|
||||||
|
7886,loss for market price decline and obsolete and slow-moving inventories,存貨跌價及呆滯損失,存货跌价及呆滞损失
|
||||||
|
7888,other non-operating expenses – other,其他營業外費用—其他,其他营业外费用—其他
|
||||||
|
8111,income tax expense ( or benefit),所得稅費用(或利益),所得税费用(或利益)
|
||||||
|
9111,income (loss) from operations of discontinued segment,停業部門損益—停業前營業損益,停业部门损益—停业前营业损益
|
||||||
|
9121,gain (loss) from disposal of discontinued segment,停業部門損益—處分損益,停业部门损益—处分损益
|
||||||
|
9211,extraordinary gain or loss,非常損益,非常损益
|
||||||
|
9311,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数
|
||||||
|
9411,minority interest income,少數股權淨利,少数股权净利
|
|
10
src/accounting/data/currencies.csv
Normal file
10
src/accounting/data/currencies.csv
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
code,name,l10n-zh_Hant,l10n-zh_Hans
|
||||||
|
TWD,New Taiwan dollar,新臺幣,新台币
|
||||||
|
USD,United States dollar,美元,美元
|
||||||
|
JPY,Japanese yen,日圓,日圆
|
||||||
|
CNY,Renminbi,人民幣,人民币
|
||||||
|
HKD,Hong Kong dollar,港元,港元
|
||||||
|
EUR,Euro,歐元,欧元
|
||||||
|
MOP,Macanese pataca,澳門元,澳门元
|
||||||
|
AUD,Australian dollar,澳洲元,澳大利亚元
|
||||||
|
ETH,Ethereum,以太坊,以太坊
|
|
96
src/accounting/forms.py
Normal file
96
src/accounting/forms.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||||
|
|
||||||
|
# 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 forms.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from flask_babel import LazyString
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, ValidationError
|
||||||
|
from wtforms.validators import DataRequired
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Currency, Account
|
||||||
|
|
||||||
|
|
||||||
|
ACCOUNT_REQUIRED: DataRequired = DataRequired(
|
||||||
|
lazy_gettext("Please select the account."))
|
||||||
|
"""The validator to check if the account code is empty."""
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyExists:
|
||||||
|
"""The validator to check if the account exists."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if db.session.get(Currency, field.data) is None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The currency does not exist."))
|
||||||
|
|
||||||
|
|
||||||
|
class AccountExists:
|
||||||
|
"""The validator to check if the account exists."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if Account.find_by_code(field.data) is None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The account does not exist."))
|
||||||
|
|
||||||
|
|
||||||
|
class IsDebitAccount:
|
||||||
|
"""The validator to check if the account is for debit line items."""
|
||||||
|
|
||||||
|
def __init__(self, message: str | LazyString):
|
||||||
|
"""Constructs the validator.
|
||||||
|
|
||||||
|
:param message: The error message.
|
||||||
|
"""
|
||||||
|
self.__message: str | LazyString = message
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
|
||||||
|
and not field.data.startswith("3351-") \
|
||||||
|
and not field.data.startswith("3353-"):
|
||||||
|
return
|
||||||
|
raise ValidationError(self.__message)
|
||||||
|
|
||||||
|
|
||||||
|
class IsCreditAccount:
|
||||||
|
"""The validator to check if the account is for credit line items."""
|
||||||
|
|
||||||
|
def __init__(self, message: str | LazyString):
|
||||||
|
"""Constructs the validator.
|
||||||
|
|
||||||
|
:param message: The error message.
|
||||||
|
"""
|
||||||
|
self.__message: str | LazyString = message
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if re.match(r"^(?:[123489]|7[1234])", field.data) \
|
||||||
|
and not field.data.startswith("3351-") \
|
||||||
|
and not field.data.startswith("3353-"):
|
||||||
|
return
|
||||||
|
raise ValidationError(self.__message)
|
37
src/accounting/journal_entry/__init__.py
Normal file
37
src/accounting/journal_entry/__init__.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||||
|
|
||||||
|
# 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 entry 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 JournalEntryConverter, JournalEntryTypeConverter, \
|
||||||
|
DateConverter
|
||||||
|
app.url_map.converters["journalEntry"] = JournalEntryConverter
|
||||||
|
app.url_map.converters["journalEntryType"] = JournalEntryTypeConverter
|
||||||
|
app.url_map.converters["date"] = DateConverter
|
||||||
|
|
||||||
|
from .views import bp as journal_entry_bp
|
||||||
|
bp.register_blueprint(journal_entry_bp, url_prefix="/journal-entries")
|
107
src/accounting/journal_entry/converters.py
Normal file
107
src/accounting/journal_entry/converters.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
||||||
|
|
||||||
|
# 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 journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from flask import abort
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
|
from accounting.models import JournalEntry, JournalEntryLineItem
|
||||||
|
from accounting.utils.journal_entry_types import JournalEntryType
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryConverter(BaseConverter):
|
||||||
|
"""The journal entry converter to convert the journal entry ID from and to
|
||||||
|
the corresponding journal entry in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> JournalEntry:
|
||||||
|
"""Converts a journal entry ID to a journal entry.
|
||||||
|
|
||||||
|
:param value: The journal entry ID.
|
||||||
|
:return: The corresponding journal entry.
|
||||||
|
"""
|
||||||
|
journal_entry: JournalEntry | None = JournalEntry.query\
|
||||||
|
.join(JournalEntryLineItem)\
|
||||||
|
.filter(JournalEntry.id == value)\
|
||||||
|
.options(selectinload(JournalEntry.line_items)
|
||||||
|
.selectinload(JournalEntryLineItem.offsets)
|
||||||
|
.selectinload(JournalEntryLineItem.journal_entry))\
|
||||||
|
.first()
|
||||||
|
if journal_entry is None:
|
||||||
|
abort(404)
|
||||||
|
return journal_entry
|
||||||
|
|
||||||
|
def to_url(self, value: JournalEntry) -> str:
|
||||||
|
"""Converts a journal entry to its ID.
|
||||||
|
|
||||||
|
:param value: The journal entry.
|
||||||
|
:return: The ID.
|
||||||
|
"""
|
||||||
|
return str(value.id)
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryTypeConverter(BaseConverter):
|
||||||
|
"""The journal entry converter to convert the journal entry type ID from
|
||||||
|
and to the corresponding journal entry type in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> JournalEntryType:
|
||||||
|
"""Converts a journal entry ID to a journal entry.
|
||||||
|
|
||||||
|
:param value: The journal entry ID.
|
||||||
|
:return: The corresponding journal entry type.
|
||||||
|
"""
|
||||||
|
type_dict: dict[str, JournalEntryType] \
|
||||||
|
= {x.value: x for x in JournalEntryType}
|
||||||
|
journal_entry_type: JournalEntryType | None = type_dict.get(value)
|
||||||
|
if journal_entry_type is None:
|
||||||
|
abort(404)
|
||||||
|
return journal_entry_type
|
||||||
|
|
||||||
|
def to_url(self, value: JournalEntryType) -> str:
|
||||||
|
"""Converts a journal entry type to its ID.
|
||||||
|
|
||||||
|
:param value: The journal entry type.
|
||||||
|
:return: The ID.
|
||||||
|
"""
|
||||||
|
return str(value.value)
|
||||||
|
|
||||||
|
|
||||||
|
class DateConverter(BaseConverter):
|
||||||
|
"""The date converter to convert the ISO date from and to the
|
||||||
|
corresponding date in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> date:
|
||||||
|
"""Converts an ISO date to a date.
|
||||||
|
|
||||||
|
:param value: The ISO date.
|
||||||
|
:return: The corresponding date.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
def to_url(self, value: date) -> str:
|
||||||
|
"""Converts a date to its ISO date.
|
||||||
|
|
||||||
|
:param value: The date.
|
||||||
|
:return: The ISO date.
|
||||||
|
"""
|
||||||
|
return value.isoformat()
|
22
src/accounting/journal_entry/forms/__init__.py
Normal file
22
src/accounting/journal_entry/forms/__init__.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# 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 forms for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from .reorder import sort_journal_entries_in, JournalEntryReorderForm
|
||||||
|
from .journal_entry import JournalEntryForm, CashReceiptJournalEntryForm, \
|
||||||
|
CashDisbursementJournalEntryForm, TransferJournalEntryForm
|
294
src/accounting/journal_entry/forms/currency.py
Normal file
294
src/accounting/journal_entry/forms/currency.py
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# 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 currency sub-forms for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask_babel import LazyString
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, ValidationError, FieldList, IntegerField, \
|
||||||
|
BooleanField, FormField
|
||||||
|
from wtforms.validators import DataRequired
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.forms import CurrencyExists
|
||||||
|
from accounting.journal_entry.utils.offset_alias import offset_alias
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import JournalEntryLineItem
|
||||||
|
from accounting.utils.cast import be
|
||||||
|
from accounting.utils.strip_text import strip_text
|
||||||
|
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
|
||||||
|
|
||||||
|
|
||||||
|
CURRENCY_REQUIRED: DataRequired = DataRequired(
|
||||||
|
lazy_gettext("Please select the currency."))
|
||||||
|
"""The validator to check if the currency code is empty."""
|
||||||
|
|
||||||
|
|
||||||
|
class SameCurrencyAsOriginalLineItems:
|
||||||
|
"""The validator to check if the currency is the same as the
|
||||||
|
original line items."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, CurrencyForm)
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
original_line_item_id: set[int] \
|
||||||
|
= {x.original_line_item_id.data
|
||||||
|
for x in form.line_items
|
||||||
|
if x.original_line_item_id.data is not None}
|
||||||
|
if len(original_line_item_id) == 0:
|
||||||
|
return
|
||||||
|
original_line_item_currency_codes: set[str] = set(db.session.scalars(
|
||||||
|
sa.select(JournalEntryLineItem.currency_code)
|
||||||
|
.filter(JournalEntryLineItem.id.in_(original_line_item_id))).all())
|
||||||
|
for currency_code in original_line_item_currency_codes:
|
||||||
|
if field.data != currency_code:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The currency must be the same as the"
|
||||||
|
" original line item."))
|
||||||
|
|
||||||
|
|
||||||
|
class KeepCurrencyWhenHavingOffset:
|
||||||
|
"""The validator to check if the currency is the same when there is
|
||||||
|
offset."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, CurrencyForm)
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
offset: sa.Alias = offset_alias()
|
||||||
|
original_line_items: list[JournalEntryLineItem]\
|
||||||
|
= JournalEntryLineItem.query\
|
||||||
|
.join(offset, be(JournalEntryLineItem.id
|
||||||
|
== offset.c.original_line_item_id),
|
||||||
|
isouter=True)\
|
||||||
|
.filter(JournalEntryLineItem.id
|
||||||
|
.in_({x.id.data for x in form.line_items
|
||||||
|
if x.id.data is not None}))\
|
||||||
|
.group_by(JournalEntryLineItem.id,
|
||||||
|
JournalEntryLineItem.currency_code)\
|
||||||
|
.having(sa.func.count(offset.c.id) > 0).all()
|
||||||
|
for original_line_item in original_line_items:
|
||||||
|
if original_line_item.currency_code != field.data:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The currency must not be changed when there is offset."))
|
||||||
|
|
||||||
|
|
||||||
|
class NeedSomeLineItems:
|
||||||
|
"""The validator to check if there is any line item sub-form."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: FieldList) -> None:
|
||||||
|
if len(field) == 0:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"Please add some line items."))
|
||||||
|
|
||||||
|
|
||||||
|
class IsBalanced:
|
||||||
|
"""The validator to check that the total amount of the debit and credit
|
||||||
|
line items are equal."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
|
||||||
|
assert isinstance(form, TransferCurrencyForm)
|
||||||
|
if len(form.debit) == 0 or len(form.credit) == 0:
|
||||||
|
return
|
||||||
|
if form.debit_total != form.credit_total:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The totals of the debit and credit amounts do not match."))
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyForm(FlaskForm):
|
||||||
|
"""The form to create or edit a currency in a journal entry."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the journal entry."""
|
||||||
|
code = StringField()
|
||||||
|
"""The currency code."""
|
||||||
|
whole_form = BooleanField()
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def line_items(self) -> list[LineItemForm]:
|
||||||
|
"""Returns the line item sub-forms.
|
||||||
|
|
||||||
|
:return: The line item sub-forms.
|
||||||
|
"""
|
||||||
|
line_item_forms: list[LineItemForm] = []
|
||||||
|
if isinstance(self, CashReceiptCurrencyForm):
|
||||||
|
line_item_forms.extend([x.form for x in self.credit])
|
||||||
|
elif isinstance(self, CashDisbursementCurrencyForm):
|
||||||
|
line_item_forms.extend([x.form for x in self.debit])
|
||||||
|
elif isinstance(self, TransferCurrencyForm):
|
||||||
|
line_item_forms.extend([x.form for x in self.debit])
|
||||||
|
line_item_forms.extend([x.form for x in self.credit])
|
||||||
|
return line_item_forms
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_code_locked(self) -> bool:
|
||||||
|
"""Returns whether the currency code should not be changed.
|
||||||
|
|
||||||
|
:return: True if the currency code should not be changed, or False
|
||||||
|
otherwise
|
||||||
|
"""
|
||||||
|
line_item_forms: list[LineItemForm] = self.line_items
|
||||||
|
original_line_item_id: set[int] \
|
||||||
|
= {x.original_line_item_id.data for x in line_item_forms
|
||||||
|
if x.original_line_item_id.data is not None}
|
||||||
|
if len(original_line_item_id) > 0:
|
||||||
|
return True
|
||||||
|
line_item_id: set[int] = {x.id.data for x in line_item_forms
|
||||||
|
if x.id.data is not None}
|
||||||
|
select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
|
||||||
|
.filter(JournalEntryLineItem.original_line_item_id
|
||||||
|
.in_(line_item_id))
|
||||||
|
return db.session.scalar(select) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class CashReceiptCurrencyForm(CurrencyForm):
|
||||||
|
"""The form to create or edit a currency in a
|
||||||
|
cash receipt journal entry."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the journal entry."""
|
||||||
|
code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[CURRENCY_REQUIRED,
|
||||||
|
CurrencyExists(),
|
||||||
|
SameCurrencyAsOriginalLineItems(),
|
||||||
|
KeepCurrencyWhenHavingOffset()])
|
||||||
|
"""The currency code."""
|
||||||
|
credit = FieldList(FormField(CreditLineItemForm),
|
||||||
|
validators=[NeedSomeLineItems()])
|
||||||
|
"""The credit line items."""
|
||||||
|
whole_form = BooleanField()
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the credit line items.
|
||||||
|
|
||||||
|
:return: The total amount of the credit line items.
|
||||||
|
"""
|
||||||
|
return sum([x.amount.data for x in self.credit
|
||||||
|
if x.amount.data is not None])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the credit line item errors, without the errors in their
|
||||||
|
sub-forms.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return [x for x in self.credit.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
|
||||||
|
class CashDisbursementCurrencyForm(CurrencyForm):
|
||||||
|
"""The form to create or edit a currency in a
|
||||||
|
cash disbursement journal entry."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the journal entry."""
|
||||||
|
code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[CURRENCY_REQUIRED,
|
||||||
|
CurrencyExists(),
|
||||||
|
SameCurrencyAsOriginalLineItems(),
|
||||||
|
KeepCurrencyWhenHavingOffset()])
|
||||||
|
"""The currency code."""
|
||||||
|
debit = FieldList(FormField(DebitLineItemForm),
|
||||||
|
validators=[NeedSomeLineItems()])
|
||||||
|
"""The debit line items."""
|
||||||
|
whole_form = BooleanField()
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the debit line items.
|
||||||
|
|
||||||
|
:return: The total amount of the debit line items.
|
||||||
|
"""
|
||||||
|
return sum([x.amount.data for x in self.debit
|
||||||
|
if x.amount.data is not None])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the debit line item errors, without the errors in their
|
||||||
|
sub-forms.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return [x for x in self.debit.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
|
||||||
|
class TransferCurrencyForm(CurrencyForm):
|
||||||
|
"""The form to create or edit a currency in a transfer journal entry."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the journal entry."""
|
||||||
|
code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[CURRENCY_REQUIRED,
|
||||||
|
CurrencyExists(),
|
||||||
|
SameCurrencyAsOriginalLineItems(),
|
||||||
|
KeepCurrencyWhenHavingOffset()])
|
||||||
|
"""The currency code."""
|
||||||
|
debit = FieldList(FormField(DebitLineItemForm),
|
||||||
|
validators=[NeedSomeLineItems()])
|
||||||
|
"""The debit line items."""
|
||||||
|
credit = FieldList(FormField(CreditLineItemForm),
|
||||||
|
validators=[NeedSomeLineItems()])
|
||||||
|
"""The credit line items."""
|
||||||
|
whole_form = BooleanField(validators=[IsBalanced()])
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the debit line items.
|
||||||
|
|
||||||
|
:return: The total amount of the debit line items.
|
||||||
|
"""
|
||||||
|
return sum([x.amount.data for x in self.debit
|
||||||
|
if x.amount.data is not None])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the credit line items.
|
||||||
|
|
||||||
|
:return: The total amount of the credit line items.
|
||||||
|
"""
|
||||||
|
return sum([x.amount.data for x in self.credit
|
||||||
|
if x.amount.data is not None])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the debit line item errors, without the errors in their
|
||||||
|
sub-forms.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return [x for x in self.debit.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the credit line item errors, without the errors in their
|
||||||
|
sub-forms.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return [x for x in self.credit.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
593
src/accounting/journal_entry/forms/journal_entry.py
Normal file
593
src/accounting/journal_entry/forms/journal_entry.py
Normal file
@ -0,0 +1,593 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||||
|
|
||||||
|
# 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 entry forms for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
import typing as t
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask_babel import LazyString
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import DateField, FieldList, FormField, TextAreaField, \
|
||||||
|
BooleanField
|
||||||
|
from wtforms.validators import DataRequired, ValidationError
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
|
||||||
|
JournalEntryCurrency
|
||||||
|
from accounting.journal_entry.utils.account_option import AccountOption
|
||||||
|
from accounting.journal_entry.utils.original_line_items import \
|
||||||
|
get_selectable_original_line_items
|
||||||
|
from accounting.journal_entry.utils.description_editor import DescriptionEditor
|
||||||
|
from accounting.utils.random_id import new_id
|
||||||
|
from accounting.utils.strip_text import strip_multiline_text
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
from .currency import CurrencyForm, CashReceiptCurrencyForm, \
|
||||||
|
CashDisbursementCurrencyForm, TransferCurrencyForm
|
||||||
|
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
|
||||||
|
from .reorder import sort_journal_entries_in
|
||||||
|
|
||||||
|
DATE_REQUIRED: DataRequired = DataRequired(
|
||||||
|
lazy_gettext("Please fill in the date."))
|
||||||
|
"""The validator to check if the date is empty."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotBeforeOriginalLineItems:
|
||||||
|
"""The validator to check if the date is not before the
|
||||||
|
original line items."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: DateField) -> None:
|
||||||
|
assert isinstance(form, JournalEntryForm)
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
min_date: dt.date | None = form.min_date
|
||||||
|
if min_date is None:
|
||||||
|
return
|
||||||
|
if field.data < min_date:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The date cannot be earlier than the original line items."))
|
||||||
|
|
||||||
|
|
||||||
|
class NotAfterOffsetItems:
|
||||||
|
"""The validator to check if the date is not after the offset items."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: DateField) -> None:
|
||||||
|
assert isinstance(form, JournalEntryForm)
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
max_date: dt.date | None = form.max_date
|
||||||
|
if max_date is None:
|
||||||
|
return
|
||||||
|
if field.data > max_date:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The date cannot be later than the offset items."))
|
||||||
|
|
||||||
|
|
||||||
|
class NeedSomeCurrencies:
|
||||||
|
"""The validator to check if there is any currency sub-form."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: FieldList) -> None:
|
||||||
|
if len(field) == 0:
|
||||||
|
raise ValidationError(lazy_gettext("Please add some currencies."))
|
||||||
|
|
||||||
|
|
||||||
|
class CannotDeleteOriginalLineItemsWithOffset:
|
||||||
|
"""The validator to check the original line items with offset."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: FieldList) -> None:
|
||||||
|
assert isinstance(form, JournalEntryForm)
|
||||||
|
if form.obj is None:
|
||||||
|
return
|
||||||
|
existing_matched_original_line_item_id: set[int] \
|
||||||
|
= {x.id for x in form.obj.line_items if len(x.offsets) > 0}
|
||||||
|
line_item_id_in_form: set[int] \
|
||||||
|
= {x.id.data for x in form.line_items if x.id.data is not None}
|
||||||
|
for line_item_id in existing_matched_original_line_item_id:
|
||||||
|
if line_item_id not in line_item_id_in_form:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"Line items with offset cannot be deleted."))
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryForm(FlaskForm):
|
||||||
|
"""The base form to create or edit a journal entry."""
|
||||||
|
date = DateField()
|
||||||
|
"""The date."""
|
||||||
|
currencies = FieldList(FormField(CurrencyForm))
|
||||||
|
"""The line items categorized by their currencies."""
|
||||||
|
note = TextAreaField()
|
||||||
|
"""The note."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Constructs a base journal entry form.
|
||||||
|
|
||||||
|
:param args: The arguments.
|
||||||
|
:param kwargs: The keyword arguments.
|
||||||
|
"""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.is_modified: bool = False
|
||||||
|
"""Whether the journal entry is modified during populate_obj()."""
|
||||||
|
self.collector: t.Type[LineItemCollector] = LineItemCollector
|
||||||
|
"""The line item collector. The default is the base abstract
|
||||||
|
collector only to provide the correct type. The subclass forms should
|
||||||
|
provide their own collectors."""
|
||||||
|
self.obj: JournalEntry | None = kwargs.get("obj")
|
||||||
|
"""The journal entry, when editing an existing one."""
|
||||||
|
self._is_need_payable: bool = False
|
||||||
|
"""Whether we need the payable original line items."""
|
||||||
|
self._is_need_receivable: bool = False
|
||||||
|
"""Whether we need the receivable original line items."""
|
||||||
|
self.__original_line_item_options: list[JournalEntryLineItem] | None \
|
||||||
|
= None
|
||||||
|
"""The options of the original line items."""
|
||||||
|
self.__net_balance_exceeded: dict[int, LazyString] | None = None
|
||||||
|
"""The original line items whose net balances were exceeded by the
|
||||||
|
amounts in the line item sub-forms."""
|
||||||
|
for line_item in self.line_items:
|
||||||
|
line_item.journal_entry_form = self
|
||||||
|
|
||||||
|
def populate_obj(self, obj: JournalEntry) -> None:
|
||||||
|
"""Populates the form data into a journal entry object.
|
||||||
|
|
||||||
|
:param obj: The journal entry object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
is_new: bool = obj.id is None
|
||||||
|
if is_new:
|
||||||
|
obj.id = new_id(JournalEntry)
|
||||||
|
self.date: DateField
|
||||||
|
self.__set_date(obj, self.date.data)
|
||||||
|
obj.note = self.note.data
|
||||||
|
|
||||||
|
collector_cls: t.Type[LineItemCollector] = self.collector
|
||||||
|
collector: collector_cls = collector_cls(self, obj)
|
||||||
|
collector.collect()
|
||||||
|
|
||||||
|
to_delete: set[int] = {x.id for x in obj.line_items
|
||||||
|
if x.id not in collector.to_keep}
|
||||||
|
if len(to_delete) > 0:
|
||||||
|
JournalEntryLineItem.query\
|
||||||
|
.filter(JournalEntryLineItem.id.in_(to_delete)).delete()
|
||||||
|
self.is_modified = True
|
||||||
|
|
||||||
|
if is_new or db.session.is_modified(obj):
|
||||||
|
self.is_modified = True
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.created_by_id = current_user_pk
|
||||||
|
obj.updated_by_id = current_user_pk
|
||||||
|
|
||||||
|
@property
|
||||||
|
def line_items(self) -> list[LineItemForm]:
|
||||||
|
"""Collects and returns the line item sub-forms.
|
||||||
|
|
||||||
|
:return: The line item sub-forms.
|
||||||
|
"""
|
||||||
|
line_items: list[LineItemForm] = []
|
||||||
|
for currency in self.currencies:
|
||||||
|
line_items.extend(currency.line_items)
|
||||||
|
return line_items
|
||||||
|
|
||||||
|
def __set_date(self, obj: JournalEntry, new_date: dt.date) -> None:
|
||||||
|
"""Sets the journal entry date and number.
|
||||||
|
|
||||||
|
:param obj: The journal entry object.
|
||||||
|
:param new_date: The new date.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if obj.date is None or obj.date != new_date:
|
||||||
|
if obj.date is not None:
|
||||||
|
sort_journal_entries_in(obj.date, obj.id)
|
||||||
|
if self.max_date is not None and new_date == self.max_date:
|
||||||
|
db_min_no: int | None = db.session.scalar(
|
||||||
|
sa.select(sa.func.min(JournalEntry.no))
|
||||||
|
.filter(JournalEntry.date == new_date))
|
||||||
|
if db_min_no is None:
|
||||||
|
obj.date = new_date
|
||||||
|
obj.no = 1
|
||||||
|
else:
|
||||||
|
obj.date = new_date
|
||||||
|
obj.no = db_min_no - 1
|
||||||
|
sort_journal_entries_in(new_date)
|
||||||
|
else:
|
||||||
|
sort_journal_entries_in(new_date, obj.id)
|
||||||
|
count: int = JournalEntry.query\
|
||||||
|
.filter(JournalEntry.date == new_date).count()
|
||||||
|
obj.date = new_date
|
||||||
|
obj.no = count + 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_account_options(self) -> list[AccountOption]:
|
||||||
|
"""The selectable debit accounts.
|
||||||
|
|
||||||
|
:return: The selectable debit accounts.
|
||||||
|
"""
|
||||||
|
accounts: list[AccountOption] \
|
||||||
|
= [AccountOption(x) for x in Account.selectable_debit()
|
||||||
|
if not (x.code[0] == "2" and x.is_need_offset)]
|
||||||
|
in_use: set[int] = set(db.session.scalars(
|
||||||
|
sa.select(JournalEntryLineItem.account_id)
|
||||||
|
.filter(JournalEntryLineItem.is_debit)
|
||||||
|
.group_by(JournalEntryLineItem.account_id)).all())
|
||||||
|
for account in accounts:
|
||||||
|
account.is_in_use = account.id in in_use
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_account_options(self) -> list[AccountOption]:
|
||||||
|
"""The selectable credit accounts.
|
||||||
|
|
||||||
|
:return: The selectable credit accounts.
|
||||||
|
"""
|
||||||
|
accounts: list[AccountOption] \
|
||||||
|
= [AccountOption(x) for x in Account.selectable_credit()
|
||||||
|
if not (x.code[0] == "1" and x.is_need_offset)]
|
||||||
|
in_use: set[int] = set(db.session.scalars(
|
||||||
|
sa.select(JournalEntryLineItem.account_id)
|
||||||
|
.filter(sa.not_(JournalEntryLineItem.is_debit))
|
||||||
|
.group_by(JournalEntryLineItem.account_id)).all())
|
||||||
|
for account in accounts:
|
||||||
|
account.is_in_use = account.id in in_use
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currencies_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the currency errors, without the errors in their sub-forms.
|
||||||
|
|
||||||
|
:return: The currency errors, without the errors in their sub-forms.
|
||||||
|
"""
|
||||||
|
return [x for x in self.currencies.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description_editor(self) -> DescriptionEditor:
|
||||||
|
"""Returns the description editor.
|
||||||
|
|
||||||
|
:return: The description editor.
|
||||||
|
"""
|
||||||
|
return DescriptionEditor()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def original_line_item_options(self) -> list[JournalEntryLineItem]:
|
||||||
|
"""Returns the selectable original line items.
|
||||||
|
|
||||||
|
:return: The selectable original line items.
|
||||||
|
"""
|
||||||
|
if self.__original_line_item_options is None:
|
||||||
|
self.__original_line_item_options \
|
||||||
|
= get_selectable_original_line_items(
|
||||||
|
{x.id.data for x in self.line_items
|
||||||
|
if x.id.data is not None},
|
||||||
|
self._is_need_payable, self._is_need_receivable)
|
||||||
|
return self.__original_line_item_options
|
||||||
|
|
||||||
|
@property
|
||||||
|
def min_date(self) -> dt.date | None:
|
||||||
|
"""Returns the minimal available date.
|
||||||
|
|
||||||
|
:return: The minimal available date.
|
||||||
|
"""
|
||||||
|
original_line_item_id: set[int] \
|
||||||
|
= {x.original_line_item_id.data for x in self.line_items
|
||||||
|
if x.original_line_item_id.data is not None}
|
||||||
|
if len(original_line_item_id) == 0:
|
||||||
|
return None
|
||||||
|
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
|
||||||
|
.join(JournalEntryLineItem)\
|
||||||
|
.filter(JournalEntryLineItem.id.in_(original_line_item_id))
|
||||||
|
return db.session.scalar(select)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_date(self) -> dt.date | None:
|
||||||
|
"""Returns the maximum available date.
|
||||||
|
|
||||||
|
:return: The maximum available date.
|
||||||
|
"""
|
||||||
|
line_item_id: set[int] = {x.id.data for x in self.line_items
|
||||||
|
if x.id.data is not None}
|
||||||
|
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
|
||||||
|
.join(JournalEntryLineItem)\
|
||||||
|
.filter(JournalEntryLineItem.original_line_item_id
|
||||||
|
.in_(line_item_id))
|
||||||
|
return db.session.scalar(select)
|
||||||
|
|
||||||
|
|
||||||
|
T = t.TypeVar("T", bound=JournalEntryForm)
|
||||||
|
"""A journal entry form variant."""
|
||||||
|
|
||||||
|
|
||||||
|
class LineItemCollector(t.Generic[T], ABC):
|
||||||
|
"""The line item collector."""
|
||||||
|
|
||||||
|
def __init__(self, form: T, obj: JournalEntry):
|
||||||
|
"""Constructs the line item collector.
|
||||||
|
|
||||||
|
:param form: The journal entry form.
|
||||||
|
:param obj: The journal entry.
|
||||||
|
"""
|
||||||
|
self.form: T = form
|
||||||
|
"""The journal entry form."""
|
||||||
|
self.__obj: JournalEntry = obj
|
||||||
|
"""The journal entry object."""
|
||||||
|
self.__line_items: list[JournalEntryLineItem] = list(obj.line_items)
|
||||||
|
"""The existing line items."""
|
||||||
|
self.__line_items_by_id: dict[int, JournalEntryLineItem] \
|
||||||
|
= {x.id: x for x in self.__line_items}
|
||||||
|
"""A dictionary from the line item ID to their line items."""
|
||||||
|
self.__no_by_id: dict[int, int] \
|
||||||
|
= {x.id: x.no for x in self.__line_items}
|
||||||
|
"""A dictionary from the line item number to their line items."""
|
||||||
|
self.__currencies: list[JournalEntryCurrency] = obj.currencies
|
||||||
|
"""The currencies in the journal entry."""
|
||||||
|
self._debit_no: int = 1
|
||||||
|
"""The number index for the debit line items."""
|
||||||
|
self._credit_no: int = 1
|
||||||
|
"""The number index for the credit line items."""
|
||||||
|
self.to_keep: set[int] = set()
|
||||||
|
"""The ID of the existing line items to keep."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def collect(self) -> set[int]:
|
||||||
|
"""Collects the line items.
|
||||||
|
|
||||||
|
:return: The ID of the line items to keep.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _add_line_item(self, form: LineItemForm, currency_code: str, no: int) \
|
||||||
|
-> None:
|
||||||
|
"""Composes a line item from the form.
|
||||||
|
|
||||||
|
:param form: The line item form.
|
||||||
|
:param currency_code: The code of the currency.
|
||||||
|
:param no: The number of the line item.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
line_item: JournalEntryLineItem | None \
|
||||||
|
= self.__line_items_by_id.get(form.id.data)
|
||||||
|
if line_item is not None:
|
||||||
|
line_item.currency_code = currency_code
|
||||||
|
form.populate_obj(line_item)
|
||||||
|
line_item.no = no
|
||||||
|
if db.session.is_modified(line_item):
|
||||||
|
self.form.is_modified = True
|
||||||
|
else:
|
||||||
|
line_item = JournalEntryLineItem()
|
||||||
|
line_item.currency_code = currency_code
|
||||||
|
form.populate_obj(line_item)
|
||||||
|
line_item.no = no
|
||||||
|
self.__obj.line_items.append(line_item)
|
||||||
|
self.form.is_modified = True
|
||||||
|
self.to_keep.add(line_item.id)
|
||||||
|
|
||||||
|
def _make_cash_line_item(self, forms: list[LineItemForm], is_debit: bool,
|
||||||
|
currency_code: str, no: int) -> None:
|
||||||
|
"""Composes the cash line item at the other debit or credit of the
|
||||||
|
cash journal entry.
|
||||||
|
|
||||||
|
:param forms: The line item forms in the same currency.
|
||||||
|
:param is_debit: True for a cash receipt journal entry, or False for a
|
||||||
|
cash disbursement journal entry.
|
||||||
|
:param currency_code: The code of the currency.
|
||||||
|
:param no: The number of the line item.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
candidates: list[JournalEntryLineItem] \
|
||||||
|
= [x for x in self.__line_items
|
||||||
|
if x.is_debit == is_debit and x.currency_code == currency_code]
|
||||||
|
line_item: JournalEntryLineItem
|
||||||
|
if len(candidates) > 0:
|
||||||
|
candidates.sort(key=lambda x: x.no)
|
||||||
|
line_item = candidates[0]
|
||||||
|
line_item.account_id = Account.cash().id
|
||||||
|
line_item.description = None
|
||||||
|
line_item.amount = sum([x.amount.data for x in forms])
|
||||||
|
line_item.no = no
|
||||||
|
if db.session.is_modified(line_item):
|
||||||
|
self.form.is_modified = True
|
||||||
|
else:
|
||||||
|
line_item = JournalEntryLineItem()
|
||||||
|
line_item.id = new_id(JournalEntryLineItem)
|
||||||
|
line_item.is_debit = is_debit
|
||||||
|
line_item.currency_code = currency_code
|
||||||
|
line_item.account_id = Account.cash().id
|
||||||
|
line_item.description = None
|
||||||
|
line_item.amount = sum([x.amount.data for x in forms])
|
||||||
|
line_item.no = no
|
||||||
|
self.__obj.line_items.append(line_item)
|
||||||
|
self.form.is_modified = True
|
||||||
|
self.to_keep.add(line_item.id)
|
||||||
|
|
||||||
|
def _sort_line_item_forms(self, forms: list[LineItemForm]) -> None:
|
||||||
|
"""Sorts the line item sub-forms.
|
||||||
|
|
||||||
|
:param forms: The line item sub-forms.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
missing_no: int = 100 if len(self.__no_by_id) == 0 \
|
||||||
|
else max(self.__no_by_id.values()) + 100
|
||||||
|
ord_by_form: dict[LineItemForm, int] \
|
||||||
|
= {forms[i]: i for i in range(len(forms))}
|
||||||
|
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||||
|
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||||
|
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||||
|
missing_no if x.id.data is None else
|
||||||
|
self.__no_by_id.get(x.id.data, missing_no),
|
||||||
|
ord_by_form.get(x)))
|
||||||
|
|
||||||
|
def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None:
|
||||||
|
"""Sorts the currency forms.
|
||||||
|
|
||||||
|
:param forms: The currency forms.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
missing_no: int = len(self.__currencies) + 100
|
||||||
|
no_by_code: dict[str, int] = {self.__currencies[i].code: i
|
||||||
|
for i in range(len(self.__currencies))}
|
||||||
|
ord_by_form: dict[CurrencyForm, int] \
|
||||||
|
= {forms[i]: i for i in range(len(forms))}
|
||||||
|
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||||
|
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||||
|
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||||
|
no_by_code.get(x.code.data, missing_no),
|
||||||
|
ord_by_form.get(x)))
|
||||||
|
|
||||||
|
|
||||||
|
class CashReceiptJournalEntryForm(JournalEntryForm):
|
||||||
|
"""The form to create or edit a cash receipt journal entry."""
|
||||||
|
date = DateField(
|
||||||
|
validators=[DATE_REQUIRED,
|
||||||
|
NotBeforeOriginalLineItems(),
|
||||||
|
NotAfterOffsetItems()])
|
||||||
|
"""The date."""
|
||||||
|
currencies = FieldList(FormField(CashReceiptCurrencyForm), name="currency",
|
||||||
|
validators=[NeedSomeCurrencies()])
|
||||||
|
"""The line items categorized by their currencies."""
|
||||||
|
note = TextAreaField(filters=[strip_multiline_text])
|
||||||
|
"""The note."""
|
||||||
|
whole_form = BooleanField(
|
||||||
|
validators=[CannotDeleteOriginalLineItemsWithOffset()])
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._is_need_receivable = True
|
||||||
|
|
||||||
|
class Collector(LineItemCollector[CashReceiptJournalEntryForm]):
|
||||||
|
"""The line item collector for the cash receipt journal entries."""
|
||||||
|
|
||||||
|
def collect(self) -> None:
|
||||||
|
currencies: list[CashReceiptCurrencyForm] \
|
||||||
|
= [x.form for x in self.form.currencies]
|
||||||
|
self._sort_currency_forms(currencies)
|
||||||
|
for currency in currencies:
|
||||||
|
# The debit cash line item
|
||||||
|
self._make_cash_line_item(list(currency.credit), True,
|
||||||
|
currency.code.data,
|
||||||
|
self._debit_no)
|
||||||
|
self._debit_no = self._debit_no + 1
|
||||||
|
|
||||||
|
# The credit forms
|
||||||
|
credit_forms: list[CreditLineItemForm] \
|
||||||
|
= [x.form for x in currency.credit]
|
||||||
|
self._sort_line_item_forms(credit_forms)
|
||||||
|
for credit_form in credit_forms:
|
||||||
|
self._add_line_item(credit_form, currency.code.data,
|
||||||
|
self._credit_no)
|
||||||
|
self._credit_no = self._credit_no + 1
|
||||||
|
|
||||||
|
self.collector = Collector
|
||||||
|
|
||||||
|
|
||||||
|
class CashDisbursementJournalEntryForm(JournalEntryForm):
|
||||||
|
"""The form to create or edit a cash disbursement journal entry."""
|
||||||
|
date = DateField(
|
||||||
|
validators=[DATE_REQUIRED,
|
||||||
|
NotBeforeOriginalLineItems(),
|
||||||
|
NotAfterOffsetItems()])
|
||||||
|
"""The date."""
|
||||||
|
currencies = FieldList(FormField(CashDisbursementCurrencyForm),
|
||||||
|
name="currency",
|
||||||
|
validators=[NeedSomeCurrencies()])
|
||||||
|
"""The line items categorized by their currencies."""
|
||||||
|
note = TextAreaField(filters=[strip_multiline_text])
|
||||||
|
"""The note."""
|
||||||
|
whole_form = BooleanField(
|
||||||
|
validators=[CannotDeleteOriginalLineItemsWithOffset()])
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._is_need_payable = True
|
||||||
|
|
||||||
|
class Collector(LineItemCollector[CashDisbursementJournalEntryForm]):
|
||||||
|
"""The line item collector for the cash disbursement journal
|
||||||
|
entries."""
|
||||||
|
|
||||||
|
def collect(self) -> None:
|
||||||
|
currencies: list[CashDisbursementCurrencyForm] \
|
||||||
|
= [x.form for x in self.form.currencies]
|
||||||
|
self._sort_currency_forms(currencies)
|
||||||
|
for currency in currencies:
|
||||||
|
# The debit forms
|
||||||
|
debit_forms: list[DebitLineItemForm] \
|
||||||
|
= [x.form for x in currency.debit]
|
||||||
|
self._sort_line_item_forms(debit_forms)
|
||||||
|
for debit_form in debit_forms:
|
||||||
|
self._add_line_item(debit_form, currency.code.data,
|
||||||
|
self._debit_no)
|
||||||
|
self._debit_no = self._debit_no + 1
|
||||||
|
|
||||||
|
# The credit forms
|
||||||
|
self._make_cash_line_item(list(currency.debit), False,
|
||||||
|
currency.code.data,
|
||||||
|
self._credit_no)
|
||||||
|
self._credit_no = self._credit_no + 1
|
||||||
|
|
||||||
|
self.collector = Collector
|
||||||
|
|
||||||
|
|
||||||
|
class TransferJournalEntryForm(JournalEntryForm):
|
||||||
|
"""The form to create or edit a transfer journal entry."""
|
||||||
|
date = DateField(
|
||||||
|
validators=[DATE_REQUIRED,
|
||||||
|
NotBeforeOriginalLineItems(),
|
||||||
|
NotAfterOffsetItems()])
|
||||||
|
"""The date."""
|
||||||
|
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
|
||||||
|
validators=[NeedSomeCurrencies()])
|
||||||
|
"""The line items categorized by their currencies."""
|
||||||
|
note = TextAreaField(filters=[strip_multiline_text])
|
||||||
|
"""The note."""
|
||||||
|
whole_form = BooleanField(
|
||||||
|
validators=[CannotDeleteOriginalLineItemsWithOffset()])
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._is_need_payable = True
|
||||||
|
self._is_need_receivable = True
|
||||||
|
|
||||||
|
class Collector(LineItemCollector[TransferJournalEntryForm]):
|
||||||
|
"""The line item collector for the transfer journal entries."""
|
||||||
|
|
||||||
|
def collect(self) -> None:
|
||||||
|
currencies: list[TransferCurrencyForm] \
|
||||||
|
= [x.form for x in self.form.currencies]
|
||||||
|
self._sort_currency_forms(currencies)
|
||||||
|
for currency in currencies:
|
||||||
|
# The debit forms
|
||||||
|
debit_forms: list[DebitLineItemForm] \
|
||||||
|
= [x.form for x in currency.debit]
|
||||||
|
self._sort_line_item_forms(debit_forms)
|
||||||
|
for debit_form in debit_forms:
|
||||||
|
self._add_line_item(debit_form, currency.code.data,
|
||||||
|
self._debit_no)
|
||||||
|
self._debit_no = self._debit_no + 1
|
||||||
|
|
||||||
|
# The credit forms
|
||||||
|
credit_forms: list[CreditLineItemForm] \
|
||||||
|
= [x.form for x in currency.credit]
|
||||||
|
self._sort_line_item_forms(credit_forms)
|
||||||
|
for credit_form in credit_forms:
|
||||||
|
self._add_line_item(credit_form, currency.code.data,
|
||||||
|
self._credit_no)
|
||||||
|
self._credit_no = self._credit_no + 1
|
||||||
|
|
||||||
|
self.collector = Collector
|
499
src/accounting/journal_entry/forms/line_item.py
Normal file
499
src/accounting/journal_entry/forms/line_item.py
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# 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 line item sub-forms for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask_babel import LazyString
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from wtforms import StringField, ValidationError, DecimalField, IntegerField
|
||||||
|
from wtforms.validators import Optional
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
|
||||||
|
IsCreditAccount
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Account, JournalEntryLineItem
|
||||||
|
from accounting.template_filters import format_amount
|
||||||
|
from accounting.utils.cast import be
|
||||||
|
from accounting.utils.random_id import new_id
|
||||||
|
from accounting.utils.strip_text import strip_text
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
|
||||||
|
|
||||||
|
class OriginalLineItemExists:
|
||||||
|
"""The validator to check if the original line item exists."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if db.session.get(JournalEntryLineItem, field.data) is None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The original line item does not exist."))
|
||||||
|
|
||||||
|
|
||||||
|
class OriginalLineItemOppositeDebitCredit:
|
||||||
|
"""The validator to check if the original line item is on the opposite
|
||||||
|
debit or credit."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
original_line_item: JournalEntryLineItem | None \
|
||||||
|
= db.session.get(JournalEntryLineItem, field.data)
|
||||||
|
if original_line_item is None:
|
||||||
|
return
|
||||||
|
if isinstance(form, CreditLineItemForm) \
|
||||||
|
and original_line_item.is_debit:
|
||||||
|
return
|
||||||
|
if isinstance(form, DebitLineItemForm) \
|
||||||
|
and not original_line_item.is_debit:
|
||||||
|
return
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The original line item is on the same debit or credit."))
|
||||||
|
|
||||||
|
|
||||||
|
class OriginalLineItemNeedOffset:
|
||||||
|
"""The validator to check if the original line item needs offset."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
original_line_item: JournalEntryLineItem | None \
|
||||||
|
= db.session.get(JournalEntryLineItem, field.data)
|
||||||
|
if original_line_item is None:
|
||||||
|
return
|
||||||
|
if not original_line_item.account.is_need_offset:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The original line item does not need offset."))
|
||||||
|
|
||||||
|
|
||||||
|
class OriginalLineItemNotOffset:
|
||||||
|
"""The validator to check if the original line item is not itself an
|
||||||
|
offset item."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
original_line_item: JournalEntryLineItem | None \
|
||||||
|
= db.session.get(JournalEntryLineItem, field.data)
|
||||||
|
if original_line_item is None:
|
||||||
|
return
|
||||||
|
if original_line_item.original_line_item_id is not None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The original line item cannot be an offset item."))
|
||||||
|
|
||||||
|
|
||||||
|
class SameAccountAsOriginalLineItem:
|
||||||
|
"""The validator to check if the account is the same as the
|
||||||
|
original line item."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, LineItemForm)
|
||||||
|
if field.data is None or form.original_line_item_id.data is None:
|
||||||
|
return
|
||||||
|
original_line_item: JournalEntryLineItem | None \
|
||||||
|
= db.session.get(JournalEntryLineItem,
|
||||||
|
form.original_line_item_id.data)
|
||||||
|
if original_line_item is None:
|
||||||
|
return
|
||||||
|
if field.data != original_line_item.account_code:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The account must be the same as the original line item."))
|
||||||
|
|
||||||
|
|
||||||
|
class KeepAccountWhenHavingOffset:
|
||||||
|
"""The validator to check if the account is the same when having offset."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, LineItemForm)
|
||||||
|
if field.data is None or form.id.data is None:
|
||||||
|
return
|
||||||
|
line_item: JournalEntryLineItem | None = db.session\
|
||||||
|
.query(JournalEntryLineItem)\
|
||||||
|
.filter(JournalEntryLineItem.id == form.id.data)\
|
||||||
|
.options(selectinload(JournalEntryLineItem.offsets)).first()
|
||||||
|
if line_item is None or len(line_item.offsets) == 0:
|
||||||
|
return
|
||||||
|
if field.data != line_item.account_code:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The account must not be changed when there is offset."))
|
||||||
|
|
||||||
|
|
||||||
|
class NotStartPayableFromDebit:
|
||||||
|
"""The validator to check that a payable line item does not start from
|
||||||
|
debit."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, DebitLineItemForm)
|
||||||
|
if field.data is None \
|
||||||
|
or field.data[0] != "2" \
|
||||||
|
or form.original_line_item_id.data is not None:
|
||||||
|
return
|
||||||
|
account: Account | None = Account.find_by_code(field.data)
|
||||||
|
if account is not None and account.is_need_offset:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"A payable line item cannot start from debit."))
|
||||||
|
|
||||||
|
|
||||||
|
class NotStartReceivableFromCredit:
|
||||||
|
"""The validator to check that a receivable line item does not start
|
||||||
|
from credit."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, CreditLineItemForm)
|
||||||
|
if field.data is None \
|
||||||
|
or field.data[0] != "1" \
|
||||||
|
or form.original_line_item_id.data is not None:
|
||||||
|
return
|
||||||
|
account: Account | None = Account.find_by_code(field.data)
|
||||||
|
if account is not None and account.is_need_offset:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"A receivable line item cannot start from credit."))
|
||||||
|
|
||||||
|
|
||||||
|
class PositiveAmount:
|
||||||
|
"""The validator to check if the amount is positive."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if field.data <= 0:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"Please fill in a positive amount."))
|
||||||
|
|
||||||
|
|
||||||
|
class NotExceedingOriginalLineItemNetBalance:
|
||||||
|
"""The validator to check if the amount exceeds the net balance of the
|
||||||
|
original line item."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
||||||
|
assert isinstance(form, LineItemForm)
|
||||||
|
if field.data is None or form.original_line_item_id.data is None:
|
||||||
|
return
|
||||||
|
original_line_item: JournalEntryLineItem | None \
|
||||||
|
= db.session.get(JournalEntryLineItem,
|
||||||
|
form.original_line_item_id.data)
|
||||||
|
if original_line_item is None:
|
||||||
|
return
|
||||||
|
is_debit: bool = isinstance(form, DebitLineItemForm)
|
||||||
|
existing_line_item_id: set[int] = set()
|
||||||
|
if form.journal_entry_form.obj is not None:
|
||||||
|
existing_line_item_id \
|
||||||
|
= {x.id for x in form.journal_entry_form.obj.line_items}
|
||||||
|
offset_total_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(be(JournalEntryLineItem.is_debit == is_debit),
|
||||||
|
JournalEntryLineItem.amount),
|
||||||
|
else_=-JournalEntryLineItem.amount))
|
||||||
|
offset_total_but_form: Decimal | None = db.session.scalar(
|
||||||
|
sa.select(offset_total_func)
|
||||||
|
.filter(be(JournalEntryLineItem.original_line_item_id
|
||||||
|
== original_line_item.id),
|
||||||
|
JournalEntryLineItem.id.not_in(existing_line_item_id)))
|
||||||
|
if offset_total_but_form is None:
|
||||||
|
offset_total_but_form = Decimal("0")
|
||||||
|
offset_total_on_form: Decimal = sum(
|
||||||
|
[x.amount.data for x in form.journal_entry_form.line_items
|
||||||
|
if x.original_line_item_id.data == original_line_item.id
|
||||||
|
and x.amount != field and x.amount.data is not None])
|
||||||
|
net_balance: Decimal = original_line_item.amount \
|
||||||
|
- offset_total_but_form - offset_total_on_form
|
||||||
|
if field.data > net_balance:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The amount must not exceed the net balance %(balance)s of the"
|
||||||
|
" original line item.", balance=format_amount(net_balance)))
|
||||||
|
|
||||||
|
|
||||||
|
class NotLessThanOffsetTotal:
|
||||||
|
"""The validator to check if the amount is less than the offset total."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
||||||
|
assert isinstance(form, LineItemForm)
|
||||||
|
if field.data is None or form.id.data is None:
|
||||||
|
return
|
||||||
|
is_debit: bool = isinstance(form, DebitLineItemForm)
|
||||||
|
select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
|
||||||
|
(JournalEntryLineItem.is_debit != is_debit,
|
||||||
|
JournalEntryLineItem.amount),
|
||||||
|
else_=-JournalEntryLineItem.amount)))\
|
||||||
|
.filter(be(JournalEntryLineItem.original_line_item_id
|
||||||
|
== form.id.data))
|
||||||
|
offset_total: Decimal | None = db.session.scalar(select_offset_total)
|
||||||
|
if offset_total is not None and field.data < offset_total:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The amount must not be less than the offset total %(total)s.",
|
||||||
|
total=format_amount(offset_total)))
|
||||||
|
|
||||||
|
|
||||||
|
class LineItemForm(FlaskForm):
|
||||||
|
"""The base form to create or edit a line item."""
|
||||||
|
id = IntegerField()
|
||||||
|
"""The existing line item ID."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the currency."""
|
||||||
|
original_line_item_id = IntegerField()
|
||||||
|
"""The Id of the original line item."""
|
||||||
|
account_code = StringField()
|
||||||
|
"""The account code."""
|
||||||
|
description = StringField()
|
||||||
|
"""The description."""
|
||||||
|
amount = DecimalField()
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Constructs a base line item form.
|
||||||
|
|
||||||
|
:param args: The arguments.
|
||||||
|
:param kwargs: The keyword arguments.
|
||||||
|
"""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
from .journal_entry import JournalEntryForm
|
||||||
|
self.journal_entry_form: JournalEntryForm | None = None
|
||||||
|
"""The source journal entry form."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_text(self) -> str:
|
||||||
|
"""Returns the text representation of the account.
|
||||||
|
|
||||||
|
:return: The text representation of the account.
|
||||||
|
"""
|
||||||
|
if self.account_code.data is None:
|
||||||
|
return ""
|
||||||
|
account: Account | None = Account.find_by_code(self.account_code.data)
|
||||||
|
if account is None:
|
||||||
|
return ""
|
||||||
|
return str(account)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __original_line_item(self) -> JournalEntryLineItem | None:
|
||||||
|
"""Returns the original line item.
|
||||||
|
|
||||||
|
:return: The original line item.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "____original_line_item"):
|
||||||
|
def get_line_item() -> JournalEntryLineItem | None:
|
||||||
|
if self.original_line_item_id.data is None:
|
||||||
|
return None
|
||||||
|
return db.session.get(JournalEntryLineItem,
|
||||||
|
self.original_line_item_id.data)
|
||||||
|
setattr(self, "____original_line_item", get_line_item())
|
||||||
|
return getattr(self, "____original_line_item")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def original_line_item_date(self) -> date | None:
|
||||||
|
"""Returns the text representation of the original line item.
|
||||||
|
|
||||||
|
:return: The text representation of the original line item.
|
||||||
|
"""
|
||||||
|
return None if self.__original_line_item is None \
|
||||||
|
else self.__original_line_item.journal_entry.date
|
||||||
|
|
||||||
|
@property
|
||||||
|
def original_line_item_text(self) -> str | None:
|
||||||
|
"""Returns the text representation of the original line item.
|
||||||
|
|
||||||
|
:return: The text representation of the original line item.
|
||||||
|
"""
|
||||||
|
return None if self.__original_line_item is None \
|
||||||
|
else str(self.__original_line_item)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_need_offset(self) -> bool:
|
||||||
|
"""Returns whether the line item needs offset.
|
||||||
|
|
||||||
|
:return: True if the line item needs offset, or False otherwise.
|
||||||
|
"""
|
||||||
|
if self.account_code.data is None:
|
||||||
|
return False
|
||||||
|
if self.account_code.data[0] == "1":
|
||||||
|
if isinstance(self, CreditLineItemForm):
|
||||||
|
return False
|
||||||
|
elif self.account_code.data[0] == "2":
|
||||||
|
if isinstance(self, DebitLineItemForm):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
account: Account | None = Account.find_by_code(self.account_code.data)
|
||||||
|
return account is not None and account.is_need_offset
|
||||||
|
|
||||||
|
@property
|
||||||
|
def offsets(self) -> list[JournalEntryLineItem]:
|
||||||
|
"""Returns the offsets.
|
||||||
|
|
||||||
|
:return: The offsets.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__offsets"):
|
||||||
|
def get_offsets() -> list[JournalEntryLineItem]:
|
||||||
|
if not self.is_need_offset or self.id.data is None:
|
||||||
|
return []
|
||||||
|
return JournalEntryLineItem.query\
|
||||||
|
.filter(JournalEntryLineItem.original_line_item_id
|
||||||
|
== self.id.data)\
|
||||||
|
.options(selectinload(JournalEntryLineItem.journal_entry),
|
||||||
|
selectinload(JournalEntryLineItem.account),
|
||||||
|
selectinload(JournalEntryLineItem.offsets)
|
||||||
|
.selectinload(
|
||||||
|
JournalEntryLineItem.journal_entry)).all()
|
||||||
|
setattr(self, "__offsets", get_offsets())
|
||||||
|
return getattr(self, "__offsets")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def offset_total(self) -> Decimal | None:
|
||||||
|
"""Returns the total amount of the offsets.
|
||||||
|
|
||||||
|
:return: The total amount of the offsets.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__offset_total"):
|
||||||
|
def get_offset_total():
|
||||||
|
if not self.is_need_offset or self.id.data is None:
|
||||||
|
return None
|
||||||
|
is_debit: bool = isinstance(self, DebitLineItemForm)
|
||||||
|
return sum([x.amount if x.is_debit != is_debit else -x.amount
|
||||||
|
for x in self.offsets])
|
||||||
|
setattr(self, "__offset_total", get_offset_total())
|
||||||
|
return getattr(self, "__offset_total")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def net_balance(self) -> Decimal | None:
|
||||||
|
"""Returns the net balance.
|
||||||
|
|
||||||
|
:return: The net balance.
|
||||||
|
"""
|
||||||
|
if not self.is_need_offset or self.id.data is None \
|
||||||
|
or self.amount.data is None:
|
||||||
|
return None
|
||||||
|
return self.amount.data - self.offset_total
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns all the errors of the form.
|
||||||
|
|
||||||
|
:return: All the errors of the form.
|
||||||
|
"""
|
||||||
|
all_errors: list[str | LazyString] = []
|
||||||
|
for key in self.errors:
|
||||||
|
if key != "csrf_token":
|
||||||
|
all_errors.extend(self.errors[key])
|
||||||
|
return all_errors
|
||||||
|
|
||||||
|
|
||||||
|
class DebitLineItemForm(LineItemForm):
|
||||||
|
"""The form to create or edit a debit line item."""
|
||||||
|
id = IntegerField()
|
||||||
|
"""The existing line item ID."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the currency."""
|
||||||
|
original_line_item_id = IntegerField(
|
||||||
|
validators=[Optional(),
|
||||||
|
OriginalLineItemExists(),
|
||||||
|
OriginalLineItemOppositeDebitCredit(),
|
||||||
|
OriginalLineItemNeedOffset(),
|
||||||
|
OriginalLineItemNotOffset()])
|
||||||
|
"""The ID of the original line item."""
|
||||||
|
account_code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[
|
||||||
|
ACCOUNT_REQUIRED,
|
||||||
|
AccountExists(),
|
||||||
|
IsDebitAccount(lazy_gettext(
|
||||||
|
"This account is not for debit line items.")),
|
||||||
|
SameAccountAsOriginalLineItem(),
|
||||||
|
KeepAccountWhenHavingOffset(),
|
||||||
|
NotStartPayableFromDebit()])
|
||||||
|
"""The account code."""
|
||||||
|
description = StringField(filters=[strip_text])
|
||||||
|
"""The description."""
|
||||||
|
amount = DecimalField(
|
||||||
|
validators=[PositiveAmount(),
|
||||||
|
NotExceedingOriginalLineItemNetBalance(),
|
||||||
|
NotLessThanOffsetTotal()])
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
def populate_obj(self, obj: JournalEntryLineItem) -> None:
|
||||||
|
"""Populates the form data into a line item object.
|
||||||
|
|
||||||
|
:param obj: The line item object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
is_new: bool = obj.id is None
|
||||||
|
if is_new:
|
||||||
|
obj.id = new_id(JournalEntryLineItem)
|
||||||
|
obj.original_line_item_id = self.original_line_item_id.data
|
||||||
|
obj.account_id = Account.find_by_code(self.account_code.data).id
|
||||||
|
obj.description = self.description.data
|
||||||
|
obj.is_debit = True
|
||||||
|
obj.amount = self.amount.data
|
||||||
|
if is_new:
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.created_by_id = current_user_pk
|
||||||
|
obj.updated_by_id = current_user_pk
|
||||||
|
|
||||||
|
|
||||||
|
class CreditLineItemForm(LineItemForm):
|
||||||
|
"""The form to create or edit a credit line item."""
|
||||||
|
id = IntegerField()
|
||||||
|
"""The existing line item ID."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the currency."""
|
||||||
|
original_line_item_id = IntegerField(
|
||||||
|
validators=[Optional(),
|
||||||
|
OriginalLineItemExists(),
|
||||||
|
OriginalLineItemOppositeDebitCredit(),
|
||||||
|
OriginalLineItemNeedOffset(),
|
||||||
|
OriginalLineItemNotOffset()])
|
||||||
|
"""The ID of the original line item."""
|
||||||
|
account_code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[
|
||||||
|
ACCOUNT_REQUIRED,
|
||||||
|
AccountExists(),
|
||||||
|
IsCreditAccount(lazy_gettext(
|
||||||
|
"This account is not for credit line items.")),
|
||||||
|
SameAccountAsOriginalLineItem(),
|
||||||
|
KeepAccountWhenHavingOffset(),
|
||||||
|
NotStartReceivableFromCredit()])
|
||||||
|
"""The account code."""
|
||||||
|
description = StringField(filters=[strip_text])
|
||||||
|
"""The description."""
|
||||||
|
amount = DecimalField(
|
||||||
|
validators=[PositiveAmount(),
|
||||||
|
NotExceedingOriginalLineItemNetBalance(),
|
||||||
|
NotLessThanOffsetTotal()])
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
def populate_obj(self, obj: JournalEntryLineItem) -> None:
|
||||||
|
"""Populates the form data into a line item object.
|
||||||
|
|
||||||
|
:param obj: The line item object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
is_new: bool = obj.id is None
|
||||||
|
if is_new:
|
||||||
|
obj.id = new_id(JournalEntryLineItem)
|
||||||
|
obj.original_line_item_id = self.original_line_item_id.data
|
||||||
|
obj.account_id = Account.find_by_code(self.account_code.data).id
|
||||||
|
obj.description = self.description.data
|
||||||
|
obj.is_debit = False
|
||||||
|
obj.amount = self.amount.data
|
||||||
|
if is_new:
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.created_by_id = current_user_pk
|
||||||
|
obj.updated_by_id = current_user_pk
|
95
src/accounting/journal_entry/forms/reorder.py
Normal file
95
src/accounting/journal_entry/forms/reorder.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# 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 reorder forms for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import JournalEntry
|
||||||
|
|
||||||
|
|
||||||
|
def sort_journal_entries_in(journal_entry_date: date,
|
||||||
|
exclude: int | None = None) -> None:
|
||||||
|
"""Sorts the journal entries under a date after changing the date or
|
||||||
|
deleting a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry_date: The date of the journal entry.
|
||||||
|
:param exclude: The journal entry ID to exclude.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.date == journal_entry_date]
|
||||||
|
if exclude is not None:
|
||||||
|
conditions.append(JournalEntry.id != exclude)
|
||||||
|
journal_entries: list[JournalEntry] = JournalEntry.query\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.order_by(JournalEntry.no).all()
|
||||||
|
for i in range(len(journal_entries)):
|
||||||
|
if journal_entries[i].no != i + 1:
|
||||||
|
journal_entries[i].no = i + 1
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryReorderForm:
|
||||||
|
"""The form to reorder the journal entries."""
|
||||||
|
|
||||||
|
def __init__(self, journal_entry_date: date):
|
||||||
|
"""Constructs the form to reorder the journal entries in a day.
|
||||||
|
|
||||||
|
:param journal_entry_date: The date.
|
||||||
|
"""
|
||||||
|
self.date: date = journal_entry_date
|
||||||
|
self.is_modified: bool = False
|
||||||
|
|
||||||
|
def save_order(self) -> None:
|
||||||
|
"""Saves the order of the account.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
journal_entries: list[JournalEntry] = JournalEntry.query\
|
||||||
|
.filter(JournalEntry.date == self.date).all()
|
||||||
|
|
||||||
|
# Collects the specified order.
|
||||||
|
orders: dict[JournalEntry, int] = {}
|
||||||
|
for journal_entry in journal_entries:
|
||||||
|
if f"{journal_entry.id}-no" in request.form:
|
||||||
|
try:
|
||||||
|
orders[journal_entry] \
|
||||||
|
= int(request.form[f"{journal_entry.id}-no"])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Missing and invalid orders are appended to the end.
|
||||||
|
missing: list[JournalEntry] \
|
||||||
|
= [x for x in journal_entries if x not in orders]
|
||||||
|
if len(missing) > 0:
|
||||||
|
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
|
||||||
|
for journal_entry in missing:
|
||||||
|
orders[journal_entry] = next_no
|
||||||
|
|
||||||
|
# Sort by the specified order first, and their original order.
|
||||||
|
journal_entries.sort(key=lambda x: (orders[x], x.no))
|
||||||
|
|
||||||
|
# Update the orders.
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
for i in range(len(journal_entries)):
|
||||||
|
if journal_entries[i].no != i + 1:
|
||||||
|
journal_entries[i].no = i + 1
|
||||||
|
self.is_modified = True
|
82
src/accounting/journal_entry/template_filters.py
Normal file
82
src/accounting/journal_entry/template_filters.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
|
||||||
|
|
||||||
|
# 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 journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
from html import escape
|
||||||
|
from urllib.parse import ParseResult, urlparse, parse_qsl, urlencode, \
|
||||||
|
urlunparse
|
||||||
|
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
|
||||||
|
def with_type(uri: str) -> str:
|
||||||
|
"""Adds the journal entry type to the URI, if it is specified.
|
||||||
|
|
||||||
|
:param uri: The URI.
|
||||||
|
:return: The result URL, optionally with the journal entry 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] != "as"]
|
||||||
|
params.append(("as", request.args["as"]))
|
||||||
|
parts: list[str] = list(uri_p)
|
||||||
|
parts[4] = urlencode(params)
|
||||||
|
return urlunparse(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def to_transfer(uri: str) -> str:
|
||||||
|
"""Adds the transfer journal entry type to the URI.
|
||||||
|
|
||||||
|
:param uri: The URI.
|
||||||
|
:return: The result URL, with the transfer journal entry type added.
|
||||||
|
"""
|
||||||
|
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", "transfer"))
|
||||||
|
parts: list[str] = list(uri_p)
|
||||||
|
parts[4] = urlencode(params)
|
||||||
|
return urlunparse(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def format_amount_input(value: Decimal | None) -> str:
|
||||||
|
"""Format an amount for an input value.
|
||||||
|
|
||||||
|
:param value: The amount.
|
||||||
|
:return: The formatted amount text for an input value.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
whole: int = int(value)
|
||||||
|
frac: Decimal = (value - whole).normalize()
|
||||||
|
return str(whole) + str(frac)[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def text2html(value: str) -> str:
|
||||||
|
"""Converts plain text into HTML.
|
||||||
|
|
||||||
|
:param value: The plain text.
|
||||||
|
:return: The HTML.
|
||||||
|
"""
|
||||||
|
s: str = escape(value)
|
||||||
|
s = s.replace("\n", "<br>")
|
||||||
|
s = s.replace(" ", " ")
|
||||||
|
return s
|
19
src/accounting/journal_entry/utils/__init__.py
Normal file
19
src/accounting/journal_entry/utils/__init__.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# 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 journal entry management.
|
||||||
|
|
||||||
|
"""
|
49
src/accounting/journal_entry/utils/account_option.py
Normal file
49
src/accounting/journal_entry/utils/account_option.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# 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 account option for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from accounting.models import Account
|
||||||
|
|
||||||
|
|
||||||
|
class AccountOption:
|
||||||
|
"""An account option."""
|
||||||
|
|
||||||
|
def __init__(self, account: Account):
|
||||||
|
"""Constructs an account option.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
"""
|
||||||
|
self.id: str = account.id
|
||||||
|
"""The account ID."""
|
||||||
|
self.code: str = account.code
|
||||||
|
"""The account code."""
|
||||||
|
self.query_values: list[str] = account.query_values
|
||||||
|
"""The values to be queried."""
|
||||||
|
self.__str: str = str(account)
|
||||||
|
"""The string representation of the account option."""
|
||||||
|
self.is_in_use: bool = False
|
||||||
|
"""True if this account is in use, or False otherwise."""
|
||||||
|
self.is_need_offset: bool = account.is_need_offset
|
||||||
|
"""True if this account needs offset, or False otherwise."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the account option.
|
||||||
|
|
||||||
|
:return: The string representation of the account option.
|
||||||
|
"""
|
||||||
|
return self.__str
|
346
src/accounting/journal_entry/utils/description_editor.py
Normal file
346
src/accounting/journal_entry/utils/description_editor.py
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
|
||||||
|
|
||||||
|
# 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 description editor.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import typing as t
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import Account, JournalEntryLineItem
|
||||||
|
from accounting.utils.options import options, Recurring
|
||||||
|
|
||||||
|
|
||||||
|
class DescriptionAccount:
|
||||||
|
"""An account for a description tag."""
|
||||||
|
|
||||||
|
def __init__(self, account: Account, freq: int):
|
||||||
|
"""Constructs an account for a description tag.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:param freq: The frequency of the tag with the account.
|
||||||
|
"""
|
||||||
|
self.__account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.id: int = account.id
|
||||||
|
"""The account ID."""
|
||||||
|
self.code: str = account.code
|
||||||
|
"""The account code."""
|
||||||
|
self.is_need_offset: bool = account.is_need_offset
|
||||||
|
"""Whether the journal entry line items of this account need offset."""
|
||||||
|
self.freq: int = freq
|
||||||
|
"""The frequency of the tag with the account."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the account.
|
||||||
|
|
||||||
|
:return: The string representation of the account.
|
||||||
|
"""
|
||||||
|
return str(self.__account)
|
||||||
|
|
||||||
|
def add_freq(self, freq: int) -> None:
|
||||||
|
"""Adds the frequency of an account.
|
||||||
|
|
||||||
|
:param freq: The frequency of the tag name with the account.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.freq = self.freq + freq
|
||||||
|
|
||||||
|
|
||||||
|
class DescriptionTag:
|
||||||
|
"""A description tag."""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
"""Constructs a description tag.
|
||||||
|
|
||||||
|
:param name: The tag name.
|
||||||
|
"""
|
||||||
|
self.name: str = name
|
||||||
|
"""The tag name."""
|
||||||
|
self.__account_dict: dict[int, DescriptionAccount] = {}
|
||||||
|
"""The accounts that come with the tag, in the order of their
|
||||||
|
frequency."""
|
||||||
|
self.freq: int = 0
|
||||||
|
"""The frequency of the tag."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the tag.
|
||||||
|
|
||||||
|
:return: The string representation of the tag.
|
||||||
|
"""
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def add_account(self, account: Account, freq: int):
|
||||||
|
"""Adds an account.
|
||||||
|
|
||||||
|
:param account: The associated account.
|
||||||
|
:param freq: The frequency of the tag name with the account.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__account_dict[account.id] = DescriptionAccount(account, freq)
|
||||||
|
self.freq = self.freq + freq
|
||||||
|
|
||||||
|
@property
|
||||||
|
def accounts(self) -> list[DescriptionAccount]:
|
||||||
|
"""Returns the accounts by the order of their frequencies.
|
||||||
|
|
||||||
|
:return: The accounts by the order of their frequencies.
|
||||||
|
"""
|
||||||
|
return sorted(self.__account_dict.values(), key=lambda x: -x.freq)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_codes(self) -> list[str]:
|
||||||
|
"""Returns the account codes by the order of their frequencies.
|
||||||
|
|
||||||
|
:return: The account codes by the order of their frequencies.
|
||||||
|
"""
|
||||||
|
return [x.code for x in self.accounts]
|
||||||
|
|
||||||
|
|
||||||
|
class DescriptionType:
|
||||||
|
"""A description type"""
|
||||||
|
|
||||||
|
def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
|
||||||
|
"""Constructs a description type.
|
||||||
|
|
||||||
|
:param type_id: The type ID, either "general", "travel", or "bus".
|
||||||
|
"""
|
||||||
|
self.id: t.Literal["general", "travel", "bus"] = type_id
|
||||||
|
"""The type ID."""
|
||||||
|
self.__tag_dict: dict[str, DescriptionTag] = {}
|
||||||
|
"""A dictionary from the tag name to their corresponding tag."""
|
||||||
|
|
||||||
|
def add_tag(self, name: str, account: Account, freq: int) -> None:
|
||||||
|
"""Adds a tag.
|
||||||
|
|
||||||
|
:param name: The tag name.
|
||||||
|
:param account: The associated account.
|
||||||
|
:param freq: The frequency of the tag name with the account.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if name not in self.__tag_dict:
|
||||||
|
self.__tag_dict[name] = DescriptionTag(name)
|
||||||
|
self.__tag_dict[name].add_account(account, freq)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tags(self) -> list[DescriptionTag]:
|
||||||
|
"""Returns the tags by the order of their frequencies.
|
||||||
|
|
||||||
|
:return: The tags by the order of their frequencies.
|
||||||
|
"""
|
||||||
|
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
|
||||||
|
|
||||||
|
|
||||||
|
class DescriptionRecurring:
|
||||||
|
"""A recurring transaction."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, account: Account, description_template: str):
|
||||||
|
"""Constructs a recurring transaction.
|
||||||
|
|
||||||
|
:param name: The name.
|
||||||
|
:param description_template: The description template.
|
||||||
|
:param account: The account.
|
||||||
|
"""
|
||||||
|
self.name: str = name
|
||||||
|
self.account: DescriptionAccount = DescriptionAccount(account, 0)
|
||||||
|
self.description_template: str = description_template
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_codes(self) -> list[str]:
|
||||||
|
"""Returns the account codes by the order of their frequencies.
|
||||||
|
|
||||||
|
:return: The account codes by the order of their frequencies.
|
||||||
|
"""
|
||||||
|
return [self.account.code]
|
||||||
|
|
||||||
|
|
||||||
|
class DescriptionDebitCredit:
|
||||||
|
"""The description on debit or credit."""
|
||||||
|
|
||||||
|
def __init__(self, debit_credit: t.Literal["debit", "credit"]):
|
||||||
|
"""Constructs the description on debit or credit.
|
||||||
|
|
||||||
|
:param debit_credit: Either "debit" or "credit".
|
||||||
|
"""
|
||||||
|
self.debit_credit: t.Literal["debit", "credit"] = debit_credit
|
||||||
|
"""Either debit or credit."""
|
||||||
|
self.general: DescriptionType = DescriptionType("general")
|
||||||
|
"""The general tags."""
|
||||||
|
self.travel: DescriptionType = DescriptionType("travel")
|
||||||
|
"""The travel tags."""
|
||||||
|
self.bus: DescriptionType = DescriptionType("bus")
|
||||||
|
"""The bus tags."""
|
||||||
|
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
|
||||||
|
DescriptionType] \
|
||||||
|
= {x.id: x for x in {self.general, self.travel, self.bus}}
|
||||||
|
"""A dictionary from the type ID to the corresponding tags."""
|
||||||
|
self.recurring: list[DescriptionRecurring] = []
|
||||||
|
"""The recurring transactions."""
|
||||||
|
|
||||||
|
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
|
||||||
|
name: str, account: Account, freq: int) -> None:
|
||||||
|
"""Adds a tag.
|
||||||
|
|
||||||
|
:param tag_type: The tag type, either "general", "travel", or "bus".
|
||||||
|
:param name: The name.
|
||||||
|
:param account: The associated account.
|
||||||
|
:param freq: The frequency of the tag name with the account.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__type_dict[tag_type].add_tag(name, account, freq)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def accounts(self) -> list[DescriptionAccount]:
|
||||||
|
"""Returns the suggested accounts of all tags in the description editor
|
||||||
|
in debit or credit, in their frequency order.
|
||||||
|
|
||||||
|
:return: The suggested accounts of all tags, in their frequency order.
|
||||||
|
"""
|
||||||
|
accounts: dict[int, DescriptionAccount] = {}
|
||||||
|
freq: dict[int, int] = {}
|
||||||
|
for tag_type in self.__type_dict.values():
|
||||||
|
for tag in tag_type.tags:
|
||||||
|
for account in tag.accounts:
|
||||||
|
accounts[account.id] = account
|
||||||
|
if account.id not in freq:
|
||||||
|
freq[account.id] = 0
|
||||||
|
freq[account.id] \
|
||||||
|
= freq[account.id] + account.freq
|
||||||
|
for recurring in self.recurring:
|
||||||
|
accounts[recurring.account.id] = recurring.account
|
||||||
|
if recurring.account.id not in freq:
|
||||||
|
freq[recurring.account.id] = 0
|
||||||
|
return [accounts[y] for y in sorted(freq.keys(),
|
||||||
|
key=lambda x: -freq[x])]
|
||||||
|
|
||||||
|
|
||||||
|
class DescriptionEditor:
|
||||||
|
"""The description editor."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Constructs the description editor."""
|
||||||
|
self.debit: DescriptionDebitCredit = DescriptionDebitCredit("debit")
|
||||||
|
"""The debit tags."""
|
||||||
|
self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit")
|
||||||
|
"""The credit tags."""
|
||||||
|
self.__init_tags()
|
||||||
|
self.__init_recurring()
|
||||||
|
|
||||||
|
def __init_tags(self):
|
||||||
|
"""Initializes the tags.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
debit_credit: sa.Label = sa.case(
|
||||||
|
(JournalEntryLineItem.is_debit, "debit"),
|
||||||
|
else_="credit").label("debit_credit")
|
||||||
|
tag_type: sa.Label = sa.case(
|
||||||
|
(JournalEntryLineItem.description.like("_%—_%—_%→_%"), "bus"),
|
||||||
|
(sa.or_(JournalEntryLineItem.description.like("_%—_%→_%"),
|
||||||
|
JournalEntryLineItem.description.like("_%—_%↔_%")),
|
||||||
|
"travel"),
|
||||||
|
else_="general").label("tag_type")
|
||||||
|
tag: sa.Label = get_prefix(JournalEntryLineItem.description, "—")\
|
||||||
|
.label("tag")
|
||||||
|
select: sa.Select = sa.Select(debit_credit, tag_type, tag,
|
||||||
|
JournalEntryLineItem.account_id,
|
||||||
|
sa.func.count().label("freq"))\
|
||||||
|
.filter(JournalEntryLineItem.description.is_not(None),
|
||||||
|
JournalEntryLineItem.description.like("_%—_%"),
|
||||||
|
JournalEntryLineItem.original_line_item_id.is_(None))\
|
||||||
|
.group_by(debit_credit, tag_type, tag,
|
||||||
|
JournalEntryLineItem.account_id)
|
||||||
|
result: list[sa.Row] = db.session.execute(select).all()
|
||||||
|
accounts: dict[int, Account] \
|
||||||
|
= {x.id: x for x in Account.query
|
||||||
|
.filter(Account.id.in_({x.account_id for x in result})).all()}
|
||||||
|
debit_credit_dict: dict[t.Literal["debit", "credit"],
|
||||||
|
DescriptionDebitCredit] \
|
||||||
|
= {x.debit_credit: x for x in {self.debit, self.credit}}
|
||||||
|
for row in result:
|
||||||
|
debit_credit_dict[row.debit_credit].add_tag(
|
||||||
|
row.tag_type, row.tag, accounts[row.account_id], row.freq)
|
||||||
|
|
||||||
|
def __init_recurring(self) -> None:
|
||||||
|
"""Initializes the recurring transactions.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
recurring: Recurring = options.recurring
|
||||||
|
accounts: dict[str, Account] \
|
||||||
|
= self.__get_accounts(recurring.codes)
|
||||||
|
self.debit.recurring \
|
||||||
|
= [DescriptionRecurring(x.name, accounts[x.account_code],
|
||||||
|
x.description_template)
|
||||||
|
for x in recurring.expenses]
|
||||||
|
self.credit.recurring \
|
||||||
|
= [DescriptionRecurring(x.name, accounts[x.account_code],
|
||||||
|
x.description_template)
|
||||||
|
for x in recurring.incomes]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_accounts(codes: set[str]) -> dict[str, Account]:
|
||||||
|
"""Finds and returns the accounts by codes.
|
||||||
|
|
||||||
|
:param codes: The account codes.
|
||||||
|
:return: The account.
|
||||||
|
"""
|
||||||
|
if len(codes) == 0:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_condition(code0: str) -> sa.BinaryExpression:
|
||||||
|
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
|
||||||
|
assert m is not None,\
|
||||||
|
f"Malformed account code \"{code0}\" for regular transactions."
|
||||||
|
return sa.and_(Account.base_code == m.group(1),
|
||||||
|
Account.no == int(m.group(2)))
|
||||||
|
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [get_condition(x) for x in codes]
|
||||||
|
accounts: dict[str, Account] \
|
||||||
|
= {x.code: x for x in
|
||||||
|
Account.query.filter(sa.or_(*conditions)).all()}
|
||||||
|
for code in codes:
|
||||||
|
assert code in accounts,\
|
||||||
|
f"Unknown account \"{code}\" for regular transactions."
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
|
||||||
|
-> sa.Function:
|
||||||
|
"""Returns the SQL function to find the prefix of a string.
|
||||||
|
|
||||||
|
:param string: The string.
|
||||||
|
:param separator: The separator.
|
||||||
|
:return: The position of the substring, starting from 1.
|
||||||
|
"""
|
||||||
|
return sa.func.substr(string, 0, get_position(string, separator))
|
||||||
|
|
||||||
|
|
||||||
|
def get_position(string: str | sa.Column, substring: str | sa.Column) \
|
||||||
|
-> sa.Function:
|
||||||
|
"""Returns the SQL function to find the position of a substring.
|
||||||
|
|
||||||
|
:param string: The string.
|
||||||
|
:param substring: The substring.
|
||||||
|
:return: The position of the substring, starting from 1.
|
||||||
|
"""
|
||||||
|
if db.engine.name == "postgresql":
|
||||||
|
return sa.func.strpos(string, substring)
|
||||||
|
return sa.func.instr(string, substring)
|
39
src/accounting/journal_entry/utils/offset_alias.py
Normal file
39
src/accounting/journal_entry/utils/offset_alias.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/15
|
||||||
|
|
||||||
|
# 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 SQLAlchemy alias for the offset items.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from accounting.models import JournalEntryLineItem
|
||||||
|
|
||||||
|
|
||||||
|
def offset_alias() -> sa.Alias:
|
||||||
|
"""Returns the SQLAlchemy alias for the offset items.
|
||||||
|
|
||||||
|
:return: The SQLAlchemy alias for the offset items.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def as_from(model_cls: t.Any) -> sa.FromClause:
|
||||||
|
return model_cls
|
||||||
|
|
||||||
|
def as_alias(alias: t.Any) -> sa.Alias:
|
||||||
|
return alias
|
||||||
|
|
||||||
|
return as_alias(sa.alias(as_from(JournalEntryLineItem), name="offset"))
|
336
src/accounting/journal_entry/utils/operators.py
Normal file
336
src/accounting/journal_entry/utils/operators.py
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
||||||
|
|
||||||
|
# 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 operators for different journal entry types.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from flask import render_template, request, abort
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
|
||||||
|
from accounting.models import JournalEntry
|
||||||
|
from accounting.template_globals import default_currency_code
|
||||||
|
from accounting.utils.journal_entry_types import JournalEntryType
|
||||||
|
from accounting.journal_entry.forms import JournalEntryForm, \
|
||||||
|
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
|
||||||
|
TransferJournalEntryForm
|
||||||
|
from accounting.journal_entry.forms.line_item import LineItemForm
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryOperator(ABC):
|
||||||
|
"""The base journal entry operator."""
|
||||||
|
CHECK_ORDER: int = -1
|
||||||
|
"""The order when checking the journal entry operator."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def form(self) -> t.Type[JournalEntryForm]:
|
||||||
|
"""Returns the form class.
|
||||||
|
|
||||||
|
:return: The form class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render_create_template(self, form: FlaskForm) -> str:
|
||||||
|
"""Renders the template for the form to create a journal entry.
|
||||||
|
|
||||||
|
:param form: The journal entry form.
|
||||||
|
:return: the form to create a journal entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render_detail_template(self, journal_entry: JournalEntry) -> str:
|
||||||
|
"""Renders the template for the detail page.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: the detail page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render_edit_template(self, journal_entry: JournalEntry,
|
||||||
|
form: FlaskForm) -> str:
|
||||||
|
"""Renders the template for the form to edit a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:param form: The form.
|
||||||
|
:return: the form to edit a journal entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_my_type(self, journal_entry: JournalEntry) -> bool:
|
||||||
|
"""Checks and returns whether the journal entry belongs to the type.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: True if the journal entry belongs to the type, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _line_item_template(self) -> str:
|
||||||
|
"""Renders and returns the template for the line item sub-form.
|
||||||
|
|
||||||
|
:return: The template for the line item sub-form.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/include/form-line-item.html",
|
||||||
|
currency_index="CURRENCY_INDEX",
|
||||||
|
debit_credit="DEBIT_CREDIT",
|
||||||
|
line_item_index="LINE_ITEM_INDEX",
|
||||||
|
form=LineItemForm())
|
||||||
|
|
||||||
|
|
||||||
|
class CashReceiptJournalEntry(JournalEntryOperator):
|
||||||
|
"""A cash receipt journal entry."""
|
||||||
|
CHECK_ORDER: int = 2
|
||||||
|
"""The order when checking the journal entry operator."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def form(self) -> t.Type[JournalEntryForm]:
|
||||||
|
"""Returns the form class.
|
||||||
|
|
||||||
|
:return: The form class.
|
||||||
|
"""
|
||||||
|
return CashReceiptJournalEntryForm
|
||||||
|
|
||||||
|
def render_create_template(self, form: CashReceiptJournalEntryForm) -> str:
|
||||||
|
"""Renders the template for the form to create a journal entry.
|
||||||
|
|
||||||
|
:param form: The journal entry form.
|
||||||
|
:return: the form to create a journal entry.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/receipt/create.html",
|
||||||
|
form=form,
|
||||||
|
journal_entry_type=JournalEntryType.CASH_RECEIPT,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
line_item_template=self._line_item_template)
|
||||||
|
|
||||||
|
def render_detail_template(self, journal_entry: JournalEntry) -> str:
|
||||||
|
"""Renders the template for the detail page.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: the detail page.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/journal-entry/receipt/detail.html",
|
||||||
|
obj=journal_entry)
|
||||||
|
|
||||||
|
def render_edit_template(self, journal_entry: JournalEntry,
|
||||||
|
form: CashReceiptJournalEntryForm) -> str:
|
||||||
|
"""Renders the template for the form to edit a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:param form: The form.
|
||||||
|
:return: the form to edit a journal entry.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/journal-entry/receipt/edit.html",
|
||||||
|
journal_entry=journal_entry, form=form,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
line_item_template=self._line_item_template)
|
||||||
|
|
||||||
|
def is_my_type(self, journal_entry: JournalEntry) -> bool:
|
||||||
|
"""Checks and returns whether the journal entry belongs to the type.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: True if the journal entry belongs to the type, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
return journal_entry.is_cash_receipt
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __currency_template(self) -> str:
|
||||||
|
"""Renders and returns the template for the currency sub-form.
|
||||||
|
|
||||||
|
:return: The template for the currency sub-form.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/receipt/include/form-currency.html",
|
||||||
|
currency_index="CURRENCY_INDEX",
|
||||||
|
currency_code_data=default_currency_code(),
|
||||||
|
credit_total="-")
|
||||||
|
|
||||||
|
|
||||||
|
class CashDisbursementJournalEntry(JournalEntryOperator):
|
||||||
|
"""A cash disbursement journal entry."""
|
||||||
|
CHECK_ORDER: int = 1
|
||||||
|
"""The order when checking the journal entry operator."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def form(self) -> t.Type[JournalEntryForm]:
|
||||||
|
"""Returns the form class.
|
||||||
|
|
||||||
|
:return: The form class.
|
||||||
|
"""
|
||||||
|
return CashDisbursementJournalEntryForm
|
||||||
|
|
||||||
|
def render_create_template(self, form: CashDisbursementJournalEntryForm) \
|
||||||
|
-> str:
|
||||||
|
"""Renders the template for the form to create a journal entry.
|
||||||
|
|
||||||
|
:param form: The journal entry form.
|
||||||
|
:return: the form to create a journal entry.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/disbursement/create.html",
|
||||||
|
form=form,
|
||||||
|
journal_entry_type=JournalEntryType.CASH_DISBURSEMENT,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
line_item_template=self._line_item_template)
|
||||||
|
|
||||||
|
def render_detail_template(self, journal_entry: JournalEntry) -> str:
|
||||||
|
"""Renders the template for the detail page.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: the detail page.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/disbursement/detail.html",
|
||||||
|
obj=journal_entry)
|
||||||
|
|
||||||
|
def render_edit_template(self, journal_entry: JournalEntry,
|
||||||
|
form: CashDisbursementJournalEntryForm) -> str:
|
||||||
|
"""Renders the template for the form to edit a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:param form: The form.
|
||||||
|
:return: the form to edit a journal entry.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/disbursement/edit.html",
|
||||||
|
journal_entry=journal_entry, form=form,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
line_item_template=self._line_item_template)
|
||||||
|
|
||||||
|
def is_my_type(self, journal_entry: JournalEntry) -> bool:
|
||||||
|
"""Checks and returns whether the journal entry belongs to the type.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: True if the journal entry belongs to the type, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
return journal_entry.is_cash_disbursement
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __currency_template(self) -> str:
|
||||||
|
"""Renders and returns the template for the currency sub-form.
|
||||||
|
|
||||||
|
:return: The template for the currency sub-form.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/disbursement/include/form-currency.html",
|
||||||
|
currency_index="CURRENCY_INDEX",
|
||||||
|
currency_code_data=default_currency_code(),
|
||||||
|
debit_total="-")
|
||||||
|
|
||||||
|
|
||||||
|
class TransferJournalEntry(JournalEntryOperator):
|
||||||
|
"""A transfer journal entry."""
|
||||||
|
CHECK_ORDER: int = 3
|
||||||
|
"""The order when checking the journal entry operator."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def form(self) -> t.Type[JournalEntryForm]:
|
||||||
|
"""Returns the form class.
|
||||||
|
|
||||||
|
:return: The form class.
|
||||||
|
"""
|
||||||
|
return TransferJournalEntryForm
|
||||||
|
|
||||||
|
def render_create_template(self, form: TransferJournalEntryForm) -> str:
|
||||||
|
"""Renders the template for the form to create a journal entry.
|
||||||
|
|
||||||
|
:param form: The journal entry form.
|
||||||
|
:return: the form to create a journal entry.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/transfer/create.html",
|
||||||
|
form=form,
|
||||||
|
journal_entry_type=JournalEntryType.TRANSFER,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
line_item_template=self._line_item_template)
|
||||||
|
|
||||||
|
def render_detail_template(self, journal_entry: JournalEntry) -> str:
|
||||||
|
"""Renders the template for the detail page.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: the detail page.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/journal-entry/transfer/detail.html",
|
||||||
|
obj=journal_entry)
|
||||||
|
|
||||||
|
def render_edit_template(self, journal_entry: JournalEntry,
|
||||||
|
form: TransferJournalEntryForm) -> str:
|
||||||
|
"""Renders the template for the form to edit a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:param form: The form.
|
||||||
|
:return: the form to edit a journal entry.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/journal-entry/transfer/edit.html",
|
||||||
|
journal_entry=journal_entry, form=form,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
line_item_template=self._line_item_template)
|
||||||
|
|
||||||
|
def is_my_type(self, journal_entry: JournalEntry) -> bool:
|
||||||
|
"""Checks and returns whether the journal entry belongs to the type.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: True if the journal entry belongs to the type, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __currency_template(self) -> str:
|
||||||
|
"""Renders and returns the template for the currency sub-form.
|
||||||
|
|
||||||
|
:return: The template for the currency sub-form.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/transfer/include/form-currency.html",
|
||||||
|
currency_index="CURRENCY_INDEX",
|
||||||
|
currency_code_data=default_currency_code(),
|
||||||
|
debit_total="-", credit_total="-")
|
||||||
|
|
||||||
|
|
||||||
|
JOURNAL_ENTRY_TYPE_TO_OP: dict[JournalEntryType, JournalEntryOperator] \
|
||||||
|
= {JournalEntryType.CASH_RECEIPT: CashReceiptJournalEntry(),
|
||||||
|
JournalEntryType.CASH_DISBURSEMENT: CashDisbursementJournalEntry(),
|
||||||
|
JournalEntryType.TRANSFER: TransferJournalEntry()}
|
||||||
|
"""The map from the journal entry types to their operators."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_journal_entry_op(journal_entry: JournalEntry,
|
||||||
|
is_check_as: bool = False) -> JournalEntryOperator:
|
||||||
|
"""Returns the journal entry operator that may be specified in the "as"
|
||||||
|
query parameter. If it is not specified, check the journal entry type from
|
||||||
|
the journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:param is_check_as: True to check the "as" parameter, or False otherwise.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if is_check_as and "as" in request.args:
|
||||||
|
type_dict: dict[str, JournalEntryType] \
|
||||||
|
= {x.value: x for x in JournalEntryType}
|
||||||
|
if request.args["as"] not in type_dict:
|
||||||
|
abort(404)
|
||||||
|
return JOURNAL_ENTRY_TYPE_TO_OP[type_dict[request.args["as"]]]
|
||||||
|
for journal_entry_type in sorted(JOURNAL_ENTRY_TYPE_TO_OP.values(),
|
||||||
|
key=lambda x: x.CHECK_ORDER):
|
||||||
|
if journal_entry_type.is_my_type(journal_entry):
|
||||||
|
return journal_entry_type
|
85
src/accounting/journal_entry/utils/original_line_items.py
Normal file
85
src/accounting/journal_entry/utils/original_line_items.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# 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 selectable original line items.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import Account, JournalEntry, JournalEntryLineItem
|
||||||
|
from accounting.utils.cast import be
|
||||||
|
from .offset_alias import offset_alias
|
||||||
|
|
||||||
|
|
||||||
|
def get_selectable_original_line_items(
|
||||||
|
line_item_id_on_form: set[int], is_payable: bool,
|
||||||
|
is_receivable: bool) -> list[JournalEntryLineItem]:
|
||||||
|
"""Queries and returns the selectable original line items, with their net
|
||||||
|
balances. The offset amounts of the form is excluded.
|
||||||
|
|
||||||
|
:param line_item_id_on_form: The ID of the line items on the form.
|
||||||
|
:param is_payable: True to check the payable original line items, or False
|
||||||
|
otherwise.
|
||||||
|
:param is_receivable: True to check the receivable original line items, or
|
||||||
|
False otherwise.
|
||||||
|
:return: The selectable original line items, with their net balances.
|
||||||
|
"""
|
||||||
|
assert is_payable or is_receivable
|
||||||
|
offset: sa.Alias = offset_alias()
|
||||||
|
net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case(
|
||||||
|
(offset.c.id.in_(line_item_id_on_form), 0),
|
||||||
|
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
|
||||||
|
offset.c.amount),
|
||||||
|
else_=-offset.c.amount))).label("net_balance")
|
||||||
|
conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
|
||||||
|
sub_conditions: list[sa.BinaryExpression] = []
|
||||||
|
if is_payable:
|
||||||
|
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
|
||||||
|
sa.not_(JournalEntryLineItem.is_debit)))
|
||||||
|
if is_receivable:
|
||||||
|
sub_conditions.append(sa.and_(Account.base_code.startswith("1"),
|
||||||
|
JournalEntryLineItem.is_debit))
|
||||||
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
|
select_net_balances: sa.Select \
|
||||||
|
= sa.select(JournalEntryLineItem.id, net_balance)\
|
||||||
|
.join(Account)\
|
||||||
|
.join(offset, be(JournalEntryLineItem.id
|
||||||
|
== offset.c.original_line_item_id),
|
||||||
|
isouter=True)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.group_by(JournalEntryLineItem.id)\
|
||||||
|
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
|
||||||
|
net_balances: dict[int, Decimal] \
|
||||||
|
= {x.id: x.net_balance
|
||||||
|
for x in db.session.execute(select_net_balances).all()}
|
||||||
|
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\
|
||||||
|
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\
|
||||||
|
.join(JournalEntry)\
|
||||||
|
.order_by(JournalEntry.date, JournalEntry.no,
|
||||||
|
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
|
||||||
|
.options(selectinload(JournalEntryLineItem.currency),
|
||||||
|
selectinload(JournalEntryLineItem.account),
|
||||||
|
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||||
|
line_items.reverse()
|
||||||
|
for line_item in line_items:
|
||||||
|
line_item.net_balance = line_item.amount \
|
||||||
|
if net_balances[line_item.id] is None \
|
||||||
|
else net_balances[line_item.id]
|
||||||
|
return line_items
|
238
src/accounting/journal_entry/views.py
Normal file
238
src/accounting/journal_entry/views.py
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||||
|
|
||||||
|
# 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 journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import Blueprint, render_template, session, redirect, request, \
|
||||||
|
flash, url_for
|
||||||
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import JournalEntry
|
||||||
|
from accounting.utils.cast import s
|
||||||
|
from accounting.utils.flash_errors import flash_form_errors
|
||||||
|
from accounting.utils.next_uri import inherit_next, or_next
|
||||||
|
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||||
|
from accounting.utils.journal_entry_types import JournalEntryType
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
from .forms import sort_journal_entries_in, JournalEntryReorderForm
|
||||||
|
from .template_filters import with_type, to_transfer, format_amount_input, \
|
||||||
|
text2html
|
||||||
|
from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
|
||||||
|
get_journal_entry_op
|
||||||
|
|
||||||
|
bp: Blueprint = Blueprint("journal-entry", __name__)
|
||||||
|
"""The view blueprint for the journal entry management."""
|
||||||
|
bp.add_app_template_filter(with_type, "accounting_journal_entry_with_type")
|
||||||
|
bp.add_app_template_filter(to_transfer, "accounting_journal_entry_to_transfer")
|
||||||
|
bp.add_app_template_filter(format_amount_input,
|
||||||
|
"accounting_journal_entry_format_amount_input")
|
||||||
|
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/create/<journalEntryType:journal_entry_type>", endpoint="create")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
|
||||||
|
"""Shows the form to add a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry_type: The journal entry type.
|
||||||
|
:return: The form to add a journal entry.
|
||||||
|
"""
|
||||||
|
journal_entry_op: JournalEntryOperator \
|
||||||
|
= JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
|
||||||
|
form: journal_entry_op.form
|
||||||
|
if "form" in session:
|
||||||
|
form = journal_entry_op.form(
|
||||||
|
ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = journal_entry_op.form()
|
||||||
|
form.date.data = date.today()
|
||||||
|
return journal_entry_op.render_create_template(form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/store/<journalEntryType:journal_entry_type>", endpoint="store")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
|
||||||
|
"""Adds a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry_type: The journal entry type.
|
||||||
|
:return: The redirection to the journal entry detail on success, or the
|
||||||
|
journal entry creation form on error.
|
||||||
|
"""
|
||||||
|
journal_entry_op: JournalEntryOperator \
|
||||||
|
= JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
|
||||||
|
form: journal_entry_op.form = journal_entry_op.form(request.form)
|
||||||
|
if not form.validate():
|
||||||
|
flash_form_errors(form)
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(with_type(
|
||||||
|
url_for("accounting.journal-entry.create",
|
||||||
|
journal_entry_type=journal_entry_type))))
|
||||||
|
journal_entry: JournalEntry = JournalEntry()
|
||||||
|
form.populate_obj(journal_entry)
|
||||||
|
db.session.add(journal_entry)
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The journal entry is added successfully.")),
|
||||||
|
"success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<journalEntry:journal_entry>", endpoint="detail")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
|
||||||
|
"""Shows the journal entry detail.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: The detail.
|
||||||
|
"""
|
||||||
|
journal_entry_op: JournalEntryOperator \
|
||||||
|
= get_journal_entry_op(journal_entry)
|
||||||
|
return journal_entry_op.render_detail_template(journal_entry)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<journalEntry:journal_entry>/edit", endpoint="edit")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
|
||||||
|
"""Shows the form to edit a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: The form to edit the journal entry.
|
||||||
|
"""
|
||||||
|
journal_entry_op: JournalEntryOperator \
|
||||||
|
= get_journal_entry_op(journal_entry, is_check_as=True)
|
||||||
|
form: journal_entry_op.form
|
||||||
|
if "form" in session:
|
||||||
|
form = journal_entry_op.form(
|
||||||
|
ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.obj = journal_entry
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = journal_entry_op.form(obj=journal_entry)
|
||||||
|
return journal_entry_op.render_edit_template(journal_entry, form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<journalEntry:journal_entry>/update", endpoint="update")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
|
||||||
|
"""Updates a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: The redirection to the journal entry detail on success, or the
|
||||||
|
journal entry edit form on error.
|
||||||
|
"""
|
||||||
|
journal_entry_op: JournalEntryOperator \
|
||||||
|
= get_journal_entry_op(journal_entry, is_check_as=True)
|
||||||
|
form: journal_entry_op.form = journal_entry_op.form(request.form)
|
||||||
|
form.obj = journal_entry
|
||||||
|
if not form.validate():
|
||||||
|
flash_form_errors(form)
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(with_type(
|
||||||
|
url_for("accounting.journal-entry.edit",
|
||||||
|
journal_entry=journal_entry))))
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
form.populate_obj(journal_entry)
|
||||||
|
if not form.is_modified:
|
||||||
|
flash(s(lazy_gettext("The journal entry was not modified.")),
|
||||||
|
"success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||||
|
journal_entry.updated_by_id = get_current_user_pk()
|
||||||
|
journal_entry.updated_at = sa.func.now()
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The journal entry is updated successfully.")),
|
||||||
|
"success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<journalEntry:journal_entry>/delete", endpoint="delete")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
|
||||||
|
"""Deletes a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: The redirection to the journal entry list on success, or the
|
||||||
|
journal entry detail on error.
|
||||||
|
"""
|
||||||
|
if not journal_entry.can_delete:
|
||||||
|
flash(s(lazy_gettext("The journal entry cannot be deleted.")), "error")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||||
|
journal_entry.delete()
|
||||||
|
sort_journal_entries_in(journal_entry.date, journal_entry.id)
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The journal entry is deleted successfully.")),
|
||||||
|
"success")
|
||||||
|
return redirect(or_next(__get_default_page_uri()))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/dates/<date:journal_entry_date>", endpoint="order")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_journal_entry_order(journal_entry_date: date) -> str:
|
||||||
|
"""Shows the order of the journal entries in a same date.
|
||||||
|
|
||||||
|
:param journal_entry_date: The date.
|
||||||
|
:return: The order of the journal entries in the date.
|
||||||
|
"""
|
||||||
|
journal_entries: list[JournalEntry] = JournalEntry.query \
|
||||||
|
.filter(JournalEntry.date == journal_entry_date) \
|
||||||
|
.order_by(JournalEntry.no).all()
|
||||||
|
return render_template("accounting/journal-entry/order.html",
|
||||||
|
date=journal_entry_date, list=journal_entries)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/dates/<date:journal_entry_date>", endpoint="sort")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def sort_journal_entries(journal_entry_date: date) -> redirect:
|
||||||
|
"""Reorders the journal entries in a date.
|
||||||
|
|
||||||
|
:param journal_entry_date: The date.
|
||||||
|
:return: The redirection to the incoming account or the account list. The
|
||||||
|
reordering operation does not fail.
|
||||||
|
"""
|
||||||
|
form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date)
|
||||||
|
form.save_order()
|
||||||
|
if not form.is_modified:
|
||||||
|
flash(s(lazy_gettext("The order was not modified.")), "success")
|
||||||
|
return redirect(or_next(__get_default_page_uri()))
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The order is updated successfully.")), "success")
|
||||||
|
return redirect(or_next(__get_default_page_uri()))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_detail_uri(journal_entry: JournalEntry) -> str:
|
||||||
|
"""Returns the detail URI of a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: The detail URI of the journal entry.
|
||||||
|
"""
|
||||||
|
return url_for("accounting.journal-entry.detail",
|
||||||
|
journal_entry=journal_entry)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_default_page_uri() -> str:
|
||||||
|
"""Returns the URI for the default page.
|
||||||
|
|
||||||
|
:return: The URI for the default page.
|
||||||
|
"""
|
||||||
|
return url_for("accounting-report.default")
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@ -17,15 +17,19 @@
|
|||||||
"""The data models.
|
"""The data models.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import typing as t
|
import typing as t
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import current_app
|
from babel import Locale
|
||||||
from flask_babel import get_locale
|
from flask_babel import get_locale, get_babel
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from accounting.database import db
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
from accounting.utils.user import user_cls, user_pk_column
|
from accounting.utils.user import user_cls, user_pk_column
|
||||||
|
|
||||||
|
|
||||||
@ -48,7 +52,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.title()}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
@ -56,11 +60,11 @@ class BaseAccount(db.Model):
|
|||||||
|
|
||||||
:return: The title in the current locale.
|
:return: The title in the current locale.
|
||||||
"""
|
"""
|
||||||
current_locale = str(get_locale())
|
current_locale: Locale = get_locale()
|
||||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
if current_locale == get_babel().instance.default_locale:
|
||||||
return self.title_l10n
|
return self.title_l10n
|
||||||
for l10n in self.l10n:
|
for l10n in self.l10n:
|
||||||
if l10n.locale == current_locale:
|
if l10n.locale == str(current_locale):
|
||||||
return l10n.title
|
return l10n.title
|
||||||
return self.title_l10n
|
return self.title_l10n
|
||||||
|
|
||||||
@ -109,8 +113,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_offset_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 offsets."""
|
"""Whether the journal entry line items 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."""
|
||||||
@ -134,18 +138,15 @@ class Account(db.Model):
|
|||||||
l10n = db.relationship("AccountL10n", back_populates="account",
|
l10n = db.relationship("AccountL10n", back_populates="account",
|
||||||
lazy=False)
|
lazy=False)
|
||||||
"""The localized titles."""
|
"""The localized titles."""
|
||||||
|
line_items = db.relationship("JournalEntryLineItem",
|
||||||
|
back_populates="account")
|
||||||
|
"""The journal entry line items."""
|
||||||
|
|
||||||
__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:
|
||||||
@ -153,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.title()}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def code(self) -> str:
|
def code(self) -> str:
|
||||||
@ -161,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:
|
||||||
@ -169,11 +170,11 @@ class Account(db.Model):
|
|||||||
|
|
||||||
:return: The title in the current locale.
|
:return: The title in the current locale.
|
||||||
"""
|
"""
|
||||||
current_locale = str(get_locale())
|
current_locale: Locale = get_locale()
|
||||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
if current_locale == get_babel().instance.default_locale:
|
||||||
return self.title_l10n
|
return self.title_l10n
|
||||||
for l10n in self.l10n:
|
for l10n in self.l10n:
|
||||||
if l10n.locale == current_locale:
|
if l10n.locale == str(current_locale):
|
||||||
return l10n.title
|
return l10n.title
|
||||||
return self.title_l10n
|
return self.title_l10n
|
||||||
|
|
||||||
@ -187,15 +188,71 @@ class Account(db.Model):
|
|||||||
if self.title_l10n is None:
|
if self.title_l10n is None:
|
||||||
self.title_l10n = value
|
self.title_l10n = value
|
||||||
return
|
return
|
||||||
current_locale = str(get_locale())
|
current_locale: Locale = get_locale()
|
||||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
if current_locale == get_babel().instance.default_locale:
|
||||||
self.title_l10n = value
|
self.title_l10n = value
|
||||||
return
|
return
|
||||||
for l10n in self.l10n:
|
for l10n in self.l10n:
|
||||||
if l10n.locale == current_locale:
|
if l10n.locale == str(current_locale):
|
||||||
l10n.title = value
|
l10n.title = value
|
||||||
return
|
return
|
||||||
self.l10n.append(AccountL10n(locale=current_locale, title=value))
|
self.l10n.append(AccountL10n(locale=str(current_locale), title=value))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_real(self) -> bool:
|
||||||
|
"""Returns whether the account is a real account.
|
||||||
|
|
||||||
|
:return: True if the account is a real account, or False otherwise.
|
||||||
|
"""
|
||||||
|
return self.base_code[0] in {"1", "2", "3"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_nominal(self) -> bool:
|
||||||
|
"""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
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_delete(self) -> bool:
|
||||||
|
"""Returns whether the account can be deleted.
|
||||||
|
|
||||||
|
:return: True if the account can be deleted, or False otherwise.
|
||||||
|
"""
|
||||||
|
if self.code in {"1111-001", "3351-001", "3353-001"}:
|
||||||
|
return False
|
||||||
|
return len(self.line_items) == 0
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_by_code(cls, code: str) -> t.Self | None:
|
def find_by_code(cls, code: str) -> t.Self | None:
|
||||||
@ -204,20 +261,22 @@ class Account(db.Model):
|
|||||||
:param code: The code.
|
:param code: The code.
|
||||||
:return: The account, or None if this account does not exist.
|
:return: The account, or None if this account does not exist.
|
||||||
"""
|
"""
|
||||||
m = re.match("^([1-9]{4})-([0-9]{3})$", code)
|
m = re.match(r"^([1-9]{4})-(\d{3})$", code)
|
||||||
if m is None:
|
if m is None:
|
||||||
return None
|
return None
|
||||||
return cls.query.filter(cls.base_code == m.group(1),
|
return cls.query.filter(cls.base_code == m.group(1),
|
||||||
cls.no == int(m.group(2))).first()
|
cls.no == int(m.group(2))).first()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def debit(cls) -> list[t.Self]:
|
def selectable_debit(cls) -> list[t.Self]:
|
||||||
"""Returns the debit accounts.
|
"""Returns the selectable debit accounts.
|
||||||
|
Payable line items can not start from debit.
|
||||||
|
|
||||||
:return: The debit accounts.
|
:return: The selectable debit accounts.
|
||||||
"""
|
"""
|
||||||
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
||||||
cls.base_code.startswith("2"),
|
sa.and_(cls.base_code.startswith("2"),
|
||||||
|
sa.not_(cls.is_need_offset)),
|
||||||
cls.base_code.startswith("3"),
|
cls.base_code.startswith("3"),
|
||||||
cls.base_code.startswith("5"),
|
cls.base_code.startswith("5"),
|
||||||
cls.base_code.startswith("6"),
|
cls.base_code.startswith("6"),
|
||||||
@ -232,12 +291,14 @@ class Account(db.Model):
|
|||||||
.order_by(cls.base_code, cls.no).all()
|
.order_by(cls.base_code, cls.no).all()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def credit(cls) -> list[t.Self]:
|
def selectable_credit(cls) -> list[t.Self]:
|
||||||
"""Returns the debit accounts.
|
"""Returns the selectable debit accounts.
|
||||||
|
Receivable line items can not start from credit.
|
||||||
|
|
||||||
:return: The debit accounts.
|
:return: The selectable debit accounts.
|
||||||
"""
|
"""
|
||||||
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
return cls.query.filter(sa.or_(sa.and_(cls.base_code.startswith("1"),
|
||||||
|
sa.not_(cls.is_need_offset)),
|
||||||
cls.base_code.startswith("2"),
|
cls.base_code.startswith("2"),
|
||||||
cls.base_code.startswith("3"),
|
cls.base_code.startswith("3"),
|
||||||
cls.base_code.startswith("4"),
|
cls.base_code.startswith("4"),
|
||||||
@ -257,23 +318,7 @@ class Account(db.Model):
|
|||||||
|
|
||||||
: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:
|
||||||
@ -281,45 +326,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):
|
||||||
@ -370,13 +377,16 @@ class Currency(db.Model):
|
|||||||
l10n = db.relationship("CurrencyL10n", back_populates="currency",
|
l10n = db.relationship("CurrencyL10n", back_populates="currency",
|
||||||
lazy=False)
|
lazy=False)
|
||||||
"""The localized names."""
|
"""The localized names."""
|
||||||
|
line_items = db.relationship("JournalEntryLineItem",
|
||||||
|
back_populates="currency")
|
||||||
|
"""The journal entry line items."""
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""Returns the string representation of the currency.
|
"""Returns the string representation of the currency.
|
||||||
|
|
||||||
:return: The string representation of the currency.
|
:return: The string representation of the currency.
|
||||||
"""
|
"""
|
||||||
return F"{self.name} ({self.code})"
|
return f"{self.name.title()} ({self.code})"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -384,11 +394,11 @@ class Currency(db.Model):
|
|||||||
|
|
||||||
:return: The name in the current locale.
|
:return: The name in the current locale.
|
||||||
"""
|
"""
|
||||||
current_locale = str(get_locale())
|
current_locale: Locale = get_locale()
|
||||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
if current_locale == get_babel().instance.default_locale:
|
||||||
return self.name_l10n
|
return self.name_l10n
|
||||||
for l10n in self.l10n:
|
for l10n in self.l10n:
|
||||||
if l10n.locale == current_locale:
|
if l10n.locale == str(current_locale):
|
||||||
return l10n.name
|
return l10n.name
|
||||||
return self.name_l10n
|
return self.name_l10n
|
||||||
|
|
||||||
@ -402,15 +412,15 @@ class Currency(db.Model):
|
|||||||
if self.name_l10n is None:
|
if self.name_l10n is None:
|
||||||
self.name_l10n = value
|
self.name_l10n = value
|
||||||
return
|
return
|
||||||
current_locale = str(get_locale())
|
current_locale: Locale = get_locale()
|
||||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
if current_locale == get_babel().instance.default_locale:
|
||||||
self.name_l10n = value
|
self.name_l10n = value
|
||||||
return
|
return
|
||||||
for l10n in self.l10n:
|
for l10n in self.l10n:
|
||||||
if l10n.locale == current_locale:
|
if l10n.locale == str(current_locale):
|
||||||
l10n.name = value
|
l10n.name = value
|
||||||
return
|
return
|
||||||
self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
|
self.l10n.append(CurrencyL10n(locale=str(current_locale), name=value))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_modified(self) -> bool:
|
def is_modified(self) -> bool:
|
||||||
@ -425,6 +435,17 @@ class Currency(db.Model):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_delete(self) -> bool:
|
||||||
|
"""Returns whether the currency can be deleted.
|
||||||
|
|
||||||
|
:return: True if the currency can be deleted, or False otherwise.
|
||||||
|
"""
|
||||||
|
from accounting.template_globals import default_currency_code
|
||||||
|
if self.code == default_currency_code():
|
||||||
|
return False
|
||||||
|
return len(self.line_items) == 0
|
||||||
|
|
||||||
def delete(self) -> None:
|
def delete(self) -> None:
|
||||||
"""Deletes the currency.
|
"""Deletes the currency.
|
||||||
|
|
||||||
@ -450,3 +471,336 @@ class CurrencyL10n(db.Model):
|
|||||||
"""The locale."""
|
"""The locale."""
|
||||||
name = db.Column(db.String, nullable=False)
|
name = db.Column(db.String, nullable=False)
|
||||||
"""The localized name."""
|
"""The localized name."""
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryCurrency:
|
||||||
|
"""A currency in a journal entry."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, debit: list[JournalEntryLineItem],
|
||||||
|
credit: list[JournalEntryLineItem]):
|
||||||
|
"""Constructs the currency in the journal entry.
|
||||||
|
|
||||||
|
:param code: The currency code.
|
||||||
|
:param debit: The debit line items.
|
||||||
|
:param credit: The credit line items.
|
||||||
|
"""
|
||||||
|
self.code: str = code
|
||||||
|
"""The currency code."""
|
||||||
|
self.debit: list[JournalEntryLineItem] = debit
|
||||||
|
"""The debit line items."""
|
||||||
|
self.credit: list[JournalEntryLineItem] = credit
|
||||||
|
"""The credit line items."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Returns the currency name.
|
||||||
|
|
||||||
|
:return: The currency name.
|
||||||
|
"""
|
||||||
|
return db.session.get(Currency, self.code).name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the debit line items.
|
||||||
|
|
||||||
|
:return: The total amount of the debit line items.
|
||||||
|
"""
|
||||||
|
return sum([x.amount for x in self.debit])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_total(self) -> str:
|
||||||
|
"""Returns the total amount of the credit line items.
|
||||||
|
|
||||||
|
:return: The total amount of the credit line items.
|
||||||
|
"""
|
||||||
|
return sum([x.amount for x in self.credit])
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntry(db.Model):
|
||||||
|
"""A journal entry."""
|
||||||
|
__tablename__ = "accounting_journal_entries"
|
||||||
|
"""The table name."""
|
||||||
|
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||||
|
autoincrement=False)
|
||||||
|
"""The journal entry ID."""
|
||||||
|
date = db.Column(db.Date, nullable=False)
|
||||||
|
"""The date."""
|
||||||
|
no = db.Column(db.Integer, nullable=False, default=text("1"))
|
||||||
|
"""The account number under the date."""
|
||||||
|
note = db.Column(db.String)
|
||||||
|
"""The note."""
|
||||||
|
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The time of creation."""
|
||||||
|
created_by_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(user_pk_column,
|
||||||
|
onupdate="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The ID of the creator."""
|
||||||
|
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
||||||
|
"""The creator."""
|
||||||
|
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The time of last update."""
|
||||||
|
updated_by_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(user_pk_column,
|
||||||
|
onupdate="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The ID of the updator."""
|
||||||
|
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||||
|
"""The updator."""
|
||||||
|
line_items = db.relationship("JournalEntryLineItem",
|
||||||
|
back_populates="journal_entry")
|
||||||
|
"""The line items."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of this journal entry.
|
||||||
|
|
||||||
|
:return: The string representation of this journal entry.
|
||||||
|
"""
|
||||||
|
if self.is_cash_disbursement:
|
||||||
|
return gettext("Cash Disbursement Journal Entry#%(id)s",
|
||||||
|
id=self.id)
|
||||||
|
if self.is_cash_receipt:
|
||||||
|
return gettext("Cash Receipt Journal Entry#%(id)s", id=self.id)
|
||||||
|
return gettext("Transfer Journal Entry#%(id)s", id=self.id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currencies(self) -> list[JournalEntryCurrency]:
|
||||||
|
"""Returns the line items categorized by their currencies.
|
||||||
|
|
||||||
|
:return: The currency categories.
|
||||||
|
"""
|
||||||
|
line_items: list[JournalEntryLineItem] = sorted(self.line_items,
|
||||||
|
key=lambda x: x.no)
|
||||||
|
codes: list[str] = []
|
||||||
|
by_currency: dict[str, list[JournalEntryLineItem]] = {}
|
||||||
|
for line_item in line_items:
|
||||||
|
if line_item.currency_code not in by_currency:
|
||||||
|
codes.append(line_item.currency_code)
|
||||||
|
by_currency[line_item.currency_code] = []
|
||||||
|
by_currency[line_item.currency_code].append(line_item)
|
||||||
|
return [JournalEntryCurrency(code=x,
|
||||||
|
debit=[y for y in by_currency[x]
|
||||||
|
if y.is_debit],
|
||||||
|
credit=[y for y in by_currency[x]
|
||||||
|
if not y.is_debit])
|
||||||
|
for x in codes]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cash_receipt(self) -> bool:
|
||||||
|
"""Returns whether this is a cash receipt journal entry.
|
||||||
|
|
||||||
|
:return: True if this is a cash receipt journal entry, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
for currency in self.currencies:
|
||||||
|
if len(currency.debit) > 1:
|
||||||
|
return False
|
||||||
|
if currency.debit[0].account.code != Account.CASH_CODE:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cash_disbursement(self) -> bool:
|
||||||
|
"""Returns whether this is a cash disbursement journal entry.
|
||||||
|
|
||||||
|
:return: True if this is a cash disbursement journal entry, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
for currency in self.currencies:
|
||||||
|
if len(currency.credit) > 1:
|
||||||
|
return False
|
||||||
|
if currency.credit[0].account.code != Account.CASH_CODE:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_delete(self) -> bool:
|
||||||
|
"""Returns whether the journal entry can be deleted.
|
||||||
|
|
||||||
|
:return: True if the journal entry can be deleted, or False otherwise.
|
||||||
|
"""
|
||||||
|
for line_item in self.line_items:
|
||||||
|
if len(line_item.offsets) > 0:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Deletes the journal entry.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
JournalEntryLineItem.query\
|
||||||
|
.filter(JournalEntryLineItem.journal_entry_id == self.id).delete()
|
||||||
|
db.session.delete(self)
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryLineItem(db.Model):
|
||||||
|
"""A line item in the journal entry."""
|
||||||
|
__tablename__ = "accounting_journal_entry_line_items"
|
||||||
|
"""The table name."""
|
||||||
|
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||||
|
autoincrement=False)
|
||||||
|
"""The line item ID."""
|
||||||
|
journal_entry_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(JournalEntry.id,
|
||||||
|
onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The journal entry ID."""
|
||||||
|
journal_entry = db.relationship(JournalEntry, back_populates="line_items")
|
||||||
|
"""The journal entry."""
|
||||||
|
is_debit = db.Column(db.Boolean, nullable=False)
|
||||||
|
"""True for a debit line item, or False for a credit line item."""
|
||||||
|
no = db.Column(db.Integer, nullable=False)
|
||||||
|
"""The line item number under the journal entry and debit or credit."""
|
||||||
|
original_line_item_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(id, onupdate="CASCADE"),
|
||||||
|
nullable=True)
|
||||||
|
"""The ID of the original line item."""
|
||||||
|
original_line_item = db.relationship("JournalEntryLineItem",
|
||||||
|
back_populates="offsets",
|
||||||
|
remote_side=id, passive_deletes=True)
|
||||||
|
"""The original line item."""
|
||||||
|
offsets = db.relationship("JournalEntryLineItem",
|
||||||
|
back_populates="original_line_item")
|
||||||
|
"""The offset items."""
|
||||||
|
currency_code = db.Column(db.String,
|
||||||
|
db.ForeignKey(Currency.code, onupdate="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The currency code."""
|
||||||
|
currency = db.relationship(Currency, back_populates="line_items")
|
||||||
|
"""The currency."""
|
||||||
|
account_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(Account.id,
|
||||||
|
onupdate="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The account ID."""
|
||||||
|
account = db.relationship(Account, back_populates="line_items", lazy=False)
|
||||||
|
"""The account."""
|
||||||
|
description = db.Column(db.String, nullable=True)
|
||||||
|
"""The description."""
|
||||||
|
amount = db.Column(db.Numeric(14, 2), nullable=False)
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the line item.
|
||||||
|
|
||||||
|
:return: The string representation of the line item.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__str"):
|
||||||
|
from accounting.template_filters import format_date, format_amount
|
||||||
|
setattr(self, "__str",
|
||||||
|
gettext("%(date)s %(description)s %(amount)s",
|
||||||
|
date=format_date(self.journal_entry.date),
|
||||||
|
description="" if self.description is None
|
||||||
|
else self.description,
|
||||||
|
amount=format_amount(self.amount)))
|
||||||
|
return getattr(self, "__str")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_code(self) -> str:
|
||||||
|
"""Returns the account code.
|
||||||
|
|
||||||
|
:return: The 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 line item.
|
||||||
|
"""
|
||||||
|
return self.amount if self.is_debit else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_need_offset(self) -> bool:
|
||||||
|
"""Returns whether the line item needs offset.
|
||||||
|
|
||||||
|
:return: True if the line item 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 line item.
|
||||||
|
"""
|
||||||
|
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) -> 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:]
|
||||||
|
|
||||||
|
return ["{}/{}/{}".format(self.journal_entry.date.year,
|
||||||
|
self.journal_entry.date.month,
|
||||||
|
self.journal_entry.date.day),
|
||||||
|
"" if self.description is None else self.description,
|
||||||
|
format_amount(self.amount)]
|
||||||
|
|
||||||
|
|
||||||
|
class Option(db.Model):
|
||||||
|
"""An option."""
|
||||||
|
__tablename__ = "accounting_options"
|
||||||
|
"""The table name."""
|
||||||
|
name = db.Column(db.String, nullable=False, primary_key=True)
|
||||||
|
"""The name."""
|
||||||
|
value = db.Column(db.Text, nullable=False)
|
||||||
|
"""The option value."""
|
||||||
|
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The time of creation."""
|
||||||
|
created_by_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(user_pk_column,
|
||||||
|
onupdate="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The ID of the creator."""
|
||||||
|
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
||||||
|
"""The creator."""
|
||||||
|
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The time of last update."""
|
||||||
|
updated_by_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(user_pk_column,
|
||||||
|
onupdate="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The ID of the updator."""
|
||||||
|
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||||
|
"""The updator."""
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
#
|
#
|
||||||
@ -14,26 +14,17 @@
|
|||||||
# 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 option management.
|
||||||
"""The database instance factory for the base account management.
|
|
||||||
|
|
||||||
This is to overcome the problem that the database instance needs to be
|
|
||||||
initialized at compile time, but as a submodule it is only available at run
|
|
||||||
time.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from flask import Blueprint
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
|
||||||
|
|
||||||
db: SQLAlchemy = SQLAlchemy()
|
|
||||||
"""The database instance."""
|
|
||||||
|
|
||||||
|
|
||||||
def set_db(new_db: SQLAlchemy) -> None:
|
def init_app(bp: Blueprint) -> None:
|
||||||
"""Sets the database instance.
|
"""Initialize the application.
|
||||||
|
|
||||||
:param new_db: The database instance.
|
:param bp: The blueprint of the accounting application.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
global db
|
from .views import bp as option_bp
|
||||||
db = new_db
|
bp.register_blueprint(option_bp, url_prefix="/options")
|
269
src/accounting/option/forms.py
Normal file
269
src/accounting/option/forms.py
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||||
|
|
||||||
|
# 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 forms for the option management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import render_template
|
||||||
|
from flask_babel import LazyString
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, FieldList, FormField, IntegerField
|
||||||
|
from wtforms.validators import DataRequired, ValidationError
|
||||||
|
|
||||||
|
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
|
||||||
|
IsDebitAccount, IsCreditAccount
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Account
|
||||||
|
from accounting.utils.current_account import CurrentAccount
|
||||||
|
from accounting.utils.options import Options
|
||||||
|
from accounting.utils.strip_text import strip_text
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentAccountExists:
|
||||||
|
"""The validator to check that the current account exists."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
|
||||||
|
return
|
||||||
|
if Account.find_by_code(field.data) is None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The account does not exist."))
|
||||||
|
|
||||||
|
|
||||||
|
class AccountNotCurrent:
|
||||||
|
"""The validator to check that the account is a current account."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
|
||||||
|
return
|
||||||
|
if field.data[:2] not in {"11", "12", "21", "22"}:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"This is not a current account."))
|
||||||
|
|
||||||
|
|
||||||
|
class NotStartPayableFromExpense:
|
||||||
|
"""The validator to check that a payable line item does not start from
|
||||||
|
expense."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None or field.data[0] != "2":
|
||||||
|
return
|
||||||
|
account: Account | None = Account.find_by_code(field.data)
|
||||||
|
if account is not None and account.is_need_offset:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"You cannot select a payable account as expense."))
|
||||||
|
|
||||||
|
|
||||||
|
class NotStartReceivableFromIncome:
|
||||||
|
"""The validator to check that a receivable line item does not start
|
||||||
|
from income."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None or field.data[0] != "1":
|
||||||
|
return
|
||||||
|
account: Account | None = Account.find_by_code(field.data)
|
||||||
|
if account is not None and account.is_need_offset:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"You cannot select a receivable account as income."))
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringItemForm(FlaskForm):
|
||||||
|
"""The base sub-form to add or update the recurring item."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order number of this recurring item."""
|
||||||
|
name = StringField()
|
||||||
|
"""The name of the recurring item."""
|
||||||
|
account_code = StringField()
|
||||||
|
"""The account code."""
|
||||||
|
description_template = StringField()
|
||||||
|
"""The description template."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_text(self) -> str | None:
|
||||||
|
"""Returns the account text.
|
||||||
|
|
||||||
|
:return: The account text.
|
||||||
|
"""
|
||||||
|
if self.account_code.data is None:
|
||||||
|
return None
|
||||||
|
account: Account | None = Account.find_by_code(self.account_code.data)
|
||||||
|
return None if account is None else str(account)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns all the errors of the form.
|
||||||
|
|
||||||
|
:return: All the errors of the form.
|
||||||
|
"""
|
||||||
|
all_errors: list[str | LazyString] = []
|
||||||
|
for key in self.errors:
|
||||||
|
if key != "csrf_token":
|
||||||
|
all_errors.extend(self.errors[key])
|
||||||
|
return all_errors
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringExpenseForm(RecurringItemForm):
|
||||||
|
"""The sub-form to add or update the recurring expenses."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order number of this recurring item."""
|
||||||
|
name = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
|
||||||
|
"""The name of the recurring item."""
|
||||||
|
account_code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[
|
||||||
|
ACCOUNT_REQUIRED,
|
||||||
|
AccountExists(),
|
||||||
|
IsDebitAccount(lazy_gettext("This account is not for expense.")),
|
||||||
|
NotStartPayableFromExpense()])
|
||||||
|
"""The account code."""
|
||||||
|
description_template = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[
|
||||||
|
DataRequired(lazy_gettext(
|
||||||
|
"Please fill in the description template."))])
|
||||||
|
"""The template for the line item description."""
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringIncomeForm(RecurringItemForm):
|
||||||
|
"""The sub-form to add or update the recurring incomes."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order number of this recurring item."""
|
||||||
|
name = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
|
||||||
|
"""The name of the recurring item."""
|
||||||
|
account_code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[
|
||||||
|
ACCOUNT_REQUIRED,
|
||||||
|
AccountExists(),
|
||||||
|
IsCreditAccount(lazy_gettext("This account is not for income.")),
|
||||||
|
NotStartReceivableFromIncome()])
|
||||||
|
"""The account code."""
|
||||||
|
description_template = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[
|
||||||
|
DataRequired(lazy_gettext(
|
||||||
|
"Please fill in the description template."))])
|
||||||
|
"""The description template."""
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringForm(RecurringItemForm):
|
||||||
|
"""The sub-form for the recurring expenses and incomes."""
|
||||||
|
expenses = FieldList(FormField(RecurringExpenseForm), name="expense")
|
||||||
|
"""The recurring expenses."""
|
||||||
|
incomes = FieldList(FormField(RecurringIncomeForm), name="income")
|
||||||
|
"""The recurring incomes."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def item_template(self) -> str:
|
||||||
|
"""Returns the template of a recurring item.
|
||||||
|
|
||||||
|
:return: The template of a recurring item.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/option/include/form-recurring-item.html",
|
||||||
|
expense_income="EXPENSE_INCOME",
|
||||||
|
item_index="ITEM_INDEX",
|
||||||
|
form=RecurringItemForm())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expense_accounts(self) -> list[Account]:
|
||||||
|
"""The expense accounts.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
return Account.selectable_debit()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def income_accounts(self) -> list[Account]:
|
||||||
|
"""The income accounts.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
return Account.selectable_credit()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def as_data(self) -> dict[str, list[tuple[str, str, str]]]:
|
||||||
|
"""Returns the form data.
|
||||||
|
|
||||||
|
:return: The form data.
|
||||||
|
"""
|
||||||
|
def as_tuple(item: RecurringItemForm) -> tuple[str, str, str]:
|
||||||
|
return (item.name.data, item.account_code.data,
|
||||||
|
item.description_template.data)
|
||||||
|
|
||||||
|
expenses: list[RecurringItemForm] = [x.form for x in self.expenses]
|
||||||
|
self.__sort_item_forms(expenses)
|
||||||
|
incomes: list[RecurringItemForm] = [x.form for x in self.incomes]
|
||||||
|
self.__sort_item_forms(incomes)
|
||||||
|
return {"expense": [as_tuple(x) for x in expenses],
|
||||||
|
"income": [as_tuple(x) for x in incomes]}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __sort_item_forms(forms: list[RecurringItemForm]) -> None:
|
||||||
|
"""Sorts the recurring item sub-forms.
|
||||||
|
|
||||||
|
:param forms: The recurring item sub-forms.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
ord_by_form: dict[RecurringItemForm, int] \
|
||||||
|
= {forms[i]: i for i in range(len(forms))}
|
||||||
|
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||||
|
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||||
|
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||||
|
ord_by_form.get(x)))
|
||||||
|
|
||||||
|
|
||||||
|
class OptionForm(FlaskForm):
|
||||||
|
"""The form to update the options."""
|
||||||
|
default_currency_code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[
|
||||||
|
DataRequired(lazy_gettext("Please select the default currency.")),
|
||||||
|
CurrencyExists()])
|
||||||
|
"""The default currency code."""
|
||||||
|
default_ie_account_code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[
|
||||||
|
DataRequired(lazy_gettext(
|
||||||
|
"Please select the default account"
|
||||||
|
" for the income and expenses log.")),
|
||||||
|
CurrentAccountExists(),
|
||||||
|
AccountNotCurrent()])
|
||||||
|
"""The default account code for the income and expenses log."""
|
||||||
|
recurring = FormField(RecurringForm)
|
||||||
|
"""The recurring expenses and incomes."""
|
||||||
|
|
||||||
|
def populate_obj(self, obj: Options) -> None:
|
||||||
|
"""Populates the form data into a currency object.
|
||||||
|
|
||||||
|
:param obj: The currency object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
obj.default_currency_code = self.default_currency_code.data
|
||||||
|
obj.default_ie_account_code = self.default_ie_account_code.data
|
||||||
|
obj.recurring_data = self.recurring.form.as_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_accounts(self) -> list[CurrentAccount]:
|
||||||
|
"""Returns the current accounts.
|
||||||
|
|
||||||
|
:return: The current accounts.
|
||||||
|
"""
|
||||||
|
return CurrentAccount.accounts()
|
83
src/accounting/option/views.py
Normal file
83
src/accounting/option/views.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||||
|
|
||||||
|
# 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 option management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, redirect, session, request, \
|
||||||
|
flash, url_for
|
||||||
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.utils.cast import s
|
||||||
|
from accounting.utils.flash_errors import flash_form_errors
|
||||||
|
from accounting.utils.next_uri import inherit_next
|
||||||
|
from accounting.utils.options import options
|
||||||
|
from accounting.utils.permission import has_permission, can_admin
|
||||||
|
from .forms import OptionForm
|
||||||
|
|
||||||
|
bp: Blueprint = Blueprint("option", __name__)
|
||||||
|
"""The view blueprint for the currency management."""
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("", endpoint="detail")
|
||||||
|
@has_permission(can_admin)
|
||||||
|
def show_options() -> str:
|
||||||
|
"""Shows the options.
|
||||||
|
|
||||||
|
:return: The options.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/option/detail.html", obj=options)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("edit", endpoint="edit")
|
||||||
|
@has_permission(can_admin)
|
||||||
|
def show_option_form() -> str:
|
||||||
|
"""Shows the option form.
|
||||||
|
|
||||||
|
:return: The option form.
|
||||||
|
"""
|
||||||
|
form: OptionForm
|
||||||
|
if "form" in session:
|
||||||
|
form = OptionForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = OptionForm(obj=options)
|
||||||
|
return render_template("accounting/option/form.html", form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("update", endpoint="update")
|
||||||
|
@has_permission(can_admin)
|
||||||
|
def update_options() -> redirect:
|
||||||
|
"""Updates the options.
|
||||||
|
|
||||||
|
:return: The redirection to the option form.
|
||||||
|
"""
|
||||||
|
form = OptionForm(request.form)
|
||||||
|
if not form.validate():
|
||||||
|
flash_form_errors(form)
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(url_for("accounting.option.edit")))
|
||||||
|
form.populate_obj(options)
|
||||||
|
if not options.is_modified:
|
||||||
|
flash(s(lazy_gettext("The settings were not modified.")), "success")
|
||||||
|
return redirect(inherit_next(url_for("accounting.option.detail")))
|
||||||
|
options.commit()
|
||||||
|
flash(s(lazy_gettext("The settings are saved successfully.")), "success")
|
||||||
|
return redirect(inherit_next(url_for("accounting.option.detail")))
|
35
src/accounting/report/__init__.py
Normal file
35
src/accounting/report/__init__.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# The Mia! Accounting 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
|
||||||
|
|
||||||
|
|
||||||
|
def init_app(app: Flask, url_prefix: str) -> None:
|
||||||
|
"""Initialize the application.
|
||||||
|
|
||||||
|
:param app: The Flask application.
|
||||||
|
:param url_prefix: The URL prefix 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
|
||||||
|
app.register_blueprint(report_bp, url_prefix=url_prefix)
|
79
src/accounting/report/converters.py
Normal file
79
src/accounting/report/converters.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# The Mia! Accounting 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 accounting.utils.current_account import CurrentAccount
|
||||||
|
from .period import Period, get_period
|
||||||
|
|
||||||
|
|
||||||
|
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) -> CurrentAccount:
|
||||||
|
"""Converts an account code to an account.
|
||||||
|
|
||||||
|
:param value: The account code.
|
||||||
|
:return: The corresponding account.
|
||||||
|
"""
|
||||||
|
if value == CurrentAccount.CURRENT_AL_CODE:
|
||||||
|
return CurrentAccount.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 CurrentAccount(account)
|
||||||
|
|
||||||
|
def to_url(self, value: CurrentAccount) -> 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 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 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 JournalEntry
|
||||||
|
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: JournalEntry | None \
|
||||||
|
= JournalEntry.query.order_by(JournalEntry.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 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 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 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 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 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 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 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
|
475
src/accounting/report/reports/balance_sheet.py
Normal file
475
src/accounting/report/reports/balance_sheet.py
Normal file
@ -0,0 +1,475 @@
|
|||||||
|
# The Mia! Accounting 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, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
|
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] \
|
||||||
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
|
sa.or_(*sub_conditions)]
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
|
else_=-JournalEntryLineItem.amount)).label("balance")
|
||||||
|
select_balance: sa.Select \
|
||||||
|
= sa.select(Account.id, Account.base_code, Account.no,
|
||||||
|
balance_func)\
|
||||||
|
.join(JournalEntry).join(Account)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.group_by(Account.id, Account.base_code, Account.no)\
|
||||||
|
.having(balance_func != 0)\
|
||||||
|
.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] \
|
||||||
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
|
JournalEntry.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] \
|
||||||
|
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
||||||
|
if self.__period.start is not None:
|
||||||
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(JournalEntry.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(
|
||||||
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
|
else_=-JournalEntryLineItem.amount))
|
||||||
|
select_balance: sa.Select = sa.select(balance_func)\
|
||||||
|
.join(JournalEntry).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)
|
460
src/accounting/report/reports/income_expenses.py
Normal file
460
src/accounting/report/reports/income_expenses.py
Normal file
@ -0,0 +1,460 @@
|
|||||||
|
# The Mia! Accounting 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, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
|
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 income_expenses_url
|
||||||
|
from accounting.utils.cast import be
|
||||||
|
from accounting.utils.current_account import CurrentAccount
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class ReportLineItem:
|
||||||
|
"""A line item in the report."""
|
||||||
|
|
||||||
|
def __init__(self, line_item: JournalEntryLineItem | None = None):
|
||||||
|
"""Constructs the line item in the report.
|
||||||
|
|
||||||
|
:param line_item: The journal entry line item.
|
||||||
|
"""
|
||||||
|
self.is_brought_forward: bool = False
|
||||||
|
"""Whether this is the brought-forward line item."""
|
||||||
|
self.is_total: bool = False
|
||||||
|
"""Whether this is the total line item."""
|
||||||
|
self.date: date | None = None
|
||||||
|
"""The date."""
|
||||||
|
self.account: Account | None = None
|
||||||
|
"""The account."""
|
||||||
|
self.description: str | None = None
|
||||||
|
"""The description."""
|
||||||
|
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 line item."""
|
||||||
|
if line_item is not None:
|
||||||
|
self.date = line_item.journal_entry.date
|
||||||
|
self.account = line_item.account
|
||||||
|
self.description = line_item.description
|
||||||
|
self.income = None if line_item.is_debit else line_item.amount
|
||||||
|
self.expense = line_item.amount if line_item.is_debit else None
|
||||||
|
self.note = line_item.journal_entry.note
|
||||||
|
self.url = url_for("accounting.journal-entry.detail",
|
||||||
|
journal_entry=line_item.journal_entry)
|
||||||
|
|
||||||
|
|
||||||
|
class LineItemCollector:
|
||||||
|
"""The line item collector."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: CurrentAccount,
|
||||||
|
period: Period):
|
||||||
|
"""Constructs the line item collector.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__account: CurrentAccount = account
|
||||||
|
"""The account."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period"""
|
||||||
|
self.brought_forward: ReportLineItem | None
|
||||||
|
"""The brought-forward line item."""
|
||||||
|
self.line_items: list[ReportLineItem]
|
||||||
|
"""The line items."""
|
||||||
|
self.total: ReportLineItem | None
|
||||||
|
"""The total line item."""
|
||||||
|
self.brought_forward = self.__get_brought_forward()
|
||||||
|
self.line_items = self.__query_line_items()
|
||||||
|
self.total = self.__get_total()
|
||||||
|
self.__populate_balance()
|
||||||
|
|
||||||
|
def __get_brought_forward(self) -> ReportLineItem | None:
|
||||||
|
"""Queries, composes and returns the brought-forward line item.
|
||||||
|
|
||||||
|
:return: The brought-forward line item, 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(
|
||||||
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
|
else_=-JournalEntryLineItem.amount))
|
||||||
|
select: sa.Select = sa.Select(balance_func)\
|
||||||
|
.join(JournalEntry).join(Account)\
|
||||||
|
.filter(be(JournalEntryLineItem.currency_code
|
||||||
|
== self.__currency.code),
|
||||||
|
self.__account_condition,
|
||||||
|
JournalEntry.date < self.__period.start)
|
||||||
|
balance: int | None = db.session.scalar(select)
|
||||||
|
if balance is None:
|
||||||
|
return None
|
||||||
|
line_item: ReportLineItem = ReportLineItem()
|
||||||
|
line_item.is_brought_forward = True
|
||||||
|
line_item.date = self.__period.start
|
||||||
|
line_item.account = Account.accumulated_change()
|
||||||
|
line_item.description = gettext("Brought forward")
|
||||||
|
if balance > 0:
|
||||||
|
line_item.income = balance
|
||||||
|
elif balance < 0:
|
||||||
|
line_item.expense = -balance
|
||||||
|
line_item.balance = balance
|
||||||
|
return line_item
|
||||||
|
|
||||||
|
def __query_line_items(self) -> list[ReportLineItem]:
|
||||||
|
"""Queries and returns the line items.
|
||||||
|
|
||||||
|
:return: The line items.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
|
self.__account_condition]
|
||||||
|
if self.__period.start is not None:
|
||||||
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
|
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
|
||||||
|
join(JournalEntryLineItem).join(Account).filter(*conditions)
|
||||||
|
|
||||||
|
return [ReportLineItem(x)
|
||||||
|
for x in JournalEntryLineItem.query
|
||||||
|
.join(JournalEntry).join(Account)
|
||||||
|
.filter(JournalEntryLineItem.journal_entry_id
|
||||||
|
.in_(journal_entry_with_account),
|
||||||
|
JournalEntryLineItem.currency_code
|
||||||
|
== self.__currency.code,
|
||||||
|
sa.not_(self.__account_condition))
|
||||||
|
.order_by(JournalEntry.date,
|
||||||
|
JournalEntry.no,
|
||||||
|
JournalEntryLineItem.is_debit,
|
||||||
|
JournalEntryLineItem.no)
|
||||||
|
.options(selectinload(JournalEntryLineItem.account),
|
||||||
|
selectinload(JournalEntryLineItem.journal_entry))]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __account_condition(self) -> sa.BinaryExpression:
|
||||||
|
if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
|
||||||
|
return CurrentAccount.sql_condition()
|
||||||
|
return Account.id == self.__account.id
|
||||||
|
|
||||||
|
def __get_total(self) -> ReportLineItem | None:
|
||||||
|
"""Composes the total line item.
|
||||||
|
|
||||||
|
:return: The total line item, or None if there is no data.
|
||||||
|
"""
|
||||||
|
if self.brought_forward is None and len(self.line_items) == 0:
|
||||||
|
return None
|
||||||
|
line_item: ReportLineItem = ReportLineItem()
|
||||||
|
line_item.is_total = True
|
||||||
|
line_item.description = gettext("Total")
|
||||||
|
line_item.income = sum([x.income for x in self.line_items
|
||||||
|
if x.income is not None])
|
||||||
|
line_item.expense = sum([x.expense for x in self.line_items
|
||||||
|
if x.expense is not None])
|
||||||
|
line_item.balance = line_item.income - line_item.expense
|
||||||
|
if self.brought_forward is not None:
|
||||||
|
line_item.balance \
|
||||||
|
= self.brought_forward.balance + line_item.balance
|
||||||
|
return line_item
|
||||||
|
|
||||||
|
def __populate_balance(self) -> None:
|
||||||
|
"""Populates the balance of the line items.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
balance: Decimal = 0 if self.brought_forward is None \
|
||||||
|
else self.brought_forward.balance
|
||||||
|
for line_item in self.line_items:
|
||||||
|
if line_item.income is not None:
|
||||||
|
balance = balance + line_item.income
|
||||||
|
if line_item.expense is not None:
|
||||||
|
balance = balance - line_item.expense
|
||||||
|
line_item.balance = balance
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, journal_entry_date: date | str | None,
|
||||||
|
account: str | None,
|
||||||
|
description: str | None,
|
||||||
|
income: str | Decimal | None,
|
||||||
|
expense: str | Decimal | None,
|
||||||
|
balance: str | Decimal | None,
|
||||||
|
note: str | None):
|
||||||
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
|
:param journal_entry_date: The journal entry date.
|
||||||
|
:param account: The account.
|
||||||
|
:param description: The description.
|
||||||
|
:param income: The income.
|
||||||
|
:param expense: The expense.
|
||||||
|
:param balance: The balance.
|
||||||
|
:param note: The note.
|
||||||
|
"""
|
||||||
|
self.date: date | str | None = journal_entry_date
|
||||||
|
"""The date."""
|
||||||
|
self.account: str | None = account
|
||||||
|
"""The account."""
|
||||||
|
self.description: str | None = description
|
||||||
|
"""The description."""
|
||||||
|
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.description,
|
||||||
|
self.income, self.expense, self.balance, self.note]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
account: CurrentAccount,
|
||||||
|
period: Period,
|
||||||
|
has_data: bool,
|
||||||
|
pagination: Pagination[ReportLineItem],
|
||||||
|
brought_forward: ReportLineItem | None,
|
||||||
|
line_items: list[ReportLineItem],
|
||||||
|
total: ReportLineItem | 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 line item.
|
||||||
|
:param line_items: The line items.
|
||||||
|
:param total: The total line item.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.account: CurrentAccount = 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[ReportLineItem] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.brought_forward: ReportLineItem | None = brought_forward
|
||||||
|
"""The brought-forward line item."""
|
||||||
|
self.line_items: list[ReportLineItem] = line_items
|
||||||
|
"""The line items."""
|
||||||
|
self.total: ReportLineItem | None = total
|
||||||
|
"""The total line item."""
|
||||||
|
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: CurrentAccount \
|
||||||
|
= CurrentAccount.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(JournalEntryLineItem.account_id)\
|
||||||
|
.join(Account)\
|
||||||
|
.filter(be(JournalEntryLineItem.currency_code
|
||||||
|
== self.currency.code),
|
||||||
|
CurrentAccount.sql_condition())\
|
||||||
|
.group_by(JournalEntryLineItem.account_id)
|
||||||
|
options.extend([OptionLink(str(x),
|
||||||
|
income_expenses_url(
|
||||||
|
self.currency,
|
||||||
|
CurrentAccount(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: CurrentAccount,
|
||||||
|
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: CurrentAccount = account
|
||||||
|
"""The account."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
collector: LineItemCollector = LineItemCollector(
|
||||||
|
self.__currency, self.__account, self.__period)
|
||||||
|
self.__brought_forward: ReportLineItem | None \
|
||||||
|
= collector.brought_forward
|
||||||
|
"""The brought-forward line item."""
|
||||||
|
self.__line_items: list[ReportLineItem] = collector.line_items
|
||||||
|
"""The line items."""
|
||||||
|
self.__total: ReportLineItem | None = collector.total
|
||||||
|
"""The total line item."""
|
||||||
|
|
||||||
|
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("Description"), 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.description,
|
||||||
|
self.__brought_forward.income,
|
||||||
|
self.__brought_forward.expense,
|
||||||
|
self.__brought_forward.balance,
|
||||||
|
None))
|
||||||
|
rows.extend([CSVRow(x.date, str(x.account).title(), x.description,
|
||||||
|
x.income, x.expense, x.balance, x.note)
|
||||||
|
for x in self.__line_items])
|
||||||
|
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_line_items: list[ReportLineItem] = []
|
||||||
|
if self.__brought_forward is not None:
|
||||||
|
all_line_items.append(self.__brought_forward)
|
||||||
|
all_line_items.extend(self.__line_items)
|
||||||
|
if self.__total is not None:
|
||||||
|
all_line_items.append(self.__total)
|
||||||
|
pagination: Pagination[ReportLineItem] \
|
||||||
|
= Pagination[ReportLineItem](all_line_items, is_reversed=True)
|
||||||
|
page_line_items: list[ReportLineItem] = pagination.list
|
||||||
|
has_data: bool = len(page_line_items) > 0
|
||||||
|
brought_forward: ReportLineItem | None = None
|
||||||
|
if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
|
||||||
|
brought_forward = page_line_items[0]
|
||||||
|
page_line_items = page_line_items[1:]
|
||||||
|
total: ReportLineItem | None = None
|
||||||
|
if len(page_line_items) > 0 and page_line_items[-1].is_total:
|
||||||
|
total = page_line_items[-1]
|
||||||
|
page_line_items = page_line_items[:-1]
|
||||||
|
params: PageParams = PageParams(currency=self.__currency,
|
||||||
|
account=self.__account,
|
||||||
|
period=self.__period,
|
||||||
|
has_data=has_data,
|
||||||
|
pagination=pagination,
|
||||||
|
brought_forward=brought_forward,
|
||||||
|
line_items=page_line_items,
|
||||||
|
total=total)
|
||||||
|
return render_template("accounting/report/income-expenses.html",
|
||||||
|
report=params)
|
326
src/accounting/report/reports/income_statement.py
Normal file
326
src/accounting/report/reports/income_statement.py
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
# The Mia! Accounting 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, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
|
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] \
|
||||||
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
|
sa.or_(*sub_conditions)]
|
||||||
|
if self.__period.start is not None:
|
||||||
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntryLineItem.is_debit, -JournalEntryLineItem.amount),
|
||||||
|
else_=JournalEntryLineItem.amount)).label("balance")
|
||||||
|
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||||
|
.join(JournalEntry).join(Account)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.group_by(Account.id)\
|
||||||
|
.having(balance_func != 0)\
|
||||||
|
.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)
|
220
src/accounting/report/reports/journal.py
Normal file
220
src/accounting/report/reports/journal.py
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
# The Mia! Accounting 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, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
|
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 ReportLineItem:
|
||||||
|
"""A line item in the report."""
|
||||||
|
|
||||||
|
def __init__(self, line_item: JournalEntryLineItem):
|
||||||
|
"""Constructs the line item in the report.
|
||||||
|
|
||||||
|
:param line_item: The journal entry line item.
|
||||||
|
"""
|
||||||
|
self.line_item: JournalEntryLineItem = line_item
|
||||||
|
"""The journal entry line item."""
|
||||||
|
self.journal_entry: JournalEntry = line_item.journal_entry
|
||||||
|
"""The journal entry."""
|
||||||
|
self.currency: Currency = line_item.currency
|
||||||
|
"""The account."""
|
||||||
|
self.account: Account = line_item.account
|
||||||
|
"""The account."""
|
||||||
|
self.description: str | None = line_item.description
|
||||||
|
"""The description."""
|
||||||
|
self.debit: Decimal | None = line_item.debit
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: Decimal | None = line_item.credit
|
||||||
|
"""The credit amount."""
|
||||||
|
self.amount: Decimal = line_item.amount
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, journal_entry_date: str | date,
|
||||||
|
currency: str,
|
||||||
|
account: str,
|
||||||
|
description: str | None,
|
||||||
|
debit: str | Decimal | None,
|
||||||
|
credit: str | Decimal | None,
|
||||||
|
note: str | None):
|
||||||
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
|
:param journal_entry_date: The journal entry date.
|
||||||
|
:param description: The description.
|
||||||
|
:param debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
:param note: The note.
|
||||||
|
"""
|
||||||
|
self.date: str | date = journal_entry_date
|
||||||
|
"""The date."""
|
||||||
|
self.currency: str = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.account: str = account
|
||||||
|
"""The account."""
|
||||||
|
self.description: str | None = description
|
||||||
|
"""The description."""
|
||||||
|
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.description,
|
||||||
|
self.debit, self.credit, self.note]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, period: Period,
|
||||||
|
pagination: Pagination[JournalEntryLineItem],
|
||||||
|
line_items: list[JournalEntryLineItem]):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
:param line_items: The line items.
|
||||||
|
"""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.line_items: list[JournalEntryLineItem] = line_items
|
||||||
|
"""The line items."""
|
||||||
|
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.line_items) > 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(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows from the line items.
|
||||||
|
|
||||||
|
:param line_items: The line items.
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
|
||||||
|
gettext("Account"), gettext("Description"),
|
||||||
|
gettext("Debit"), gettext("Credit"),
|
||||||
|
gettext("Note"))]
|
||||||
|
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
|
||||||
|
str(x.account).title(), x.description,
|
||||||
|
x.debit, x.credit, x.journal_entry.note)
|
||||||
|
for x in line_items])
|
||||||
|
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.__line_items: list[JournalEntryLineItem] \
|
||||||
|
= self.__query_line_items()
|
||||||
|
"""The line items."""
|
||||||
|
|
||||||
|
def __query_line_items(self) -> list[JournalEntryLineItem]:
|
||||||
|
"""Queries and returns the line items.
|
||||||
|
|
||||||
|
:return: The line items.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] = []
|
||||||
|
if self.__period.start is not None:
|
||||||
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
|
return JournalEntryLineItem.query.join(JournalEntry)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.order_by(JournalEntry.date,
|
||||||
|
JournalEntry.no,
|
||||||
|
JournalEntryLineItem.is_debit.desc(),
|
||||||
|
JournalEntryLineItem.no)\
|
||||||
|
.options(selectinload(JournalEntryLineItem.account),
|
||||||
|
selectinload(JournalEntryLineItem.currency),
|
||||||
|
selectinload(JournalEntryLineItem.journal_entry)).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.__line_items))
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
pagination: Pagination[JournalEntryLineItem] \
|
||||||
|
= Pagination[JournalEntryLineItem](self.__line_items,
|
||||||
|
is_reversed=True)
|
||||||
|
params: PageParams = PageParams(period=self.__period,
|
||||||
|
pagination=pagination,
|
||||||
|
line_items=pagination.list)
|
||||||
|
return render_template("accounting/report/journal.html",
|
||||||
|
report=params)
|
417
src/accounting/report/reports/ledger.py
Normal file
417
src/accounting/report/reports/ledger.py
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
# The Mia! Accounting 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, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
|
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 ReportLineItem:
|
||||||
|
"""A line item in the report."""
|
||||||
|
|
||||||
|
def __init__(self, line_item: JournalEntryLineItem | None = None):
|
||||||
|
"""Constructs the line item in the report.
|
||||||
|
|
||||||
|
:param line_item: The journal entry line item.
|
||||||
|
"""
|
||||||
|
self.is_brought_forward: bool = False
|
||||||
|
"""Whether this is the brought-forward line item."""
|
||||||
|
self.is_total: bool = False
|
||||||
|
"""Whether this is the total line item."""
|
||||||
|
self.date: date | None = None
|
||||||
|
"""The date."""
|
||||||
|
self.description: str | None = None
|
||||||
|
"""The description."""
|
||||||
|
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 line item."""
|
||||||
|
if line_item is not None:
|
||||||
|
self.date = line_item.journal_entry.date
|
||||||
|
self.description = line_item.description
|
||||||
|
self.debit = line_item.amount if line_item.is_debit else None
|
||||||
|
self.credit = None if line_item.is_debit else line_item.amount
|
||||||
|
self.note = line_item.journal_entry.note
|
||||||
|
self.url = url_for("accounting.journal-entry.detail",
|
||||||
|
journal_entry=line_item.journal_entry)
|
||||||
|
|
||||||
|
|
||||||
|
class LineItemCollector:
|
||||||
|
"""The line item collector."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||||
|
"""Constructs the line item 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: ReportLineItem | None
|
||||||
|
"""The brought-forward line item."""
|
||||||
|
self.line_items: list[ReportLineItem]
|
||||||
|
"""The line items."""
|
||||||
|
self.total: ReportLineItem | None
|
||||||
|
"""The total line item."""
|
||||||
|
self.brought_forward = self.__get_brought_forward()
|
||||||
|
self.line_items = self.__query_line_items()
|
||||||
|
self.total = self.__get_total()
|
||||||
|
self.__populate_balance()
|
||||||
|
|
||||||
|
def __get_brought_forward(self) -> ReportLineItem | None:
|
||||||
|
"""Queries, composes and returns the brought-forward line item.
|
||||||
|
|
||||||
|
:return: The brought-forward line item, 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(
|
||||||
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
|
else_=-JournalEntryLineItem.amount))
|
||||||
|
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
|
||||||
|
.filter(be(JournalEntryLineItem.currency_code
|
||||||
|
== self.__currency.code),
|
||||||
|
be(JournalEntryLineItem.account_id
|
||||||
|
== self.__account.id),
|
||||||
|
JournalEntry.date < self.__period.start)
|
||||||
|
balance: int | None = db.session.scalar(select)
|
||||||
|
if balance is None:
|
||||||
|
return None
|
||||||
|
line_item: ReportLineItem = ReportLineItem()
|
||||||
|
line_item.is_brought_forward = True
|
||||||
|
line_item.date = self.__period.start
|
||||||
|
line_item.description = gettext("Brought forward")
|
||||||
|
if balance > 0:
|
||||||
|
line_item.debit = balance
|
||||||
|
elif balance < 0:
|
||||||
|
line_item.credit = -balance
|
||||||
|
line_item.balance = balance
|
||||||
|
return line_item
|
||||||
|
|
||||||
|
def __query_line_items(self) -> list[ReportLineItem]:
|
||||||
|
"""Queries and returns the line items.
|
||||||
|
|
||||||
|
:return: The line items.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
|
JournalEntryLineItem.account_id == self.__account.id]
|
||||||
|
if self.__period.start is not None:
|
||||||
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
|
return [ReportLineItem(x) for x in JournalEntryLineItem.query
|
||||||
|
.join(JournalEntry)
|
||||||
|
.filter(*conditions)
|
||||||
|
.order_by(JournalEntry.date,
|
||||||
|
JournalEntry.no,
|
||||||
|
JournalEntryLineItem.is_debit.desc(),
|
||||||
|
JournalEntryLineItem.no)
|
||||||
|
.options(selectinload(JournalEntryLineItem.journal_entry))
|
||||||
|
.all()]
|
||||||
|
|
||||||
|
def __get_total(self) -> ReportLineItem | None:
|
||||||
|
"""Composes the total line item.
|
||||||
|
|
||||||
|
:return: The total line item, or None if there is no data.
|
||||||
|
"""
|
||||||
|
if self.brought_forward is None and len(self.line_items) == 0:
|
||||||
|
return None
|
||||||
|
line_item: ReportLineItem = ReportLineItem()
|
||||||
|
line_item.is_total = True
|
||||||
|
line_item.description = gettext("Total")
|
||||||
|
line_item.debit = sum([x.debit for x in self.line_items
|
||||||
|
if x.debit is not None])
|
||||||
|
line_item.credit = sum([x.credit for x in self.line_items
|
||||||
|
if x.credit is not None])
|
||||||
|
line_item.balance = line_item.debit - line_item.credit
|
||||||
|
if self.brought_forward is not None:
|
||||||
|
line_item.balance \
|
||||||
|
= self.brought_forward.balance + line_item.balance
|
||||||
|
return line_item
|
||||||
|
|
||||||
|
def __populate_balance(self) -> None:
|
||||||
|
"""Populates the balance of the line items.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if self.__account.is_nominal:
|
||||||
|
return None
|
||||||
|
balance: Decimal = 0 if self.brought_forward is None \
|
||||||
|
else self.brought_forward.balance
|
||||||
|
for line_item in self.line_items:
|
||||||
|
if line_item.debit is not None:
|
||||||
|
balance = balance + line_item.debit
|
||||||
|
if line_item.credit is not None:
|
||||||
|
balance = balance - line_item.credit
|
||||||
|
line_item.balance = balance
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, journal_entry_date: date | str | None,
|
||||||
|
description: str | None,
|
||||||
|
debit: str | Decimal | None,
|
||||||
|
credit: str | Decimal | None,
|
||||||
|
balance: str | Decimal | None,
|
||||||
|
note: str | None):
|
||||||
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
|
:param journal_entry_date: The journal entry date.
|
||||||
|
:param description: The description.
|
||||||
|
:param debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
:param balance: The balance.
|
||||||
|
:param note: The note.
|
||||||
|
"""
|
||||||
|
self.date: date | str | None = journal_entry_date
|
||||||
|
"""The date."""
|
||||||
|
self.description: str | None = description
|
||||||
|
"""The description."""
|
||||||
|
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.description,
|
||||||
|
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[ReportLineItem],
|
||||||
|
brought_forward: ReportLineItem | None,
|
||||||
|
line_items: list[ReportLineItem],
|
||||||
|
total: ReportLineItem | 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 line item.
|
||||||
|
:param line_items: The line items.
|
||||||
|
:param total: The total line item.
|
||||||
|
"""
|
||||||
|
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[ReportLineItem] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.brought_forward: ReportLineItem | None = brought_forward
|
||||||
|
"""The brought-forward line item."""
|
||||||
|
self.line_items: list[ReportLineItem] = line_items
|
||||||
|
"""The line items."""
|
||||||
|
self.total: ReportLineItem | None = total
|
||||||
|
"""The total line item."""
|
||||||
|
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(JournalEntryLineItem.account_id)\
|
||||||
|
.filter(be(JournalEntryLineItem.currency_code
|
||||||
|
== self.currency.code))\
|
||||||
|
.group_by(JournalEntryLineItem.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: LineItemCollector = LineItemCollector(
|
||||||
|
self.__currency, self.__account, self.__period)
|
||||||
|
self.__brought_forward: ReportLineItem | None \
|
||||||
|
= collector.brought_forward
|
||||||
|
"""The brought-forward line item."""
|
||||||
|
self.__line_items: list[ReportLineItem] = collector.line_items
|
||||||
|
"""The line items."""
|
||||||
|
self.__total: ReportLineItem | None = collector.total
|
||||||
|
"""The total line item."""
|
||||||
|
|
||||||
|
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("Description"),
|
||||||
|
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.description,
|
||||||
|
self.__brought_forward.debit,
|
||||||
|
self.__brought_forward.credit,
|
||||||
|
self.__brought_forward.balance,
|
||||||
|
None))
|
||||||
|
rows.extend([CSVRow(x.date, x.description,
|
||||||
|
x.debit, x.credit, x.balance, x.note)
|
||||||
|
for x in self.__line_items])
|
||||||
|
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_line_items: list[ReportLineItem] = []
|
||||||
|
if self.__brought_forward is not None:
|
||||||
|
all_line_items.append(self.__brought_forward)
|
||||||
|
all_line_items.extend(self.__line_items)
|
||||||
|
if self.__total is not None:
|
||||||
|
all_line_items.append(self.__total)
|
||||||
|
pagination: Pagination[ReportLineItem] \
|
||||||
|
= Pagination[ReportLineItem](all_line_items, is_reversed=True)
|
||||||
|
page_line_items: list[ReportLineItem] = pagination.list
|
||||||
|
has_data: bool = len(page_line_items) > 0
|
||||||
|
brought_forward: ReportLineItem | None = None
|
||||||
|
if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
|
||||||
|
brought_forward = page_line_items[0]
|
||||||
|
page_line_items = page_line_items[1:]
|
||||||
|
total: ReportLineItem | None = None
|
||||||
|
if len(page_line_items) > 0 and page_line_items[-1].is_total:
|
||||||
|
total = page_line_items[-1]
|
||||||
|
page_line_items = page_line_items[:-1]
|
||||||
|
params: PageParams = PageParams(currency=self.__currency,
|
||||||
|
account=self.__account,
|
||||||
|
period=self.__period,
|
||||||
|
has_data=has_data,
|
||||||
|
pagination=pagination,
|
||||||
|
brought_forward=brought_forward,
|
||||||
|
line_items=page_line_items,
|
||||||
|
total=total)
|
||||||
|
return render_template("accounting/report/ledger.html",
|
||||||
|
report=params)
|
216
src/accounting/report/reports/search.py
Normal file
216
src/accounting/report/reports/search.py
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
# The Mia! Accounting 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, \
|
||||||
|
JournalEntry, JournalEntryLineItem
|
||||||
|
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 LineItemCollector:
|
||||||
|
"""The line item collector."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Constructs the line item collector."""
|
||||||
|
self.line_items: list[JournalEntryLineItem] = self.__query_line_items()
|
||||||
|
"""The line items."""
|
||||||
|
|
||||||
|
def __query_line_items(self) -> list[JournalEntryLineItem]:
|
||||||
|
"""Queries and returns the line items.
|
||||||
|
|
||||||
|
:return: The line items.
|
||||||
|
"""
|
||||||
|
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] \
|
||||||
|
= [JournalEntryLineItem.description.icontains(k),
|
||||||
|
JournalEntryLineItem.account_id.in_(
|
||||||
|
self.__get_account_condition(k)),
|
||||||
|
JournalEntryLineItem.currency_code.in_(
|
||||||
|
self.__get_currency_condition(k)),
|
||||||
|
JournalEntryLineItem.journal_entry_id.in_(
|
||||||
|
self.__get_journal_entry_condition(k))]
|
||||||
|
try:
|
||||||
|
sub_conditions.append(
|
||||||
|
JournalEntryLineItem.amount == Decimal(k))
|
||||||
|
except ArithmeticError:
|
||||||
|
pass
|
||||||
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
|
return JournalEntryLineItem.query.join(JournalEntry)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.order_by(JournalEntry.date,
|
||||||
|
JournalEntry.no,
|
||||||
|
JournalEntryLineItem.is_debit,
|
||||||
|
JournalEntryLineItem.no)\
|
||||||
|
.options(selectinload(JournalEntryLineItem.account),
|
||||||
|
selectinload(JournalEntryLineItem.currency),
|
||||||
|
selectinload(JournalEntryLineItem.journal_entry)).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.icontains(k))
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [Account.base_code.contains(k),
|
||||||
|
Account.title_l10n.icontains(k),
|
||||||
|
code.contains(k),
|
||||||
|
Account.id.in_(select_l10n)]
|
||||||
|
if k in gettext("Needs 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.icontains(k))
|
||||||
|
return sa.select(Currency.code).filter(
|
||||||
|
sa.or_(Currency.code.icontains(k),
|
||||||
|
Currency.name_l10n.icontains(k),
|
||||||
|
Currency.code.in_(select_l10n)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __get_journal_entry_condition(k: str) -> sa.Select:
|
||||||
|
"""Composes and returns the condition to filter the journal entry.
|
||||||
|
|
||||||
|
:param k: The keyword.
|
||||||
|
:return: The condition to filter the journal entry.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.note.icontains(k)]
|
||||||
|
journal_entry_date: datetime
|
||||||
|
try:
|
||||||
|
journal_entry_date = datetime.strptime(k, "%Y")
|
||||||
|
conditions.append(
|
||||||
|
be(sa.extract("year", JournalEntry.date)
|
||||||
|
== journal_entry_date.year))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
journal_entry_date = datetime.strptime(k, "%Y/%m")
|
||||||
|
conditions.append(sa.and_(
|
||||||
|
sa.extract("year", JournalEntry.date)
|
||||||
|
== journal_entry_date.year,
|
||||||
|
sa.extract("month", JournalEntry.date)
|
||||||
|
== journal_entry_date.month))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||||
|
conditions.append(sa.and_(
|
||||||
|
sa.extract("month", JournalEntry.date)
|
||||||
|
== journal_entry_date.month,
|
||||||
|
sa.extract("day", JournalEntry.date)
|
||||||
|
== journal_entry_date.day))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, pagination: Pagination[JournalEntryLineItem],
|
||||||
|
line_items: list[JournalEntryLineItem]):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param line_items: The search result line items.
|
||||||
|
"""
|
||||||
|
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.line_items: list[JournalEntryLineItem] = line_items
|
||||||
|
"""The line items."""
|
||||||
|
|
||||||
|
@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.line_items) > 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.__line_items: list[JournalEntryLineItem] \
|
||||||
|
= LineItemCollector().line_items
|
||||||
|
"""The line items."""
|
||||||
|
|
||||||
|
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.__line_items))
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
pagination: Pagination[JournalEntryLineItem] \
|
||||||
|
= Pagination[JournalEntryLineItem](self.__line_items,
|
||||||
|
is_reversed=True)
|
||||||
|
params: PageParams = PageParams(pagination=pagination,
|
||||||
|
line_items=pagination.list)
|
||||||
|
return render_template("accounting/report/search.html",
|
||||||
|
report=params)
|
243
src/accounting/report/reports/trial_balance.py
Normal file
243
src/accounting/report/reports/trial_balance.py
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
# The Mia! Accounting 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, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
|
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] \
|
||||||
|
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
||||||
|
if self.__period.start is not None:
|
||||||
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
|
if self.__period.end is not None:
|
||||||
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
|
else_=-JournalEntryLineItem.amount)).label("balance")
|
||||||
|
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||||
|
.join(JournalEntry).join(Account)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.group_by(Account.id)\
|
||||||
|
.having(balance_func != 0)\
|
||||||
|
.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 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 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 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, JournalEntryLineItem
|
||||||
|
from accounting.utils.journal_entry_types import JournalEntryType
|
||||||
|
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 journal_entry_types(self) -> t.Type[JournalEntryType]:
|
||||||
|
"""Returns the journal entry types.
|
||||||
|
|
||||||
|
:return: The journal entry types.
|
||||||
|
"""
|
||||||
|
return JournalEntryType
|
||||||
|
|
||||||
|
@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(JournalEntryLineItem.currency_code)
|
||||||
|
.group_by(JournalEntryLineItem.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 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 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")
|
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 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 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 accounting.utils.current_account import CurrentAccount
|
||||||
|
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.__income_expenses)
|
||||||
|
self.__reports.append(self.__ledger)
|
||||||
|
self.__reports.append(self.__journal)
|
||||||
|
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 __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,
|
||||||
|
CurrentAccount(account),
|
||||||
|
self.__period),
|
||||||
|
self.__active_report == ReportType.INCOME_EXPENSES,
|
||||||
|
fa_icon="fa-solid fa-money-bill-wave")
|
||||||
|
|
||||||
|
@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 __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 __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 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."""
|
118
src/accounting/report/utils/urls.py
Normal file
118
src/accounting/report/utils/urls.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# The Mia! Accounting 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 accounting.utils.current_account import CurrentAccount
|
||||||
|
from accounting.utils.options import options
|
||||||
|
|
||||||
|
|
||||||
|
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: CurrentAccount,
|
||||||
|
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 == options.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 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.current_account import CurrentAccount
|
||||||
|
from accounting.utils.options import options
|
||||||
|
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
|
||||||
|
|
||||||
|
bp: Blueprint = Blueprint("accounting-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()),
|
||||||
|
options.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: CurrentAccount) \
|
||||||
|
-> 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: CurrentAccount,
|
||||||
|
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: CurrentAccount,
|
||||||
|
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()
|
@ -1,4 +1,4 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
/* The Mia! Accounting Project
|
||||||
* style.css: The style sheet for the accounting application.
|
* style.css: The style sheet for the accounting application.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -24,15 +24,70 @@
|
|||||||
.accounting-clickable {
|
.accounting-clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-group .btn .accounting-search-input {
|
.form-floating > textarea.form-control {
|
||||||
min-height: calc(1em + .5rem + 2px);
|
height: 6rem;
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
}
|
||||||
.btn-group .btn .accounting-search-label button {
|
.accounting-dragged {
|
||||||
border: none;
|
color: #141619;
|
||||||
|
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;
|
background-color: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
padding-right: 0;
|
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-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.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 */
|
||||||
@ -51,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-line-item {
|
||||||
|
border-top: thin solid darkslategray;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
.accounting-original-line-item a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.accounting-original-line-item a:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.accounting-offset-line-items {
|
||||||
|
border-top: thin solid darkslategray;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
.accounting-offset-line-items ul li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.accounting-offset-line-items ul li a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.accounting-offset-line-items ul li a:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
/** The option selector */
|
/** The option selector */
|
||||||
.accounting-selector-list {
|
.accounting-selector-list {
|
||||||
@ -58,6 +149,200 @@
|
|||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The journal entry management */
|
||||||
|
.accounting-currency-control {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
.accounting-currency-content {
|
||||||
|
width: calc(100% - 3rem);
|
||||||
|
}
|
||||||
|
.accounting-line-item-content {
|
||||||
|
width: calc(100% - 3rem);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
}
|
||||||
|
.accounting-list-group-hover .list-group-item:hover {
|
||||||
|
background-color: #ececec;
|
||||||
|
}
|
||||||
|
.accounting-journal-entry-line-item {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.accounting-journal-entry-line-item-header {
|
||||||
|
font-weight: bolder;
|
||||||
|
border-bottom: thick double slategray;
|
||||||
|
}
|
||||||
|
.list-group-item.accounting-journal-entry-line-item-total {
|
||||||
|
font-weight: bolder;
|
||||||
|
border-top: thick double slategray;
|
||||||
|
}
|
||||||
|
.accounting-line-item-editor-original-line-item-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 description editor */
|
||||||
|
.accounting-description-editor-buttons {
|
||||||
|
max-height: 7rem;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
.accounting-description-editor-buttons .btn {
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The order of the journal entries in a same day */
|
||||||
|
.accounting-journal-entry-order-item, .accounting-journal-entry-order-item:hover {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.accounting-journal-entry-order-item-currency {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
border-top: thin solid lightgray;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The illustration of the description template for the recurring transactions */
|
||||||
|
.accounting-recurring-description-template-illustration p {
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
.accounting-recurring-description-template-illustration ul {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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 {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -82,7 +367,7 @@
|
|||||||
.accounting-material-fab {
|
.accounting-material-fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 2rem;
|
right: 2rem;
|
||||||
bottom: 1rem;
|
bottom: 2rem;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
@ -96,6 +381,36 @@
|
|||||||
.accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus {
|
.accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus {
|
||||||
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
|
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
|
||||||
}
|
}
|
||||||
|
.accounting-btn-material-fab {
|
||||||
|
transition: transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out;
|
||||||
|
}
|
||||||
|
.show .accounting-btn-material-fab {
|
||||||
|
transform: scale(1.5) rotate(-45deg);
|
||||||
|
}
|
||||||
|
.accounting-material-fab-speed-dial-group {
|
||||||
|
position: absolute;
|
||||||
|
right: -2rem;
|
||||||
|
bottom: -7rem;
|
||||||
|
text-align: right;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.1);
|
||||||
|
line-height: 5.5rem;
|
||||||
|
transition: opacity .1s ease-in-out, transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out;
|
||||||
|
}
|
||||||
|
.show .accounting-material-fab-speed-dial-group {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(0.6);
|
||||||
|
right: -0.5rem;
|
||||||
|
bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
.accounting-material-fab-speed-dial-group .btn {
|
||||||
|
background-color: white;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
.accounting-material-fab-speed-dial-group .btn:hover, .accounting-material-fab-speed-dial-group .btn:focus {
|
||||||
|
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
|
||||||
|
}
|
||||||
|
|
||||||
/* The Material Design form switch */
|
/* The Material Design form switch */
|
||||||
@media(max-width:767px) {
|
@media(max-width:767px) {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
/* The Mia! Accounting Project
|
||||||
* account-form.js: The JavaScript for the account form
|
* account-form.js: The JavaScript for the account form
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -20,165 +20,408 @@
|
|||||||
/* 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-model");
|
|
||||||
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.#validate();
|
||||||
|
};
|
||||||
|
this.#baseControl.onclick = () => {
|
||||||
|
this.#baseControl.classList.add("accounting-not-empty");
|
||||||
|
this.#baseAccountSelector.onOpen();
|
||||||
};
|
};
|
||||||
});
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the query on the base account options.
|
* Returns the base code.
|
||||||
*
|
*
|
||||||
* @private
|
* @return {string|null}
|
||||||
*/
|
*/
|
||||||
function initializeBaseAccountQuery() {
|
get baseCode() {
|
||||||
const query = document.getElementById("accounting-base-selector-query");
|
return this.#baseCode.value === ""? null: this.#baseCode.value;
|
||||||
const optionList = document.getElementById("accounting-base-option-list");
|
}
|
||||||
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
|
||||||
const queryNoResult = document.getElementById("accounting-base-option-no-result");
|
/**
|
||||||
query.addEventListener("input", function () {
|
* The callback when the base account selector is closed.
|
||||||
console.log(query.value);
|
*
|
||||||
if (query.value === "") {
|
*/
|
||||||
options.forEach(function (option) {
|
onBaseAccountSelectorClosed() {
|
||||||
option.classList.remove("d-none");
|
if (this.#baseCode.value === "") {
|
||||||
});
|
this.#baseControl.classList.remove("accounting-not-empty");
|
||||||
optionList.classList.remove("d-none");
|
|
||||||
queryNoResult.classList.add("d-none");
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
let hasAnyMatched = false;
|
}
|
||||||
options.forEach(function (option) {
|
|
||||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
/**
|
||||||
let isMatched = false;
|
* Saves the selected base account.
|
||||||
for (let i = 0; i < queryValues.length; i++) {
|
*
|
||||||
if (queryValues[i].includes(query.value)) {
|
* @param account {BaseAccountOption} the selected base account
|
||||||
isMatched = true;
|
*/
|
||||||
break;
|
saveBaseAccount(account) {
|
||||||
}
|
this.#baseCode.value = account.code;
|
||||||
}
|
this.#base.innerText = account.text;
|
||||||
if (isMatched) {
|
if (["1", "2", "3"].includes(account.code.substring(0, 1))) {
|
||||||
option.classList.remove("d-none");
|
this.#isNeedOffsetControl.classList.remove("d-none");
|
||||||
hasAnyMatched = true;
|
this.#isNeedOffset.disabled = false;
|
||||||
} else {
|
|
||||||
option.classList.add("d-none");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!hasAnyMatched) {
|
|
||||||
optionList.classList.add("d-none");
|
|
||||||
queryNoResult.classList.remove("d-none");
|
|
||||||
} else {
|
} else {
|
||||||
optionList.classList.remove("d-none");
|
this.#isNeedOffsetControl.classList.add("d-none");
|
||||||
queryNoResult.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
|
||||||
|
*/
|
||||||
|
#validate() {
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the form.
|
* The base account selector.
|
||||||
*
|
*
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
function validateForm() {
|
class BaseAccountSelector {
|
||||||
let isValid = true;
|
|
||||||
isValid = validateBase() && isValid;
|
/**
|
||||||
isValid = validateTitle() && isValid;
|
* The account form
|
||||||
return isValid;
|
* @type {AccountForm}
|
||||||
|
*/
|
||||||
|
form;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selector modal
|
||||||
|
* @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 {BaseAccountOption[]}
|
||||||
|
*/
|
||||||
|
#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;
|
||||||
|
const prefix = "accounting-base-selector";
|
||||||
|
this.#modal = document.getElementById(`${prefix}-modal`);
|
||||||
|
this.#query = document.getElementById(`${prefix}-query`);
|
||||||
|
this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
|
||||||
|
this.#optionList = document.getElementById(`${prefix}-option-list`);
|
||||||
|
this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(this, element));
|
||||||
|
this.#clearButton = document.getElementById(`${prefix}-clear`);
|
||||||
|
|
||||||
|
this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed());
|
||||||
|
this.#query.oninput = () => this.#filterOptions();
|
||||||
|
this.#clearButton.onclick = () => this.form.clearBaseAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the options.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#filterOptions() {
|
||||||
|
let isAnyMatched = false;
|
||||||
|
for (const option of this.#options) {
|
||||||
|
if (option.isMatched(this.#query.value)) {
|
||||||
|
option.setShown(true);
|
||||||
|
isAnyMatched = true;
|
||||||
|
} else {
|
||||||
|
option.setShown(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isAnyMatched) {
|
||||||
|
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 base account selector is shown.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
onOpen() {
|
||||||
|
this.#query.value = "";
|
||||||
|
this.#filterOptions();
|
||||||
|
for (const option of this.#options) {
|
||||||
|
option.setActive(option.code === this.form.baseCode);
|
||||||
|
}
|
||||||
|
if (this.form.baseCode === 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the base account.
|
* A base account option.
|
||||||
*
|
*
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
*/
|
||||||
function validateBase() {
|
class BaseAccountOption {
|
||||||
const field = document.getElementById("accounting-base-code");
|
|
||||||
const error = document.getElementById("accounting-base-code-error");
|
/**
|
||||||
const displayField = document.getElementById("accounting-base");
|
* The element
|
||||||
field.value = field.value.trim();
|
* @type {HTMLLIElement}
|
||||||
if (field.value === "") {
|
*/
|
||||||
displayField.classList.add("is-invalid");
|
#element;
|
||||||
error.innerText = A_("Please select the base account.");
|
|
||||||
|
/**
|
||||||
|
* The account code
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account text
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
text;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The values to query against
|
||||||
|
* @type {string[]}
|
||||||
|
*/
|
||||||
|
#queryValues;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the account in the base account selector.
|
||||||
|
*
|
||||||
|
* @param selector {BaseAccountSelector} the base account selector
|
||||||
|
* @param element {HTMLLIElement} the element
|
||||||
|
*/
|
||||||
|
constructor(selector, element) {
|
||||||
|
this.#element = element;
|
||||||
|
this.code = element.dataset.code;
|
||||||
|
this.text = element.dataset.text;
|
||||||
|
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
||||||
|
|
||||||
|
this.#element.onclick = () => selector.form.saveBaseAccount(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the account matches the query.
|
||||||
|
*
|
||||||
|
* @param query {string} the query term
|
||||||
|
* @return {boolean} true if the option matches, or false otherwise
|
||||||
|
*/
|
||||||
|
isMatched(query) {
|
||||||
|
if (query === "") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const queryValue of this.#queryValues) {
|
||||||
|
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
displayField.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the title.
|
* Sets whether the option is shown.
|
||||||
*
|
*
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
* @param isShown {boolean} true to show, or false otherwise
|
||||||
* @private
|
*/
|
||||||
*/
|
setShown(isShown) {
|
||||||
function validateTitle() {
|
if (isShown) {
|
||||||
const field = document.getElementById("accounting-title");
|
this.#element.classList.remove("d-none");
|
||||||
const error = document.getElementById("accounting-title-error");
|
} else {
|
||||||
field.value = field.value.trim();
|
this.#element.classList.add("d-none");
|
||||||
if (field.value === "") {
|
}
|
||||||
field.classList.add("is-invalid");
|
}
|
||||||
error.innerText = A_("Please fill in the title.");
|
|
||||||
return false;
|
/**
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
/* The Mia! Accounting Project
|
||||||
* account-order.js: The JavaScript for the account order
|
* account-order.js: The JavaScript for the account order
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -20,18 +20,20 @@
|
|||||||
/* 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`);
|
||||||
const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code");
|
const code = document.getElementById(`accounting-order-${accounts[i].dataset.id}-code`);
|
||||||
no.value = String(i + 1);
|
no.value = String(i + 1);
|
||||||
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3);
|
const zeroPaddedNo = `000${no.value}`.slice(-3)
|
||||||
|
code.innerText = `${list.dataset.baseCode}-${zeroPaddedNo}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
initializeDragAndDropReordering(list, onReorder);
|
initializeDragAndDropReordering(list, onReorder);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
/* The Mia! Accounting Project
|
||||||
* currency-form.js: The JavaScript for the currency form
|
* currency-form.js: The JavaScript for the currency form
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -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.#validate().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 #validate() {
|
||||||
|
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;
|
|
||||||
}
|
}
|
||||||
|
1389
src/accounting/static/js/description-editor.js
Normal file
1389
src/accounting/static/js/description-editor.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
/* The Mia! Accounting Project
|
||||||
* drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
|
* drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -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("list-group-item-dark");
|
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("list-group-item-dark");
|
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("list-group-item-dark");
|
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("list-group-item-dark");
|
item.classList.remove("accounting-dragged");
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -91,7 +92,7 @@ function initializeTouchDragAndDropReordering(list, onReorder) {
|
|||||||
* @param target {Element} the other item that was dragged over
|
* @param target {Element} the other item that was dragged over
|
||||||
*/
|
*/
|
||||||
function onDragOver(dragged, target) {
|
function onDragOver(dragged, target) {
|
||||||
if (target.parentElement !== dragged.parentElement || target === dragged) {
|
if (dragged === null || target.parentElement !== dragged.parentElement || target === dragged) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let isBefore = false;
|
let isBefore = false;
|
||||||
|
312
src/accounting/static/js/journal-entry-account-selector.js
Normal file
312
src/accounting/static/js/journal-entry-account-selector.js
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
/* The Mia! Accounting Project
|
||||||
|
* journal-entry-account-selector.js: The JavaScript for the account selector of the journal entry 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 JournalEntryAccountSelector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line item editor
|
||||||
|
* @type {JournalEntryLineItemEditor}
|
||||||
|
*/
|
||||||
|
lineItemEditor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Either "debit" or "credit"
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#debitCredit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {JournalEntryAccountOption[]}
|
||||||
|
*/
|
||||||
|
#options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The more item to show all accounts
|
||||||
|
* @type {HTMLLIElement}
|
||||||
|
*/
|
||||||
|
#more;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show all accounts
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
#isShowMore = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an account selector.
|
||||||
|
*
|
||||||
|
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||||
|
* @param debitCredit {string} either "debit" or "credit"
|
||||||
|
*/
|
||||||
|
constructor(lineItemEditor, debitCredit) {
|
||||||
|
this.lineItemEditor = lineItemEditor
|
||||||
|
this.#debitCredit = debitCredit;
|
||||||
|
const prefix = `accounting-account-selector-${debitCredit}`;
|
||||||
|
this.#query = document.getElementById(`${prefix}-query`);
|
||||||
|
this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
|
||||||
|
this.#optionList = document.getElementById(`${prefix}-option-list`);
|
||||||
|
this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new JournalEntryAccountOption(this, element));
|
||||||
|
this.#more = document.getElementById(`${prefix}-more`);
|
||||||
|
this.#clearButton = document.getElementById(`${prefix}-btn-clear`);
|
||||||
|
|
||||||
|
this.#more.onclick = () => {
|
||||||
|
this.#isShowMore = true;
|
||||||
|
this.#more.classList.add("d-none");
|
||||||
|
this.#filterOptions();
|
||||||
|
};
|
||||||
|
this.#query.oninput = () => this.#filterOptions();
|
||||||
|
this.#clearButton.onclick = () => this.lineItemEditor.clearAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the options.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#filterOptions() {
|
||||||
|
const codesInUse = this.#getCodesUsedInForm();
|
||||||
|
let isAnyMatched = false;
|
||||||
|
for (const option of this.#options) {
|
||||||
|
if (option.isMatched(this.#isShowMore, codesInUse, this.#query.value)) {
|
||||||
|
option.setShown(true);
|
||||||
|
isAnyMatched = true;
|
||||||
|
} else {
|
||||||
|
option.setShown(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isAnyMatched) {
|
||||||
|
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.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
|
||||||
|
if (this.lineItemEditor.account !== null) {
|
||||||
|
inUse.push(this.lineItemEditor.account.code);
|
||||||
|
}
|
||||||
|
return inUse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback when the account selector is shown.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
onOpen() {
|
||||||
|
this.#query.value = "";
|
||||||
|
this.#isShowMore = false;
|
||||||
|
this.#more.classList.remove("d-none");
|
||||||
|
this.#filterOptions();
|
||||||
|
for (const option of this.#options) {
|
||||||
|
option.setActive(this.lineItemEditor.account !== null && option.code === this.lineItemEditor.account.code);
|
||||||
|
}
|
||||||
|
if (this.lineItemEditor.account === 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 lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||||
|
* @return {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}}
|
||||||
|
*/
|
||||||
|
static getInstances(lineItemEditor) {
|
||||||
|
const selectors = {}
|
||||||
|
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
|
||||||
|
for (const modal of modals) {
|
||||||
|
selectors[modal.dataset.debitCredit] = new JournalEntryAccountSelector(lineItemEditor, modal.dataset.debitCredit);
|
||||||
|
}
|
||||||
|
return selectors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An account option
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class JournalEntryAccountOption {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The element
|
||||||
|
* @type {HTMLLIElement}
|
||||||
|
*/
|
||||||
|
#element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account code
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account text
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
text;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the account is in use
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
#isInUse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether line items in the account need offset
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isNeedOffset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The values to query against
|
||||||
|
* @type {string[]}
|
||||||
|
*/
|
||||||
|
#queryValues;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the account in the account selector.
|
||||||
|
*
|
||||||
|
* @param selector {JournalEntryAccountSelector} the account selector
|
||||||
|
* @param element {HTMLLIElement} the element
|
||||||
|
*/
|
||||||
|
constructor(selector, element) {
|
||||||
|
this.#element = element;
|
||||||
|
this.code = element.dataset.code;
|
||||||
|
this.text = element.dataset.text;
|
||||||
|
this.#isInUse = element.classList.contains("accounting-account-is-in-use");
|
||||||
|
this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset");
|
||||||
|
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
||||||
|
|
||||||
|
this.#element.onclick = () => selector.lineItemEditor.saveAccount(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the account matches the query.
|
||||||
|
*
|
||||||
|
* @param isShowMore {boolean} true to show all accounts, or false to show only those in use
|
||||||
|
* @param codesInUse {string[]} the account codes that are used in the form
|
||||||
|
* @param query {string} the query term
|
||||||
|
* @return {boolean} true if the option matches, or false otherwise
|
||||||
|
*/
|
||||||
|
isMatched(isShowMore, codesInUse, query) {
|
||||||
|
return this.#isInUseMatched(isShowMore, codesInUse) && this.#isQueryMatched(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the account matches the "in-use" condition.
|
||||||
|
*
|
||||||
|
* @param isShowMore {boolean} true to show all accounts, or false to show only those in use
|
||||||
|
* @param codesInUse {string[]} the account codes that are used in the form
|
||||||
|
* @return {boolean} true if the option matches, or false otherwise
|
||||||
|
*/
|
||||||
|
#isInUseMatched(isShowMore, codesInUse) {
|
||||||
|
return isShowMore || this.#isInUse || codesInUse.includes(this.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the account matches the query term.
|
||||||
|
*
|
||||||
|
* @param query {string} the query term
|
||||||
|
* @return {boolean} true if the option matches, or false otherwise
|
||||||
|
*/
|
||||||
|
#isQueryMatched(query) {
|
||||||
|
if (query === "") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const queryValue of this.#queryValues) {
|
||||||
|
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1156
src/accounting/static/js/journal-entry-form.js
Normal file
1156
src/accounting/static/js/journal-entry-form.js
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user