Compare commits
1032 Commits
Author | SHA1 | Date | |
---|---|---|---|
937908717b | |||
0104fa4c21 | |||
14365ca255 | |||
cd86651606 | |||
9147744ff7 | |||
1a212a5330 | |||
0614457b7b | |||
545f49043b | |||
cac0d66ca1 | |||
5ffd37c859 | |||
9ae8c1bce9 | |||
ec0ff3e2e6 | |||
40a8080751 | |||
736a4086ee | |||
6723077b72 | |||
0ae00bce79 | |||
356d2010cc | |||
501c4b1d22 | |||
64b9c8c11f | |||
9072de82d4 | |||
30fd9c2164 | |||
7cb01b4cee | |||
9a4e04c41f | |||
a9c4fa9de0 | |||
3a676e0b5a | |||
9cc7b64bb3 | |||
352867797d | |||
09a344d749 | |||
818c357613 | |||
822c8fc49b | |||
3b8a2e3bb1 | |||
9e4927ee0b | |||
3b030c577c | |||
60b33f2a3b | |||
08fdf59844 | |||
b397515457 | |||
abe90d3483 | |||
65e7dcdf6d | |||
74e414badf | |||
69175979ff | |||
2f69e0f215 | |||
961385c389 | |||
a691cfd2da | |||
482a0faa23 | |||
0ecf7b6617 | |||
4408bbfc82 | |||
433110f486 | |||
0b1dd4f4fc | |||
46bd27e126 | |||
b718d19450 | |||
2969e83afe | |||
a732656746 | |||
1daed940b6 | |||
f29cb00aec | |||
693f07a49c | |||
8c899776f2 | |||
f9aa226bf9 | |||
c9bb4197be | |||
9ae8d587d8 | |||
158058dcfb | |||
0bc9947234 | |||
8c58a9083a | |||
f45663754c | |||
cda9e4e3c6 | |||
ee5b447c23 | |||
25bfcf4aa4 | |||
5956d2cd4c | |||
833285d924 | |||
dee4f5e83f | |||
f0d1cae32d | |||
5dc71697b3 | |||
1bb1e03c08 | |||
914ff92e0f | |||
8a1cf463b1 | |||
d4cf224d6b | |||
8d412ec00a | |||
2986c518ce | |||
f1351243a6 | |||
969e8c76a6 | |||
10f5e75752 | |||
169b3c292a | |||
3eb3aef2f2 | |||
6c455a615c | |||
4f3339bf68 | |||
b5aa7e923f | |||
359c335662 | |||
c11ae23885 | |||
e083b11394 | |||
167990fc4c | |||
d5c1be3d80 | |||
f6567794e0 | |||
ded85d88f7 | |||
6d780e9296 | |||
86637267d3 | |||
71e97721aa | |||
5815608288 | |||
5f75d93c6a | |||
118c4b458e | |||
3f7e4c0dda | |||
eed4c923f6 | |||
09dd5ae541 | |||
172a12b134 | |||
f3c558f48a | |||
988757d30e | |||
50cea90d1b | |||
71dfb6f003 | |||
be628b4aa1 | |||
5d444adec4 | |||
014d67f7b8 | |||
26b4d4388f | |||
6e2e92d0fb | |||
b1f87cb707 | |||
928dea8312 | |||
b8cec8a2af | |||
b6ae946f32 | |||
a9acc18a6f | |||
5468010c87 | |||
b505e380df | |||
412da170e1 | |||
fa237795cf | |||
e2f854b5cc | |||
f8895e3bff | |||
84ad065782 | |||
260e3cbe82 | |||
cd039520b6 | |||
05e652aa62 | |||
5c9bf0638c | |||
bbc78433fd | |||
7bcc2b28b2 | |||
c1d9ca284c | |||
165e28441a | |||
621020b0f0 | |||
6ad36cfaa3 | |||
20b0412091 | |||
3ca246d3e0 | |||
85d1b13ccd | |||
3bada28b8f | |||
8f2cef8d81 | |||
e62316c477 | |||
24ddb0c278 | |||
536f3390aa | |||
fadd8e73b6 | |||
12ccf658bf | |||
e30d1257e5 | |||
404b902d88 | |||
a560ff175a | |||
4be1ead6b5 | |||
700e4f822a | |||
c21ed59dfe | |||
c4a8326bfc | |||
371c80f668 | |||
40be3fb664 | |||
1e56403b35 | |||
650c26036a | |||
b19a6e5ffe | |||
1224d6f83e | |||
3a8618f7c3 | |||
5d87205659 | |||
04de4f5c5e | |||
f8ea863b80 | |||
5ae0d03b32 | |||
a9a3ad5871 | |||
5edc95afce | |||
943ace6fc7 | |||
a63bc977e9 | |||
dabe6ddbca | |||
f47e9b3150 | |||
bb5383febe | |||
87f9063ceb | |||
51f0185bcf | |||
7ca08d6cc8 | |||
c8e9e562be | |||
ba43bd7e90 | |||
4e550413ba | |||
59a3cbb472 | |||
d1b64d069e | |||
d823d3254f | |||
5e9a2fb0c3 | |||
3f2e659ba5 | |||
9f7bb6b9de | |||
6857164702 | |||
6bac76be64 | |||
370d2668e5 | |||
5e3e695e62 | |||
510d369e9c | |||
b65cae9252 | |||
285c12406b | |||
df240472a4 | |||
1218b224fc | |||
79689ac0e5 | |||
1660e66766 | |||
12d00c9c7d | |||
428018e4a9 | |||
a8f318b0bb | |||
a3507494e5 | |||
3aa6c8d6f6 | |||
052b62cdd4 | |||
3728a4037d | |||
6eee17d44f | |||
e5cc2b5a2f | |||
ac3b5523b1 | |||
5af6fd9619 | |||
71a20cba29 | |||
4a4cf1ea40 | |||
e9824808ec | |||
c984d2d596 | |||
720e77c814 | |||
0f0412827d | |||
3a0e978f76 | |||
8c10d42d7b | |||
04ec51afbe | |||
fe7a8842ce | |||
66daa5c42c | |||
27fb44937d | |||
7026ed3a65 | |||
fdd3e93778 | |||
def7559457 | |||
7905820d68 | |||
7ae332c975 | |||
86c5b91697 | |||
9168840e64 | |||
21b9cfa8b8 | |||
b0b3b3acb1 | |||
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 | |||
8dc340dbf1 | |||
4b5b348270 | |||
d9585f0e53 | |||
5737d6cef4 | |||
1d61fa93d3 | |||
b1c7bc61c4 | |||
708a434b5d | |||
8e524674a3 | |||
699db20308 | |||
c3cedf714b | |||
c67ed4471c | |||
2d3b9f68b8 | |||
f82278b48a | |||
85480804e7 | |||
9e85c14431 | |||
31dc8fab04 | |||
dc24af1db0 | |||
59795635ee | |||
399afe56c8 | |||
16e2a146db | |||
f7ce94902f | |||
5cf3cb1e11 | |||
a78057a8c3 | |||
0491614ae4 | |||
fb9ff1d7ff | |||
be10984cbb | |||
7b2089bdfb | |||
be8dc21c5a | |||
2f8c6f6981 | |||
cdd010427b | |||
d78b941674 | |||
570c84c196 | |||
7873e16cc3 | |||
52351c52bc | |||
591fb4a7ab | |||
2a6c5de6d6 | |||
6b94cfb908 | |||
eb90e83c98 | |||
6bf18be455 | |||
895bca2508 | |||
6af29e7df7 | |||
50f8f06687 | |||
cd5b1b97fd | |||
b7dd53d2f9 | |||
b07b0e3be4 | |||
e7fb2288ce | |||
17ba7659b6 | |||
2c8d5e7c8a | |||
e2f707f696 | |||
b5c0d0b7b3 | |||
7fe2bb6135 | |||
4d870f1dcc | |||
16b2eb1c93 | |||
fd63149066 | |||
a7a432914d | |||
1a44f08b90 | |||
3e68cfe690 | |||
809f2b6df3 | |||
c286aa8b8b | |||
1326d9538c | |||
b9cecf343a | |||
3d9e6c10da | |||
5090e59bb1 | |||
62697fb782 | |||
8c462e7b2c | |||
90a8229db9 | |||
8be44ccf5f | |||
511328a0bd | |||
0d8cf85ec0 | |||
6e212f0e33 | |||
2fbe137243 | |||
f4e2c21ece | |||
fff07a2552 | |||
975b00bce9 | |||
d648538fbb | |||
dde9c38bb8 | |||
fecf33baa8 | |||
cea2a44226 | |||
b5d87d2387 | |||
784e7bde49 | |||
60280f415d | |||
f32d268494 | |||
1c1be87f3e | |||
589da0c1c6 | |||
8363ce6602 | |||
6a83f95c9f | |||
7dc754174c | |||
5238168b2d | |||
eeb05b8616 | |||
9920377266 | |||
9f9c40c30e | |||
d368c5e062 | |||
4aed2f6ba7 | |||
6876fdf75e | |||
d9624c7be6 | |||
8364025668 | |||
dd3690dd6a | |||
3312c835fd | |||
fce9d04896 | |||
c68786f78a | |||
581e803707 | |||
15007ada4f | |||
e50d6267d5 | |||
2359842e80 | |||
9497fa371e | |||
b0ef4fb059 | |||
9f63db174c | |||
9b22331a5a | |||
cb0dea58f1 | |||
241ad337c8 | |||
2964f206a6 | |||
9118b631e4 | |||
ce6c8508df | |||
e29b99b0a7 | |||
e9f6b769f4 |
5
.gitignore
vendored
5
.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.
|
||||||
@ -37,4 +37,5 @@ excludes
|
|||||||
*.pot
|
*.pot
|
||||||
*.mo
|
*.mo
|
||||||
zh_Hans
|
zh_Hans
|
||||||
node_modules
|
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 *
|
||||||
|
66
README.rst
66
README.rst
@ -1,24 +1,59 @@
|
|||||||
=====================
|
===============
|
||||||
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 is designed both for mobile and desktop environments. It
|
||||||
|
implements `double-entry bookkeeping`_. It generates the following
|
||||||
|
accounting reports:
|
||||||
|
|
||||||
|
* Trial balance
|
||||||
|
* Income statement
|
||||||
|
* Balance sheet
|
||||||
|
|
||||||
|
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
|
||||||
|
receivables.
|
||||||
|
|
||||||
|
|
||||||
Install
|
Live Demonstration and Test Site
|
||||||
=======
|
================================
|
||||||
|
|
||||||
Install the latest source from the
|
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
|
||||||
`Mia! Accounting Flask repository`_.
|
same code as the `test site`_ in the `source distribution`_. It is
|
||||||
|
the simplest website that works with *Mia! Accounting*. It is also
|
||||||
|
used in the automatic tests.
|
||||||
|
|
||||||
|
If you do not have a running Flask application or do not know how to
|
||||||
|
start one, you may start with the test site.
|
||||||
|
|
||||||
|
|
||||||
|
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 from the `PyPI project page`_ or the
|
||||||
|
`release page`_ on the `Git repository`_.
|
||||||
|
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
=============
|
||||||
|
|
||||||
|
Refer to the `documentation on Read the Docs`_.
|
||||||
|
|
||||||
|
|
||||||
|
Change Log
|
||||||
|
==========
|
||||||
|
|
||||||
|
Refer to the `change log`_.
|
||||||
|
|
||||||
|
|
||||||
Copyright
|
Copyright
|
||||||
@ -46,5 +81,14 @@ 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
|
||||||
|
.. _live demonstration: https://accounting.imacat.idv.tw
|
||||||
|
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
|
||||||
|
.. _source distribution: https://pypi.org/project/mia-accounting/#files
|
||||||
|
.. _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
|
||||||
|
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io
|
||||||
|
.. _change log: https://mia-accounting.readthedocs.io/en/latest/changelog.html
|
||||||
|
53
docs/source/accounting.account.rst
Normal file
53
docs/source/accounting.account.rst
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
accounting.account package
|
||||||
|
==========================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.account.commands module
|
||||||
|
----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.account.commands
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.account.converters module
|
||||||
|
------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.account.converters
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.account.forms module
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.account.forms
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.account.queries module
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.account.queries
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.account.views module
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.account.views
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.account
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
@ -12,26 +12,18 @@ accounting.base\_account.commands module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.base\_account.database module
|
accounting.base\_account.converters module
|
||||||
----------------------------------------
|
------------------------------------------
|
||||||
|
|
||||||
.. automodule:: accounting.base_account.database
|
.. automodule:: accounting.base_account.converters
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.base\_account.models module
|
accounting.base\_account.queries module
|
||||||
--------------------------------------
|
---------------------------------------
|
||||||
|
|
||||||
.. automodule:: accounting.base_account.models
|
.. automodule:: accounting.base_account.queries
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.base\_account.query module
|
|
||||||
-------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.base_account.query
|
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
53
docs/source/accounting.currency.rst
Normal file
53
docs/source/accounting.currency.rst
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
accounting.currency package
|
||||||
|
===========================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.currency.commands module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.currency.commands
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.currency.converters module
|
||||||
|
-------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.currency.converters
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.currency.forms module
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.currency.forms
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.currency.queries module
|
||||||
|
----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.currency.queries
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.currency.views module
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.currency.views
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.currency
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
: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:
|
45
docs/source/accounting.journal_entry.utils.rst
Normal file
45
docs/source/accounting.journal_entry.utils.rst
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
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.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:
|
101
docs/source/accounting.report.reports.rst
Normal file
101
docs/source/accounting.report.reports.rst
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
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:
|
||||||
|
|
||||||
|
accounting.report.reports.unapplied module
|
||||||
|
------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.unapplied
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.reports.unapplied\_accounts module
|
||||||
|
----------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.unapplied_accounts
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.reports.unmatched module
|
||||||
|
------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.unmatched
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.reports.unmatched\_accounts module
|
||||||
|
----------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.unmatched_accounts
|
||||||
|
: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:
|
93
docs/source/accounting.report.utils.rst
Normal file
93
docs/source/accounting.report.utils.rst
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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.offset\_matcher module
|
||||||
|
----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.offset_matcher
|
||||||
|
: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.unapplied module
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.unapplied
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.utils.unmatched module
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.unmatched
|
||||||
|
: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:
|
@ -7,12 +7,33 @@ Subpackages
|
|||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 4
|
:maxdepth: 4
|
||||||
|
|
||||||
|
accounting.account
|
||||||
accounting.base_account
|
accounting.base_account
|
||||||
|
accounting.currency
|
||||||
|
accounting.journal_entry
|
||||||
|
accounting.option
|
||||||
|
accounting.report
|
||||||
accounting.utils
|
accounting.utils
|
||||||
|
|
||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
accounting.commands module
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.commands
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.forms module
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.forms
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.locale module
|
accounting.locale module
|
||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
@ -21,6 +42,30 @@ accounting.locale module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.models module
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.models
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
: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,6 +4,62 @@ accounting.utils package
|
|||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
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_uri
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.offset\_alias module
|
||||||
|
-------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.offset_alias
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.options module
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.options
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.utils.pagination module
|
accounting.utils.pagination module
|
||||||
----------------------------------
|
----------------------------------
|
||||||
|
|
||||||
@ -28,6 +84,38 @@ accounting.utils.query module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.random\_id module
|
||||||
|
----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.random_id
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.strip\_text module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.strip_text
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.title\_case module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.title_case
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.user module
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.user
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
Module contents
|
Module contents
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
410
docs/source/changelog.rst
Normal file
410
docs/source/changelog.rst
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
Change Log
|
||||||
|
==========
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.8
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/10/24
|
||||||
|
|
||||||
|
Bug fix.
|
||||||
|
|
||||||
|
* Fixed an icon in the detail of the cash receipt journal entry.
|
||||||
|
|
||||||
|
Released at Jaipur, India on vacation.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.7
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/7/29
|
||||||
|
|
||||||
|
Revised account title capitalization to capitalize account titles
|
||||||
|
upon initialization of base accounts, rather than when displaying
|
||||||
|
the accounts. This prevents the system from incorrectly
|
||||||
|
capitalizing titles of user-added accounts.
|
||||||
|
|
||||||
|
For existing installation, run the ``accounting-titleize`` console
|
||||||
|
command to capitalize the existing account titles that were already
|
||||||
|
initialized.
|
||||||
|
|
||||||
|
Other fixes:
|
||||||
|
|
||||||
|
* Added missing documentation to the global variables, class
|
||||||
|
properties, and object properties.
|
||||||
|
* Various minor fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.6
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/5/23
|
||||||
|
|
||||||
|
Bug fixes.
|
||||||
|
|
||||||
|
* Fixed the return URI of the creation forms to decode the next URI.
|
||||||
|
* Fixed the unmatched offset list to use the encoded next URI.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.5
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/5/23
|
||||||
|
|
||||||
|
Security fixes.
|
||||||
|
|
||||||
|
* Revised the next URI utilities to encode and decode the next URI
|
||||||
|
preventing tampering with the next URI.
|
||||||
|
* Added the integrity value of the CDN stylesheet links.
|
||||||
|
* Various fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.4
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/5/18
|
||||||
|
|
||||||
|
Security fixes.
|
||||||
|
|
||||||
|
* Added safeguard to the next URI utilities, to prevent Cross-Site
|
||||||
|
Scripting (XSS) attacks.
|
||||||
|
* Applied the safe next URI utilities to the test site.
|
||||||
|
* Added the ``SameSite`` and ``Secure`` flags to the session cookie
|
||||||
|
of the test site.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.3
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/30
|
||||||
|
|
||||||
|
* Fixed the error of the net balance in the unmatched offset list.
|
||||||
|
* Revised the original line item editor not to override the existing
|
||||||
|
amount when the existing amount is less or equal to the net
|
||||||
|
balance.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.2
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/30
|
||||||
|
|
||||||
|
* Fixed the error of the net balance in the unmatched offset list.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.1
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/30
|
||||||
|
|
||||||
|
* Fixed the error calling the old ``setEnableDescriptionAccount``
|
||||||
|
method in the ``saveOriginalLineItem`` method of the JavaScript
|
||||||
|
``JournalEntryLineItemEditor`` class.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/23
|
||||||
|
|
||||||
|
* Updated to require ``SQLAlchemy >= 2``.
|
||||||
|
* Added the change log.
|
||||||
|
* Added the ``VERSION`` constant to the ``accounting`` module for
|
||||||
|
the package version, and revised ``pyproject.toml`` and ``conf.py``
|
||||||
|
to read the version from it.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.4.1
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/22
|
||||||
|
|
||||||
|
* Updated to allow editing the description of the journal entry line
|
||||||
|
item with offsets or are offsetting to original line items.
|
||||||
|
* Updated not to override the existing description of a journal entry
|
||||||
|
line item after choosing the original line item to offset to.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.4.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/18
|
||||||
|
|
||||||
|
* Rewrote the unapplied original line items and unmatched offsets.
|
||||||
|
|
||||||
|
* The unapplied original line items and unmatched offsets are both
|
||||||
|
in the report submodule. They can be filtered with currency and
|
||||||
|
period now.
|
||||||
|
* Show the unapplied original line items and unmatched offsets
|
||||||
|
together, and added the accumulated balance in the unmatched
|
||||||
|
offset list, for ease of reference.
|
||||||
|
|
||||||
|
* Removed the account code from the journal entry detail and journal
|
||||||
|
entry form for mobile devices.
|
||||||
|
* Made the account options in the reports to be scrollable.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.3.3
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/13
|
||||||
|
|
||||||
|
Changed the sample data generation in the test site live demonstration
|
||||||
|
from pre-recorded data to real-time generation, to avoid the problem
|
||||||
|
with the start of months and weeks changed with the date of the
|
||||||
|
import.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.3.2
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/12
|
||||||
|
|
||||||
|
Added the sample data generation and database reset on the test site
|
||||||
|
for live demonstration.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.3.1
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/11
|
||||||
|
|
||||||
|
* Fixed the permission of the navigation menu of the unmatched offsets.
|
||||||
|
* Revised the test site to be more accessible as the live demonstration.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.3.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/11
|
||||||
|
|
||||||
|
Added the ``accounting-init-db`` console command to replace all the
|
||||||
|
other console commands to initialize the accounting database. The
|
||||||
|
test site does not work with previous versions (<1.3.0).
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.2.1
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/9
|
||||||
|
|
||||||
|
Fixed the search result to allow full ``year/month/day``
|
||||||
|
specification.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.2.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/9
|
||||||
|
|
||||||
|
* Simplified the URL of the default reports.
|
||||||
|
* Fixed the crash with malformed Chinese translation.
|
||||||
|
* Fixed the crash when downloading CSV data with non-US-ASCII
|
||||||
|
filenames.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.1.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/9
|
||||||
|
|
||||||
|
* Added the unapplied original line item list, to track unpaid
|
||||||
|
payables, unreceived receivables, assets, prepaids, refundable
|
||||||
|
deposits, etc.
|
||||||
|
* Added the offset matcher to match unapplied original line items
|
||||||
|
with unmatched offsets.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.0.1
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/6
|
||||||
|
|
||||||
|
Documentation fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.0.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/6
|
||||||
|
|
||||||
|
The first formal release in Flask.
|
||||||
|
|
||||||
|
Added the documentation.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.11.1 (Pre-release)
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
Released 2023/4/5
|
||||||
|
|
||||||
|
Removed the zero balances from the trial balance, the income
|
||||||
|
statement, and the balance sheet.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.11.0 (Pre-release)
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
Released 2023/4/5
|
||||||
|
|
||||||
|
* Renamed the project from ``mia-accounting-flask`` to
|
||||||
|
``mia-accounting``.
|
||||||
|
* Updated the URL of the reports, as the default views of the
|
||||||
|
accounting application.
|
||||||
|
* Updated ``README``.
|
||||||
|
* Various fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.10.0 (Pre-release)
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
Released 2023/4/3
|
||||||
|
|
||||||
|
* Added the unauthorized method to the ``UserUtilityInterface``
|
||||||
|
interface to allow fine control to how to handle the case when the
|
||||||
|
user has not logged in.
|
||||||
|
* Revised the JavaScript description editor to respect the account
|
||||||
|
that the user has confirmed or specifically selected.
|
||||||
|
* Various fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.9.1 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/24
|
||||||
|
|
||||||
|
* A distinguishable look in the option detail than the option form.
|
||||||
|
* A better look in the new journal entry forms when there is no line
|
||||||
|
item yet.
|
||||||
|
* Fixed the search in the original entry selector in the journal
|
||||||
|
entry form to always do a partial match, to fix the problem that
|
||||||
|
there is no match when typing is not finished yet.
|
||||||
|
* Fixed the search in the original entry selector to search the net
|
||||||
|
balance correctly.
|
||||||
|
* Replaced the ``editor`` and ``editor2`` accounts with the ``admin``
|
||||||
|
and ``editor`` accounts.
|
||||||
|
* Various fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.9.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/23
|
||||||
|
|
||||||
|
Moved the settings from the ``.env`` file to the option table in the
|
||||||
|
database that can be set and updated on the web interface. Added the
|
||||||
|
settings page to show and update the settings.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.8.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/22
|
||||||
|
|
||||||
|
* Added the recurring transactions to the description editor.
|
||||||
|
* Added prevention to delete database objects that are essential or
|
||||||
|
referenced by others with foreign keys.
|
||||||
|
* Various fixes on the visual layout.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.7.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/21
|
||||||
|
|
||||||
|
* Renamed "transaction" to "journal entry", and "journal entry" to
|
||||||
|
"journal entry line item".
|
||||||
|
* Renamed ``summary`` to ``description``.
|
||||||
|
* Updated tempus-dominus from version 6.2.10 to 6.4.3.
|
||||||
|
* Fixed titles and capitalization.
|
||||||
|
* Fixed to search case-insensitively.
|
||||||
|
* Added favicon to the test site.
|
||||||
|
* Fixed the navigation menu when there is no matching endpoint.
|
||||||
|
* Various fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.6.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/18
|
||||||
|
|
||||||
|
* Added offset tracking to the journal entries in the payable and
|
||||||
|
receivable accounts.
|
||||||
|
* Renamed the ``is_offset_needed`` column to ``is_need_offset`` in
|
||||||
|
the ``Account`` data model.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.5.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/10
|
||||||
|
|
||||||
|
Added the accounting reports.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.4.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/1
|
||||||
|
|
||||||
|
Added the transaction summary helper.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.3.1 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/2/28
|
||||||
|
|
||||||
|
* Fixed the error that cannot select any account when adding new
|
||||||
|
transactions.
|
||||||
|
* Fixed the database error when adding new transactions.
|
||||||
|
* Added the button to convert a cash income or cash expense
|
||||||
|
transaction to a transfer transaction.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.3.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/2/27
|
||||||
|
|
||||||
|
Added the transaction management.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.2.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/2/7
|
||||||
|
|
||||||
|
* Added the currency management.
|
||||||
|
* Changed the ``can_edit`` permission to at least require the user to
|
||||||
|
log in first.
|
||||||
|
* Changed the type hint of the ``current_user`` pseudo property of
|
||||||
|
the ``AbstractUserUtils`` class to return ``None`` when the user
|
||||||
|
has not logged in.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.1.1 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/2/3
|
||||||
|
|
||||||
|
Finalized the account management, with tests and reordering.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.1.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/2/3
|
||||||
|
|
||||||
|
Added the account management, and updated the API to initialize the
|
||||||
|
accounting application.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.0.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/2/3
|
||||||
|
|
||||||
|
Initial release with main account list, localization, pagination,
|
||||||
|
query, permission, Sphinx documentation, and a test case based on a
|
||||||
|
test demonstration site.
|
@ -6,14 +6,15 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath('../../src/'))
|
sys.path.insert(0, os.path.abspath('../../src/'))
|
||||||
|
import accounting
|
||||||
|
|
||||||
# -- 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 = accounting.VERSION
|
||||||
|
|
||||||
# -- 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 +29,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 UserUtils(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
|
57
docs/source/history.rst
Normal file
57
docs/source/history.rst
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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 needed something in 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! Accounting Django`_
|
||||||
|
application. It took me another 1.5 months to make it an independent
|
||||||
|
module, which I later released as an open source project on PyPI.
|
||||||
|
|
||||||
|
The application worked nicely for my household bookkeeping for two
|
||||||
|
years. However, new demands arose over time, especially with tracking
|
||||||
|
payables and receivables. This was critical `during the pandemic`_ as
|
||||||
|
more payments were made online with credit cards.
|
||||||
|
|
||||||
|
The biggest issue I encountered was with
|
||||||
|
`Django's MTV architectural pattern`_. Django takes over the control
|
||||||
|
flow. I had to override several parts of the `class-based views`_ for
|
||||||
|
different but yet simple control flow logic. In the end, it became
|
||||||
|
very difficult to track whether things went wrong because I overrode
|
||||||
|
something or because it just wouldn't work with the basic assumption
|
||||||
|
of the class-based views. By the time I realized it, it was too late
|
||||||
|
for me to drop Django's MTV and rewrite everything from class-based
|
||||||
|
views to function-based views.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
.. _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! Accounting Django: https://github.com/imacat/mia-accounting-django
|
||||||
|
.. _during the pandemic: https://en.wikipedia.org/wiki/COVID-19_pandemic
|
||||||
|
.. _FastAPI: https://fastapi.tiangolo.com
|
||||||
|
.. _Django's MTV architectural pattern: https://docs.djangoproject.com/en/dev/faq/general/#django-appears-to-be-a-mvc-framework-but-you-call-the-controller-the-view-and-the-view-the-template-how-come-you-don-t-use-the-standard-names
|
||||||
|
.. _class-based views: https://docs.djangoproject.com/en/4.2/topics/class-based-views/
|
||||||
|
.. _microframeworks: https://en.wikipedia.org/wiki/Microframework
|
||||||
|
.. _Flask: https://flask.palletsprojects.com
|
@ -1,15 +1,21 @@
|
|||||||
.. 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
|
||||||
|
history
|
||||||
|
changelog
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Indices and tables
|
Indices and tables
|
||||||
|
120
docs/source/intro.rst
Normal file
120
docs/source/intro.rst
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
Introduction
|
||||||
|
============
|
||||||
|
|
||||||
|
*Mia! Accounting* is an accounting module for Flask_ applications.
|
||||||
|
It is designed both for mobile and desktop environments. It
|
||||||
|
implements `double-entry bookkeeping`_. It generates the following
|
||||||
|
accounting reports:
|
||||||
|
|
||||||
|
* Trial balance
|
||||||
|
* Income statement
|
||||||
|
* Balance sheet
|
||||||
|
|
||||||
|
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
|
||||||
|
receivables.
|
||||||
|
|
||||||
|
|
||||||
|
Live Demonstration and Test Site
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
|
||||||
|
same code as the `test site`_ in the `source distribution`_. It is
|
||||||
|
the simplest website that works with *Mia! Accounting*. It is also
|
||||||
|
used in the automatic tests.
|
||||||
|
|
||||||
|
If you do not have a running Flask application or do not know how to
|
||||||
|
start one, you may start with the test site.
|
||||||
|
|
||||||
|
|
||||||
|
Installation
|
||||||
|
------------
|
||||||
|
|
||||||
|
Install *Mia! Accounting* with ``pip``:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
pip install mia-accounting
|
||||||
|
|
||||||
|
You may also download 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. You also
|
||||||
|
need at least one user.
|
||||||
|
|
||||||
|
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.4.0 or above
|
||||||
|
* `decimal.js`_ 10.4.3 or above, or `decimal.js-light`_ 2.5.1 or above.
|
||||||
|
* `Tempus-Dominus`_ 6.7.7 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, run the ``accounting-init-db`` console
|
||||||
|
command to initialize the accounting database. You need to specify
|
||||||
|
the username of a user as the data creator.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
% flask --app myapp accounting-init-db -u username
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
.. _Flask: https://flask.palletsprojects.com
|
||||||
|
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
|
||||||
|
.. _live demonstration: https://accounting.imacat.idv.tw
|
||||||
|
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
|
||||||
|
.. _source distribution: https://pypi.org/project/mia-accounting/#files
|
||||||
|
.. _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
|
||||||
|
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
|
||||||
|
.. _Bootstrap: https://getbootstrap.com
|
||||||
|
.. _FontAwesome: https://fontawesome.com
|
||||||
|
.. _decimal.js: https://mikemcl.github.io/decimal.js
|
||||||
|
.. _decimal.js-light: https://mikemcl.github.io/decimal.js-light
|
||||||
|
.. _Tempus-Dominus: https://getdatepicker.com
|
||||||
|
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
|
@ -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,56 @@
|
|||||||
# 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"
|
||||||
|
dynamic = ["version"]
|
||||||
|
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",
|
||||||
|
"SQLAlchemy >= 2",
|
||||||
|
"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"
|
||||||
|
"Change Log" = "https://mia-accounting.readthedocs.io/en/latest/changelog.html"
|
||||||
|
"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.dynamic]
|
||||||
|
version = {attr = "accounting.VERSION"}
|
||||||
|
|
||||||
|
[tool.setuptools.exclude-package-data]
|
||||||
|
"*" = [
|
||||||
|
"babel.cfg",
|
||||||
|
"*.pot",
|
||||||
|
"*.po",
|
||||||
|
]
|
||||||
|
56
setup.cfg
56
setup.cfg
@ -1,56 +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.0.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.10
|
|
||||||
install_requires =
|
|
||||||
flask
|
|
||||||
Flask-SQLAlchemy
|
|
||||||
Flask-WTF
|
|
||||||
Flask-Babel >= 3
|
|
||||||
Flask-Babel-JS
|
|
||||||
tests_require =
|
|
||||||
unittest
|
|
||||||
httpx
|
|
||||||
OpenCC
|
|
||||||
|
|
||||||
[options.package_data]
|
|
||||||
accounting =
|
|
||||||
templates/**
|
|
||||||
translations/*/LC_MESSAGES/*.mo
|
|
||||||
accounting.base_account =
|
|
||||||
templates/**
|
|
@ -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,41 +17,81 @@
|
|||||||
"""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 UserUtilityInterface
|
||||||
|
|
||||||
|
VERSION: str = "1.5.8"
|
||||||
|
"""The package version."""
|
||||||
|
db: SQLAlchemy = SQLAlchemy()
|
||||||
|
"""The database instance."""
|
||||||
|
data_dir: Path = Path(__file__).parent / "data"
|
||||||
|
"""The data directory."""
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask, url_prefix: str = "/accounting",
|
def init_app(app: Flask, user_utils: UserUtilityInterface,
|
||||||
can_view_func: t.Callable[[], bool] | None = None,
|
url_prefix: str = "/accounting") -> 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 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
|
||||||
|
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 .commands import init_db_command, titleize_command
|
||||||
|
app.cli.add_command(init_db_command)
|
||||||
|
app.cli.add_command(titleize_command)
|
||||||
|
|
||||||
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(app, 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)
|
||||||
|
|
||||||
app.register_blueprint(bp)
|
from . import account
|
||||||
|
account.init_app(app, bp)
|
||||||
|
|
||||||
|
from . import currency
|
||||||
|
currency.init_app(app, bp)
|
||||||
|
|
||||||
|
from . import journal_entry
|
||||||
|
journal_entry.init_app(app, 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)
|
||||||
|
36
src/accounting/account/__init__.py
Normal file
36
src/accounting/account/__init__.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
|
# 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 management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import Flask, Blueprint
|
||||||
|
|
||||||
|
from .commands import init_accounts_command
|
||||||
|
|
||||||
|
|
||||||
|
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 AccountConverter
|
||||||
|
app.url_map.converters["account"] = AccountConverter
|
||||||
|
|
||||||
|
from .views import bp as account_bp
|
||||||
|
bp.register_blueprint(account_bp, url_prefix="/accounts")
|
107
src/accounting/account/commands.py
Normal file
107
src/accounting/account/commands.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
|
# 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 console commands for the account management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from secrets import randbelow
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import click
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import BaseAccount, Account, AccountL10n
|
||||||
|
from accounting.utils.user import get_user_pk
|
||||||
|
|
||||||
|
AccountData = tuple[int, str, int, str, str, str, bool]
|
||||||
|
"""The format of the account data, as a list of (ID, base account code, number,
|
||||||
|
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
|
||||||
|
|
||||||
|
|
||||||
|
def init_accounts_command(username: str) -> None:
|
||||||
|
"""Initializes the accounts."""
|
||||||
|
creator_pk: int = get_user_pk(username)
|
||||||
|
|
||||||
|
bases: list[BaseAccount] = BaseAccount.query\
|
||||||
|
.filter(db.func.length(BaseAccount.code) == 4)\
|
||||||
|
.order_by(BaseAccount.code).all()
|
||||||
|
if len(bases) == 0:
|
||||||
|
raise click.Abort
|
||||||
|
|
||||||
|
existing: list[Account] = Account.query.all()
|
||||||
|
|
||||||
|
existing_base_code: set[str] = {x.base_code for x in existing}
|
||||||
|
bases_to_add: list[BaseAccount] = [x for x in bases
|
||||||
|
if x.code not in existing_base_code]
|
||||||
|
if len(bases_to_add) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_id: set[int] = {x.id for x in existing}
|
||||||
|
|
||||||
|
def get_new_id() -> int:
|
||||||
|
"""Returns a new random account ID.
|
||||||
|
|
||||||
|
:return: The newly-generated random account ID.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
new_id: int = 100000000 + randbelow(900000000)
|
||||||
|
if new_id not in existing_id:
|
||||||
|
existing_id.add(new_id)
|
||||||
|
return new_id
|
||||||
|
|
||||||
|
data: list[dict[str, Any]] = []
|
||||||
|
l10n_data: list[dict[str, Any]] = []
|
||||||
|
for base in bases_to_add:
|
||||||
|
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
||||||
|
account_id: int = get_new_id()
|
||||||
|
data.append({"id": account_id,
|
||||||
|
"base_code": base.code,
|
||||||
|
"no": 1,
|
||||||
|
"title_l10n": base.title_l10n,
|
||||||
|
"is_need_offset": __is_need_offset(base.code),
|
||||||
|
"created_by_id": creator_pk,
|
||||||
|
"updated_by_id": creator_pk})
|
||||||
|
for locale in {"zh_Hant", "zh_Hans"}:
|
||||||
|
l10n_data.append({"account_id": account_id,
|
||||||
|
"locale": locale,
|
||||||
|
"title": l10n[locale]})
|
||||||
|
db.session.execute(sa.insert(Account), data)
|
||||||
|
db.session.execute(sa.insert(AccountL10n), l10n_data)
|
||||||
|
|
||||||
|
|
||||||
|
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", "186"}:
|
||||||
|
return True
|
||||||
|
if base_code in {"1286", "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", "2861"}:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
# Only assets and liabilities need offset
|
||||||
|
return False
|
47
src/accounting/account/converters.py
Normal file
47
src/accounting/account/converters.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
|
||||||
|
|
||||||
|
# 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 account management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import abort
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
|
from accounting.models import Account
|
||||||
|
|
||||||
|
|
||||||
|
class AccountConverter(BaseConverter):
|
||||||
|
"""The account converter to convert the account code and to the
|
||||||
|
corresponding account in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> Account:
|
||||||
|
"""Converts an account code to an account.
|
||||||
|
|
||||||
|
:param value: The account code.
|
||||||
|
:return: The corresponding account.
|
||||||
|
"""
|
||||||
|
account: Account | None = Account.find_by_code(value)
|
||||||
|
if account is None:
|
||||||
|
abort(404)
|
||||||
|
return account
|
||||||
|
|
||||||
|
def to_url(self, value: Account) -> str:
|
||||||
|
"""Converts an account to its code.
|
||||||
|
|
||||||
|
:param value: The account.
|
||||||
|
:return: The code.
|
||||||
|
"""
|
||||||
|
return value.code
|
206
src/accounting/account/forms.py
Normal file
206
src/accounting/account/forms.py
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||||
|
|
||||||
|
# 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 account management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import request
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, BooleanField
|
||||||
|
from wtforms.validators import DataRequired, ValidationError
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import BaseAccount, Account
|
||||||
|
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 BaseAccountExists:
|
||||||
|
"""The validator to check if the base account exists."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data == "":
|
||||||
|
return
|
||||||
|
if db.session.get(BaseAccount, field.data) is None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The base account does not exist."))
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAccountAvailable:
|
||||||
|
"""The validator to check if the base account is available."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data == "":
|
||||||
|
return
|
||||||
|
if len(field.data) != 4:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"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):
|
||||||
|
"""The form to create or edit an account."""
|
||||||
|
base_code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[
|
||||||
|
DataRequired(lazy_gettext("Please select the base account.")),
|
||||||
|
BaseAccountExists(),
|
||||||
|
BaseAccountAvailable()])
|
||||||
|
"""The code of the base account."""
|
||||||
|
title = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
|
||||||
|
"""The title."""
|
||||||
|
is_need_offset = BooleanField(
|
||||||
|
validators=[NoOffsetNominalAccount()])
|
||||||
|
"""Whether the the journal entry line items of this account need offset."""
|
||||||
|
|
||||||
|
def populate_obj(self, obj: Account) -> None:
|
||||||
|
"""Populates the form data into an account object.
|
||||||
|
|
||||||
|
:param obj: The account object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
is_new: bool = obj.id is None
|
||||||
|
if is_new:
|
||||||
|
obj.id = new_id(Account)
|
||||||
|
if obj.base_code != self.base_code.data:
|
||||||
|
if obj.base_code is not None:
|
||||||
|
sort_accounts_in(obj.base_code, obj.id)
|
||||||
|
sort_accounts_in(self.base_code.data, obj.id)
|
||||||
|
count: int = Account.query\
|
||||||
|
.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
|
||||||
|
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:
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.created_by_id = current_user_pk
|
||||||
|
obj.updated_by_id = current_user_pk
|
||||||
|
|
||||||
|
def post_update(self, obj: Account) -> None:
|
||||||
|
"""The post-processing after the update.
|
||||||
|
|
||||||
|
:param obj: The account object.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.updated_by_id = current_user_pk
|
||||||
|
obj.updated_at = sa.func.now()
|
||||||
|
if hasattr(self, "__post_update"):
|
||||||
|
getattr(self, "__post_update")()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected_base(self) -> BaseAccount | None:
|
||||||
|
"""The selected base account in the form.
|
||||||
|
|
||||||
|
:return: The selected base account in the form.
|
||||||
|
"""
|
||||||
|
return db.session.get(BaseAccount, self.base_code.data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_options(self) -> list[BaseAccount]:
|
||||||
|
"""The selectable base accounts.
|
||||||
|
|
||||||
|
:return: The selectable base accounts.
|
||||||
|
"""
|
||||||
|
return BaseAccount.query\
|
||||||
|
.filter(sa.func.char_length(BaseAccount.code) == 4)\
|
||||||
|
.order_by(BaseAccount.code).all()
|
||||||
|
|
||||||
|
|
||||||
|
def sort_accounts_in(base_code: str, exclude: int) -> None:
|
||||||
|
"""Sorts the accounts under a base account after changing the base
|
||||||
|
account or deleting an account.
|
||||||
|
|
||||||
|
:param base_code: The code of the base account.
|
||||||
|
:param exclude: The account ID to exclude.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
accounts: list[Account] = Account.query\
|
||||||
|
.filter(Account.base_code == base_code,
|
||||||
|
Account.id != exclude)\
|
||||||
|
.order_by(Account.no).all()
|
||||||
|
for i in range(len(accounts)):
|
||||||
|
if accounts[i].no != i + 1:
|
||||||
|
accounts[i].no = i + 1
|
||||||
|
|
||||||
|
|
||||||
|
class AccountReorderForm:
|
||||||
|
"""The form to reorder the accounts."""
|
||||||
|
|
||||||
|
def __init__(self, base: BaseAccount):
|
||||||
|
"""Constructs the form to reorder the accounts under a base account.
|
||||||
|
|
||||||
|
:param base: The base account.
|
||||||
|
"""
|
||||||
|
self.base: BaseAccount = base
|
||||||
|
"""The base account."""
|
||||||
|
self.is_modified: bool = False
|
||||||
|
"""Whether the order is modified."""
|
||||||
|
|
||||||
|
def save_order(self) -> None:
|
||||||
|
"""Saves the order of the account.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
accounts: list[Account] = self.base.accounts
|
||||||
|
|
||||||
|
# Collects the specified order.
|
||||||
|
orders: dict[Account, int] = {}
|
||||||
|
for account in accounts:
|
||||||
|
if f"{account.id}-no" in request.form:
|
||||||
|
try:
|
||||||
|
orders[account] = int(request.form[f"{account.id}-no"])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Missing and invalid orders are appended to the end.
|
||||||
|
missing: list[Account] = [x for x in accounts if x not in orders]
|
||||||
|
if len(missing) > 0:
|
||||||
|
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
|
||||||
|
for account in missing:
|
||||||
|
orders[account] = next_no
|
||||||
|
|
||||||
|
# Sort by the specified order first, and their original order.
|
||||||
|
accounts = sorted(accounts, key=lambda x: (orders[x], x.no, x.code))
|
||||||
|
|
||||||
|
# Update the orders.
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
for i in range(len(accounts)):
|
||||||
|
if accounts[i].no != i + 1:
|
||||||
|
accounts[i].no = i + 1
|
||||||
|
self.is_modified = True
|
55
src/accounting/account/queries.py
Normal file
55
src/accounting/account/queries.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
|
# 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 queries for the account management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Account, AccountL10n
|
||||||
|
from accounting.utils.query import parse_query_keywords
|
||||||
|
|
||||||
|
|
||||||
|
def get_account_query() -> list[Account]:
|
||||||
|
"""Returns the accounts, optionally filtered by the query.
|
||||||
|
|
||||||
|
:return: The accounts.
|
||||||
|
"""
|
||||||
|
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||||
|
if len(keywords) == 0:
|
||||||
|
return Account.query.order_by(Account.base_code, Account.no).all()
|
||||||
|
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)
|
||||||
|
conditions: list[sa.BinaryExpression] = []
|
||||||
|
for k in keywords:
|
||||||
|
l10n: list[AccountL10n] = AccountL10n.query\
|
||||||
|
.filter(AccountL10n.title.icontains(k)).all()
|
||||||
|
l10n_matches: set[str] = {x.account_id for x in l10n}
|
||||||
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
|
= [Account.base_code.contains(k),
|
||||||
|
Account.title_l10n.icontains(k),
|
||||||
|
code.contains(k),
|
||||||
|
Account.id.in_(l10n_matches)]
|
||||||
|
if k in gettext("Needs Offset"):
|
||||||
|
sub_conditions.append(Account.is_need_offset)
|
||||||
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
|
|
||||||
|
return Account.query.filter(*conditions)\
|
||||||
|
.order_by(Account.base_code, Account.no).all()
|
214
src/accounting/account/views.py
Normal file
214
src/accounting/account/views.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
|
# 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 account management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import Blueprint, render_template, session, redirect, flash, \
|
||||||
|
url_for, request
|
||||||
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Account, BaseAccount
|
||||||
|
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.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 .queries import get_account_query
|
||||||
|
|
||||||
|
bp: Blueprint = Blueprint("account", __name__)
|
||||||
|
"""The view blueprint for the account management."""
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("", endpoint="list")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def list_accounts() -> str:
|
||||||
|
"""Lists the accounts.
|
||||||
|
|
||||||
|
:return: The account list.
|
||||||
|
"""
|
||||||
|
accounts: list[BaseAccount] = get_account_query()
|
||||||
|
pagination: Pagination = Pagination[BaseAccount](accounts)
|
||||||
|
return render_template("accounting/account/list.html",
|
||||||
|
list=pagination.list, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("create", endpoint="create")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def show_add_account_form() -> str:
|
||||||
|
"""Shows the form to add an account.
|
||||||
|
|
||||||
|
:return: The form to add an account.
|
||||||
|
"""
|
||||||
|
if "form" in session:
|
||||||
|
form = AccountForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = AccountForm()
|
||||||
|
return render_template("accounting/account/create.html",
|
||||||
|
form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("store", endpoint="store")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def add_account() -> redirect:
|
||||||
|
"""Adds an account.
|
||||||
|
|
||||||
|
:return: The redirection to the account detail on success, or the account
|
||||||
|
creation form on error.
|
||||||
|
"""
|
||||||
|
form = AccountForm(request.form)
|
||||||
|
if not form.validate():
|
||||||
|
flash_form_errors(form)
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(url_for("accounting.account.create")))
|
||||||
|
account: Account = Account()
|
||||||
|
form.populate_obj(account)
|
||||||
|
db.session.add(account)
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The account is added successfully")), "success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("<account:account>", endpoint="detail")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_account_detail(account: Account) -> str:
|
||||||
|
"""Shows the account detail.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:return: The detail.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/account/detail.html", obj=account)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("<account:account>/edit", endpoint="edit")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def show_account_edit_form(account: Account) -> str:
|
||||||
|
"""Shows the form to edit an account.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:return: The form to edit the account.
|
||||||
|
"""
|
||||||
|
form: AccountForm
|
||||||
|
if "form" in session:
|
||||||
|
form = AccountForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = AccountForm(obj=account)
|
||||||
|
return render_template("accounting/account/edit.html",
|
||||||
|
account=account, form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("<account:account>/update", endpoint="update")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def update_account(account: Account) -> redirect:
|
||||||
|
"""Updates an account.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:return: The redirection to the account detail on success, or the account
|
||||||
|
edit form on error.
|
||||||
|
"""
|
||||||
|
form = AccountForm(request.form)
|
||||||
|
if not form.validate():
|
||||||
|
flash_form_errors(form)
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(url_for("accounting.account.edit",
|
||||||
|
account=account)))
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
form.populate_obj(account)
|
||||||
|
if not account.is_modified:
|
||||||
|
flash(s(lazy_gettext("The account was not modified.")), "success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
|
account.updated_by_id = get_current_user_pk()
|
||||||
|
account.updated_at = sa.func.now()
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The account is updated successfully.")), "success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("<account:account>/delete", endpoint="delete")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def delete_account(account: Account) -> redirect:
|
||||||
|
"""Deletes an account.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:return: The redirection to the account list on success, or the account
|
||||||
|
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()
|
||||||
|
sort_accounts_in(account.base_code, account.id)
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The account is deleted successfully.")), "success")
|
||||||
|
return redirect(or_next(__get_list_uri()))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("bases/<baseAccount:base>", endpoint="order")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_account_order(base: BaseAccount) -> str:
|
||||||
|
"""Shows the order of the accounts under a same base account.
|
||||||
|
|
||||||
|
:param base: The base account.
|
||||||
|
:return: The order of the accounts under the base account.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/account/order.html", base=base)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("bases/<baseAccount:base>", endpoint="sort")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def sort_accounts(base: BaseAccount) -> redirect:
|
||||||
|
"""Reorders the accounts under a base account.
|
||||||
|
|
||||||
|
:param base: The base account.
|
||||||
|
:return: The redirection to the incoming account or the account list. The
|
||||||
|
reordering operation does not fail.
|
||||||
|
"""
|
||||||
|
form: AccountReorderForm = AccountReorderForm(base)
|
||||||
|
form.save_order()
|
||||||
|
if not form.is_modified:
|
||||||
|
flash(s(lazy_gettext("The order was not modified.")), "success")
|
||||||
|
return redirect(or_next(__get_list_uri()))
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The order is updated successfully.")), "success")
|
||||||
|
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.
|
||||||
@ -19,16 +19,18 @@
|
|||||||
"""
|
"""
|
||||||
from flask import Flask, Blueprint
|
from flask import Flask, Blueprint
|
||||||
|
|
||||||
|
from .commands import init_base_accounts_command
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||||
"""Initialize the application.
|
"""Initialize the application.
|
||||||
|
|
||||||
:param bp: The blueprint of the accounting application.
|
|
||||||
:param app: The Flask application.
|
:param app: The Flask application.
|
||||||
|
:param bp: The blueprint of the accounting application.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
|
from .converters import BaseAccountConverter
|
||||||
|
app.url_map.converters["baseAccount"] = BaseAccountConverter
|
||||||
|
|
||||||
from .views import bp as base_account_bp
|
from .views import bp as base_account_bp
|
||||||
bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")
|
bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")
|
||||||
|
|
||||||
from .commands import init_base_accounts_command
|
|
||||||
app.cli.add_command(init_base_accounts_command)
|
|
||||||
|
@ -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,693 +17,30 @@
|
|||||||
"""The console commands for the base account management.
|
"""The console commands for the base account management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import click
|
import csv
|
||||||
from flask.cli import with_appcontext
|
|
||||||
|
|
||||||
from accounting.database import db
|
import sqlalchemy as sa
|
||||||
from .models import BaseAccount, BaseAccountL10n
|
|
||||||
|
|
||||||
BaseAccountData = tuple[int, str, str, str]
|
from accounting import data_dir
|
||||||
"""The format of the base account data, as a list of (code, English,
|
from accounting import db
|
||||||
Traditional Chinese, Simplified Chinese) tuples."""
|
from accounting.models import BaseAccount, BaseAccountL10n
|
||||||
|
from accounting.utils.title_case import title_case
|
||||||
|
|
||||||
|
|
||||||
@click.command("accounting-init-base")
|
|
||||||
@with_appcontext
|
|
||||||
def init_base_accounts_command() -> None:
|
def init_base_accounts_command() -> None:
|
||||||
"""Initializes the base accounts."""
|
"""Initializes the base accounts."""
|
||||||
if BaseAccount.query.first() is not None:
|
if BaseAccount.query.first() is not None:
|
||||||
click.echo("Base accounts already exist.")
|
return
|
||||||
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": title_case(x["title"])}
|
||||||
for x in DATA for y in (("zh_Hant", x[2]), ("zh_Hans", x[3]))])
|
for x in data]
|
||||||
db.session.commit()
|
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
|
||||||
click.echo("Base accounts initialized.")
|
l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
|
||||||
|
"locale": y,
|
||||||
|
"title": x[f"l10n-{y}"]}
|
||||||
DATA: list[BaseAccountData] = [
|
for x in data for y in locales]
|
||||||
(1, "assets", "資產", "资产"),
|
db.session.execute(sa.insert(BaseAccount), account_data)
|
||||||
(2, "liabilities", "負債", "负债"),
|
db.session.execute(sa.insert(BaseAccountL10n), l10n_data)
|
||||||
(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."""
|
|
||||||
|
48
src/accounting/base_account/converters.py
Normal file
48
src/accounting/base_account/converters.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||||
|
|
||||||
|
# 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 base account management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import abort
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import BaseAccount
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAccountConverter(BaseConverter):
|
||||||
|
"""The account converter to convert the account code and to the
|
||||||
|
corresponding base account in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> BaseAccount:
|
||||||
|
"""Converts an account code to a base account.
|
||||||
|
|
||||||
|
:param value: The account code.
|
||||||
|
:return: The corresponding base account.
|
||||||
|
"""
|
||||||
|
account: BaseAccount | None = db.session.get(BaseAccount, value)
|
||||||
|
if account is None:
|
||||||
|
abort(404)
|
||||||
|
return account
|
||||||
|
|
||||||
|
def to_url(self, value: BaseAccount) -> str:
|
||||||
|
"""Converts a base account to its code.
|
||||||
|
|
||||||
|
:param value: The base account.
|
||||||
|
:return: The code.
|
||||||
|
"""
|
||||||
|
return value.code
|
@ -1,73 +0,0 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/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 data models for the base account management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from flask import current_app
|
|
||||||
from flask_babel import get_locale
|
|
||||||
|
|
||||||
from accounting.database import db
|
|
||||||
|
|
||||||
|
|
||||||
class BaseAccount(db.Model):
|
|
||||||
"""A base account."""
|
|
||||||
__tablename__ = "accounting_base_accounts"
|
|
||||||
"""The table name."""
|
|
||||||
code = db.Column(db.String, nullable=False, primary_key=True)
|
|
||||||
"""The code."""
|
|
||||||
title_l10n = db.Column("title", db.String, nullable=False)
|
|
||||||
"""The title."""
|
|
||||||
l10n = db.relationship("BaseAccountL10n", back_populates="account",
|
|
||||||
lazy=False)
|
|
||||||
"""The localized titles."""
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
"""Returns the string representation of the base account.
|
|
||||||
|
|
||||||
:return: The string representation of the base account.
|
|
||||||
"""
|
|
||||||
return F"{self.code} {self.title}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def title(self) -> str:
|
|
||||||
"""Returns the title in the current locale.
|
|
||||||
|
|
||||||
:return: The title in the current locale.
|
|
||||||
"""
|
|
||||||
current_locale = str(get_locale())
|
|
||||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
|
||||||
return self.title_l10n
|
|
||||||
for l10n in self.l10n:
|
|
||||||
if l10n.locale == current_locale:
|
|
||||||
return l10n.title
|
|
||||||
return self.title_l10n
|
|
||||||
|
|
||||||
|
|
||||||
class BaseAccountL10n(db.Model):
|
|
||||||
"""A localized base account title."""
|
|
||||||
__tablename__ = "accounting_base_accounts_l10n"
|
|
||||||
"""The table name."""
|
|
||||||
account_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
|
|
||||||
ondelete="CASCADE"),
|
|
||||||
nullable=False, primary_key=True)
|
|
||||||
"""The code of the account."""
|
|
||||||
account = db.relationship(BaseAccount, back_populates="l10n")
|
|
||||||
"""The account."""
|
|
||||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
|
||||||
"""The locale."""
|
|
||||||
title = db.Column(db.String, nullable=False)
|
|
||||||
"""The localized title."""
|
|
@ -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,14 +14,14 @@
|
|||||||
# 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
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
|
from accounting.models import BaseAccount, BaseAccountL10n
|
||||||
from accounting.utils.query import parse_query_keywords
|
from accounting.utils.query import parse_query_keywords
|
||||||
from .models import BaseAccount, BaseAccountL10n
|
|
||||||
|
|
||||||
|
|
||||||
def get_base_account_query() -> list[BaseAccount]:
|
def get_base_account_query() -> list[BaseAccount]:
|
||||||
@ -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.
|
||||||
@ -19,8 +19,10 @@
|
|||||||
"""
|
"""
|
||||||
from flask import Blueprint, render_template
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
from accounting.models import BaseAccount
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
from accounting.utils.permission import has_permission, can_view
|
from accounting.utils.permission import has_permission, can_view
|
||||||
|
from .queries import get_base_account_query
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("base-account", __name__)
|
bp: Blueprint = Blueprint("base-account", __name__)
|
||||||
"""The view blueprint for the base account management."""
|
"""The view blueprint for the base account management."""
|
||||||
@ -33,9 +35,19 @@ def list_accounts() -> str:
|
|||||||
|
|
||||||
:return: The account list.
|
:return: The account list.
|
||||||
"""
|
"""
|
||||||
from .models import BaseAccount
|
|
||||||
from .query 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",
|
||||||
list=pagination.list, pagination=pagination)
|
list=pagination.list, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("<baseAccount:account>", endpoint="detail")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_account_detail(account: BaseAccount) -> str:
|
||||||
|
"""Shows the account detail.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:return: The detail.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/base-account/detail.html", obj=account)
|
||||||
|
|
||||||
|
94
src/accounting/commands.py
Normal file
94
src/accounting/commands.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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 console commands.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import click
|
||||||
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.account import init_accounts_command
|
||||||
|
from accounting.base_account import init_base_accounts_command
|
||||||
|
from accounting.currency import init_currencies_command
|
||||||
|
from accounting.models import BaseAccount, Account
|
||||||
|
from accounting.utils.title_case import title_case
|
||||||
|
from accounting.utils.user import has_user, get_user_pk
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||||
|
value: str) -> str:
|
||||||
|
"""Validates the username for the click console command.
|
||||||
|
|
||||||
|
:param ctx: The console command context.
|
||||||
|
:param param: The console command option.
|
||||||
|
:param value: The username.
|
||||||
|
:raise click.BadParameter: When validation fails.
|
||||||
|
:return: The username.
|
||||||
|
"""
|
||||||
|
value = value.strip()
|
||||||
|
if value == "":
|
||||||
|
raise click.BadParameter("Username empty.")
|
||||||
|
if not has_user(value):
|
||||||
|
raise click.BadParameter(f"User {value} does not exist.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("accounting-init-db")
|
||||||
|
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||||
|
help="The username.", callback=__validate_username,
|
||||||
|
default=lambda: os.getlogin())
|
||||||
|
@with_appcontext
|
||||||
|
def init_db_command(username: str) -> None:
|
||||||
|
"""Initializes the accounting database."""
|
||||||
|
db.create_all()
|
||||||
|
init_base_accounts_command()
|
||||||
|
init_accounts_command(username)
|
||||||
|
init_currencies_command(username)
|
||||||
|
db.session.commit()
|
||||||
|
click.echo("Accounting database initialized.")
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("accounting-titleize")
|
||||||
|
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||||
|
help="The username.", callback=__validate_username,
|
||||||
|
default=lambda: os.getlogin())
|
||||||
|
@with_appcontext
|
||||||
|
def titleize_command(username: str) -> None:
|
||||||
|
"""Capitalize the account titles."""
|
||||||
|
updater_pk: int = get_user_pk(username)
|
||||||
|
updated: int = 0
|
||||||
|
for base in BaseAccount.query:
|
||||||
|
new_title: str = title_case(base.title_l10n)
|
||||||
|
if base.title_l10n != new_title:
|
||||||
|
base.title_l10n = new_title
|
||||||
|
updated = updated + 1
|
||||||
|
for account in Account.query:
|
||||||
|
if account.title_l10n.lower() == account.base.title_l10n.lower():
|
||||||
|
new_title: str = title_case(account.title_l10n)
|
||||||
|
if account.title_l10n != new_title:
|
||||||
|
account.title_l10n = new_title
|
||||||
|
account.updated_at = sa.func.now()
|
||||||
|
account.updated_by_id = updater_pk
|
||||||
|
updated = updated + 1
|
||||||
|
if updated == 0:
|
||||||
|
click.echo("All account titles were already capitalized.")
|
||||||
|
return
|
||||||
|
db.session.commit()
|
||||||
|
click.echo(f"{updated} account titles capitalized.")
|
37
src/accounting/currency/__init__.py
Normal file
37
src/accounting/currency/__init__.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/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 currency management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import Flask, Blueprint
|
||||||
|
|
||||||
|
from .commands import init_currencies_command
|
||||||
|
|
||||||
|
|
||||||
|
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 CurrencyConverter
|
||||||
|
app.url_map.converters["currency"] = CurrencyConverter
|
||||||
|
|
||||||
|
from .views import bp as currency_bp, api_bp as currency_api_bp
|
||||||
|
bp.register_blueprint(currency_bp, url_prefix="/currencies")
|
||||||
|
bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
|
53
src/accounting/currency/commands.py
Normal file
53
src/accounting/currency/commands.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/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 console commands for the currency management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from accounting import db, data_dir
|
||||||
|
from accounting.models import Currency, CurrencyL10n
|
||||||
|
from accounting.utils.user import get_user_pk
|
||||||
|
|
||||||
|
|
||||||
|
def init_currencies_command(username: str) -> None:
|
||||||
|
"""Initializes the currencies."""
|
||||||
|
existing_codes: set[str] = {x.code for x in Currency.query.all()}
|
||||||
|
|
||||||
|
with open(data_dir / "currencies.csv") as fp:
|
||||||
|
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
||||||
|
to_add: list[dict[str, str]] = [x for x in data
|
||||||
|
if x["code"] not in existing_codes]
|
||||||
|
if len(to_add) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
creator_pk: int = get_user_pk(username)
|
||||||
|
currency_data: list[dict[str, Any]] = [{"code": x["code"],
|
||||||
|
"name_l10n": x["name"],
|
||||||
|
"created_by_id": creator_pk,
|
||||||
|
"updated_by_id": creator_pk}
|
||||||
|
for x in to_add]
|
||||||
|
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.execute(sa.insert(Currency), currency_data)
|
||||||
|
db.session.execute(sa.insert(CurrencyL10n), l10n_data)
|
48
src/accounting/currency/converters.py
Normal file
48
src/accounting/currency/converters.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/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 path converters for the currency management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import abort
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import Currency
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyConverter(BaseConverter):
|
||||||
|
"""The currency converter to convert the currency code and to the
|
||||||
|
corresponding currency in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> Currency:
|
||||||
|
"""Converts a currency code to a currency.
|
||||||
|
|
||||||
|
:param value: The currency code.
|
||||||
|
:return: The corresponding currency.
|
||||||
|
"""
|
||||||
|
currency: Currency | None = db.session.get(Currency, value)
|
||||||
|
if currency is None:
|
||||||
|
abort(404)
|
||||||
|
return currency
|
||||||
|
|
||||||
|
def to_url(self, value: Currency) -> str:
|
||||||
|
"""Converts a currency to its code.
|
||||||
|
|
||||||
|
:param value: The currency.
|
||||||
|
:return: The code.
|
||||||
|
"""
|
||||||
|
return value.code
|
83
src/accounting/currency/forms.py
Normal file
83
src/accounting/currency/forms.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/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 forms for the currency management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, ValidationError
|
||||||
|
from wtforms.validators import DataRequired, Regexp, NoneOf
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Currency
|
||||||
|
from accounting.utils.strip_text import strip_text
|
||||||
|
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):
|
||||||
|
"""The form to create or edit a currency."""
|
||||||
|
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
|
||||||
|
"""The reserved codes that are not available."""
|
||||||
|
code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[DataRequired(lazy_gettext("Please fill in the code.")),
|
||||||
|
Regexp(r"^[A-Z]{3}$",
|
||||||
|
message=lazy_gettext(
|
||||||
|
"Code can only be composed of 3 upper-cased"
|
||||||
|
" letters.")),
|
||||||
|
NoneOf(CODE_BLOCKLIST, message=lazy_gettext(
|
||||||
|
"This code is not available.")),
|
||||||
|
CodeUnique()])
|
||||||
|
"""The code. It may not conflict with another currency."""
|
||||||
|
name = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
|
||||||
|
"""The name."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.obj_code: str | None = None
|
||||||
|
"""The current code of the currency, or None when adding a new
|
||||||
|
currency."""
|
||||||
|
|
||||||
|
def populate_obj(self, obj: Currency) -> None:
|
||||||
|
"""Populates the form data into a currency object.
|
||||||
|
|
||||||
|
:param obj: The currency object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
is_new: bool = obj.code is None
|
||||||
|
obj.code = self.code.data
|
||||||
|
obj.name = self.name.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
|
44
src/accounting/currency/queries.py
Normal file
44
src/accounting/currency/queries.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/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 queries for the currency management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from accounting.models import Currency, CurrencyL10n
|
||||||
|
from accounting.utils.query import parse_query_keywords
|
||||||
|
|
||||||
|
|
||||||
|
def get_currency_query() -> list[Currency]:
|
||||||
|
"""Returns the base accounts, optionally filtered by the query.
|
||||||
|
|
||||||
|
:return: The base accounts.
|
||||||
|
"""
|
||||||
|
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||||
|
if len(keywords) == 0:
|
||||||
|
return Currency.query.order_by(Currency.code).all()
|
||||||
|
conditions: list[sa.BinaryExpression] = []
|
||||||
|
for k in keywords:
|
||||||
|
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
||||||
|
.filter(CurrencyL10n.name.icontains(k)).all()
|
||||||
|
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||||
|
conditions.append(sa.or_(Currency.code.icontains(k),
|
||||||
|
Currency.name_l10n.icontains(k),
|
||||||
|
Currency.code.in_(l10n_matches)))
|
||||||
|
return Currency.query.filter(*conditions)\
|
||||||
|
.order_by(Currency.code).all()
|
188
src/accounting/currency/views.py
Normal file
188
src/accounting/currency/views.py
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/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 views for the currency management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from urllib.parse import urlencode, parse_qsl
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import Blueprint, render_template, redirect, session, request, \
|
||||||
|
flash, url_for
|
||||||
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Currency
|
||||||
|
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.permission import has_permission, can_view, can_edit
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
from .forms import CurrencyForm
|
||||||
|
from .queries import get_currency_query
|
||||||
|
|
||||||
|
bp: Blueprint = Blueprint("currency", __name__)
|
||||||
|
"""The view blueprint for the currency management."""
|
||||||
|
api_bp: Blueprint = Blueprint("currency-api", __name__)
|
||||||
|
"""The view blueprint for the currency management API."""
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("", endpoint="list")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def list_currencies() -> str:
|
||||||
|
"""Lists the currencies.
|
||||||
|
|
||||||
|
:return: The currency list.
|
||||||
|
"""
|
||||||
|
currencies: list[Currency] = get_currency_query()
|
||||||
|
pagination: Pagination = Pagination[Currency](currencies)
|
||||||
|
return render_template("accounting/currency/list.html",
|
||||||
|
list=pagination.list, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("create", endpoint="create")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def show_add_currency_form() -> str:
|
||||||
|
"""Shows the form to add a currency.
|
||||||
|
|
||||||
|
:return: The form to add a currency.
|
||||||
|
"""
|
||||||
|
if "form" in session:
|
||||||
|
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = CurrencyForm()
|
||||||
|
return render_template("accounting/currency/create.html",
|
||||||
|
form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("store", endpoint="store")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def add_currency() -> redirect:
|
||||||
|
"""Adds a currency.
|
||||||
|
|
||||||
|
:return: The redirection to the currency detail on success, or the currency
|
||||||
|
creation form on error.
|
||||||
|
"""
|
||||||
|
form = CurrencyForm(request.form)
|
||||||
|
if not form.validate():
|
||||||
|
flash_form_errors(form)
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(url_for("accounting.currency.create")))
|
||||||
|
currency: Currency = Currency()
|
||||||
|
form.populate_obj(currency)
|
||||||
|
db.session.add(currency)
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The currency is added successfully.")), "success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("<currency:currency>", endpoint="detail")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_currency_detail(currency: Currency) -> str:
|
||||||
|
"""Shows the currency detail.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The detail.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/currency/detail.html", obj=currency)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("<currency:currency>/edit", endpoint="edit")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def show_currency_edit_form(currency: Currency) -> str:
|
||||||
|
"""Shows the form to edit a currency.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The form to edit the currency.
|
||||||
|
"""
|
||||||
|
form: CurrencyForm
|
||||||
|
if "form" in session:
|
||||||
|
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = CurrencyForm(obj=currency)
|
||||||
|
return render_template("accounting/currency/edit.html",
|
||||||
|
currency=currency, form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("<currency:currency>/update", endpoint="update")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def update_currency(currency: Currency) -> redirect:
|
||||||
|
"""Updates a currency.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The redirection to the currency detail on success, or the currency
|
||||||
|
edit form on error.
|
||||||
|
"""
|
||||||
|
form = CurrencyForm(request.form)
|
||||||
|
form.obj_code = currency.code
|
||||||
|
if not form.validate():
|
||||||
|
flash_form_errors(form)
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(url_for("accounting.currency.edit",
|
||||||
|
currency=currency)))
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
form.populate_obj(currency)
|
||||||
|
if not currency.is_modified:
|
||||||
|
flash(s(lazy_gettext("The currency was not modified.")), "success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
|
currency.updated_by_id = get_current_user_pk()
|
||||||
|
currency.updated_at = sa.func.now()
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The currency is updated successfully.")), "success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("<currency:currency>/delete", endpoint="delete")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def delete_currency(currency: Currency) -> redirect:
|
||||||
|
"""Deletes a currency.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The redirection to the currency list on success, or the currency
|
||||||
|
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()
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
|
||||||
|
return redirect(or_next(url_for("accounting.currency.list")))
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.get("exists-code", endpoint="exists")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def exists_code() -> dict[str, bool]:
|
||||||
|
"""Validates whether a currency code exists.
|
||||||
|
|
||||||
|
:return: Whether the currency code exists.
|
||||||
|
"""
|
||||||
|
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,以太坊,以太坊
|
|
98
src/accounting/forms.py
Normal file
98
src/accounting/forms.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
# 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
|
||||||
|
"""The error 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
|
||||||
|
"""The error 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")
|
102
src/accounting/journal_entry/converters.py
Normal file
102
src/accounting/journal_entry/converters.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from flask import abort
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import JournalEntry
|
||||||
|
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 \
|
||||||
|
= db.session.get(JournalEntry, value)
|
||||||
|
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) -> dt.date:
|
||||||
|
"""Converts an ISO date to a date.
|
||||||
|
|
||||||
|
:param value: The ISO date.
|
||||||
|
:return: The corresponding date.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return dt.date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
def to_url(self, value: dt.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
|
292
src/accounting/journal_entry/forms/currency.py
Normal file
292
src/accounting/journal_entry/forms/currency.py
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
# 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.locale import lazy_gettext
|
||||||
|
from accounting.models import JournalEntryLineItem
|
||||||
|
from accounting.utils.offset_alias import offset_alias
|
||||||
|
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,
|
||||||
|
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)]
|
592
src/accounting/journal_entry/forms/journal_entry.py
Normal file
592
src/accounting/journal_entry/forms/journal_entry.py
Normal file
@ -0,0 +1,592 @@
|
|||||||
|
# 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
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import TypeVar, Generic, Type
|
||||||
|
|
||||||
|
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.journal_entry.utils.account_option import AccountOption
|
||||||
|
from accounting.journal_entry.utils.description_editor import DescriptionEditor
|
||||||
|
from accounting.journal_entry.utils.original_line_items import \
|
||||||
|
get_selectable_original_line_items
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
|
||||||
|
JournalEntryCurrency
|
||||||
|
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: 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.__set_date(obj, self.date.data)
|
||||||
|
obj.note = self.note.data
|
||||||
|
|
||||||
|
collector_cls: 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 = TypeVar("T", bound=JournalEntryForm)
|
||||||
|
"""A journal entry form variant."""
|
||||||
|
|
||||||
|
|
||||||
|
class LineItemCollector(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
|
507
src/accounting/journal_entry/forms/line_item.py
Normal file
507
src/accounting/journal_entry/forms/line_item.py
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
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, JournalEntry, JournalEntryLineItem
|
||||||
|
from accounting.template_filters import format_amount
|
||||||
|
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.get(JournalEntryLineItem, form.id.data)
|
||||||
|
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(
|
||||||
|
(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(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(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_title(self) -> str:
|
||||||
|
"""Returns the title of the account.
|
||||||
|
|
||||||
|
:return: The title 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 account.title
|
||||||
|
|
||||||
|
@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) -> dt.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.join(JournalEntry)\
|
||||||
|
.filter(JournalEntryLineItem.original_line_item_id
|
||||||
|
== self.id.data)\
|
||||||
|
.order_by(JournalEntry.date, JournalEntry.no,
|
||||||
|
JournalEntryLineItem.no)\
|
||||||
|
.options(selectinload(JournalEntryLineItem.journal_entry),
|
||||||
|
selectinload(JournalEntryLineItem.account)).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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import JournalEntry
|
||||||
|
|
||||||
|
|
||||||
|
def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
|
||||||
|
"""Sorts the journal entries under a date after changing the date or
|
||||||
|
deleting a journal entry.
|
||||||
|
|
||||||
|
:param date: The date of the journal entry.
|
||||||
|
:param exclude: The journal entry ID to exclude.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] = [JournalEntry.date == 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, date: dt.date):
|
||||||
|
"""Constructs the form to reorder the journal entries in a day.
|
||||||
|
|
||||||
|
:param date: The date.
|
||||||
|
"""
|
||||||
|
self.date: dt.date = date
|
||||||
|
"""The date."""
|
||||||
|
self.is_modified: bool = False
|
||||||
|
"""Whether the order is modified."""
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
"""
|
51
src/accounting/journal_entry/utils/account_option.py
Normal file
51
src/accounting/journal_entry/utils/account_option.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# 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.title: str = account.title
|
||||||
|
"""The account title."""
|
||||||
|
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
|
357
src/accounting/journal_entry/utils/description_editor.py
Normal file
357
src/accounting/journal_entry/utils/description_editor.py
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
# 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
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> str:
|
||||||
|
"""Returns the account title.
|
||||||
|
|
||||||
|
:return: The account title.
|
||||||
|
"""
|
||||||
|
return self.__account.title
|
||||||
|
|
||||||
|
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: Literal["general", "travel", "bus"]):
|
||||||
|
"""Constructs a description type.
|
||||||
|
|
||||||
|
:param type_id: The type ID, either "general", "travel", or "bus".
|
||||||
|
"""
|
||||||
|
self.id: 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
|
||||||
|
"""The name."""
|
||||||
|
self.account: DescriptionAccount = DescriptionAccount(account, 0)
|
||||||
|
"""The account."""
|
||||||
|
self.description_template: str = description_template
|
||||||
|
"""The 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: Literal["debit", "credit"]):
|
||||||
|
"""Constructs the description on debit or credit.
|
||||||
|
|
||||||
|
:param debit_credit: Either "debit" or "credit".
|
||||||
|
"""
|
||||||
|
self.debit_credit: 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[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: 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[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)
|
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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
|
from flask import render_template, request, abort
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
|
||||||
|
from accounting.journal_entry.forms import JournalEntryForm, \
|
||||||
|
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
|
||||||
|
TransferJournalEntryForm
|
||||||
|
from accounting.journal_entry.forms.line_item import LineItemForm
|
||||||
|
from accounting.models import JournalEntry
|
||||||
|
from accounting.template_globals import default_currency_code
|
||||||
|
from accounting.utils.journal_entry_types import JournalEntryType
|
||||||
|
|
||||||
|
|
||||||
|
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) -> 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) -> 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) -> 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) -> 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
|
83
src/accounting/journal_entry/utils/original_line_items.py
Normal file
83
src/accounting/journal_entry/utils/original_line_items.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# 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.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),
|
||||||
|
(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,
|
||||||
|
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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
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.journal_entry_types import JournalEntryType
|
||||||
|
from accounting.utils.next_uri import inherit_next, or_next
|
||||||
|
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||||
|
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 = dt.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:date>", endpoint="order")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_journal_entry_order(date: dt.date) -> str:
|
||||||
|
"""Shows the order of the journal entries in a same date.
|
||||||
|
|
||||||
|
:param date: The date.
|
||||||
|
:return: The order of the journal entries in the date.
|
||||||
|
"""
|
||||||
|
journal_entries: list[JournalEntry] = JournalEntry.query \
|
||||||
|
.filter(JournalEntry.date == date) \
|
||||||
|
.order_by(JournalEntry.no).all()
|
||||||
|
return render_template("accounting/journal-entry/order.html",
|
||||||
|
date=date, list=journal_entries)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("dates/<date:date>", endpoint="sort")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def sort_journal_entries(date: dt.date) -> redirect:
|
||||||
|
"""Reorders the journal entries in a date.
|
||||||
|
|
||||||
|
:param date: The date.
|
||||||
|
:return: The redirection to the incoming account or the account list. The
|
||||||
|
reordering operation does not fail.
|
||||||
|
"""
|
||||||
|
form: JournalEntryReorderForm = JournalEntryReorderForm(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.
|
||||||
@ -25,8 +25,10 @@ from flask_babel import LazyString, Domain
|
|||||||
from flask_babel_js import JAVASCRIPT, c2js
|
from flask_babel_js import JAVASCRIPT, c2js
|
||||||
|
|
||||||
translation_dir: Path = Path(__file__).parent / "translations"
|
translation_dir: Path = Path(__file__).parent / "translations"
|
||||||
|
"""The directory of the translation files."""
|
||||||
domain: Domain = Domain(translation_directories=[translation_dir],
|
domain: Domain = Domain(translation_directories=[translation_dir],
|
||||||
domain="accounting")
|
domain="accounting")
|
||||||
|
"""The message domain."""
|
||||||
|
|
||||||
|
|
||||||
def gettext(string, **variables) -> str:
|
def gettext(string, **variables) -> str:
|
||||||
@ -39,6 +41,17 @@ def gettext(string, **variables) -> str:
|
|||||||
return domain.gettext(string, **variables)
|
return domain.gettext(string, **variables)
|
||||||
|
|
||||||
|
|
||||||
|
def pgettext(context, string, **variables) -> str:
|
||||||
|
"""A replacement of the Babel gettext() function..
|
||||||
|
|
||||||
|
:param context: The context.
|
||||||
|
:param string: The message to translate.
|
||||||
|
:param variables: The variable substitution.
|
||||||
|
:return: The translated message.
|
||||||
|
"""
|
||||||
|
return domain.pgettext(context, string, **variables)
|
||||||
|
|
||||||
|
|
||||||
def lazy_gettext(string, **variables) -> LazyString:
|
def lazy_gettext(string, **variables) -> LazyString:
|
||||||
"""A replacement of the Babel lazy_gettext() function..
|
"""A replacement of the Babel lazy_gettext() function..
|
||||||
|
|
||||||
@ -105,10 +118,9 @@ def __babel_js_catalog_view() -> Response:
|
|||||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||||
"""Initializes the application.
|
"""Initializes the application.
|
||||||
|
|
||||||
:param bp: The blueprint of the accounting application.
|
|
||||||
:param app: The Flask application.
|
:param app: The Flask application.
|
||||||
|
:param bp: The blueprint of the accounting application.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
bp.add_url_rule("/_jstrans.js", "babel_catalog",
|
bp.add_url_rule("/_jstrans.js", "babel_catalog", __babel_js_catalog_view)
|
||||||
__babel_js_catalog_view)
|
|
||||||
app.jinja_env.globals["A_"] = domain.gettext
|
app.jinja_env.globals["A_"] = domain.gettext
|
||||||
|
906
src/accounting/models.py
Normal file
906
src/accounting/models.py
Normal file
@ -0,0 +1,906 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/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 data models.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import re
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Type, Self
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from babel import Locale
|
||||||
|
from flask_babel import get_locale, get_babel
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.utils.user import user_cls, user_pk_column
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAccount(db.Model):
|
||||||
|
"""A base account."""
|
||||||
|
__tablename__ = "accounting_base_accounts"
|
||||||
|
"""The table name."""
|
||||||
|
code: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
"""The account code."""
|
||||||
|
title_l10n: Mapped[str] = mapped_column("title")
|
||||||
|
"""The title."""
|
||||||
|
l10n: Mapped[list[BaseAccountL10n]] \
|
||||||
|
= db.relationship(back_populates="account", lazy=False)
|
||||||
|
"""The localized titles."""
|
||||||
|
accounts: Mapped[list[Account]] = db.relationship(back_populates="base")
|
||||||
|
"""The descendant accounts under the base account."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the base account.
|
||||||
|
|
||||||
|
:return: The string representation of the base account.
|
||||||
|
"""
|
||||||
|
return f"{self.code} {self.title}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> str:
|
||||||
|
"""Returns the title in the current locale.
|
||||||
|
|
||||||
|
:return: The title in the current locale.
|
||||||
|
"""
|
||||||
|
current_locale: Locale = get_locale()
|
||||||
|
if current_locale == get_babel().instance.default_locale:
|
||||||
|
return self.title_l10n
|
||||||
|
for l10n in self.l10n:
|
||||||
|
if l10n.locale == str(current_locale):
|
||||||
|
return l10n.title
|
||||||
|
return self.title_l10n
|
||||||
|
|
||||||
|
@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]
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAccountL10n(db.Model):
|
||||||
|
"""A localized base account title."""
|
||||||
|
__tablename__ = "accounting_base_accounts_l10n"
|
||||||
|
"""The table name."""
|
||||||
|
account_code: Mapped[str] \
|
||||||
|
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE"),
|
||||||
|
primary_key=True)
|
||||||
|
"""The account code."""
|
||||||
|
account: Mapped[BaseAccount] = db.relationship(back_populates="l10n")
|
||||||
|
"""The account."""
|
||||||
|
locale: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
"""The locale."""
|
||||||
|
title: Mapped[str]
|
||||||
|
"""The localized title."""
|
||||||
|
|
||||||
|
|
||||||
|
class Account(db.Model):
|
||||||
|
"""An account."""
|
||||||
|
__tablename__ = "accounting_accounts"
|
||||||
|
"""The table name."""
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
|
||||||
|
"""The account ID."""
|
||||||
|
base_code: Mapped[str] \
|
||||||
|
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE"))
|
||||||
|
"""The code of the base account."""
|
||||||
|
base: Mapped[BaseAccount] = db.relationship(back_populates="accounts")
|
||||||
|
"""The base account."""
|
||||||
|
no: Mapped[int] = mapped_column(default=text("1"))
|
||||||
|
"""The account number under the base account."""
|
||||||
|
title_l10n: Mapped[str] = mapped_column("title")
|
||||||
|
"""The title."""
|
||||||
|
is_need_offset: Mapped[bool] = mapped_column(default=False)
|
||||||
|
"""Whether the journal entry line items of this account need offset."""
|
||||||
|
created_at: Mapped[dt.datetime] \
|
||||||
|
= mapped_column(db.DateTime(timezone=True),
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The date and time when this record was created."""
|
||||||
|
created_by_id: Mapped[int] \
|
||||||
|
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
|
"""The ID of the user who created the record."""
|
||||||
|
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
|
||||||
|
"""The user who created the record."""
|
||||||
|
updated_at: Mapped[dt.datetime] \
|
||||||
|
= mapped_column(db.DateTime(timezone=True),
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The date and time when this record was last updated."""
|
||||||
|
updated_by_id: Mapped[int] \
|
||||||
|
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
|
"""The ID of the last user who updated the record."""
|
||||||
|
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
|
||||||
|
"""The last user who updated the record."""
|
||||||
|
l10n: Mapped[list[AccountL10n]] \
|
||||||
|
= db.relationship(back_populates="account", lazy=False)
|
||||||
|
"""The localized titles."""
|
||||||
|
line_items: Mapped[list[JournalEntryLineItem]] \
|
||||||
|
= db.relationship(back_populates="account")
|
||||||
|
"""The journal entry line items."""
|
||||||
|
|
||||||
|
CASH_CODE: str = "1111-001"
|
||||||
|
"""The code of the cash account,"""
|
||||||
|
ACCUMULATED_CHANGE_CODE: str = "3351-001"
|
||||||
|
"""The code of the accumulated-change account,"""
|
||||||
|
NET_CHANGE_CODE: str = "3353-001"
|
||||||
|
"""The code of the net-change account,"""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of this account.
|
||||||
|
|
||||||
|
:return: The string representation of this account.
|
||||||
|
"""
|
||||||
|
return f"{self.base_code}-{self.no:03d} {self.title}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def code(self) -> str:
|
||||||
|
"""Returns the code.
|
||||||
|
|
||||||
|
:return: The code.
|
||||||
|
"""
|
||||||
|
return f"{self.base_code}-{self.no:03d}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> str:
|
||||||
|
"""Returns the title in the current locale.
|
||||||
|
|
||||||
|
:return: The title in the current locale.
|
||||||
|
"""
|
||||||
|
current_locale: Locale = get_locale()
|
||||||
|
if current_locale == get_babel().instance.default_locale:
|
||||||
|
return self.title_l10n
|
||||||
|
for l10n in self.l10n:
|
||||||
|
if l10n.locale == str(current_locale):
|
||||||
|
return l10n.title
|
||||||
|
return self.title_l10n
|
||||||
|
|
||||||
|
@title.setter
|
||||||
|
def title(self, value: str) -> None:
|
||||||
|
"""Sets the title in the current locale.
|
||||||
|
|
||||||
|
:param value: The new title.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if self.title == value:
|
||||||
|
return
|
||||||
|
if self.title_l10n is None:
|
||||||
|
self.title_l10n = value
|
||||||
|
return
|
||||||
|
current_locale: Locale = get_locale()
|
||||||
|
if current_locale == get_babel().instance.default_locale:
|
||||||
|
self.title_l10n = value
|
||||||
|
return
|
||||||
|
for l10n in self.l10n:
|
||||||
|
if l10n.locale == str(current_locale):
|
||||||
|
l10n.title = value
|
||||||
|
return
|
||||||
|
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 count(self) -> int:
|
||||||
|
"""Returns the number of items in the account.
|
||||||
|
|
||||||
|
:return: The number of items in the account.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__count"):
|
||||||
|
setattr(self, "__count", 0)
|
||||||
|
return getattr(self, "__count")
|
||||||
|
|
||||||
|
@count.setter
|
||||||
|
def count(self, value: int) -> None:
|
||||||
|
"""Sets the number of items in the account.
|
||||||
|
|
||||||
|
:param value: The number of items in the account.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__count", value)
|
||||||
|
|
||||||
|
@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: Type[Self] = self.__class__
|
||||||
|
cls.query.filter(cls.id == self.id).delete()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_by_code(cls, code: str) -> Self | None:
|
||||||
|
"""Finds an account by its code.
|
||||||
|
|
||||||
|
:param code: The code.
|
||||||
|
:return: The account, or None if this account does not exist.
|
||||||
|
"""
|
||||||
|
m = re.match(r"^([1-9]{4})-(\d{3})$", code)
|
||||||
|
if m is None:
|
||||||
|
return None
|
||||||
|
return cls.query.filter(cls.base_code == m.group(1),
|
||||||
|
cls.no == int(m.group(2))).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def selectable_debit(cls) -> list[Self]:
|
||||||
|
"""Returns the selectable debit accounts.
|
||||||
|
Payable line items can not start from debit.
|
||||||
|
|
||||||
|
:return: The selectable debit accounts.
|
||||||
|
"""
|
||||||
|
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
||||||
|
sa.and_(cls.base_code.startswith("2"),
|
||||||
|
sa.not_(cls.is_need_offset)),
|
||||||
|
cls.base_code.startswith("3"),
|
||||||
|
cls.base_code.startswith("5"),
|
||||||
|
cls.base_code.startswith("6"),
|
||||||
|
cls.base_code.startswith("75"),
|
||||||
|
cls.base_code.startswith("76"),
|
||||||
|
cls.base_code.startswith("77"),
|
||||||
|
cls.base_code.startswith("78"),
|
||||||
|
cls.base_code.startswith("8"),
|
||||||
|
cls.base_code.startswith("9")),
|
||||||
|
cls.base_code != "3351",
|
||||||
|
cls.base_code != "3353")\
|
||||||
|
.order_by(cls.base_code, cls.no).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def selectable_credit(cls) -> list[Self]:
|
||||||
|
"""Returns the selectable debit accounts.
|
||||||
|
Receivable line items can not start from credit.
|
||||||
|
|
||||||
|
:return: The selectable debit accounts.
|
||||||
|
"""
|
||||||
|
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("3"),
|
||||||
|
cls.base_code.startswith("4"),
|
||||||
|
cls.base_code.startswith("71"),
|
||||||
|
cls.base_code.startswith("72"),
|
||||||
|
cls.base_code.startswith("73"),
|
||||||
|
cls.base_code.startswith("74"),
|
||||||
|
cls.base_code.startswith("8"),
|
||||||
|
cls.base_code.startswith("9")),
|
||||||
|
cls.base_code != "3351",
|
||||||
|
cls.base_code != "3353")\
|
||||||
|
.order_by(cls.base_code, cls.no).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cash(cls) -> Self:
|
||||||
|
"""Returns the cash account.
|
||||||
|
|
||||||
|
:return: The cash account
|
||||||
|
"""
|
||||||
|
return cls.find_by_code(cls.CASH_CODE)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def accumulated_change(cls) -> Self:
|
||||||
|
"""Returns the accumulated-change account.
|
||||||
|
|
||||||
|
:return: The accumulated-change account
|
||||||
|
"""
|
||||||
|
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountL10n(db.Model):
|
||||||
|
"""A localized account title."""
|
||||||
|
__tablename__ = "accounting_accounts_l10n"
|
||||||
|
"""The table name."""
|
||||||
|
account_id: Mapped[int] \
|
||||||
|
= mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE"),
|
||||||
|
primary_key=True)
|
||||||
|
"""The account ID."""
|
||||||
|
account: Mapped[Account] = db.relationship(back_populates="l10n")
|
||||||
|
"""The account."""
|
||||||
|
locale: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
"""The locale."""
|
||||||
|
title: Mapped[str]
|
||||||
|
"""The localized title."""
|
||||||
|
|
||||||
|
|
||||||
|
class Currency(db.Model):
|
||||||
|
"""A currency."""
|
||||||
|
__tablename__ = "accounting_currencies"
|
||||||
|
"""The table name."""
|
||||||
|
code: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
"""The currency code."""
|
||||||
|
name_l10n: Mapped[str] = mapped_column("name")
|
||||||
|
"""The currency name."""
|
||||||
|
created_at: Mapped[dt.datetime] \
|
||||||
|
= mapped_column(db.DateTime(timezone=True),
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The date and time when this record was created."""
|
||||||
|
created_by_id: Mapped[int] \
|
||||||
|
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
|
"""The ID of the user who created the record."""
|
||||||
|
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
|
||||||
|
"""The user who created the record."""
|
||||||
|
updated_at: Mapped[dt.datetime] \
|
||||||
|
= mapped_column(db.DateTime(timezone=True),
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The date and time when this record was last updated."""
|
||||||
|
updated_by_id: Mapped[int] \
|
||||||
|
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
|
"""The ID of the last user who updated the record."""
|
||||||
|
updated_by: Mapped[user_cls] \
|
||||||
|
= db.relationship(foreign_keys=updated_by_id)
|
||||||
|
"""The last user who updated the record."""
|
||||||
|
l10n: Mapped[list[CurrencyL10n]] \
|
||||||
|
= db.relationship(back_populates="currency", lazy=False)
|
||||||
|
"""The localized names."""
|
||||||
|
line_items: Mapped[list[JournalEntryLineItem]] \
|
||||||
|
= db.relationship(back_populates="currency")
|
||||||
|
"""The journal entry line items."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the currency.
|
||||||
|
|
||||||
|
:return: The string representation of the currency.
|
||||||
|
"""
|
||||||
|
return f"{self.name.title()} ({self.code})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Returns the name in the current locale.
|
||||||
|
|
||||||
|
:return: The name in the current locale.
|
||||||
|
"""
|
||||||
|
current_locale: Locale = get_locale()
|
||||||
|
if current_locale == get_babel().instance.default_locale:
|
||||||
|
return self.name_l10n
|
||||||
|
for l10n in self.l10n:
|
||||||
|
if l10n.locale == str(current_locale):
|
||||||
|
return l10n.name
|
||||||
|
return self.name_l10n
|
||||||
|
|
||||||
|
@name.setter
|
||||||
|
def name(self, value: str) -> None:
|
||||||
|
"""Sets the name in the current locale.
|
||||||
|
|
||||||
|
:param value: The new name.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if self.name == value:
|
||||||
|
return
|
||||||
|
if self.name_l10n is None:
|
||||||
|
self.name_l10n = value
|
||||||
|
return
|
||||||
|
current_locale: Locale = get_locale()
|
||||||
|
if current_locale == get_babel().instance.default_locale:
|
||||||
|
self.name_l10n = value
|
||||||
|
return
|
||||||
|
for l10n in self.l10n:
|
||||||
|
if l10n.locale == str(current_locale):
|
||||||
|
l10n.name = value
|
||||||
|
return
|
||||||
|
self.l10n.append(CurrencyL10n(locale=str(current_locale), name=value))
|
||||||
|
|
||||||
|
@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 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:
|
||||||
|
"""Deletes the currency.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
|
||||||
|
cls: Type[Self] = self.__class__
|
||||||
|
cls.query.filter(cls.code == self.code).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyL10n(db.Model):
|
||||||
|
"""A localized currency name."""
|
||||||
|
__tablename__ = "accounting_currencies_l10n"
|
||||||
|
"""The table name."""
|
||||||
|
currency_code: Mapped[str] \
|
||||||
|
= mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE"),
|
||||||
|
primary_key=True)
|
||||||
|
"""The currency code."""
|
||||||
|
currency: Mapped[Currency] = db.relationship(back_populates="l10n")
|
||||||
|
"""The currency."""
|
||||||
|
locale: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
"""The locale."""
|
||||||
|
name: Mapped[str]
|
||||||
|
"""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: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
|
||||||
|
"""The journal entry ID."""
|
||||||
|
date: Mapped[dt.date]
|
||||||
|
"""The date."""
|
||||||
|
no: Mapped[int] = mapped_column(default=text("1"))
|
||||||
|
"""The journal entry number under the date."""
|
||||||
|
note: Mapped[str | None]
|
||||||
|
"""The note."""
|
||||||
|
created_at: Mapped[dt.datetime] \
|
||||||
|
= mapped_column(db.DateTime(timezone=True),
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The date and time when this record was created."""
|
||||||
|
created_by_id: Mapped[int] \
|
||||||
|
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
|
"""The ID of the user who created the record."""
|
||||||
|
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
|
||||||
|
"""The user who created the record."""
|
||||||
|
updated_at: Mapped[dt.datetime] \
|
||||||
|
= mapped_column(db.DateTime(timezone=True),
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The date and time when this record was last updated."""
|
||||||
|
updated_by_id: Mapped[int] \
|
||||||
|
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
|
"""The ID of the last user who updated the record."""
|
||||||
|
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
|
||||||
|
"""The last user who updated the record."""
|
||||||
|
line_items: Mapped[list[JournalEntryLineItem]] \
|
||||||
|
= db.relationship(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: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
|
||||||
|
"""The line item ID."""
|
||||||
|
journal_entry_id: Mapped[int] \
|
||||||
|
= mapped_column(db.ForeignKey(JournalEntry.id, onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE"))
|
||||||
|
"""The journal entry ID."""
|
||||||
|
journal_entry: Mapped[JournalEntry] \
|
||||||
|
= db.relationship(back_populates="line_items")
|
||||||
|
"""The journal entry."""
|
||||||
|
is_debit: Mapped[bool]
|
||||||
|
"""True for a debit line item, or False for a credit line item."""
|
||||||
|
no: Mapped[int]
|
||||||
|
"""The line item number under the journal entry and debit or credit."""
|
||||||
|
original_line_item_id: Mapped[int | None] \
|
||||||
|
= mapped_column(db.ForeignKey(id, onupdate="CASCADE"))
|
||||||
|
"""The ID of the original line item."""
|
||||||
|
original_line_item: Mapped[JournalEntryLineItem | None] \
|
||||||
|
= db.relationship(remote_side=id, passive_deletes=True)
|
||||||
|
"""The original line item."""
|
||||||
|
currency_code: Mapped[str] \
|
||||||
|
= mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE"))
|
||||||
|
"""The currency code."""
|
||||||
|
currency: Mapped[Currency] = db.relationship(back_populates="line_items")
|
||||||
|
"""The currency."""
|
||||||
|
account_id: Mapped[int] \
|
||||||
|
= mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE"))
|
||||||
|
"""The account ID."""
|
||||||
|
account: Mapped[Account] \
|
||||||
|
= db.relationship(back_populates="line_items", lazy=False)
|
||||||
|
"""The account."""
|
||||||
|
description: Mapped[str | None]
|
||||||
|
"""The description."""
|
||||||
|
amount: Mapped[Decimal] = mapped_column(db.Numeric(14, 2))
|
||||||
|
"""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 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 debit(self) -> Decimal | None:
|
||||||
|
"""Returns the debit amount.
|
||||||
|
|
||||||
|
:return: The debit amount, or None if this is not a debit line item.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__debit"):
|
||||||
|
setattr(self, "__debit", self.amount if self.is_debit else None)
|
||||||
|
return getattr(self, "__debit")
|
||||||
|
|
||||||
|
@debit.setter
|
||||||
|
def debit(self, value: Decimal | None) -> None:
|
||||||
|
"""Sets the debit amount.
|
||||||
|
|
||||||
|
:param value: The debit amount.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__debit", value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit(self) -> Decimal | None:
|
||||||
|
"""Returns the credit amount.
|
||||||
|
|
||||||
|
:return: The credit amount, or None if this is not a credit line item.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__credit"):
|
||||||
|
setattr(self, "__credit", None if self.is_debit else self.amount)
|
||||||
|
return getattr(self, "__credit")
|
||||||
|
|
||||||
|
@credit.setter
|
||||||
|
def credit(self, value: Decimal | None) -> None:
|
||||||
|
"""Sets the credit amount.
|
||||||
|
|
||||||
|
:param value: The credit amount.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__credit", value)
|
||||||
|
|
||||||
|
@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, value: Decimal) -> None:
|
||||||
|
"""Sets the net balance.
|
||||||
|
|
||||||
|
:param value: The net balance.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__net_balance", value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def balance(self) -> Decimal:
|
||||||
|
"""Returns the balance.
|
||||||
|
|
||||||
|
:return: The balance.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__balance"):
|
||||||
|
setattr(self, "__balance", Decimal("0"))
|
||||||
|
return getattr(self, "__balance")
|
||||||
|
|
||||||
|
@balance.setter
|
||||||
|
def balance(self, value: Decimal) -> None:
|
||||||
|
"""Sets the balance.
|
||||||
|
|
||||||
|
:param value: The balance.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__balance", value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def offsets(self) -> list[Self]:
|
||||||
|
"""Returns the offset items.
|
||||||
|
|
||||||
|
:return: The offset items.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__offsets"):
|
||||||
|
cls: Type[Self] = self.__class__
|
||||||
|
offsets: list[Self] = cls.query.join(JournalEntry)\
|
||||||
|
.filter(JournalEntryLineItem.original_line_item_id == self.id)\
|
||||||
|
.order_by(JournalEntry.date, JournalEntry.no,
|
||||||
|
cls.is_debit, cls.no).all()
|
||||||
|
setattr(self, "__offsets", offsets)
|
||||||
|
return getattr(self, "__offsets")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_offset(self) -> bool:
|
||||||
|
"""Returns whether the line item is an offset.
|
||||||
|
|
||||||
|
:return: True if the line item is an offset, or False otherwise.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__is_offset"):
|
||||||
|
setattr(self, "__is_offset", False)
|
||||||
|
return getattr(self, "__is_offset")
|
||||||
|
|
||||||
|
@is_offset.setter
|
||||||
|
def is_offset(self, value: bool) -> None:
|
||||||
|
"""Sets whether the line item is an offset.
|
||||||
|
|
||||||
|
:param value: True if the line item is an offset, or False otherwise.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__is_offset", value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def match(self) -> Self | None:
|
||||||
|
"""Returns the match of the line item.
|
||||||
|
|
||||||
|
:return: The match of the line item.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__match"):
|
||||||
|
setattr(self, "__match", None)
|
||||||
|
return getattr(self, "__match")
|
||||||
|
|
||||||
|
@match.setter
|
||||||
|
def match(self, value: Self) -> None:
|
||||||
|
"""Sets the match of the line item.
|
||||||
|
|
||||||
|
:param value: The matcho of the line item.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__match", value)
|
||||||
|
|
||||||
|
@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,
|
||||||
|
str(self.account),
|
||||||
|
format_amount(self.amount)]
|
||||||
|
|
||||||
|
|
||||||
|
class Option(db.Model):
|
||||||
|
"""An option."""
|
||||||
|
__tablename__ = "accounting_options"
|
||||||
|
"""The table name."""
|
||||||
|
name: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
"""The name."""
|
||||||
|
value: Mapped[str] = mapped_column(db.Text)
|
||||||
|
"""The option value."""
|
||||||
|
created_at: Mapped[dt.datetime] \
|
||||||
|
= mapped_column(db.DateTime(timezone=True),
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The date and time when this record was created."""
|
||||||
|
created_by_id: Mapped[int] \
|
||||||
|
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
|
"""The ID of the user who created the record."""
|
||||||
|
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
|
||||||
|
"""The user who created the record."""
|
||||||
|
updated_at: Mapped[dt.datetime] \
|
||||||
|
= mapped_column(db.DateTime(timezone=True),
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The date and time when this record was last updated."""
|
||||||
|
updated_by_id: Mapped[int] \
|
||||||
|
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
|
"""The ID of the last user who updated the record."""
|
||||||
|
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
|
||||||
|
"""The last user who updated the record."""
|
@ -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,25 +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_sqlalchemy import SQLAlchemy
|
from flask import Blueprint
|
||||||
|
|
||||||
db: 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")))
|
37
src/accounting/report/__init__.py
Normal file
37
src/accounting/report/__init__.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# 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, CurrentAccountConverter, \
|
||||||
|
NeedOffsetAccountConverter
|
||||||
|
app.url_map.converters["period"] = PeriodConverter
|
||||||
|
app.url_map.converters["currentAccount"] = CurrentAccountConverter
|
||||||
|
app.url_map.converters["needOffsetAccount"] = NeedOffsetAccountConverter
|
||||||
|
|
||||||
|
from .views import bp as report_bp
|
||||||
|
app.register_blueprint(report_bp, url_prefix=url_prefix)
|
105
src/accounting/report/converters.py
Normal file
105
src/accounting/report/converters.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# 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 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 CurrentAccountConverter(BaseConverter):
|
||||||
|
"""The converter to convert the current account code from and to the
|
||||||
|
corresponding 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
|
||||||
|
|
||||||
|
|
||||||
|
class NeedOffsetAccountConverter(BaseConverter):
|
||||||
|
"""The converter to convert the unapplied original line item account code
|
||||||
|
from and to the corresponding account in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> Account:
|
||||||
|
"""Converts an account code to an account.
|
||||||
|
|
||||||
|
:param value: The account code.
|
||||||
|
:return: The corresponding account.
|
||||||
|
"""
|
||||||
|
account: Account | None = Account.find_by_code(value)
|
||||||
|
if account is None:
|
||||||
|
abort(404)
|
||||||
|
if not account.is_need_offset:
|
||||||
|
abort(404)
|
||||||
|
return account
|
||||||
|
|
||||||
|
def to_url(self, value: Account) -> 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 datetime as dt
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
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: 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: 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: dt.date | None = None if first is None else first.date
|
||||||
|
|
||||||
|
# Attributes
|
||||||
|
self.data_start: dt.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: dt.date = dt.date.today()
|
||||||
|
self.has_last_month = start < dt.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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from accounting.locale import gettext
|
||||||
|
|
||||||
|
|
||||||
|
def get_desc(start: dt.date | None, end: dt.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: dt.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: dt.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 + dt.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: dt.date, end: dt.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: dt.date, end: dt.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 + dt.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: dt.date, end: dt.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: dt.date) -> str:
|
||||||
|
"""Formats a month.
|
||||||
|
|
||||||
|
:param month: The month.
|
||||||
|
:return: The formatted month.
|
||||||
|
"""
|
||||||
|
return f"{month.year}/{month.month}"
|
||||||
|
|
||||||
|
|
||||||
|
def __format_day(day: dt.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
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
|
||||||
|
def month_end(day: dt.date) -> dt.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 dt.date(day.year, day.month, last_day)
|
120
src/accounting/report/period/parser.py
Normal file
120
src/accounting/report/period/parser.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 parser.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import calendar
|
||||||
|
import datetime as dt
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
|
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, Type[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[dt.date | None, dt.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) -> dt.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 dt.date(int(year), int(month), int(day))
|
||||||
|
if month is not None:
|
||||||
|
return dt.date(int(year), int(month), 1)
|
||||||
|
return dt.date(int(year), 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_end(year: str, month: str | None, day: str | None) -> dt.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 dt.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 dt.date(year_n, month_n, day_n)
|
||||||
|
return dt.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 datetime as dt
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
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: dt.date | None, end: dt.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: dt.date | None = start
|
||||||
|
"""The start of the period."""
|
||||||
|
self.end: dt.date | None = end
|
||||||
|
"""The end of the period."""
|
||||||
|
self.is_default: bool = False
|
||||||
|
"""Whether this 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 == dt.date(self.start.year, 1, 1) \
|
||||||
|
and self.end == dt.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) -> 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 - dt.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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
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: dt.date = dt.date.today()
|
||||||
|
this_month_start: dt.date = dt.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: dt.date = dt.date.today()
|
||||||
|
year: int = today.year
|
||||||
|
month: int = today.month - 1
|
||||||
|
if month < 1:
|
||||||
|
year = year - 1
|
||||||
|
month = 12
|
||||||
|
start: dt.date = dt.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: dt.date = dt.date.today()
|
||||||
|
year: int = today.year
|
||||||
|
month: int = today.month - 1
|
||||||
|
if month < 1:
|
||||||
|
year = year - 1
|
||||||
|
month = 12
|
||||||
|
start: dt.date = dt.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 = dt.date.today().year
|
||||||
|
start: dt.date = dt.date(year, 1, 1)
|
||||||
|
end: dt.date = dt.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 = dt.date.today().year
|
||||||
|
start: dt.date = dt.date(year - 1, 1, 1)
|
||||||
|
end: dt.date = dt.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: dt.date = dt.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: dt.date = dt.date.today() - dt.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: dt.date = dt.date(year, 1, 1)
|
||||||
|
end: dt.date = dt.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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
|
||||||
|
def get_spec(start: dt.date | None, end: dt.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: dt.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: dt.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 + dt.timedelta(days=1)).day == 1:
|
||||||
|
return end.strftime("-%Y-%m")
|
||||||
|
return end.strftime("-%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def __get_year_spec(start: dt.date, end: dt.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: dt.date, end: dt.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 + dt.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: dt.date, end: dt.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
|
477
src/accounting/report/reports/balance_sheet.py
Normal file
477
src/accounting/report/reports/balance_sheet.py
Normal file
@ -0,0 +1,477 @@
|
|||||||
|
# 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()
|
||||||
|
"""The accounts."""
|
||||||
|
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]
|
||||||
|
"""The accounts on the balance sheet."""
|
||||||
|
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, None)]
|
||||||
|
for subsection in section.subsections:
|
||||||
|
rows.append(CSVHalfRow(f" {subsection.title.title}", None))
|
||||||
|
for account in subsection.accounts:
|
||||||
|
rows.append(CSVHalfRow(f" {str(account.account)}",
|
||||||
|
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)
|
457
src/accounting/report/reports/income_expenses.py
Normal file
457
src/accounting/report/reports/income_expenses.py
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
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.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: dt.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(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, date: dt.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 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: dt.date | str | None = 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(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),
|
||||||
|
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), 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)
|
327
src/accounting/report/reports/income_statement.py
Normal file
327
src/accounting/report/reports/income_statement.py
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
# 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)
|
||||||
|
"""The accumulated total."""
|
||||||
|
|
||||||
|
@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), None))
|
||||||
|
for subsection in section.subsections:
|
||||||
|
rows.append(CSVRow(f" {str(subsection.title)}", None))
|
||||||
|
for account in subsection.accounts:
|
||||||
|
rows.append(CSVRow(f" {str(account.account)}",
|
||||||
|
account.amount))
|
||||||
|
rows.append(CSVRow(f" {total_str}", subsection.total))
|
||||||
|
rows.append(CSVRow(section.accumulated.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)
|
223
src/accounting/report/reports/journal.py
Normal file
223
src/accounting/report/reports/journal.py
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
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 | dt.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 currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param description: The description.
|
||||||
|
:param debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
:param note: The note.
|
||||||
|
"""
|
||||||
|
self.date: str | dt.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 pagination: The pagination.
|
||||||
|
: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), 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)
|
413
src/accounting/report/reports/ledger.py
Normal file
413
src/accounting/report/reports/ledger.py
Normal file
@ -0,0 +1,413 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
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.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: dt.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(JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
|
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, date: dt.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 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: dt.date | str | None = 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(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)
|
218
src/accounting/report/reports/search.py
Normal file
218
src/accounting/report/reports/search.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
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.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)]
|
||||||
|
date: dt.datetime
|
||||||
|
try:
|
||||||
|
date = dt.datetime.strptime(k, "%Y")
|
||||||
|
conditions.append(
|
||||||
|
sa.extract("year", JournalEntry.date) == date.year)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
date = dt.datetime.strptime(k, "%Y/%m")
|
||||||
|
conditions.append(sa.and_(
|
||||||
|
sa.extract("year", JournalEntry.date) == date.year,
|
||||||
|
sa.extract("month", JournalEntry.date) == date.month))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
date = dt.datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||||
|
conditions.append(sa.and_(
|
||||||
|
sa.extract("month", JournalEntry.date) == date.month,
|
||||||
|
sa.extract("day", JournalEntry.date) == date.day))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
date = dt.datetime.strptime(k, "%Y/%m/%d")
|
||||||
|
conditions.append(sa.and_(
|
||||||
|
sa.extract("year", JournalEntry.date) == date.year,
|
||||||
|
sa.extract("month", JournalEntry.date) == date.month,
|
||||||
|
sa.extract("day", JournalEntry.date) == 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), 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)
|
216
src/accounting/report/reports/unapplied.py
Normal file
216
src/accounting/report/reports/unapplied.py
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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 unapplied original line items.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
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.utils.base_page_params import BasePageParams
|
||||||
|
from accounting.report.utils.base_report import BaseReport
|
||||||
|
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
|
||||||
|
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.unapplied import get_accounts_with_unapplied, \
|
||||||
|
get_net_balances
|
||||||
|
from accounting.report.utils.urls import unapplied_url
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, journal_entry_date: str | dt.date, currency: str,
|
||||||
|
description: str | None, amount: str | Decimal,
|
||||||
|
net_balance: str | Decimal):
|
||||||
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
|
:param journal_entry_date: The journal entry date.
|
||||||
|
:param currency: The currency.
|
||||||
|
:param description: The description.
|
||||||
|
:param amount: The amount.
|
||||||
|
:param net_balance: The net balance.
|
||||||
|
"""
|
||||||
|
self.date: str | dt.date = journal_entry_date
|
||||||
|
"""The date."""
|
||||||
|
self.currency: str = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.description: str | None = description
|
||||||
|
"""The description."""
|
||||||
|
self.amount: str | Decimal = amount
|
||||||
|
"""The amount."""
|
||||||
|
self.net_balance: str | Decimal = net_balance
|
||||||
|
"""The net balance."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | dt.date | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.date, self.currency, self.description, self.amount,
|
||||||
|
self.net_balance]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
account: Account,
|
||||||
|
pagination: Pagination[JournalEntryLineItem],
|
||||||
|
line_items: list[JournalEntryLineItem]):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param pagination: The pagination.
|
||||||
|
:param line_items: The line items.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
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.UNAPPLIED, currency=self.currency,
|
||||||
|
account=self.account)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(
|
||||||
|
lambda x: unapplied_url(x, self.account), self.currency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the account options.
|
||||||
|
|
||||||
|
:return: The account options.
|
||||||
|
"""
|
||||||
|
options: list[OptionLink] \
|
||||||
|
= [OptionLink(gettext("Accounts"),
|
||||||
|
unapplied_url(self.currency, None),
|
||||||
|
False)]
|
||||||
|
options.extend(
|
||||||
|
[OptionLink(str(x), unapplied_url(self.currency, x),
|
||||||
|
x.id == self.account.id)
|
||||||
|
for x in get_accounts_with_unapplied(self.currency)])
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
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("Description"), gettext("Amount"),
|
||||||
|
gettext("Net Balance"))]
|
||||||
|
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
|
||||||
|
x.description, x.amount, x.net_balance)
|
||||||
|
for x in line_items])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class UnappliedOriginalLineItems(BaseReport):
|
||||||
|
"""The unapplied original line items."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: Account):
|
||||||
|
"""Constructs the unapplied original line items.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
net_balances: dict[int, Decimal | None] \
|
||||||
|
= get_net_balances(self.__currency, self.__account)
|
||||||
|
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query \
|
||||||
|
.join(Account).join(JournalEntry) \
|
||||||
|
.filter(JournalEntryLineItem.id.in_(net_balances)) \
|
||||||
|
.order_by(JournalEntry.date, JournalEntry.no,
|
||||||
|
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
|
||||||
|
.options(selectinload(JournalEntryLineItem.currency),
|
||||||
|
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||||
|
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
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "unapplied-{currency}-{account}.csv"\
|
||||||
|
.format(currency=self.__currency.code, account=self.__account.code)
|
||||||
|
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(currency=self.__currency,
|
||||||
|
account=self.__account,
|
||||||
|
pagination=pagination,
|
||||||
|
line_items=pagination.list)
|
||||||
|
return render_template("accounting/report/unapplied.html",
|
||||||
|
report=params)
|
155
src/accounting/report/reports/unapplied_accounts.py
Normal file
155
src/accounting/report/reports/unapplied_accounts.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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 accounts with unapplied original line items.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from flask import render_template, Response
|
||||||
|
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, Account
|
||||||
|
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
|
||||||
|
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.unapplied import get_accounts_with_unapplied
|
||||||
|
from accounting.report.utils.urls import unapplied_url
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, account: str, count: int | str):
|
||||||
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:param count: The number of unapplied original line items.
|
||||||
|
"""
|
||||||
|
self.account: str = account
|
||||||
|
"""The currency."""
|
||||||
|
self.count: int | str = count
|
||||||
|
"""The number of unapplied original line items."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | dt.date | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.account, self.count]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, accounts: list[Account]):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param accounts: The accounts.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.accounts: list[Account] = accounts
|
||||||
|
"""The accounts."""
|
||||||
|
|
||||||
|
@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.UNAPPLIED, currency=self.currency,
|
||||||
|
account=None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(lambda x: unapplied_url(x, None),
|
||||||
|
self.currency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the account options.
|
||||||
|
|
||||||
|
:return: The account options.
|
||||||
|
"""
|
||||||
|
options: list[OptionLink] \
|
||||||
|
= [OptionLink(gettext("Accounts"),
|
||||||
|
unapplied_url(self.currency, None),
|
||||||
|
True)]
|
||||||
|
options.extend(
|
||||||
|
[OptionLink(str(x), unapplied_url(self.currency, x), False)
|
||||||
|
for x in self.accounts])
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows from the line items.
|
||||||
|
|
||||||
|
:param accounts: The accounts.
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
|
||||||
|
rows.extend([CSVRow(str(x), x.count) for x in accounts])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsWithUnappliedOriginalLineItems(BaseReport):
|
||||||
|
"""The accounts with unapplied original line items."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency):
|
||||||
|
"""Constructs the outstanding balances.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__accounts: list[Account] = get_accounts_with_unapplied(currency)
|
||||||
|
"""The accounts."""
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "unapplied-accounts.csv"
|
||||||
|
return csv_download(filename, get_csv_rows(self.__accounts))
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/report/unapplied-accounts.html",
|
||||||
|
report=PageParams(currency=self.__currency,
|
||||||
|
accounts=self.__accounts))
|
214
src/accounting/report/reports/unmatched.py
Normal file
214
src/accounting/report/reports/unmatched.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
|
||||||
|
|
||||||
|
# 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 unmatched offsets.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from flask import render_template, Response
|
||||||
|
from flask_babel import LazyString
|
||||||
|
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, Account, 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 BaseCSVRow, csv_download
|
||||||
|
from accounting.report.utils.offset_matcher import OffsetMatcher, OffsetPair
|
||||||
|
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.unmatched import get_accounts_with_unmatched
|
||||||
|
from accounting.report.utils.urls import unmatched_url
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, journal_entry_date: str | dt.date, currency: str,
|
||||||
|
description: str | None, debit: str | Decimal,
|
||||||
|
credit: str | Decimal, balance: str | Decimal):
|
||||||
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
|
:param journal_entry_date: The journal entry date.
|
||||||
|
:param currency: The currency.
|
||||||
|
:param description: The description.
|
||||||
|
:param debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
:param balance: The balance.
|
||||||
|
"""
|
||||||
|
self.date: str | dt.date = journal_entry_date
|
||||||
|
"""The date."""
|
||||||
|
self.currency: str = currency
|
||||||
|
"""The currency."""
|
||||||
|
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 = balance
|
||||||
|
"""The balance."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | dt.date | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.date, self.currency, self.description, self.debit,
|
||||||
|
self.credit, self.balance]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
account: Account,
|
||||||
|
match_status: str | LazyString,
|
||||||
|
matched_pairs: list[OffsetPair],
|
||||||
|
pagination: Pagination[JournalEntryLineItem],
|
||||||
|
line_items: list[JournalEntryLineItem]):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param match_status: The match status message.
|
||||||
|
:param matched_pairs: A list of matched pairs.
|
||||||
|
:param pagination: The pagination.
|
||||||
|
:param line_items: The line items.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.match_status: str | LazyString = match_status
|
||||||
|
"""The match status message."""
|
||||||
|
self.matched_pairs: list[OffsetPair] = matched_pairs
|
||||||
|
"""A list of matched pairs."""
|
||||||
|
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.UNMATCHED, currency=self.currency,
|
||||||
|
account=self.account)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(
|
||||||
|
lambda x: unmatched_url(x, self.account), self.currency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the account options.
|
||||||
|
|
||||||
|
:return: The account options.
|
||||||
|
"""
|
||||||
|
options: list[OptionLink] \
|
||||||
|
= [OptionLink(gettext("Accounts"),
|
||||||
|
unmatched_url(self.currency, None),
|
||||||
|
False)]
|
||||||
|
options.extend(
|
||||||
|
[OptionLink(str(x), unmatched_url(self.currency, x),
|
||||||
|
x.id == self.account.id)
|
||||||
|
for x in get_accounts_with_unmatched(self.currency)])
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
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("Description"), gettext("Debit"),
|
||||||
|
gettext("Credit"), gettext("Balance"))]
|
||||||
|
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
|
||||||
|
x.description, x.debit, x.credit, x.balance)
|
||||||
|
for x in line_items])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class UnmatchedOffsets(BaseReport):
|
||||||
|
"""The unmatched offsets."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: Account):
|
||||||
|
"""Constructs the unmatched offsets.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
offset_matcher: OffsetMatcher \
|
||||||
|
= OffsetMatcher(self.__currency, self.__account)
|
||||||
|
self.__line_items: list[JournalEntryLineItem] \
|
||||||
|
= offset_matcher.line_items
|
||||||
|
"""The line items."""
|
||||||
|
self.__match_status: str | LazyString = offset_matcher.status
|
||||||
|
"""The match status message."""
|
||||||
|
self.__matched_pairs: list[OffsetPair] = offset_matcher.matched_pairs
|
||||||
|
"""A list of matched pairs."""
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "unmatched-{currency}-{account}.csv"\
|
||||||
|
.format(currency=self.__currency.code, account=self.__account.code)
|
||||||
|
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(currency=self.__currency,
|
||||||
|
account=self.__account,
|
||||||
|
match_status=self.__match_status,
|
||||||
|
matched_pairs=self.__matched_pairs,
|
||||||
|
pagination=pagination,
|
||||||
|
line_items=pagination.list)
|
||||||
|
return render_template("accounting/report/unmatched.html",
|
||||||
|
report=params)
|
156
src/accounting/report/reports/unmatched_accounts.py
Normal file
156
src/accounting/report/reports/unmatched_accounts.py
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
|
||||||
|
|
||||||
|
# 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 accounts with unmatched offsets.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from flask import render_template, Response
|
||||||
|
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, Account
|
||||||
|
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
|
||||||
|
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.unmatched import get_accounts_with_unmatched
|
||||||
|
from accounting.report.utils.urls import unmatched_url
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, account: str, count: int | str):
|
||||||
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:param count: The number of unapplied original line items.
|
||||||
|
"""
|
||||||
|
self.account: str = account
|
||||||
|
"""The currency."""
|
||||||
|
self.count: int | str = count
|
||||||
|
"""The number of unapplied original line items."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | dt.date | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.account, self.count]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, accounts: list[Account]):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param accounts: The accounts.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.accounts: list[Account] = accounts
|
||||||
|
"""The accounts."""
|
||||||
|
|
||||||
|
@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.UNMATCHED, currency=self.currency,
|
||||||
|
account=None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(lambda x: unmatched_url(x, None),
|
||||||
|
self.currency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the account options.
|
||||||
|
|
||||||
|
:return: The account options.
|
||||||
|
"""
|
||||||
|
options: list[OptionLink] \
|
||||||
|
= [OptionLink(gettext("Accounts"),
|
||||||
|
unmatched_url(self.currency, None),
|
||||||
|
True)]
|
||||||
|
options.extend(
|
||||||
|
[OptionLink(str(x), unmatched_url(self.currency, x), False)
|
||||||
|
for x in self.accounts])
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows from the line items.
|
||||||
|
|
||||||
|
:param accounts: The accounts.
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
|
||||||
|
rows.extend([CSVRow(str(x), x.count) for x in accounts])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsWithUnmatchedOffsets(BaseReport):
|
||||||
|
"""The accounts with unmatched offsets."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency):
|
||||||
|
"""Constructs the outstanding balances.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__accounts: list[Account] \
|
||||||
|
= get_accounts_with_unmatched(currency)
|
||||||
|
"""The accounts."""
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "unmatched-accounts.csv"
|
||||||
|
return csv_download(filename, get_csv_rows(self.__accounts))
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/report/unmatched-accounts.html",
|
||||||
|
report=PageParams(currency=self.__currency,
|
||||||
|
accounts=self.__accounts))
|
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.
|
||||||
|
|
||||||
|
"""
|
89
src/accounting/report/utils/base_page_params.py
Normal file
89
src/accounting/report/utils/base_page_params.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Type
|
||||||
|
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) -> 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: 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.
|
||||||
|
"""
|
109
src/accounting/report/utils/csv_export.py
Normal file
109
src/accounting/report/utils/csv_export.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# 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
|
||||||
|
import datetime as dt
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from decimal import Decimal
|
||||||
|
from io import StringIO
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
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={quote(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: dt.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: dt.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 + dt.timedelta(days=1)).day == 1:
|
||||||
|
return end.strftime("%Y%m")
|
||||||
|
return end.strftime("%Y%m%d")
|
178
src/accounting/report/utils/offset_matcher.py
Normal file
178
src/accounting/report/utils/offset_matcher.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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 forms for the unmatched offset management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask_babel import LazyString
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Currency, Account, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
|
from accounting.report.utils.unapplied import get_net_balances
|
||||||
|
|
||||||
|
|
||||||
|
class OffsetPair:
|
||||||
|
"""A pair of an original line item and its offset."""
|
||||||
|
|
||||||
|
def __init__(self, original_line_item: JournalEntryLineItem,
|
||||||
|
offset: JournalEntryLineItem):
|
||||||
|
"""Constructs a pair of an original line item and its offset.
|
||||||
|
|
||||||
|
:param original_line_item: The original line item.
|
||||||
|
:param offset: The offset.
|
||||||
|
"""
|
||||||
|
self.original_line_item: JournalEntryLineItem = original_line_item
|
||||||
|
"""The original line item."""
|
||||||
|
self.offset: JournalEntryLineItem = offset
|
||||||
|
"""The offset."""
|
||||||
|
|
||||||
|
|
||||||
|
class OffsetMatcher:
|
||||||
|
"""The offset matcher."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: Account):
|
||||||
|
"""Constructs the offset matcher.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
"""
|
||||||
|
self.__currency: Account = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.matched_pairs: list[OffsetPair] = []
|
||||||
|
"""A list of matched pairs."""
|
||||||
|
self.line_items: list[JournalEntryLineItem] = []
|
||||||
|
"""The unapplied debits or credits and unmatched offsets."""
|
||||||
|
self.unapplied: list[JournalEntryLineItem] = []
|
||||||
|
"""The unapplied debits or credits."""
|
||||||
|
self.unmatched: list[JournalEntryLineItem] = []
|
||||||
|
"""The unmatched offsets."""
|
||||||
|
self.__find_matches()
|
||||||
|
|
||||||
|
def __find_matches(self) -> None:
|
||||||
|
"""Finds the matched original line items and their offsets.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__get_line_items()
|
||||||
|
if len(self.unapplied) == 0 or len(self.unmatched) == 0:
|
||||||
|
return
|
||||||
|
remains: list[JournalEntryLineItem] = self.unmatched.copy()
|
||||||
|
for original_item in self.unapplied:
|
||||||
|
offset_candidates: list[JournalEntryLineItem] \
|
||||||
|
= [x for x in remains
|
||||||
|
if (x.journal_entry.date > original_item.journal_entry.date
|
||||||
|
or (x.journal_entry.date
|
||||||
|
== original_item.journal_entry.date
|
||||||
|
and x.journal_entry.no
|
||||||
|
> original_item.journal_entry.no))
|
||||||
|
and x.currency_code == original_item.currency_code
|
||||||
|
and x.description == original_item.description
|
||||||
|
and x.amount == original_item.net_balance]
|
||||||
|
if len(offset_candidates) == 0:
|
||||||
|
continue
|
||||||
|
self.matched_pairs.append(
|
||||||
|
OffsetPair(original_item, offset_candidates[0]))
|
||||||
|
original_item.match = offset_candidates[0]
|
||||||
|
offset_candidates[0].match = original_item
|
||||||
|
remains.remove(offset_candidates[0])
|
||||||
|
|
||||||
|
def __get_line_items(self) -> None:
|
||||||
|
"""Returns the unapplied original line items and unmatched offsets of
|
||||||
|
the account.
|
||||||
|
|
||||||
|
:return: The unapplied original line items and unmatched offsets of the
|
||||||
|
account.
|
||||||
|
"""
|
||||||
|
net_balances: dict[int, Decimal | None] \
|
||||||
|
= get_net_balances(self.__currency, self.__account)
|
||||||
|
unmatched_offset_condition: sa.BinaryExpression \
|
||||||
|
= sa.and_(Account.id == self.__account.id,
|
||||||
|
JournalEntryLineItem.currency_code
|
||||||
|
== self.__currency.code,
|
||||||
|
JournalEntryLineItem.original_line_item_id.is_(None),
|
||||||
|
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||||
|
JournalEntryLineItem.is_debit),
|
||||||
|
sa.and_(Account.base_code.startswith("1"),
|
||||||
|
sa.not_(JournalEntryLineItem.is_debit))))
|
||||||
|
self.line_items = JournalEntryLineItem.query \
|
||||||
|
.join(Account).join(JournalEntry) \
|
||||||
|
.filter(sa.or_(JournalEntryLineItem.id.in_(net_balances),
|
||||||
|
unmatched_offset_condition)) \
|
||||||
|
.order_by(JournalEntry.date, JournalEntry.no,
|
||||||
|
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
|
||||||
|
.options(selectinload(JournalEntryLineItem.currency),
|
||||||
|
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||||
|
for line_item in self.line_items:
|
||||||
|
line_item.is_offset = line_item.id not in net_balances
|
||||||
|
self.unapplied = [x for x in self.line_items if not x.is_offset]
|
||||||
|
for line_item in self.unapplied:
|
||||||
|
line_item.net_balance = line_item.amount \
|
||||||
|
if net_balances[line_item.id] is None \
|
||||||
|
else net_balances[line_item.id]
|
||||||
|
self.unmatched = [x for x in self.line_items if x.is_offset]
|
||||||
|
self.__populate_accumulated_balances()
|
||||||
|
|
||||||
|
def __populate_accumulated_balances(self) -> None:
|
||||||
|
"""Populates the accumulated balances of the line items.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
balance: Decimal = Decimal("0")
|
||||||
|
for line_item in self.line_items:
|
||||||
|
amount: Decimal = line_item.amount if line_item.is_offset \
|
||||||
|
else line_item.net_balance
|
||||||
|
if line_item.is_debit:
|
||||||
|
line_item.debit = amount
|
||||||
|
line_item.credit = None
|
||||||
|
balance = balance + amount
|
||||||
|
else:
|
||||||
|
line_item.debit = None
|
||||||
|
line_item.credit = amount
|
||||||
|
balance = balance - amount
|
||||||
|
line_item.balance = balance
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str | LazyString:
|
||||||
|
"""Returns the match status message.
|
||||||
|
|
||||||
|
:return: The match status message.
|
||||||
|
"""
|
||||||
|
if len(self.unmatched) == 0:
|
||||||
|
return lazy_gettext("There is no unmatched offset.")
|
||||||
|
if len(self.matched_pairs) == 0:
|
||||||
|
return lazy_gettext(
|
||||||
|
"%(total)s unmatched offsets without original items.",
|
||||||
|
total=len(self.unmatched))
|
||||||
|
return lazy_gettext(
|
||||||
|
"%(matches)s unmatched offsets out of %(total)s"
|
||||||
|
" can match with their original items.",
|
||||||
|
matches=len(self.matched_pairs),
|
||||||
|
total=len(self.unmatched))
|
||||||
|
|
||||||
|
def match(self) -> None:
|
||||||
|
"""Matches the original line items with offsets.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
for pair in self.matched_pairs:
|
||||||
|
pair.offset.original_line_item_id = pair.original_line_item.id
|
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."""
|
198
src/accounting/report/utils/report_chooser.py
Normal file
198
src/accounting/report/utils/report_chooser.py
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
# 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
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
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 accounting.utils.permission import can_edit
|
||||||
|
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, \
|
||||||
|
unapplied_url, unmatched_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)
|
||||||
|
self.__reports.append(self.__unapplied)
|
||||||
|
if can_edit():
|
||||||
|
self.__reports.append(self.__unmatched)
|
||||||
|
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")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __unapplied(self) -> OptionLink:
|
||||||
|
"""Returns the unapplied original line items.
|
||||||
|
|
||||||
|
:return: The unapplied original line items.
|
||||||
|
"""
|
||||||
|
account: Account = self.__account
|
||||||
|
if not account.is_need_offset:
|
||||||
|
return OptionLink(gettext("Unapplied Items"),
|
||||||
|
unapplied_url(self.__currency, None),
|
||||||
|
self.__active_report == ReportType.UNAPPLIED,
|
||||||
|
fa_icon="fa-solid fa-link-slash")
|
||||||
|
return OptionLink(gettext("Unapplied Items"),
|
||||||
|
unapplied_url(self.__currency, self.__account),
|
||||||
|
self.__active_report == ReportType.UNAPPLIED,
|
||||||
|
fa_icon="fa-solid fa-link-slash")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __unmatched(self) -> OptionLink:
|
||||||
|
"""Returns the unmatched offsets.
|
||||||
|
|
||||||
|
:return: The unmatched offsets.
|
||||||
|
"""
|
||||||
|
account: Account = self.__account
|
||||||
|
if not account.is_need_offset:
|
||||||
|
return OptionLink(gettext("Unmatched Offsets"),
|
||||||
|
unmatched_url(self.__currency, None),
|
||||||
|
self.__active_report == ReportType.UNMATCHED,
|
||||||
|
fa_icon="fa-solid fa-file-circle-question")
|
||||||
|
return OptionLink(gettext("Unmatched Offsets"),
|
||||||
|
unmatched_url(self.__currency, self.__account),
|
||||||
|
self.__active_report == ReportType.UNMATCHED,
|
||||||
|
fa_icon="fa-solid fa-file-circle-question")
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[OptionLink]:
|
||||||
|
"""Returns the iteration of the reports.
|
||||||
|
|
||||||
|
:return: The iteration of the reports.
|
||||||
|
"""
|
||||||
|
return iter(self.__reports)
|
42
src/accounting/report/utils/report_type.py
Normal file
42
src/accounting/report/utils/report_type.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# 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."""
|
||||||
|
UNAPPLIED: str = "unapplied"
|
||||||
|
"""The unapplied original line items."""
|
||||||
|
UNMATCHED: str = "unmatched"
|
||||||
|
"""The unmatched offsets."""
|
||||||
|
SEARCH: str = "search"
|
||||||
|
"""The search."""
|
104
src/accounting/report/utils/unapplied.py
Normal file
104
src/accounting/report/utils/unapplied.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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 unapplied original line item utilities.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import Currency, Account, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
|
from accounting.utils.offset_alias import offset_alias
|
||||||
|
|
||||||
|
|
||||||
|
def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
|
||||||
|
"""Returns the accounts with unapplied original line items.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The accounts with unapplied original line items.
|
||||||
|
"""
|
||||||
|
offset: sa.Alias = offset_alias()
|
||||||
|
net_balance: sa.Label \
|
||||||
|
= (JournalEntryLineItem.amount
|
||||||
|
+ sa.func.sum(sa.case(
|
||||||
|
(offset.c.is_debit == JournalEntryLineItem.is_debit,
|
||||||
|
offset.c.amount),
|
||||||
|
else_=-offset.c.amount))).label("net_balance")
|
||||||
|
select_unapplied: sa.Select \
|
||||||
|
= sa.select(JournalEntryLineItem.id)\
|
||||||
|
.join(JournalEntry).join(Account)\
|
||||||
|
.join(offset,
|
||||||
|
JournalEntryLineItem.id == offset.c.original_line_item_id,
|
||||||
|
isouter=True)\
|
||||||
|
.filter(Account.is_need_offset,
|
||||||
|
JournalEntryLineItem.currency_code == currency.code,
|
||||||
|
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||||
|
sa.not_(JournalEntryLineItem.is_debit)),
|
||||||
|
sa.and_(Account.base_code.startswith("1"),
|
||||||
|
JournalEntryLineItem.is_debit)))\
|
||||||
|
.group_by(JournalEntryLineItem.id)\
|
||||||
|
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
|
||||||
|
|
||||||
|
count_func: sa.Label \
|
||||||
|
= sa.func.count(JournalEntryLineItem.id).label("count")
|
||||||
|
select: sa.Select = sa.select(Account.id, count_func)\
|
||||||
|
.join(JournalEntryLineItem, isouter=True)\
|
||||||
|
.filter(JournalEntryLineItem.id.in_(select_unapplied))\
|
||||||
|
.group_by(Account.id)\
|
||||||
|
.having(count_func > 0)
|
||||||
|
counts: dict[int, int] \
|
||||||
|
= {x.id: x.count for x in db.session.execute(select)}
|
||||||
|
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
|
||||||
|
.order_by(Account.base_code, Account.no).all()
|
||||||
|
for account in accounts:
|
||||||
|
account.count = counts[account.id]
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
def get_net_balances(currency: Currency, account: Account) \
|
||||||
|
-> dict[int, Decimal | None]:
|
||||||
|
"""Returns the net balances of the unapplied line items of the account.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:return: The net balances of the unapplied line items of the account.
|
||||||
|
"""
|
||||||
|
offset: sa.Alias = offset_alias()
|
||||||
|
net_balance: sa.Label \
|
||||||
|
= (JournalEntryLineItem.amount
|
||||||
|
+ sa.func.sum(sa.case(
|
||||||
|
(offset.c.is_debit == JournalEntryLineItem.is_debit,
|
||||||
|
offset.c.amount),
|
||||||
|
else_=-offset.c.amount))).label("net_balance")
|
||||||
|
select_net_balances: sa.Select \
|
||||||
|
= sa.select(JournalEntryLineItem.id, net_balance) \
|
||||||
|
.join(JournalEntry).join(Account) \
|
||||||
|
.join(offset,
|
||||||
|
JournalEntryLineItem.id == offset.c.original_line_item_id,
|
||||||
|
isouter=True) \
|
||||||
|
.filter(Account.id == account.id,
|
||||||
|
JournalEntryLineItem.currency_code == currency.code,
|
||||||
|
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||||
|
sa.not_(JournalEntryLineItem.is_debit)),
|
||||||
|
sa.and_(Account.base_code.startswith("1"),
|
||||||
|
JournalEntryLineItem.is_debit))) \
|
||||||
|
.group_by(JournalEntryLineItem.id) \
|
||||||
|
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
|
||||||
|
return {x.id: x.net_balance
|
||||||
|
for x in db.session.execute(select_net_balances).all()}
|
54
src/accounting/report/utils/unmatched.py
Normal file
54
src/accounting/report/utils/unmatched.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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 unmatched offset utilities.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import Currency, Account, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
|
|
||||||
|
|
||||||
|
def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
|
||||||
|
"""Returns the accounts with unmatched offsets.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The accounts with unmatched offsets, with the "count" property set
|
||||||
|
to the number of unmatched offsets.
|
||||||
|
"""
|
||||||
|
count_func: sa.Label \
|
||||||
|
= sa.func.count(JournalEntryLineItem.id).label("count")
|
||||||
|
select: sa.Select = sa.select(Account.id, count_func)\
|
||||||
|
.select_from(Account)\
|
||||||
|
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
|
||||||
|
.filter(Account.is_need_offset,
|
||||||
|
JournalEntryLineItem.currency_code == currency.code,
|
||||||
|
JournalEntryLineItem.original_line_item_id.is_(None),
|
||||||
|
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||||
|
JournalEntryLineItem.is_debit),
|
||||||
|
sa.and_(Account.base_code.startswith("1"),
|
||||||
|
sa.not_(JournalEntryLineItem.is_debit))))\
|
||||||
|
.group_by(Account.id)\
|
||||||
|
.having(count_func > 0)
|
||||||
|
counts: dict[int, int] \
|
||||||
|
= {x.id: x.count for x in db.session.execute(select)}
|
||||||
|
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
|
||||||
|
.order_by(Account.base_code, Account.no).all()
|
||||||
|
for account in accounts:
|
||||||
|
account.count = counts[account.id]
|
||||||
|
return accounts
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user