Compare commits
	
		
			1052 Commits
		
	
	
		
			v0.0.0
			...
			aea9dcae79
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| aea9dcae79 | |||
| 40278eaf06 | |||
| e00c14f277 | |||
| f20c462685 | |||
| 80ae4bd91c | |||
| 6ee3ee76ea | |||
| 2bfcc8b889 | |||
| 99564c02d0 | |||
| 25d9904180 | |||
| 1cf83adf87 | |||
| 8e3d1f11b5 | |||
| 0ab14aa34d | |||
| e0ed81ad1f | |||
| ece7481e9e | |||
| 50d4526e0b | |||
| 3f0a0b4227 | |||
| dcc9626b23 | |||
| 79eb077129 | |||
| d5719ad223 | |||
| eb3fa8f414 | |||
| 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 | ||||||
|   | |||||||
							
								
								
									
										41
									
								
								.readthedocs.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								.readthedocs.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | # 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: . | ||||||
|  |    - requirements: docs/requirements.txt | ||||||
							
								
								
									
										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 * | ||||||
|   | |||||||
							
								
								
									
										68
									
								
								README.rst
									
									
									
									
									
								
							
							
						
						
									
										68
									
								
								README.rst
									
									
									
									
									
								
							| @@ -1,30 +1,65 @@ | |||||||
| ===================== | =============== | ||||||
| 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 | ||||||
| ========= | ========= | ||||||
|  |  | ||||||
|  Copyright (c) 2023 imacat. |  Copyright (c) 2023-2024 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. | ||||||
| @@ -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 | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								docs/requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								docs/requirements.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | sphinx_rtd_theme | ||||||
							
								
								
									
										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 | ||||||
| --------------- | --------------- | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										463
									
								
								docs/source/changelog.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										463
									
								
								docs/source/changelog.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,463 @@ | |||||||
|  | Change Log | ||||||
|  | ========== | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Version 1.6.1 | ||||||
|  | -------------- | ||||||
|  |  | ||||||
|  | Released 2024/12/3 | ||||||
|  |  | ||||||
|  | Fix test cases for compatibility with httpx 0.28.0. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Version 1.6.0 | ||||||
|  | -------------- | ||||||
|  |  | ||||||
|  | Released 2024/6/4 | ||||||
|  |  | ||||||
|  | * Updated Python version to 3.12. | ||||||
|  | * Revised the calculation of "today" to use the client's timezone instead of | ||||||
|  |   the server's timezone. | ||||||
|  | * Updated the Bootstrap, FontAwesome, and Tempus-Dominus versions in the test | ||||||
|  |   site. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Version 1.5.11 | ||||||
|  | -------------- | ||||||
|  |  | ||||||
|  | Released 2023/12/26 | ||||||
|  |  | ||||||
|  | Bug fix. | ||||||
|  |  | ||||||
|  | * Refined to enable the selection of the 3351-001 Accumulated Profit or Loss | ||||||
|  |   account. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Version 1.5.10 | ||||||
|  | -------------- | ||||||
|  |  | ||||||
|  | Released 2023/11/28 | ||||||
|  |  | ||||||
|  | Bug fix. | ||||||
|  |  | ||||||
|  | * Fixed the form validator to enable the selection of Accumulated Profit or | ||||||
|  |   Loss accounts other than 3351-001. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Version 1.5.9 | ||||||
|  | ------------- | ||||||
|  |  | ||||||
|  | Released 2023/11/28 | ||||||
|  |  | ||||||
|  | Bug fix. | ||||||
|  |  | ||||||
|  | * Refined to enable the selection of Accumulated Profit or Loss accounts other | ||||||
|  |   than 3351-001, facilitating the consolidation of existing balances. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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-2024 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,55 @@ | |||||||
| #  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.12" | ||||||
|  | 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] | ||||||
|  | devel = [ | ||||||
|  |     "httpx >= 0.20.0", | ||||||
|  |     "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,7 +1,7 @@ | |||||||
| # 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-2024 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. | ||||||
| @@ -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.6.1" | ||||||
|  | """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-2024 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 | ||||||
|  |  | ||||||
|  | type 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,以太坊,以太坊 | ||||||
| 
 | 
							
								
								
									
										96
									
								
								src/accounting/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/accounting/forms.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | |||||||
|  | # The Mia! Accounting Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The forms. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import re | ||||||
|  |  | ||||||
|  | from flask_babel import LazyString | ||||||
|  | from flask_wtf import FlaskForm | ||||||
|  | from wtforms import StringField, ValidationError | ||||||
|  | from wtforms.validators import DataRequired | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import lazy_gettext | ||||||
|  | from accounting.models import Currency, Account | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ACCOUNT_REQUIRED: DataRequired = DataRequired( | ||||||
|  |     lazy_gettext("Please select the account.")) | ||||||
|  | """The validator to check if the account code is empty.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CurrencyExists: | ||||||
|  |     """The validator to check if the account exists.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         if db.session.get(Currency, field.data) is None: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The currency does not exist.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AccountExists: | ||||||
|  |     """The validator to check if the account exists.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         if Account.find_by_code(field.data) is None: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The account does not exist.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IsDebitAccount: | ||||||
|  |     """The validator to check if the account is for debit line items.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, message: str | LazyString): | ||||||
|  |         """Constructs the validator. | ||||||
|  |  | ||||||
|  |         :param message: The error message. | ||||||
|  |         """ | ||||||
|  |         self.__message: str | LazyString = message | ||||||
|  |         """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("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("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)] | ||||||
							
								
								
									
										588
									
								
								src/accounting/journal_entry/forms/journal_entry.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										588
									
								
								src/accounting/journal_entry/forms/journal_entry.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,588 @@ | |||||||
|  | # The Mia! Accounting Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023-2024 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 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) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LineItemCollector[T: JournalEntryForm](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 | ||||||
							
								
								
									
										239
									
								
								src/accounting/journal_entry/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										239
									
								
								src/accounting/journal_entry/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,239 @@ | |||||||
|  | # The Mia! Accounting Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023-2024 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.timezone import get_tz_today | ||||||
|  | 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 = get_tz_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 | ||||||
|   | |||||||
							
								
								
									
										904
									
								
								src/accounting/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										904
									
								
								src/accounting/models.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,904 @@ | |||||||
|  | # 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 != "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 != "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 | ||||||
							
								
								
									
										98
									
								
								src/accounting/report/period/chooser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								src/accounting/report/period/chooser.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | |||||||
|  | # The Mia! Accounting Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023-2024 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 accounting.utils.timezone import get_tz_today | ||||||
|  | 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 = get_tz_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)) | ||||||
							
								
								
									
										169
									
								
								src/accounting/report/period/shortcuts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								src/accounting/report/period/shortcuts.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,169 @@ | |||||||
|  | # The Mia! Accounting Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023-2024 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 accounting.utils.timezone import get_tz_today | ||||||
|  | from .month_end import month_end | ||||||
|  | from .period import Period | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ThisMonth(Period): | ||||||
|  |     """The period of this month.""" | ||||||
|  |     def __init__(self): | ||||||
|  |         today: dt.date = get_tz_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 = get_tz_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 = get_tz_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 = get_tz_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 = get_tz_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 = get_tz_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 = get_tz_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()} | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user