Compare commits
	
		
			570 Commits
		
	
	
		
			v0.3.1
			...
			bf2c7bb785
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -38,3 +38,4 @@ excludes | |||||||
| *.mo | *.mo | ||||||
| zh_Hans | zh_Hans | ||||||
| test_temp.py | test_temp.py | ||||||
|  | dummy.js | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ | |||||||
| #  See the License for the specific language governing permissions and | #  See the License for the specific language governing permissions and | ||||||
| #  limitations under the License. | #  limitations under the License. | ||||||
|  |  | ||||||
|  | exclude src/accounting/static/js/dummy.js | ||||||
| include src/accounting/translations/* | include src/accounting/translations/* | ||||||
| include src/accounting/translations/*/LC_MESSAGES/* | include src/accounting/translations/*/LC_MESSAGES/* | ||||||
| include docs/* | include docs/* | ||||||
| @@ -22,7 +23,9 @@ include docs/source/* | |||||||
| include docs/source/_static/* | include docs/source/_static/* | ||||||
| include docs/source/_templates/* | include docs/source/_templates/* | ||||||
| include tests/* | include tests/* | ||||||
|  | exclude tests/test_temp.py | ||||||
| include tests/test_site/* | include tests/test_site/* | ||||||
|  | include tests/test_site/static/* | ||||||
| include tests/test_site/templates/* | include tests/test_site/templates/* | ||||||
| include tests/test_site/translations/* | include tests/test_site/translations/* | ||||||
| include tests/test_site/translations/*/LC_MESSAGES/* | include tests/test_site/translations/*/LC_MESSAGES/* | ||||||
|   | |||||||
| @@ -28,10 +28,10 @@ accounting.account.forms module | |||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|  |  | ||||||
| accounting.account.query module | accounting.account.queries module | ||||||
| ------------------------------- | --------------------------------- | ||||||
|  |  | ||||||
| .. automodule:: accounting.account.query | .. automodule:: accounting.account.queries | ||||||
|    :members: |    :members: | ||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|   | |||||||
| @@ -20,10 +20,10 @@ accounting.base\_account.converters module | |||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|  |  | ||||||
| accounting.base\_account.query module | accounting.base\_account.queries module | ||||||
| ------------------------------------- | --------------------------------------- | ||||||
|  |  | ||||||
| .. automodule:: accounting.base_account.query | .. automodule:: accounting.base_account.queries | ||||||
|    :members: |    :members: | ||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|   | |||||||
| @@ -28,10 +28,10 @@ accounting.currency.forms module | |||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|  |  | ||||||
| accounting.currency.query module | accounting.currency.queries module | ||||||
| -------------------------------- | ---------------------------------- | ||||||
|  |  | ||||||
| .. automodule:: accounting.currency.query | .. automodule:: accounting.currency.queries | ||||||
|    :members: |    :members: | ||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|   | |||||||
							
								
								
									
										45
									
								
								docs/source/accounting.journal_entry.forms.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								docs/source/accounting.journal_entry.forms.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | |||||||
|  | accounting.journal\_entry.forms package | ||||||
|  | ======================================= | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.journal\_entry.forms.currency module | ||||||
|  | ----------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.forms.currency | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.journal\_entry.forms.journal\_entry module | ||||||
|  | ----------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.forms.journal_entry | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.journal\_entry.forms.line\_item module | ||||||
|  | ------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.forms.line_item | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.journal\_entry.forms.reorder module | ||||||
|  | ---------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.forms.reorder | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.forms | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
							
								
								
									
										46
									
								
								docs/source/accounting.journal_entry.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								docs/source/accounting.journal_entry.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | accounting.journal\_entry package | ||||||
|  | ================================= | ||||||
|  |  | ||||||
|  | Subpackages | ||||||
|  | ----------- | ||||||
|  |  | ||||||
|  | .. toctree:: | ||||||
|  |    :maxdepth: 4 | ||||||
|  |  | ||||||
|  |    accounting.journal_entry.forms | ||||||
|  |    accounting.journal_entry.utils | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.journal\_entry.converters module | ||||||
|  | ------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.converters | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.journal\_entry.template\_filters module | ||||||
|  | -------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.template_filters | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.journal\_entry.views module | ||||||
|  | -------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.views | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
							
								
								
									
										53
									
								
								docs/source/accounting.journal_entry.utils.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								docs/source/accounting.journal_entry.utils.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | |||||||
|  | accounting.journal\_entry.utils package | ||||||
|  | ======================================= | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.journal\_entry.utils.account\_option module | ||||||
|  | ------------------------------------------------------ | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.utils.account_option | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.journal\_entry.utils.description\_editor module | ||||||
|  | ---------------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.utils.description_editor | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.journal\_entry.utils.offset\_alias module | ||||||
|  | ---------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.utils.offset_alias | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.journal\_entry.utils.operators module | ||||||
|  | ------------------------------------------------ | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.utils.operators | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.journal\_entry.utils.original\_line\_items module | ||||||
|  | ------------------------------------------------------------ | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.utils.original_line_items | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.journal_entry.utils | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
							
								
								
									
										29
									
								
								docs/source/accounting.option.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								docs/source/accounting.option.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | accounting.option package | ||||||
|  | ========================= | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.option.forms module | ||||||
|  | ------------------------------ | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.option.forms | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.option.views module | ||||||
|  | ------------------------------ | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.option.views | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.option | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
							
								
								
									
										69
									
								
								docs/source/accounting.report.period.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								docs/source/accounting.report.period.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | |||||||
|  | accounting.report.period package | ||||||
|  | ================================ | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.report.period.chooser module | ||||||
|  | --------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.period.chooser | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.period.description module | ||||||
|  | ------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.period.description | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.period.month\_end module | ||||||
|  | ------------------------------------------ | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.period.month_end | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.period.parser module | ||||||
|  | -------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.period.parser | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.period.period module | ||||||
|  | -------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.period.period | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.period.shortcuts module | ||||||
|  | ----------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.period.shortcuts | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.period.specification module | ||||||
|  | --------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.period.specification | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.period | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
							
								
								
									
										69
									
								
								docs/source/accounting.report.reports.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								docs/source/accounting.report.reports.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | |||||||
|  | accounting.report.reports package | ||||||
|  | ================================= | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.report.reports.balance\_sheet module | ||||||
|  | ----------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.reports.balance_sheet | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.reports.income\_expenses module | ||||||
|  | ------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.reports.income_expenses | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.reports.income\_statement module | ||||||
|  | -------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.reports.income_statement | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.reports.journal module | ||||||
|  | ---------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.reports.journal | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.reports.ledger module | ||||||
|  | --------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.reports.ledger | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.reports.search module | ||||||
|  | --------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.reports.search | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.reports.trial\_balance module | ||||||
|  | ----------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.reports.trial_balance | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.reports | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
							
								
								
									
										47
									
								
								docs/source/accounting.report.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								docs/source/accounting.report.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | |||||||
|  | accounting.report package | ||||||
|  | ========================= | ||||||
|  |  | ||||||
|  | Subpackages | ||||||
|  | ----------- | ||||||
|  |  | ||||||
|  | .. toctree:: | ||||||
|  |    :maxdepth: 4 | ||||||
|  |  | ||||||
|  |    accounting.report.period | ||||||
|  |    accounting.report.reports | ||||||
|  |    accounting.report.utils | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.report.converters module | ||||||
|  | ----------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.converters | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.template\_filters module | ||||||
|  | ------------------------------------------ | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.template_filters | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.views module | ||||||
|  | ------------------------------ | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.views | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
							
								
								
									
										69
									
								
								docs/source/accounting.report.utils.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								docs/source/accounting.report.utils.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | |||||||
|  | accounting.report.utils package | ||||||
|  | =============================== | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.report.utils.base\_page\_params module | ||||||
|  | ------------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.utils.base_page_params | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.utils.base\_report module | ||||||
|  | ------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.utils.base_report | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.utils.csv\_export module | ||||||
|  | ------------------------------------------ | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.utils.csv_export | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.utils.option\_link module | ||||||
|  | ------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.utils.option_link | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.utils.report\_chooser module | ||||||
|  | ---------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.utils.report_chooser | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.utils.report\_type module | ||||||
|  | ------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.utils.report_type | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.report.utils.urls module | ||||||
|  | ----------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.utils.urls | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.report.utils | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
| @@ -10,12 +10,22 @@ Subpackages | |||||||
|    accounting.account |    accounting.account | ||||||
|    accounting.base_account |    accounting.base_account | ||||||
|    accounting.currency |    accounting.currency | ||||||
|    accounting.transaction |    accounting.journal_entry | ||||||
|  |    accounting.option | ||||||
|  |    accounting.report | ||||||
|    accounting.utils |    accounting.utils | ||||||
|  |  | ||||||
| Submodules | Submodules | ||||||
| ---------- | ---------- | ||||||
|  |  | ||||||
|  | accounting.forms module | ||||||
|  | ----------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.forms | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
| accounting.locale module | accounting.locale module | ||||||
| ------------------------ | ------------------------ | ||||||
|  |  | ||||||
| @@ -32,6 +42,22 @@ accounting.models module | |||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.template\_filters module | ||||||
|  | ----------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.template_filters | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.template\_globals module | ||||||
|  | ----------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.template_globals | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
| Module contents | Module contents | ||||||
| --------------- | --------------- | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,61 +0,0 @@ | |||||||
| accounting.transaction package |  | ||||||
| ============================== |  | ||||||
|  |  | ||||||
| Submodules |  | ||||||
| ---------- |  | ||||||
|  |  | ||||||
| accounting.transaction.converters module |  | ||||||
| ---------------------------------------- |  | ||||||
|  |  | ||||||
| .. automodule:: accounting.transaction.converters |  | ||||||
|    :members: |  | ||||||
|    :undoc-members: |  | ||||||
|    :show-inheritance: |  | ||||||
|  |  | ||||||
| accounting.transaction.dispatcher module |  | ||||||
| ---------------------------------------- |  | ||||||
|  |  | ||||||
| .. automodule:: accounting.transaction.dispatcher |  | ||||||
|    :members: |  | ||||||
|    :undoc-members: |  | ||||||
|    :show-inheritance: |  | ||||||
|  |  | ||||||
| accounting.transaction.forms module |  | ||||||
| ----------------------------------- |  | ||||||
|  |  | ||||||
| .. automodule:: accounting.transaction.forms |  | ||||||
|    :members: |  | ||||||
|    :undoc-members: |  | ||||||
|    :show-inheritance: |  | ||||||
|  |  | ||||||
| accounting.transaction.query module |  | ||||||
| ----------------------------------- |  | ||||||
|  |  | ||||||
| .. automodule:: accounting.transaction.query |  | ||||||
|    :members: |  | ||||||
|    :undoc-members: |  | ||||||
|    :show-inheritance: |  | ||||||
|  |  | ||||||
| accounting.transaction.template module |  | ||||||
| -------------------------------------- |  | ||||||
|  |  | ||||||
| .. automodule:: accounting.transaction.template |  | ||||||
|    :members: |  | ||||||
|    :undoc-members: |  | ||||||
|    :show-inheritance: |  | ||||||
|  |  | ||||||
| accounting.transaction.views module |  | ||||||
| ----------------------------------- |  | ||||||
|  |  | ||||||
| .. automodule:: accounting.transaction.views |  | ||||||
|    :members: |  | ||||||
|    :undoc-members: |  | ||||||
|    :show-inheritance: |  | ||||||
|  |  | ||||||
| Module contents |  | ||||||
| --------------- |  | ||||||
|  |  | ||||||
| .. automodule:: accounting.transaction |  | ||||||
|    :members: |  | ||||||
|    :undoc-members: |  | ||||||
|    :show-inheritance: |  | ||||||
| @@ -4,6 +4,22 @@ 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 | accounting.utils.flash\_errors module | ||||||
| ------------------------------------- | ------------------------------------- | ||||||
|  |  | ||||||
| @@ -12,6 +28,14 @@ accounting.utils.flash\_errors module | |||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.utils.journal\_entry\_types module | ||||||
|  | --------------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.utils.journal_entry_types | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
| accounting.utils.next\_uri module | accounting.utils.next\_uri module | ||||||
| --------------------------------- | --------------------------------- | ||||||
|  |  | ||||||
| @@ -20,6 +44,14 @@ accounting.utils.next\_uri module | |||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.utils.options module | ||||||
|  | ------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.utils.options | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
| accounting.utils.pagination module | accounting.utils.pagination module | ||||||
| ---------------------------------- | ---------------------------------- | ||||||
|  |  | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/')) | |||||||
| project = 'Mia! Accounting Flask' | project = 'Mia! Accounting Flask' | ||||||
| copyright = '2023, imacat' | copyright = '2023, imacat' | ||||||
| author = 'imacat' | author = 'imacat' | ||||||
| release = '0.0.0' | release = '0.9.1' | ||||||
|  |  | ||||||
| # -- General configuration --------------------------------------------------- | # -- General configuration --------------------------------------------------- | ||||||
| # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration | ||||||
| @@ -28,5 +28,5 @@ exclude_patterns = [] | |||||||
| # -- Options for HTML output ------------------------------------------------- | # -- Options for HTML output ------------------------------------------------- | ||||||
| # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output | ||||||
|  |  | ||||||
| html_theme = 'nature' | html_theme = 'sphinx_rtd_theme' | ||||||
| html_static_path = ['_static'] | html_static_path = ['_static'] | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ | |||||||
|  |  | ||||||
| [metadata] | [metadata] | ||||||
| name = mia-accounting-flask | name = mia-accounting-flask | ||||||
| version = 0.3.1 | version = 0.9.1 | ||||||
| author = imacat | author = imacat | ||||||
| author_email = imacat@mail.imacat.idv.tw | author_email = imacat@mail.imacat.idv.tw | ||||||
| description = The Mia! Accounting Flask project. | description = The Mia! Accounting Flask project. | ||||||
|   | |||||||
| @@ -17,13 +17,12 @@ | |||||||
| """The accounting application. | """The accounting application. | ||||||
|  |  | ||||||
| """ | """ | ||||||
| import typing as t |  | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  |  | ||||||
| from flask import Flask, Blueprint | from flask import Flask, Blueprint | ||||||
| from flask_sqlalchemy import SQLAlchemy | from flask_sqlalchemy import SQLAlchemy | ||||||
|  |  | ||||||
| from accounting.utils.user import AbstractUserUtils | from accounting.utils.user import UserUtilityInterface | ||||||
|  |  | ||||||
| db: SQLAlchemy = SQLAlchemy() | db: SQLAlchemy = SQLAlchemy() | ||||||
| """The database instance.""" | """The database instance.""" | ||||||
| @@ -31,19 +30,13 @@ data_dir: Path = Path(__file__).parent / "data" | |||||||
| """The data directory.""" | """The data directory.""" | ||||||
|  |  | ||||||
|  |  | ||||||
| def init_app(app: Flask, user_utils: AbstractUserUtils, | def init_app(app: Flask, user_utils: UserUtilityInterface, | ||||||
|              url_prefix: str = "/accounting", |              url_prefix: str = "/accounting") -> None: | ||||||
|              can_view_func: t.Callable[[], bool] | None = None, |  | ||||||
|              can_edit_func: t.Callable[[], bool] | None = None) -> None: |  | ||||||
|     """Initialize the application. |     """Initialize the application. | ||||||
|  |  | ||||||
|     :param app: The Flask application. |     :param app: The Flask application. | ||||||
|     :param user_utils: The user utilities. |     :param user_utils: The user utilities. | ||||||
|     :param url_prefix: The URL prefix of the accounting application. |     :param url_prefix: The URL prefix of the accounting application. | ||||||
|     :param can_view_func: A callback that returns whether the current user can |  | ||||||
|         view the accounting data. |  | ||||||
|     :param can_edit_func: A callback that returns whether the current user can |  | ||||||
|         edit the accounting data. |  | ||||||
|     :return: None. |     :return: None. | ||||||
|     """ |     """ | ||||||
|     # The database instance must be set before loading everything |     # The database instance must be set before loading everything | ||||||
| @@ -58,11 +51,25 @@ def init_app(app: Flask, user_utils: AbstractUserUtils, | |||||||
|                               template_folder="templates", |                               template_folder="templates", | ||||||
|                               static_folder="static") |                               static_folder="static") | ||||||
|  |  | ||||||
|  |     from .template_filters import format_amount, format_date, default | ||||||
|  |     bp.add_app_template_filter(format_amount, "accounting_format_amount") | ||||||
|  |     bp.add_app_template_filter(format_date, "accounting_format_date") | ||||||
|  |     bp.add_app_template_filter(default, "accounting_default") | ||||||
|  |  | ||||||
|  |     from .template_globals import currency_options, default_currency_code | ||||||
|  |     bp.add_app_template_global(currency_options, | ||||||
|  |                                "accounting_currency_options") | ||||||
|  |     bp.add_app_template_global(default_currency_code, | ||||||
|  |                                "accounting_default_currency_code") | ||||||
|  |  | ||||||
|     from . import locale |     from . import locale | ||||||
|     locale.init_app(app, bp) |     locale.init_app(app, bp) | ||||||
|  |  | ||||||
|     from .utils import permission |     from .utils import permission | ||||||
|     permission.init_app(bp, can_view_func, can_edit_func) |     permission.init_app(bp, user_utils) | ||||||
|  |  | ||||||
|  |     from .utils import next_uri | ||||||
|  |     next_uri.init_app(bp) | ||||||
|  |  | ||||||
|     from . import base_account |     from . import base_account | ||||||
|     base_account.init_app(app, bp) |     base_account.init_app(app, bp) | ||||||
| @@ -73,10 +80,13 @@ def init_app(app: Flask, user_utils: AbstractUserUtils, | |||||||
|     from . import currency |     from . import currency | ||||||
|     currency.init_app(app, bp) |     currency.init_app(app, bp) | ||||||
|  |  | ||||||
|     from . import transaction |     from . import journal_entry | ||||||
|     transaction.init_app(app, bp) |     journal_entry.init_app(app, bp) | ||||||
|  |  | ||||||
|     from .utils import next_uri |     from . import report | ||||||
|     next_uri.init_app(bp) |     report.init_app(app, bp) | ||||||
|  |  | ||||||
|  |     from . import option | ||||||
|  |     option.init_app(bp) | ||||||
|  |  | ||||||
|     app.register_blueprint(bp) |     app.register_blueprint(bp) | ||||||
|   | |||||||
| @@ -18,7 +18,6 @@ | |||||||
|  |  | ||||||
| """ | """ | ||||||
| import os | import os | ||||||
| import re |  | ||||||
| from secrets import randbelow | from secrets import randbelow | ||||||
|  |  | ||||||
| import click | import click | ||||||
| @@ -30,7 +29,7 @@ from accounting.utils.user import has_user, get_user_pk | |||||||
|  |  | ||||||
| AccountData = tuple[int, str, int, str, str, str, bool] | AccountData = tuple[int, str, int, str, str, str, bool] | ||||||
| """The format of the account data, as a list of (ID, base account code, number, | """The format of the account data, as a list of (ID, base account code, number, | ||||||
| English, Traditional Chinese, Simplified Chinese, is-pay-off-needed) tuples.""" | English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples.""" | ||||||
|  |  | ||||||
|  |  | ||||||
| def __validate_username(ctx: click.core.Context, param: click.core.Option, | def __validate_username(ctx: click.core.Context, param: click.core.Option, | ||||||
| @@ -93,14 +92,37 @@ def init_accounts_command(username: str) -> None: | |||||||
|     data: list[AccountData] = [] |     data: list[AccountData] = [] | ||||||
|     for base in bases_to_add: |     for base in bases_to_add: | ||||||
|         l10n: dict[str, str] = {x.locale: x.title for x in base.l10n} |         l10n: dict[str, str] = {x.locale: x.title for x in base.l10n} | ||||||
|         is_pay_off_needed: bool = True if re.match("^[12]1[34]", base.code) \ |         is_need_offset: bool = __is_need_offset(base.code) | ||||||
|             else False |  | ||||||
|         data.append((get_new_id(), base.code, 1, base.title_l10n, |         data.append((get_new_id(), base.code, 1, base.title_l10n, | ||||||
|                      l10n["zh_Hant"], l10n["zh_Hans"], is_pay_off_needed)) |                      l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset)) | ||||||
|     __add_accounting_accounts(data, creator_pk) |     __add_accounting_accounts(data, creator_pk) | ||||||
|     click.echo(F"{len(data)} added.  Accounting accounts initialized.") |     click.echo(F"{len(data)} added.  Accounting accounts initialized.") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __is_need_offset(base_code: str) -> bool: | ||||||
|  |     """Checks that whether journal entry line items in the account need offset. | ||||||
|  |  | ||||||
|  |     :param base_code: The code of the base account. | ||||||
|  |     :return: True if journal entry line items in the account need offset, or | ||||||
|  |         False otherwise. | ||||||
|  |     """ | ||||||
|  |     # Assets | ||||||
|  |     if base_code[0] == "1": | ||||||
|  |         if base_code[:3] in {"113", "114", "118", "184"}: | ||||||
|  |             return True | ||||||
|  |         if base_code in {"1411", "1421", "1431", "1441", "1511", "1521", | ||||||
|  |                          "1581", "1611", "1851", ""}: | ||||||
|  |             return True | ||||||
|  |         return False | ||||||
|  |     # Liabilities | ||||||
|  |     if base_code[0] == "2": | ||||||
|  |         if base_code in {"2111", "2114", "2284", "2293"}: | ||||||
|  |             return False | ||||||
|  |         return True | ||||||
|  |     # Only assets and liabilities need offset | ||||||
|  |     return False | ||||||
|  |  | ||||||
|  |  | ||||||
| def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\ | def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\ | ||||||
|         -> None: |         -> None: | ||||||
|     """Adds the accounts. |     """Adds the accounts. | ||||||
| @@ -113,7 +135,7 @@ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\ | |||||||
|                                        base_code=x[1], |                                        base_code=x[1], | ||||||
|                                        no=x[2], |                                        no=x[2], | ||||||
|                                        title_l10n=x[3], |                                        title_l10n=x[3], | ||||||
|                                        is_pay_off_needed=x[6], |                                        is_need_offset=x[6], | ||||||
|                                        created_by_id=creator_pk, |                                        created_by_id=creator_pk, | ||||||
|                                        updated_by_id=creator_pk) |                                        updated_by_id=creator_pk) | ||||||
|                                for x in data] |                                for x in data] | ||||||
|   | |||||||
| @@ -53,6 +53,20 @@ class BaseAccountAvailable: | |||||||
|                 "The base account is not available.")) |                 "The base account is not available.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NoOffsetNominalAccount: | ||||||
|  |     """The validator to check nominal account is not to be offset.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: BooleanField) -> None: | ||||||
|  |         assert isinstance(form, AccountForm) | ||||||
|  |         if not field.data: | ||||||
|  |             return | ||||||
|  |         if form.base_code.data is None: | ||||||
|  |             return | ||||||
|  |         if form.base_code.data[0] not in {"1", "2", "3"}: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "A nominal account does not need offset.")) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AccountForm(FlaskForm): | class AccountForm(FlaskForm): | ||||||
|     """The form to create or edit an account.""" |     """The form to create or edit an account.""" | ||||||
|     base_code = StringField( |     base_code = StringField( | ||||||
| @@ -66,8 +80,9 @@ class AccountForm(FlaskForm): | |||||||
|         filters=[strip_text], |         filters=[strip_text], | ||||||
|         validators=[DataRequired(lazy_gettext("Please fill in the title"))]) |         validators=[DataRequired(lazy_gettext("Please fill in the title"))]) | ||||||
|     """The title.""" |     """The title.""" | ||||||
|     is_pay_off_needed = BooleanField() |     is_need_offset = BooleanField( | ||||||
|     """Whether the the entries of this account need pay-off.""" |         validators=[NoOffsetNominalAccount()]) | ||||||
|  |     """Whether the the journal entry line items of this account need offset.""" | ||||||
|  |  | ||||||
|     def populate_obj(self, obj: Account) -> None: |     def populate_obj(self, obj: Account) -> None: | ||||||
|         """Populates the form data into an account object. |         """Populates the form data into an account object. | ||||||
| @@ -87,7 +102,10 @@ class AccountForm(FlaskForm): | |||||||
|             obj.base_code = self.base_code.data |             obj.base_code = self.base_code.data | ||||||
|             obj.no = count + 1 |             obj.no = count + 1 | ||||||
|         obj.title = self.title.data |         obj.title = self.title.data | ||||||
|         obj.is_pay_off_needed = self.is_pay_off_needed.data |         if self.base_code.data[0] in {"1", "2", "3"}: | ||||||
|  |             obj.is_need_offset = self.is_need_offset.data | ||||||
|  |         else: | ||||||
|  |             obj.is_need_offset = False | ||||||
|         if is_new: |         if is_new: | ||||||
|             current_user_pk: int = get_current_user_pk() |             current_user_pk: int = get_current_user_pk() | ||||||
|             obj.created_by_id = current_user_pk |             obj.created_by_id = current_user_pk | ||||||
|   | |||||||
| @@ -14,7 +14,7 @@ | |||||||
| #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
| #  See the License for the specific language governing permissions and | #  See the License for the specific language governing permissions and | ||||||
| #  limitations under the License. | #  limitations under the License. | ||||||
| """The account query. | """The queries for the account management. | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
| import sqlalchemy as sa | import sqlalchemy as sa | ||||||
| @@ -40,15 +40,15 @@ def get_account_query() -> list[Account]: | |||||||
|     conditions: list[sa.BinaryExpression] = [] |     conditions: list[sa.BinaryExpression] = [] | ||||||
|     for k in keywords: |     for k in keywords: | ||||||
|         l10n: list[AccountL10n] = AccountL10n.query\ |         l10n: list[AccountL10n] = AccountL10n.query\ | ||||||
|             .filter(AccountL10n.title.contains(k)).all() |             .filter(AccountL10n.title.icontains(k)).all() | ||||||
|         l10n_matches: set[str] = {x.account_id for x in l10n} |         l10n_matches: set[str] = {x.account_id for x in l10n} | ||||||
|         sub_conditions: list[sa.BinaryExpression] \ |         sub_conditions: list[sa.BinaryExpression] \ | ||||||
|             = [Account.base_code.contains(k), |             = [Account.base_code.contains(k), | ||||||
|                Account.title_l10n.contains(k), |                Account.title_l10n.icontains(k), | ||||||
|                code.contains(k), |                code.contains(k), | ||||||
|                Account.id.in_(l10n_matches)] |                Account.id.in_(l10n_matches)] | ||||||
|         if k in gettext("Pay-off needed"): |         if k in gettext("Needs Offset"): | ||||||
|             sub_conditions.append(Account.is_pay_off_needed) |             sub_conditions.append(Account.is_need_offset) | ||||||
|         conditions.append(sa.or_(*sub_conditions)) |         conditions.append(sa.or_(*sub_conditions)) | ||||||
| 
 | 
 | ||||||
|     return Account.query.filter(*conditions)\ |     return Account.query.filter(*conditions)\ | ||||||
| @@ -27,13 +27,14 @@ from werkzeug.datastructures import ImmutableMultiDict | |||||||
| from accounting import db | from accounting import db | ||||||
| from accounting.locale import lazy_gettext | from accounting.locale import lazy_gettext | ||||||
| from accounting.models import Account, BaseAccount | from accounting.models import Account, BaseAccount | ||||||
|  | from accounting.utils.cast import s | ||||||
| from accounting.utils.flash_errors import flash_form_errors | from accounting.utils.flash_errors import flash_form_errors | ||||||
| from accounting.utils.next_uri import inherit_next, or_next | from accounting.utils.next_uri import inherit_next, or_next | ||||||
| from accounting.utils.pagination import Pagination | from accounting.utils.pagination import Pagination | ||||||
| from accounting.utils.permission import can_view, has_permission, can_edit | from accounting.utils.permission import can_view, has_permission, can_edit | ||||||
| from accounting.utils.user import get_current_user_pk | from accounting.utils.user import get_current_user_pk | ||||||
| from .forms import AccountForm, sort_accounts_in, AccountReorderForm | from .forms import AccountForm, sort_accounts_in, AccountReorderForm | ||||||
| from .query import get_account_query | from .queries import get_account_query | ||||||
|  |  | ||||||
| bp: Blueprint = Blueprint("account", __name__) | bp: Blueprint = Blueprint("account", __name__) | ||||||
| """The view blueprint for the account management.""" | """The view blueprint for the account management.""" | ||||||
| @@ -86,7 +87,7 @@ def add_account() -> redirect: | |||||||
|     form.populate_obj(account) |     form.populate_obj(account) | ||||||
|     db.session.add(account) |     db.session.add(account) | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The account is added successfully"), "success") |     flash(s(lazy_gettext("The account is added successfully")), "success") | ||||||
|     return redirect(inherit_next(__get_detail_uri(account))) |     return redirect(inherit_next(__get_detail_uri(account))) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -138,12 +139,12 @@ def update_account(account: Account) -> redirect: | |||||||
|     with db.session.no_autoflush: |     with db.session.no_autoflush: | ||||||
|         form.populate_obj(account) |         form.populate_obj(account) | ||||||
|     if not account.is_modified: |     if not account.is_modified: | ||||||
|         flash(lazy_gettext("The account was not modified."), "success") |         flash(s(lazy_gettext("The account was not modified.")), "success") | ||||||
|         return redirect(inherit_next(__get_detail_uri(account))) |         return redirect(inherit_next(__get_detail_uri(account))) | ||||||
|     account.updated_by_id = get_current_user_pk() |     account.updated_by_id = get_current_user_pk() | ||||||
|     account.updated_at = sa.func.now() |     account.updated_at = sa.func.now() | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The account is updated successfully."), "success") |     flash(s(lazy_gettext("The account is updated successfully.")), "success") | ||||||
|     return redirect(inherit_next(__get_detail_uri(account))) |     return redirect(inherit_next(__get_detail_uri(account))) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -156,10 +157,13 @@ def delete_account(account: Account) -> redirect: | |||||||
|     :return: The redirection to the account list on success, or the account |     :return: The redirection to the account list on success, or the account | ||||||
|         detail on error. |         detail on error. | ||||||
|     """ |     """ | ||||||
|  |     if not account.can_delete: | ||||||
|  |         flash(s(lazy_gettext("The account cannot be deleted.")), "error") | ||||||
|  |         return redirect(inherit_next(__get_detail_uri(account))) | ||||||
|     account.delete() |     account.delete() | ||||||
|     sort_accounts_in(account.base_code, account.id) |     sort_accounts_in(account.base_code, account.id) | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The account is deleted successfully."), "success") |     flash(s(lazy_gettext("The account is deleted successfully.")), "success") | ||||||
|     return redirect(or_next(__get_list_uri())) |     return redirect(or_next(__get_list_uri())) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -186,10 +190,10 @@ def sort_accounts(base: BaseAccount) -> redirect: | |||||||
|     form: AccountReorderForm = AccountReorderForm(base) |     form: AccountReorderForm = AccountReorderForm(base) | ||||||
|     form.save_order() |     form.save_order() | ||||||
|     if not form.is_modified: |     if not form.is_modified: | ||||||
|         flash(lazy_gettext("The order was not modified."), "success") |         flash(s(lazy_gettext("The order was not modified.")), "success") | ||||||
|         return redirect(or_next(__get_list_uri())) |         return redirect(or_next(__get_list_uri())) | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The order is updated successfully."), "success") |     flash(s(lazy_gettext("The order is updated successfully.")), "success") | ||||||
|     return redirect(or_next(__get_list_uri())) |     return redirect(or_next(__get_list_uri())) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -14,7 +14,7 @@ | |||||||
| #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
| #  See the License for the specific language governing permissions and | #  See the License for the specific language governing permissions and | ||||||
| #  limitations under the License. | #  limitations under the License. | ||||||
| """The base account query. | """The queries for the base account management. | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
| import sqlalchemy as sa | import sqlalchemy as sa | ||||||
| @@ -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() | ||||||
| @@ -34,7 +34,7 @@ def list_accounts() -> str: | |||||||
|  |  | ||||||
|     :return: The account list. |     :return: The account list. | ||||||
|     """ |     """ | ||||||
|     from .query import get_base_account_query |     from .queries import get_base_account_query | ||||||
|     accounts: list[BaseAccount] = get_base_account_query() |     accounts: list[BaseAccount] = get_base_account_query() | ||||||
|     pagination: Pagination = Pagination[BaseAccount](accounts) |     pagination: Pagination = Pagination[BaseAccount](accounts) | ||||||
|     return render_template("accounting/base-account/list.html", |     return render_template("accounting/base-account/list.html", | ||||||
|   | |||||||
| @@ -17,8 +17,6 @@ | |||||||
| """The forms for the currency management. | """The forms for the currency management. | ||||||
|  |  | ||||||
| """ | """ | ||||||
| from __future__ import annotations |  | ||||||
|  |  | ||||||
| from flask_wtf import FlaskForm | from flask_wtf import FlaskForm | ||||||
| from wtforms import StringField, ValidationError | from wtforms import StringField, ValidationError | ||||||
| from wtforms.validators import DataRequired, Regexp, NoneOf | from wtforms.validators import DataRequired, Regexp, NoneOf | ||||||
| @@ -30,22 +28,24 @@ from accounting.utils.strip_text import strip_text | |||||||
| from accounting.utils.user import get_current_user_pk | from accounting.utils.user import get_current_user_pk | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CodeUnique: | ||||||
|  |     """The validator to check if the code is unique.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         assert isinstance(form, CurrencyForm) | ||||||
|  |         if field.data == "": | ||||||
|  |             return | ||||||
|  |         if form.obj_code is not None and form.obj_code == field.data: | ||||||
|  |             return | ||||||
|  |         if db.session.get(Currency, field.data) is not None: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "Code conflicts with another currency.")) | ||||||
|  |  | ||||||
|  |  | ||||||
| class CurrencyForm(FlaskForm): | class CurrencyForm(FlaskForm): | ||||||
|     """The form to create or edit a currency.""" |     """The form to create or edit a currency.""" | ||||||
|     CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"] |     CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"] | ||||||
|     """The reserved codes that are not available.""" |     """The reserved codes that are not available.""" | ||||||
|  |  | ||||||
|     class CodeUnique: |  | ||||||
|         """The validator to check if the code is unique.""" |  | ||||||
|         def __call__(self, form: CurrencyForm, field: StringField) -> None: |  | ||||||
|             if field.data == "": |  | ||||||
|                 return |  | ||||||
|             if form.obj_code is not None and form.obj_code == field.data: |  | ||||||
|                 return |  | ||||||
|             if db.session.get(Currency, field.data) is not None: |  | ||||||
|                 raise ValidationError(lazy_gettext( |  | ||||||
|                     "Code conflicts with another currency.")) |  | ||||||
|  |  | ||||||
|     code = StringField( |     code = StringField( | ||||||
|         filters=[strip_text], |         filters=[strip_text], | ||||||
|         validators=[DataRequired(lazy_gettext("Please fill in the code.")), |         validators=[DataRequired(lazy_gettext("Please fill in the code.")), | ||||||
|   | |||||||
| @@ -14,7 +14,7 @@ | |||||||
| #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
| #  See the License for the specific language governing permissions and | #  See the License for the specific language governing permissions and | ||||||
| #  limitations under the License. | #  limitations under the License. | ||||||
| """The currency query. | """The queries for the currency management. | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
| import sqlalchemy as sa | import sqlalchemy as sa | ||||||
| @@ -35,10 +35,10 @@ def get_currency_query() -> list[Currency]: | |||||||
|     conditions: list[sa.BinaryExpression] = [] |     conditions: list[sa.BinaryExpression] = [] | ||||||
|     for k in keywords: |     for k in keywords: | ||||||
|         l10n: list[CurrencyL10n] = CurrencyL10n.query\ |         l10n: list[CurrencyL10n] = CurrencyL10n.query\ | ||||||
|             .filter(CurrencyL10n.name.contains(k)).all() |             .filter(CurrencyL10n.name.icontains(k)).all() | ||||||
|         l10n_matches: set[str] = {x.account_code for x in l10n} |         l10n_matches: set[str] = {x.account_code for x in l10n} | ||||||
|         conditions.append(sa.or_(Currency.code.contains(k), |         conditions.append(sa.or_(Currency.code.icontains(k), | ||||||
|                                  Currency.name_l10n.contains(k), |                                  Currency.name_l10n.icontains(k), | ||||||
|                                  Currency.code.in_(l10n_matches))) |                                  Currency.code.in_(l10n_matches))) | ||||||
|     return Currency.query.filter(*conditions)\ |     return Currency.query.filter(*conditions)\ | ||||||
|         .order_by(Currency.code).all() |         .order_by(Currency.code).all() | ||||||
| @@ -27,6 +27,7 @@ from werkzeug.datastructures import ImmutableMultiDict | |||||||
| from accounting import db | from accounting import db | ||||||
| from accounting.locale import lazy_gettext | from accounting.locale import lazy_gettext | ||||||
| from accounting.models import Currency | from accounting.models import Currency | ||||||
|  | from accounting.utils.cast import s | ||||||
| from accounting.utils.flash_errors import flash_form_errors | from accounting.utils.flash_errors import flash_form_errors | ||||||
| from accounting.utils.next_uri import inherit_next, or_next | from accounting.utils.next_uri import inherit_next, or_next | ||||||
| from accounting.utils.pagination import Pagination | from accounting.utils.pagination import Pagination | ||||||
| @@ -47,7 +48,7 @@ def list_currencies() -> str: | |||||||
|  |  | ||||||
|     :return: The currency list. |     :return: The currency list. | ||||||
|     """ |     """ | ||||||
|     from .query import get_currency_query |     from .queries import get_currency_query | ||||||
|     currencies: list[Currency] = get_currency_query() |     currencies: list[Currency] = get_currency_query() | ||||||
|     pagination: Pagination = Pagination[Currency](currencies) |     pagination: Pagination = Pagination[Currency](currencies) | ||||||
|     return render_template("accounting/currency/list.html", |     return render_template("accounting/currency/list.html", | ||||||
| @@ -88,7 +89,7 @@ def add_currency() -> redirect: | |||||||
|     form.populate_obj(currency) |     form.populate_obj(currency) | ||||||
|     db.session.add(currency) |     db.session.add(currency) | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The currency is added successfully"), "success") |     flash(s(lazy_gettext("The currency is added successfully.")), "success") | ||||||
|     return redirect(inherit_next(__get_detail_uri(currency))) |     return redirect(inherit_next(__get_detail_uri(currency))) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -141,12 +142,12 @@ def update_currency(currency: Currency) -> redirect: | |||||||
|     with db.session.no_autoflush: |     with db.session.no_autoflush: | ||||||
|         form.populate_obj(currency) |         form.populate_obj(currency) | ||||||
|     if not currency.is_modified: |     if not currency.is_modified: | ||||||
|         flash(lazy_gettext("The currency was not modified."), "success") |         flash(s(lazy_gettext("The currency was not modified.")), "success") | ||||||
|         return redirect(inherit_next(__get_detail_uri(currency))) |         return redirect(inherit_next(__get_detail_uri(currency))) | ||||||
|     currency.updated_by_id = get_current_user_pk() |     currency.updated_by_id = get_current_user_pk() | ||||||
|     currency.updated_at = sa.func.now() |     currency.updated_at = sa.func.now() | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The currency is updated successfully."), "success") |     flash(s(lazy_gettext("The currency is updated successfully.")), "success") | ||||||
|     return redirect(inherit_next(__get_detail_uri(currency))) |     return redirect(inherit_next(__get_detail_uri(currency))) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -159,9 +160,12 @@ def delete_currency(currency: Currency) -> redirect: | |||||||
|     :return: The redirection to the currency list on success, or the currency |     :return: The redirection to the currency list on success, or the currency | ||||||
|         detail on error. |         detail on error. | ||||||
|     """ |     """ | ||||||
|  |     if not currency.can_delete: | ||||||
|  |         flash(s(lazy_gettext("The currency cannot be deleted.")), "error") | ||||||
|  |         return redirect(inherit_next(__get_detail_uri(currency))) | ||||||
|     currency.delete() |     currency.delete() | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The currency is deleted successfully."), "success") |     flash(s(lazy_gettext("The currency is deleted successfully.")), "success") | ||||||
|     return redirect(or_next(url_for("accounting.currency.list"))) |     return redirect(or_next(url_for("accounting.currency.list"))) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -182,4 +186,3 @@ def __get_detail_uri(currency: Currency) -> str: | |||||||
|     :return: The detail URI of the currency. |     :return: The detail URI of the currency. | ||||||
|     """ |     """ | ||||||
|     return url_for("accounting.currency.detail", currency=currency) |     return url_for("accounting.currency.detail", currency=currency) | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										96
									
								
								src/accounting/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/accounting/forms.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The forms. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import re | ||||||
|  |  | ||||||
|  | from flask_babel import LazyString | ||||||
|  | from flask_wtf import FlaskForm | ||||||
|  | from wtforms import StringField, ValidationError | ||||||
|  | from wtforms.validators import DataRequired | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import lazy_gettext | ||||||
|  | from accounting.models import Currency, Account | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ACCOUNT_REQUIRED: DataRequired = DataRequired( | ||||||
|  |     lazy_gettext("Please select the account.")) | ||||||
|  | """The validator to check if the account code is empty.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CurrencyExists: | ||||||
|  |     """The validator to check if the account exists.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         if db.session.get(Currency, field.data) is None: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The currency does not exist.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AccountExists: | ||||||
|  |     """The validator to check if the account exists.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         if Account.find_by_code(field.data) is None: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The account does not exist.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IsDebitAccount: | ||||||
|  |     """The validator to check if the account is for debit line items.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, message: str | LazyString): | ||||||
|  |         """Constructs the validator. | ||||||
|  |  | ||||||
|  |         :param message: The error message. | ||||||
|  |         """ | ||||||
|  |         self.__message: str | LazyString = message | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         if re.match(r"^(?:[1235689]|7[5678])", field.data) \ | ||||||
|  |                 and not field.data.startswith("3351-") \ | ||||||
|  |                 and not field.data.startswith("3353-"): | ||||||
|  |             return | ||||||
|  |         raise ValidationError(self.__message) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IsCreditAccount: | ||||||
|  |     """The validator to check if the account is for credit line items.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, message: str | LazyString): | ||||||
|  |         """Constructs the validator. | ||||||
|  |  | ||||||
|  |         :param message: The error message. | ||||||
|  |         """ | ||||||
|  |         self.__message: str | LazyString = message | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         if re.match(r"^(?:[123489]|7[1234])", field.data) \ | ||||||
|  |                 and not field.data.startswith("3351-") \ | ||||||
|  |                 and not field.data.startswith("3353-"): | ||||||
|  |             return | ||||||
|  |         raise ValidationError(self.__message) | ||||||
| @@ -14,7 +14,7 @@ | |||||||
| #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
| #  See the License for the specific language governing permissions and | #  See the License for the specific language governing permissions and | ||||||
| #  limitations under the License. | #  limitations under the License. | ||||||
| """The transaction management. | """The journal entry management. | ||||||
| 
 | 
 | ||||||
| """ | """ | ||||||
| from flask import Flask, Blueprint | from flask import Flask, Blueprint | ||||||
| @@ -27,11 +27,11 @@ def init_app(app: Flask, bp: Blueprint) -> None: | |||||||
|     :param bp: The blueprint of the accounting application. |     :param bp: The blueprint of the accounting application. | ||||||
|     :return: None. |     :return: None. | ||||||
|     """ |     """ | ||||||
|     from .converters import TransactionConverter, TransactionTypeConverter, \ |     from .converters import JournalEntryConverter, JournalEntryTypeConverter, \ | ||||||
|         DateConverter |         DateConverter | ||||||
|     app.url_map.converters["transaction"] = TransactionConverter |     app.url_map.converters["journalEntry"] = JournalEntryConverter | ||||||
|     app.url_map.converters["transactionType"] = TransactionTypeConverter |     app.url_map.converters["journalEntryType"] = JournalEntryTypeConverter | ||||||
|     app.url_map.converters["date"] = DateConverter |     app.url_map.converters["date"] = DateConverter | ||||||
| 
 | 
 | ||||||
|     from .views import bp as transaction_bp |     from .views import bp as journal_entry_bp | ||||||
|     bp.register_blueprint(transaction_bp, url_prefix="/transactions") |     bp.register_blueprint(journal_entry_bp, url_prefix="/journal-entries") | ||||||
							
								
								
									
										107
									
								
								src/accounting/journal_entry/converters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src/accounting/journal_entry/converters.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The path converters for the journal entry management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date | ||||||
|  |  | ||||||
|  | from flask import abort | ||||||
|  | from sqlalchemy.orm import selectinload | ||||||
|  | from werkzeug.routing import BaseConverter | ||||||
|  |  | ||||||
|  | from accounting.models import JournalEntry, JournalEntryLineItem | ||||||
|  | from accounting.utils.journal_entry_types import JournalEntryType | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class JournalEntryConverter(BaseConverter): | ||||||
|  |     """The journal entry converter to convert the journal entry ID from and to | ||||||
|  |     the corresponding journal entry in the routes.""" | ||||||
|  |  | ||||||
|  |     def to_python(self, value: str) -> JournalEntry: | ||||||
|  |         """Converts a journal entry ID to a journal entry. | ||||||
|  |  | ||||||
|  |         :param value: The journal entry ID. | ||||||
|  |         :return: The corresponding journal entry. | ||||||
|  |         """ | ||||||
|  |         journal_entry: JournalEntry | None = JournalEntry.query\ | ||||||
|  |             .join(JournalEntryLineItem)\ | ||||||
|  |             .filter(JournalEntry.id == value)\ | ||||||
|  |             .options(selectinload(JournalEntry.line_items) | ||||||
|  |                      .selectinload(JournalEntryLineItem.offsets) | ||||||
|  |                      .selectinload(JournalEntryLineItem.journal_entry))\ | ||||||
|  |             .first() | ||||||
|  |         if journal_entry is None: | ||||||
|  |             abort(404) | ||||||
|  |         return journal_entry | ||||||
|  |  | ||||||
|  |     def to_url(self, value: JournalEntry) -> str: | ||||||
|  |         """Converts a journal entry to its ID. | ||||||
|  |  | ||||||
|  |         :param value: The journal entry. | ||||||
|  |         :return: The ID. | ||||||
|  |         """ | ||||||
|  |         return str(value.id) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class JournalEntryTypeConverter(BaseConverter): | ||||||
|  |     """The journal entry converter to convert the journal entry type ID from | ||||||
|  |     and to the corresponding journal entry type in the routes.""" | ||||||
|  |  | ||||||
|  |     def to_python(self, value: str) -> JournalEntryType: | ||||||
|  |         """Converts a journal entry ID to a journal entry. | ||||||
|  |  | ||||||
|  |         :param value: The journal entry ID. | ||||||
|  |         :return: The corresponding journal entry type. | ||||||
|  |         """ | ||||||
|  |         type_dict: dict[str, JournalEntryType] \ | ||||||
|  |             = {x.value: x for x in JournalEntryType} | ||||||
|  |         journal_entry_type: JournalEntryType | None = type_dict.get(value) | ||||||
|  |         if journal_entry_type is None: | ||||||
|  |             abort(404) | ||||||
|  |         return journal_entry_type | ||||||
|  |  | ||||||
|  |     def to_url(self, value: JournalEntryType) -> str: | ||||||
|  |         """Converts a journal entry type to its ID. | ||||||
|  |  | ||||||
|  |         :param value: The journal entry type. | ||||||
|  |         :return: The ID. | ||||||
|  |         """ | ||||||
|  |         return str(value.value) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DateConverter(BaseConverter): | ||||||
|  |     """The date converter to convert the ISO date from and to the | ||||||
|  |     corresponding date in the routes.""" | ||||||
|  |  | ||||||
|  |     def to_python(self, value: str) -> date: | ||||||
|  |         """Converts an ISO date to a date. | ||||||
|  |  | ||||||
|  |         :param value: The ISO date. | ||||||
|  |         :return: The corresponding date. | ||||||
|  |         """ | ||||||
|  |         try: | ||||||
|  |             return date.fromisoformat(value) | ||||||
|  |         except ValueError: | ||||||
|  |             abort(404) | ||||||
|  |  | ||||||
|  |     def to_url(self, value: date) -> str: | ||||||
|  |         """Converts a date to its ISO date. | ||||||
|  |  | ||||||
|  |         :param value: The date. | ||||||
|  |         :return: The ISO date. | ||||||
|  |         """ | ||||||
|  |         return value.isoformat() | ||||||
							
								
								
									
										22
									
								
								src/accounting/journal_entry/forms/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/accounting/journal_entry/forms/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The forms for the journal entry management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from .reorder import sort_journal_entries_in, JournalEntryReorderForm | ||||||
|  | from .journal_entry import JournalEntryForm, CashReceiptJournalEntryForm, \ | ||||||
|  |     CashDisbursementJournalEntryForm, TransferJournalEntryForm | ||||||
							
								
								
									
										294
									
								
								src/accounting/journal_entry/forms/currency.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										294
									
								
								src/accounting/journal_entry/forms/currency.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,294 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The currency sub-forms for the journal entry management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask_babel import LazyString | ||||||
|  | from flask_wtf import FlaskForm | ||||||
|  | from wtforms import StringField, ValidationError, FieldList, IntegerField, \ | ||||||
|  |     BooleanField, FormField | ||||||
|  | from wtforms.validators import DataRequired | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.forms import CurrencyExists | ||||||
|  | from accounting.journal_entry.utils.offset_alias import offset_alias | ||||||
|  | from accounting.locale import lazy_gettext | ||||||
|  | from accounting.models import JournalEntryLineItem | ||||||
|  | from accounting.utils.cast import be | ||||||
|  | from accounting.utils.strip_text import strip_text | ||||||
|  | from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm | ||||||
|  |  | ||||||
|  |  | ||||||
|  | CURRENCY_REQUIRED: DataRequired = DataRequired( | ||||||
|  |     lazy_gettext("Please select the currency.")) | ||||||
|  | """The validator to check if the currency code is empty.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SameCurrencyAsOriginalLineItems: | ||||||
|  |     """The validator to check if the currency is the same as the | ||||||
|  |     original line items.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         assert isinstance(form, CurrencyForm) | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         original_line_item_id: set[int] \ | ||||||
|  |             = {x.original_line_item_id.data | ||||||
|  |                for x in form.line_items | ||||||
|  |                if x.original_line_item_id.data is not None} | ||||||
|  |         if len(original_line_item_id) == 0: | ||||||
|  |             return | ||||||
|  |         original_line_item_currency_codes: set[str] = set(db.session.scalars( | ||||||
|  |             sa.select(JournalEntryLineItem.currency_code) | ||||||
|  |             .filter(JournalEntryLineItem.id.in_(original_line_item_id))).all()) | ||||||
|  |         for currency_code in original_line_item_currency_codes: | ||||||
|  |             if field.data != currency_code: | ||||||
|  |                 raise ValidationError(lazy_gettext( | ||||||
|  |                     "The currency must be the same as the" | ||||||
|  |                     " original line item.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class KeepCurrencyWhenHavingOffset: | ||||||
|  |     """The validator to check if the currency is the same when there is | ||||||
|  |     offset.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         assert isinstance(form, CurrencyForm) | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         offset: sa.Alias = offset_alias() | ||||||
|  |         original_line_items: list[JournalEntryLineItem]\ | ||||||
|  |             = JournalEntryLineItem.query\ | ||||||
|  |             .join(offset, be(JournalEntryLineItem.id | ||||||
|  |                              == offset.c.original_line_item_id), | ||||||
|  |                   isouter=True)\ | ||||||
|  |             .filter(JournalEntryLineItem.id | ||||||
|  |                     .in_({x.id.data for x in form.line_items | ||||||
|  |                           if x.id.data is not None}))\ | ||||||
|  |             .group_by(JournalEntryLineItem.id, | ||||||
|  |                       JournalEntryLineItem.currency_code)\ | ||||||
|  |             .having(sa.func.count(offset.c.id) > 0).all() | ||||||
|  |         for original_line_item in original_line_items: | ||||||
|  |             if original_line_item.currency_code != field.data: | ||||||
|  |                 raise ValidationError(lazy_gettext( | ||||||
|  |                     "The currency must not be changed when there is offset.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NeedSomeLineItems: | ||||||
|  |     """The validator to check if there is any line item sub-form.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: FieldList) -> None: | ||||||
|  |         if len(field) == 0: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "Please add some line items.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IsBalanced: | ||||||
|  |     """The validator to check that the total amount of the debit and credit | ||||||
|  |     line items are equal.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: BooleanField) -> None: | ||||||
|  |         assert isinstance(form, TransferCurrencyForm) | ||||||
|  |         if len(form.debit) == 0 or len(form.credit) == 0: | ||||||
|  |             return | ||||||
|  |         if form.debit_total != form.credit_total: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The totals of the debit and credit amounts do not match.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CurrencyForm(FlaskForm): | ||||||
|  |     """The form to create or edit a currency in a journal entry.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the journal entry.""" | ||||||
|  |     code = StringField() | ||||||
|  |     """The currency code.""" | ||||||
|  |     whole_form = BooleanField() | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def line_items(self) -> list[LineItemForm]: | ||||||
|  |         """Returns the line item sub-forms. | ||||||
|  |  | ||||||
|  |         :return: The line item sub-forms. | ||||||
|  |         """ | ||||||
|  |         line_item_forms: list[LineItemForm] = [] | ||||||
|  |         if isinstance(self, CashReceiptCurrencyForm): | ||||||
|  |             line_item_forms.extend([x.form for x in self.credit]) | ||||||
|  |         elif isinstance(self, CashDisbursementCurrencyForm): | ||||||
|  |             line_item_forms.extend([x.form for x in self.debit]) | ||||||
|  |         elif isinstance(self, TransferCurrencyForm): | ||||||
|  |             line_item_forms.extend([x.form for x in self.debit]) | ||||||
|  |             line_item_forms.extend([x.form for x in self.credit]) | ||||||
|  |         return line_item_forms | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_code_locked(self) -> bool: | ||||||
|  |         """Returns whether the currency code should not be changed. | ||||||
|  |  | ||||||
|  |         :return: True if the currency code should not be changed, or False | ||||||
|  |             otherwise | ||||||
|  |         """ | ||||||
|  |         line_item_forms: list[LineItemForm] = self.line_items | ||||||
|  |         original_line_item_id: set[int] \ | ||||||
|  |             = {x.original_line_item_id.data for x in line_item_forms | ||||||
|  |                if x.original_line_item_id.data is not None} | ||||||
|  |         if len(original_line_item_id) > 0: | ||||||
|  |             return True | ||||||
|  |         line_item_id: set[int] = {x.id.data for x in line_item_forms | ||||||
|  |                                   if x.id.data is not None} | ||||||
|  |         select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\ | ||||||
|  |             .filter(JournalEntryLineItem.original_line_item_id | ||||||
|  |                     .in_(line_item_id)) | ||||||
|  |         return db.session.scalar(select) > 0 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CashReceiptCurrencyForm(CurrencyForm): | ||||||
|  |     """The form to create or edit a currency in a | ||||||
|  |     cash receipt journal entry.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the journal entry.""" | ||||||
|  |     code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[CURRENCY_REQUIRED, | ||||||
|  |                     CurrencyExists(), | ||||||
|  |                     SameCurrencyAsOriginalLineItems(), | ||||||
|  |                     KeepCurrencyWhenHavingOffset()]) | ||||||
|  |     """The currency code.""" | ||||||
|  |     credit = FieldList(FormField(CreditLineItemForm), | ||||||
|  |                        validators=[NeedSomeLineItems()]) | ||||||
|  |     """The credit line items.""" | ||||||
|  |     whole_form = BooleanField() | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def credit_total(self) -> Decimal: | ||||||
|  |         """Returns the total amount of the credit line items. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the credit line items. | ||||||
|  |         """ | ||||||
|  |         return sum([x.amount.data for x in self.credit | ||||||
|  |                     if x.amount.data is not None]) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def credit_errors(self) -> list[str | LazyString]: | ||||||
|  |         """Returns the credit line item errors, without the errors in their | ||||||
|  |         sub-forms. | ||||||
|  |  | ||||||
|  |         :return: | ||||||
|  |         """ | ||||||
|  |         return [x for x in self.credit.errors | ||||||
|  |                 if isinstance(x, str) or isinstance(x, LazyString)] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CashDisbursementCurrencyForm(CurrencyForm): | ||||||
|  |     """The form to create or edit a currency in a | ||||||
|  |     cash disbursement journal entry.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the journal entry.""" | ||||||
|  |     code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[CURRENCY_REQUIRED, | ||||||
|  |                     CurrencyExists(), | ||||||
|  |                     SameCurrencyAsOriginalLineItems(), | ||||||
|  |                     KeepCurrencyWhenHavingOffset()]) | ||||||
|  |     """The currency code.""" | ||||||
|  |     debit = FieldList(FormField(DebitLineItemForm), | ||||||
|  |                       validators=[NeedSomeLineItems()]) | ||||||
|  |     """The debit line items.""" | ||||||
|  |     whole_form = BooleanField() | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def debit_total(self) -> Decimal: | ||||||
|  |         """Returns the total amount of the debit line items. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the debit line items. | ||||||
|  |         """ | ||||||
|  |         return sum([x.amount.data for x in self.debit | ||||||
|  |                     if x.amount.data is not None]) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def debit_errors(self) -> list[str | LazyString]: | ||||||
|  |         """Returns the debit line item errors, without the errors in their | ||||||
|  |         sub-forms. | ||||||
|  |  | ||||||
|  |         :return: | ||||||
|  |         """ | ||||||
|  |         return [x for x in self.debit.errors | ||||||
|  |                 if isinstance(x, str) or isinstance(x, LazyString)] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransferCurrencyForm(CurrencyForm): | ||||||
|  |     """The form to create or edit a currency in a transfer journal entry.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the journal entry.""" | ||||||
|  |     code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[CURRENCY_REQUIRED, | ||||||
|  |                     CurrencyExists(), | ||||||
|  |                     SameCurrencyAsOriginalLineItems(), | ||||||
|  |                     KeepCurrencyWhenHavingOffset()]) | ||||||
|  |     """The currency code.""" | ||||||
|  |     debit = FieldList(FormField(DebitLineItemForm), | ||||||
|  |                       validators=[NeedSomeLineItems()]) | ||||||
|  |     """The debit line items.""" | ||||||
|  |     credit = FieldList(FormField(CreditLineItemForm), | ||||||
|  |                        validators=[NeedSomeLineItems()]) | ||||||
|  |     """The credit line items.""" | ||||||
|  |     whole_form = BooleanField(validators=[IsBalanced()]) | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def debit_total(self) -> Decimal: | ||||||
|  |         """Returns the total amount of the debit line items. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the debit line items. | ||||||
|  |         """ | ||||||
|  |         return sum([x.amount.data for x in self.debit | ||||||
|  |                     if x.amount.data is not None]) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def credit_total(self) -> Decimal: | ||||||
|  |         """Returns the total amount of the credit line items. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the credit line items. | ||||||
|  |         """ | ||||||
|  |         return sum([x.amount.data for x in self.credit | ||||||
|  |                     if x.amount.data is not None]) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def debit_errors(self) -> list[str | LazyString]: | ||||||
|  |         """Returns the debit line item errors, without the errors in their | ||||||
|  |         sub-forms. | ||||||
|  |  | ||||||
|  |         :return: | ||||||
|  |         """ | ||||||
|  |         return [x for x in self.debit.errors | ||||||
|  |                 if isinstance(x, str) or isinstance(x, LazyString)] | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def credit_errors(self) -> list[str | LazyString]: | ||||||
|  |         """Returns the credit line item errors, without the errors in their | ||||||
|  |         sub-forms. | ||||||
|  |  | ||||||
|  |         :return: | ||||||
|  |         """ | ||||||
|  |         return [x for x in self.credit.errors | ||||||
|  |                 if isinstance(x, str) or isinstance(x, LazyString)] | ||||||
							
								
								
									
										593
									
								
								src/accounting/journal_entry/forms/journal_entry.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										593
									
								
								src/accounting/journal_entry/forms/journal_entry.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,593 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The journal entry forms for the journal entry management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import datetime as dt | ||||||
|  | import typing as t | ||||||
|  | from abc import ABC, abstractmethod | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask_babel import LazyString | ||||||
|  | from flask_wtf import FlaskForm | ||||||
|  | from wtforms import DateField, FieldList, FormField, TextAreaField, \ | ||||||
|  |     BooleanField | ||||||
|  | from wtforms.validators import DataRequired, ValidationError | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import lazy_gettext | ||||||
|  | from accounting.models import JournalEntry, Account, JournalEntryLineItem, \ | ||||||
|  |     JournalEntryCurrency | ||||||
|  | from accounting.journal_entry.utils.account_option import AccountOption | ||||||
|  | from accounting.journal_entry.utils.original_line_items import \ | ||||||
|  |     get_selectable_original_line_items | ||||||
|  | from accounting.journal_entry.utils.description_editor import DescriptionEditor | ||||||
|  | from accounting.utils.random_id import new_id | ||||||
|  | from accounting.utils.strip_text import strip_multiline_text | ||||||
|  | from accounting.utils.user import get_current_user_pk | ||||||
|  | from .currency import CurrencyForm, CashReceiptCurrencyForm, \ | ||||||
|  |     CashDisbursementCurrencyForm, TransferCurrencyForm | ||||||
|  | from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm | ||||||
|  | from .reorder import sort_journal_entries_in | ||||||
|  |  | ||||||
|  | DATE_REQUIRED: DataRequired = DataRequired( | ||||||
|  |     lazy_gettext("Please fill in the date.")) | ||||||
|  | """The validator to check if the date is empty.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NotBeforeOriginalLineItems: | ||||||
|  |     """The validator to check if the date is not before the | ||||||
|  |     original line items.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: DateField) -> None: | ||||||
|  |         assert isinstance(form, JournalEntryForm) | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         min_date: dt.date | None = form.min_date | ||||||
|  |         if min_date is None: | ||||||
|  |             return | ||||||
|  |         if field.data < min_date: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The date cannot be earlier than the original line items.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NotAfterOffsetItems: | ||||||
|  |     """The validator to check if the date is not after the offset items.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: DateField) -> None: | ||||||
|  |         assert isinstance(form, JournalEntryForm) | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         max_date: dt.date | None = form.max_date | ||||||
|  |         if max_date is None: | ||||||
|  |             return | ||||||
|  |         if field.data > max_date: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The date cannot be later than the offset items.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NeedSomeCurrencies: | ||||||
|  |     """The validator to check if there is any currency sub-form.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: FieldList) -> None: | ||||||
|  |         if len(field) == 0: | ||||||
|  |             raise ValidationError(lazy_gettext("Please add some currencies.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CannotDeleteOriginalLineItemsWithOffset: | ||||||
|  |     """The validator to check the original line items with offset.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: FieldList) -> None: | ||||||
|  |         assert isinstance(form, JournalEntryForm) | ||||||
|  |         if form.obj is None: | ||||||
|  |             return | ||||||
|  |         existing_matched_original_line_item_id: set[int] \ | ||||||
|  |             = {x.id for x in form.obj.line_items if len(x.offsets) > 0} | ||||||
|  |         line_item_id_in_form: set[int] \ | ||||||
|  |             = {x.id.data for x in form.line_items if x.id.data is not None} | ||||||
|  |         for line_item_id in existing_matched_original_line_item_id: | ||||||
|  |             if line_item_id not in line_item_id_in_form: | ||||||
|  |                 raise ValidationError(lazy_gettext( | ||||||
|  |                     "Line items with offset cannot be deleted.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class JournalEntryForm(FlaskForm): | ||||||
|  |     """The base form to create or edit a journal entry.""" | ||||||
|  |     date = DateField() | ||||||
|  |     """The date.""" | ||||||
|  |     currencies = FieldList(FormField(CurrencyForm)) | ||||||
|  |     """The line items categorized by their currencies.""" | ||||||
|  |     note = TextAreaField() | ||||||
|  |     """The note.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         """Constructs a base journal entry form. | ||||||
|  |  | ||||||
|  |         :param args: The arguments. | ||||||
|  |         :param kwargs: The keyword arguments. | ||||||
|  |         """ | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |         self.is_modified: bool = False | ||||||
|  |         """Whether the journal entry is modified during populate_obj().""" | ||||||
|  |         self.collector: t.Type[LineItemCollector] = LineItemCollector | ||||||
|  |         """The line item collector.  The default is the base abstract | ||||||
|  |         collector only to provide the correct type.  The subclass forms should | ||||||
|  |         provide their own collectors.""" | ||||||
|  |         self.obj: JournalEntry | None = kwargs.get("obj") | ||||||
|  |         """The journal entry, when editing an existing one.""" | ||||||
|  |         self._is_need_payable: bool = False | ||||||
|  |         """Whether we need the payable original line items.""" | ||||||
|  |         self._is_need_receivable: bool = False | ||||||
|  |         """Whether we need the receivable original line items.""" | ||||||
|  |         self.__original_line_item_options: list[JournalEntryLineItem] | None \ | ||||||
|  |             = None | ||||||
|  |         """The options of the original line items.""" | ||||||
|  |         self.__net_balance_exceeded: dict[int, LazyString] | None = None | ||||||
|  |         """The original line items whose net balances were exceeded by the | ||||||
|  |         amounts in the line item sub-forms.""" | ||||||
|  |         for line_item in self.line_items: | ||||||
|  |             line_item.journal_entry_form = self | ||||||
|  |  | ||||||
|  |     def populate_obj(self, obj: JournalEntry) -> None: | ||||||
|  |         """Populates the form data into a journal entry object. | ||||||
|  |  | ||||||
|  |         :param obj: The journal entry object. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         is_new: bool = obj.id is None | ||||||
|  |         if is_new: | ||||||
|  |             obj.id = new_id(JournalEntry) | ||||||
|  |         self.date: DateField | ||||||
|  |         self.__set_date(obj, self.date.data) | ||||||
|  |         obj.note = self.note.data | ||||||
|  |  | ||||||
|  |         collector_cls: t.Type[LineItemCollector] = self.collector | ||||||
|  |         collector: collector_cls = collector_cls(self, obj) | ||||||
|  |         collector.collect() | ||||||
|  |  | ||||||
|  |         to_delete: set[int] = {x.id for x in obj.line_items | ||||||
|  |                                if x.id not in collector.to_keep} | ||||||
|  |         if len(to_delete) > 0: | ||||||
|  |             JournalEntryLineItem.query\ | ||||||
|  |                 .filter(JournalEntryLineItem.id.in_(to_delete)).delete() | ||||||
|  |             self.is_modified = True | ||||||
|  |  | ||||||
|  |         if is_new or db.session.is_modified(obj): | ||||||
|  |             self.is_modified = True | ||||||
|  |  | ||||||
|  |         if is_new: | ||||||
|  |             current_user_pk: int = get_current_user_pk() | ||||||
|  |             obj.created_by_id = current_user_pk | ||||||
|  |             obj.updated_by_id = current_user_pk | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def line_items(self) -> list[LineItemForm]: | ||||||
|  |         """Collects and returns the line item sub-forms. | ||||||
|  |  | ||||||
|  |         :return: The line item sub-forms. | ||||||
|  |         """ | ||||||
|  |         line_items: list[LineItemForm] = [] | ||||||
|  |         for currency in self.currencies: | ||||||
|  |             line_items.extend(currency.line_items) | ||||||
|  |         return line_items | ||||||
|  |  | ||||||
|  |     def __set_date(self, obj: JournalEntry, new_date: dt.date) -> None: | ||||||
|  |         """Sets the journal entry date and number. | ||||||
|  |  | ||||||
|  |         :param obj: The journal entry object. | ||||||
|  |         :param new_date: The new date. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         if obj.date is None or obj.date != new_date: | ||||||
|  |             if obj.date is not None: | ||||||
|  |                 sort_journal_entries_in(obj.date, obj.id) | ||||||
|  |             if self.max_date is not None and new_date == self.max_date: | ||||||
|  |                 db_min_no: int | None = db.session.scalar( | ||||||
|  |                     sa.select(sa.func.min(JournalEntry.no)) | ||||||
|  |                     .filter(JournalEntry.date == new_date)) | ||||||
|  |                 if db_min_no is None: | ||||||
|  |                     obj.date = new_date | ||||||
|  |                     obj.no = 1 | ||||||
|  |                 else: | ||||||
|  |                     obj.date = new_date | ||||||
|  |                     obj.no = db_min_no - 1 | ||||||
|  |                     sort_journal_entries_in(new_date) | ||||||
|  |             else: | ||||||
|  |                 sort_journal_entries_in(new_date, obj.id) | ||||||
|  |                 count: int = JournalEntry.query\ | ||||||
|  |                     .filter(JournalEntry.date == new_date).count() | ||||||
|  |                 obj.date = new_date | ||||||
|  |                 obj.no = count + 1 | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def debit_account_options(self) -> list[AccountOption]: | ||||||
|  |         """The selectable debit accounts. | ||||||
|  |  | ||||||
|  |         :return: The selectable debit accounts. | ||||||
|  |         """ | ||||||
|  |         accounts: list[AccountOption] \ | ||||||
|  |             = [AccountOption(x) for x in Account.selectable_debit() | ||||||
|  |                if not (x.code[0] == "2" and x.is_need_offset)] | ||||||
|  |         in_use: set[int] = set(db.session.scalars( | ||||||
|  |             sa.select(JournalEntryLineItem.account_id) | ||||||
|  |             .filter(JournalEntryLineItem.is_debit) | ||||||
|  |             .group_by(JournalEntryLineItem.account_id)).all()) | ||||||
|  |         for account in accounts: | ||||||
|  |             account.is_in_use = account.id in in_use | ||||||
|  |         return accounts | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def credit_account_options(self) -> list[AccountOption]: | ||||||
|  |         """The selectable credit accounts. | ||||||
|  |  | ||||||
|  |         :return: The selectable credit accounts. | ||||||
|  |         """ | ||||||
|  |         accounts: list[AccountOption] \ | ||||||
|  |             = [AccountOption(x) for x in Account.selectable_credit() | ||||||
|  |                if not (x.code[0] == "1" and x.is_need_offset)] | ||||||
|  |         in_use: set[int] = set(db.session.scalars( | ||||||
|  |             sa.select(JournalEntryLineItem.account_id) | ||||||
|  |             .filter(sa.not_(JournalEntryLineItem.is_debit)) | ||||||
|  |             .group_by(JournalEntryLineItem.account_id)).all()) | ||||||
|  |         for account in accounts: | ||||||
|  |             account.is_in_use = account.id in in_use | ||||||
|  |         return accounts | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def currencies_errors(self) -> list[str | LazyString]: | ||||||
|  |         """Returns the currency errors, without the errors in their sub-forms. | ||||||
|  |  | ||||||
|  |         :return: The currency errors, without the errors in their sub-forms. | ||||||
|  |         """ | ||||||
|  |         return [x for x in self.currencies.errors | ||||||
|  |                 if isinstance(x, str) or isinstance(x, LazyString)] | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def description_editor(self) -> DescriptionEditor: | ||||||
|  |         """Returns the description editor. | ||||||
|  |  | ||||||
|  |         :return: The description editor. | ||||||
|  |         """ | ||||||
|  |         return DescriptionEditor() | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def original_line_item_options(self) -> list[JournalEntryLineItem]: | ||||||
|  |         """Returns the selectable original line items. | ||||||
|  |  | ||||||
|  |         :return: The selectable original line items. | ||||||
|  |         """ | ||||||
|  |         if self.__original_line_item_options is None: | ||||||
|  |             self.__original_line_item_options \ | ||||||
|  |                 = get_selectable_original_line_items( | ||||||
|  |                     {x.id.data for x in self.line_items | ||||||
|  |                      if x.id.data is not None}, | ||||||
|  |                     self._is_need_payable, self._is_need_receivable) | ||||||
|  |         return self.__original_line_item_options | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def min_date(self) -> dt.date | None: | ||||||
|  |         """Returns the minimal available date. | ||||||
|  |  | ||||||
|  |         :return: The minimal available date. | ||||||
|  |         """ | ||||||
|  |         original_line_item_id: set[int] \ | ||||||
|  |             = {x.original_line_item_id.data for x in self.line_items | ||||||
|  |                if x.original_line_item_id.data is not None} | ||||||
|  |         if len(original_line_item_id) == 0: | ||||||
|  |             return None | ||||||
|  |         select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\ | ||||||
|  |             .join(JournalEntryLineItem)\ | ||||||
|  |             .filter(JournalEntryLineItem.id.in_(original_line_item_id)) | ||||||
|  |         return db.session.scalar(select) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def max_date(self) -> dt.date | None: | ||||||
|  |         """Returns the maximum available date. | ||||||
|  |  | ||||||
|  |         :return: The maximum available date. | ||||||
|  |         """ | ||||||
|  |         line_item_id: set[int] = {x.id.data for x in self.line_items | ||||||
|  |                                   if x.id.data is not None} | ||||||
|  |         select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\ | ||||||
|  |             .join(JournalEntryLineItem)\ | ||||||
|  |             .filter(JournalEntryLineItem.original_line_item_id | ||||||
|  |                     .in_(line_item_id)) | ||||||
|  |         return db.session.scalar(select) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | T = t.TypeVar("T", bound=JournalEntryForm) | ||||||
|  | """A journal entry form variant.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LineItemCollector(t.Generic[T], ABC): | ||||||
|  |     """The line item collector.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, form: T, obj: JournalEntry): | ||||||
|  |         """Constructs the line item collector. | ||||||
|  |  | ||||||
|  |         :param form: The journal entry form. | ||||||
|  |         :param obj: The journal entry. | ||||||
|  |         """ | ||||||
|  |         self.form: T = form | ||||||
|  |         """The journal entry form.""" | ||||||
|  |         self.__obj: JournalEntry = obj | ||||||
|  |         """The journal entry object.""" | ||||||
|  |         self.__line_items: list[JournalEntryLineItem] = list(obj.line_items) | ||||||
|  |         """The existing line items.""" | ||||||
|  |         self.__line_items_by_id: dict[int, JournalEntryLineItem] \ | ||||||
|  |             = {x.id: x for x in self.__line_items} | ||||||
|  |         """A dictionary from the line item ID to their line items.""" | ||||||
|  |         self.__no_by_id: dict[int, int] \ | ||||||
|  |             = {x.id: x.no for x in self.__line_items} | ||||||
|  |         """A dictionary from the line item number to their line items.""" | ||||||
|  |         self.__currencies: list[JournalEntryCurrency] = obj.currencies | ||||||
|  |         """The currencies in the journal entry.""" | ||||||
|  |         self._debit_no: int = 1 | ||||||
|  |         """The number index for the debit line items.""" | ||||||
|  |         self._credit_no: int = 1 | ||||||
|  |         """The number index for the credit line items.""" | ||||||
|  |         self.to_keep: set[int] = set() | ||||||
|  |         """The ID of the existing line items to keep.""" | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def collect(self) -> set[int]: | ||||||
|  |         """Collects the line items. | ||||||
|  |  | ||||||
|  |         :return: The ID of the line items to keep. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     def _add_line_item(self, form: LineItemForm, currency_code: str, no: int) \ | ||||||
|  |             -> None: | ||||||
|  |         """Composes a line item from the form. | ||||||
|  |  | ||||||
|  |         :param form: The line item form. | ||||||
|  |         :param currency_code: The code of the currency. | ||||||
|  |         :param no: The number of the line item. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         line_item: JournalEntryLineItem | None \ | ||||||
|  |             = self.__line_items_by_id.get(form.id.data) | ||||||
|  |         if line_item is not None: | ||||||
|  |             line_item.currency_code = currency_code | ||||||
|  |             form.populate_obj(line_item) | ||||||
|  |             line_item.no = no | ||||||
|  |             if db.session.is_modified(line_item): | ||||||
|  |                 self.form.is_modified = True | ||||||
|  |         else: | ||||||
|  |             line_item = JournalEntryLineItem() | ||||||
|  |             line_item.currency_code = currency_code | ||||||
|  |             form.populate_obj(line_item) | ||||||
|  |             line_item.no = no | ||||||
|  |             self.__obj.line_items.append(line_item) | ||||||
|  |             self.form.is_modified = True | ||||||
|  |         self.to_keep.add(line_item.id) | ||||||
|  |  | ||||||
|  |     def _make_cash_line_item(self, forms: list[LineItemForm], is_debit: bool, | ||||||
|  |                              currency_code: str, no: int) -> None: | ||||||
|  |         """Composes the cash line item at the other debit or credit of the | ||||||
|  |         cash journal entry. | ||||||
|  |  | ||||||
|  |         :param forms: The line item forms in the same currency. | ||||||
|  |         :param is_debit: True for a cash receipt journal entry, or False for a | ||||||
|  |             cash disbursement journal entry. | ||||||
|  |         :param currency_code: The code of the currency. | ||||||
|  |         :param no: The number of the line item. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         candidates: list[JournalEntryLineItem] \ | ||||||
|  |             = [x for x in self.__line_items | ||||||
|  |                if x.is_debit == is_debit and x.currency_code == currency_code] | ||||||
|  |         line_item: JournalEntryLineItem | ||||||
|  |         if len(candidates) > 0: | ||||||
|  |             candidates.sort(key=lambda x: x.no) | ||||||
|  |             line_item = candidates[0] | ||||||
|  |             line_item.account_id = Account.cash().id | ||||||
|  |             line_item.description = None | ||||||
|  |             line_item.amount = sum([x.amount.data for x in forms]) | ||||||
|  |             line_item.no = no | ||||||
|  |             if db.session.is_modified(line_item): | ||||||
|  |                 self.form.is_modified = True | ||||||
|  |         else: | ||||||
|  |             line_item = JournalEntryLineItem() | ||||||
|  |             line_item.id = new_id(JournalEntryLineItem) | ||||||
|  |             line_item.is_debit = is_debit | ||||||
|  |             line_item.currency_code = currency_code | ||||||
|  |             line_item.account_id = Account.cash().id | ||||||
|  |             line_item.description = None | ||||||
|  |             line_item.amount = sum([x.amount.data for x in forms]) | ||||||
|  |             line_item.no = no | ||||||
|  |             self.__obj.line_items.append(line_item) | ||||||
|  |             self.form.is_modified = True | ||||||
|  |         self.to_keep.add(line_item.id) | ||||||
|  |  | ||||||
|  |     def _sort_line_item_forms(self, forms: list[LineItemForm]) -> None: | ||||||
|  |         """Sorts the line item sub-forms. | ||||||
|  |  | ||||||
|  |         :param forms: The line item sub-forms. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         missing_no: int = 100 if len(self.__no_by_id) == 0 \ | ||||||
|  |             else max(self.__no_by_id.values()) + 100 | ||||||
|  |         ord_by_form: dict[LineItemForm, int] \ | ||||||
|  |             = {forms[i]: i for i in range(len(forms))} | ||||||
|  |         recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None} | ||||||
|  |         missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100 | ||||||
|  |         forms.sort(key=lambda x: (x.no.data or missing_recv_no, | ||||||
|  |                                   missing_no if x.id.data is None else | ||||||
|  |                                   self.__no_by_id.get(x.id.data, missing_no), | ||||||
|  |                                   ord_by_form.get(x))) | ||||||
|  |  | ||||||
|  |     def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None: | ||||||
|  |         """Sorts the currency forms. | ||||||
|  |  | ||||||
|  |         :param forms: The currency forms. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         missing_no: int = len(self.__currencies) + 100 | ||||||
|  |         no_by_code: dict[str, int] = {self.__currencies[i].code: i | ||||||
|  |                                       for i in range(len(self.__currencies))} | ||||||
|  |         ord_by_form: dict[CurrencyForm, int] \ | ||||||
|  |             = {forms[i]: i for i in range(len(forms))} | ||||||
|  |         recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None} | ||||||
|  |         missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100 | ||||||
|  |         forms.sort(key=lambda x: (x.no.data or missing_recv_no, | ||||||
|  |                                   no_by_code.get(x.code.data, missing_no), | ||||||
|  |                                   ord_by_form.get(x))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CashReceiptJournalEntryForm(JournalEntryForm): | ||||||
|  |     """The form to create or edit a cash receipt journal entry.""" | ||||||
|  |     date = DateField( | ||||||
|  |         validators=[DATE_REQUIRED, | ||||||
|  |                     NotBeforeOriginalLineItems(), | ||||||
|  |                     NotAfterOffsetItems()]) | ||||||
|  |     """The date.""" | ||||||
|  |     currencies = FieldList(FormField(CashReceiptCurrencyForm), name="currency", | ||||||
|  |                            validators=[NeedSomeCurrencies()]) | ||||||
|  |     """The line items categorized by their currencies.""" | ||||||
|  |     note = TextAreaField(filters=[strip_multiline_text]) | ||||||
|  |     """The note.""" | ||||||
|  |     whole_form = BooleanField( | ||||||
|  |         validators=[CannotDeleteOriginalLineItemsWithOffset()]) | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |         self._is_need_receivable = True | ||||||
|  |  | ||||||
|  |         class Collector(LineItemCollector[CashReceiptJournalEntryForm]): | ||||||
|  |             """The line item collector for the cash receipt journal entries.""" | ||||||
|  |  | ||||||
|  |             def collect(self) -> None: | ||||||
|  |                 currencies: list[CashReceiptCurrencyForm] \ | ||||||
|  |                     = [x.form for x in self.form.currencies] | ||||||
|  |                 self._sort_currency_forms(currencies) | ||||||
|  |                 for currency in currencies: | ||||||
|  |                     # The debit cash line item | ||||||
|  |                     self._make_cash_line_item(list(currency.credit), True, | ||||||
|  |                                               currency.code.data, | ||||||
|  |                                               self._debit_no) | ||||||
|  |                     self._debit_no = self._debit_no + 1 | ||||||
|  |  | ||||||
|  |                     # The credit forms | ||||||
|  |                     credit_forms: list[CreditLineItemForm] \ | ||||||
|  |                         = [x.form for x in currency.credit] | ||||||
|  |                     self._sort_line_item_forms(credit_forms) | ||||||
|  |                     for credit_form in credit_forms: | ||||||
|  |                         self._add_line_item(credit_form, currency.code.data, | ||||||
|  |                                             self._credit_no) | ||||||
|  |                         self._credit_no = self._credit_no + 1 | ||||||
|  |  | ||||||
|  |         self.collector = Collector | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CashDisbursementJournalEntryForm(JournalEntryForm): | ||||||
|  |     """The form to create or edit a cash disbursement journal entry.""" | ||||||
|  |     date = DateField( | ||||||
|  |         validators=[DATE_REQUIRED, | ||||||
|  |                     NotBeforeOriginalLineItems(), | ||||||
|  |                     NotAfterOffsetItems()]) | ||||||
|  |     """The date.""" | ||||||
|  |     currencies = FieldList(FormField(CashDisbursementCurrencyForm), | ||||||
|  |                            name="currency", | ||||||
|  |                            validators=[NeedSomeCurrencies()]) | ||||||
|  |     """The line items categorized by their currencies.""" | ||||||
|  |     note = TextAreaField(filters=[strip_multiline_text]) | ||||||
|  |     """The note.""" | ||||||
|  |     whole_form = BooleanField( | ||||||
|  |         validators=[CannotDeleteOriginalLineItemsWithOffset()]) | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |         self._is_need_payable = True | ||||||
|  |  | ||||||
|  |         class Collector(LineItemCollector[CashDisbursementJournalEntryForm]): | ||||||
|  |             """The line item collector for the cash disbursement journal | ||||||
|  |             entries.""" | ||||||
|  |  | ||||||
|  |             def collect(self) -> None: | ||||||
|  |                 currencies: list[CashDisbursementCurrencyForm] \ | ||||||
|  |                     = [x.form for x in self.form.currencies] | ||||||
|  |                 self._sort_currency_forms(currencies) | ||||||
|  |                 for currency in currencies: | ||||||
|  |                     # The debit forms | ||||||
|  |                     debit_forms: list[DebitLineItemForm] \ | ||||||
|  |                         = [x.form for x in currency.debit] | ||||||
|  |                     self._sort_line_item_forms(debit_forms) | ||||||
|  |                     for debit_form in debit_forms: | ||||||
|  |                         self._add_line_item(debit_form, currency.code.data, | ||||||
|  |                                             self._debit_no) | ||||||
|  |                         self._debit_no = self._debit_no + 1 | ||||||
|  |  | ||||||
|  |                     # The credit forms | ||||||
|  |                     self._make_cash_line_item(list(currency.debit), False, | ||||||
|  |                                               currency.code.data, | ||||||
|  |                                               self._credit_no) | ||||||
|  |                     self._credit_no = self._credit_no + 1 | ||||||
|  |  | ||||||
|  |         self.collector = Collector | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransferJournalEntryForm(JournalEntryForm): | ||||||
|  |     """The form to create or edit a transfer journal entry.""" | ||||||
|  |     date = DateField( | ||||||
|  |         validators=[DATE_REQUIRED, | ||||||
|  |                     NotBeforeOriginalLineItems(), | ||||||
|  |                     NotAfterOffsetItems()]) | ||||||
|  |     """The date.""" | ||||||
|  |     currencies = FieldList(FormField(TransferCurrencyForm), name="currency", | ||||||
|  |                            validators=[NeedSomeCurrencies()]) | ||||||
|  |     """The line items categorized by their currencies.""" | ||||||
|  |     note = TextAreaField(filters=[strip_multiline_text]) | ||||||
|  |     """The note.""" | ||||||
|  |     whole_form = BooleanField( | ||||||
|  |         validators=[CannotDeleteOriginalLineItemsWithOffset()]) | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |         self._is_need_payable = True | ||||||
|  |         self._is_need_receivable = True | ||||||
|  |  | ||||||
|  |         class Collector(LineItemCollector[TransferJournalEntryForm]): | ||||||
|  |             """The line item collector for the transfer journal entries.""" | ||||||
|  |  | ||||||
|  |             def collect(self) -> None: | ||||||
|  |                 currencies: list[TransferCurrencyForm] \ | ||||||
|  |                     = [x.form for x in self.form.currencies] | ||||||
|  |                 self._sort_currency_forms(currencies) | ||||||
|  |                 for currency in currencies: | ||||||
|  |                     # The debit forms | ||||||
|  |                     debit_forms: list[DebitLineItemForm] \ | ||||||
|  |                         = [x.form for x in currency.debit] | ||||||
|  |                     self._sort_line_item_forms(debit_forms) | ||||||
|  |                     for debit_form in debit_forms: | ||||||
|  |                         self._add_line_item(debit_form, currency.code.data, | ||||||
|  |                                             self._debit_no) | ||||||
|  |                         self._debit_no = self._debit_no + 1 | ||||||
|  |  | ||||||
|  |                     # The credit forms | ||||||
|  |                     credit_forms: list[CreditLineItemForm] \ | ||||||
|  |                         = [x.form for x in currency.credit] | ||||||
|  |                     self._sort_line_item_forms(credit_forms) | ||||||
|  |                     for credit_form in credit_forms: | ||||||
|  |                         self._add_line_item(credit_form, currency.code.data, | ||||||
|  |                                             self._credit_no) | ||||||
|  |                         self._credit_no = self._credit_no + 1 | ||||||
|  |  | ||||||
|  |         self.collector = Collector | ||||||
							
								
								
									
										499
									
								
								src/accounting/journal_entry/forms/line_item.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										499
									
								
								src/accounting/journal_entry/forms/line_item.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,499 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The line item sub-forms for the journal entry management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask_babel import LazyString | ||||||
|  | from flask_wtf import FlaskForm | ||||||
|  | from sqlalchemy.orm import selectinload | ||||||
|  | from wtforms import StringField, ValidationError, DecimalField, IntegerField | ||||||
|  | from wtforms.validators import Optional | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \ | ||||||
|  |     IsCreditAccount | ||||||
|  | from accounting.locale import lazy_gettext | ||||||
|  | from accounting.models import Account, JournalEntryLineItem | ||||||
|  | from accounting.template_filters import format_amount | ||||||
|  | from accounting.utils.cast import be | ||||||
|  | from accounting.utils.random_id import new_id | ||||||
|  | from accounting.utils.strip_text import strip_text | ||||||
|  | from accounting.utils.user import get_current_user_pk | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OriginalLineItemExists: | ||||||
|  |     """The validator to check if the original line item exists.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: IntegerField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         if db.session.get(JournalEntryLineItem, field.data) is None: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The original line item does not exist.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OriginalLineItemOppositeDebitCredit: | ||||||
|  |     """The validator to check if the original line item is on the opposite | ||||||
|  |     debit or credit.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: IntegerField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         original_line_item: JournalEntryLineItem | None \ | ||||||
|  |             = db.session.get(JournalEntryLineItem, field.data) | ||||||
|  |         if original_line_item is None: | ||||||
|  |             return | ||||||
|  |         if isinstance(form, CreditLineItemForm) \ | ||||||
|  |                 and original_line_item.is_debit: | ||||||
|  |             return | ||||||
|  |         if isinstance(form, DebitLineItemForm) \ | ||||||
|  |                 and not original_line_item.is_debit: | ||||||
|  |             return | ||||||
|  |         raise ValidationError(lazy_gettext( | ||||||
|  |             "The original line item is on the same debit or credit.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OriginalLineItemNeedOffset: | ||||||
|  |     """The validator to check if the original line item needs offset.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: IntegerField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         original_line_item: JournalEntryLineItem | None \ | ||||||
|  |             = db.session.get(JournalEntryLineItem, field.data) | ||||||
|  |         if original_line_item is None: | ||||||
|  |             return | ||||||
|  |         if not original_line_item.account.is_need_offset: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The original line item does not need offset.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OriginalLineItemNotOffset: | ||||||
|  |     """The validator to check if the original line item is not itself an | ||||||
|  |     offset item.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: IntegerField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         original_line_item: JournalEntryLineItem | None \ | ||||||
|  |             = db.session.get(JournalEntryLineItem, field.data) | ||||||
|  |         if original_line_item is None: | ||||||
|  |             return | ||||||
|  |         if original_line_item.original_line_item_id is not None: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The original line item cannot be an offset item.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SameAccountAsOriginalLineItem: | ||||||
|  |     """The validator to check if the account is the same as the | ||||||
|  |     original line item.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         assert isinstance(form, LineItemForm) | ||||||
|  |         if field.data is None or form.original_line_item_id.data is None: | ||||||
|  |             return | ||||||
|  |         original_line_item: JournalEntryLineItem | None \ | ||||||
|  |             = db.session.get(JournalEntryLineItem, | ||||||
|  |                              form.original_line_item_id.data) | ||||||
|  |         if original_line_item is None: | ||||||
|  |             return | ||||||
|  |         if field.data != original_line_item.account_code: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The account must be the same as the original line item.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class KeepAccountWhenHavingOffset: | ||||||
|  |     """The validator to check if the account is the same when having offset.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         assert isinstance(form, LineItemForm) | ||||||
|  |         if field.data is None or form.id.data is None: | ||||||
|  |             return | ||||||
|  |         line_item: JournalEntryLineItem | None = db.session\ | ||||||
|  |             .query(JournalEntryLineItem)\ | ||||||
|  |             .filter(JournalEntryLineItem.id == form.id.data)\ | ||||||
|  |             .options(selectinload(JournalEntryLineItem.offsets)).first() | ||||||
|  |         if line_item is None or len(line_item.offsets) == 0: | ||||||
|  |             return | ||||||
|  |         if field.data != line_item.account_code: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The account must not be changed when there is offset.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NotStartPayableFromDebit: | ||||||
|  |     """The validator to check that a payable line item does not start from | ||||||
|  |     debit.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         assert isinstance(form, DebitLineItemForm) | ||||||
|  |         if field.data is None \ | ||||||
|  |                 or field.data[0] != "2" \ | ||||||
|  |                 or form.original_line_item_id.data is not None: | ||||||
|  |             return | ||||||
|  |         account: Account | None = Account.find_by_code(field.data) | ||||||
|  |         if account is not None and account.is_need_offset: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "A payable line item cannot start from debit.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NotStartReceivableFromCredit: | ||||||
|  |     """The validator to check that a receivable line item does not start | ||||||
|  |     from credit.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         assert isinstance(form, CreditLineItemForm) | ||||||
|  |         if field.data is None \ | ||||||
|  |                 or field.data[0] != "1" \ | ||||||
|  |                 or form.original_line_item_id.data is not None: | ||||||
|  |             return | ||||||
|  |         account: Account | None = Account.find_by_code(field.data) | ||||||
|  |         if account is not None and account.is_need_offset: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "A receivable line item cannot start from credit.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PositiveAmount: | ||||||
|  |     """The validator to check if the amount is positive.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: DecimalField) -> None: | ||||||
|  |         if field.data is None: | ||||||
|  |             return | ||||||
|  |         if field.data <= 0: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "Please fill in a positive amount.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NotExceedingOriginalLineItemNetBalance: | ||||||
|  |     """The validator to check if the amount exceeds the net balance of the | ||||||
|  |     original line item.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: DecimalField) -> None: | ||||||
|  |         assert isinstance(form, LineItemForm) | ||||||
|  |         if field.data is None or form.original_line_item_id.data is None: | ||||||
|  |             return | ||||||
|  |         original_line_item: JournalEntryLineItem | None \ | ||||||
|  |             = db.session.get(JournalEntryLineItem, | ||||||
|  |                              form.original_line_item_id.data) | ||||||
|  |         if original_line_item is None: | ||||||
|  |             return | ||||||
|  |         is_debit: bool = isinstance(form, DebitLineItemForm) | ||||||
|  |         existing_line_item_id: set[int] = set() | ||||||
|  |         if form.journal_entry_form.obj is not None: | ||||||
|  |             existing_line_item_id \ | ||||||
|  |                 = {x.id for x in form.journal_entry_form.obj.line_items} | ||||||
|  |         offset_total_func: sa.Function = sa.func.sum(sa.case( | ||||||
|  |             (be(JournalEntryLineItem.is_debit == is_debit), | ||||||
|  |              JournalEntryLineItem.amount), | ||||||
|  |             else_=-JournalEntryLineItem.amount)) | ||||||
|  |         offset_total_but_form: Decimal | None = db.session.scalar( | ||||||
|  |             sa.select(offset_total_func) | ||||||
|  |             .filter(be(JournalEntryLineItem.original_line_item_id | ||||||
|  |                        == original_line_item.id), | ||||||
|  |                     JournalEntryLineItem.id.not_in(existing_line_item_id))) | ||||||
|  |         if offset_total_but_form is None: | ||||||
|  |             offset_total_but_form = Decimal("0") | ||||||
|  |         offset_total_on_form: Decimal = sum( | ||||||
|  |             [x.amount.data for x in form.journal_entry_form.line_items | ||||||
|  |              if x.original_line_item_id.data == original_line_item.id | ||||||
|  |              and x.amount != field and x.amount.data is not None]) | ||||||
|  |         net_balance: Decimal = original_line_item.amount \ | ||||||
|  |             - offset_total_but_form - offset_total_on_form | ||||||
|  |         if field.data > net_balance: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The amount must not exceed the net balance %(balance)s of the" | ||||||
|  |                 " original line item.", balance=format_amount(net_balance))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NotLessThanOffsetTotal: | ||||||
|  |     """The validator to check if the amount is less than the offset total.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: DecimalField) -> None: | ||||||
|  |         assert isinstance(form, LineItemForm) | ||||||
|  |         if field.data is None or form.id.data is None: | ||||||
|  |             return | ||||||
|  |         is_debit: bool = isinstance(form, DebitLineItemForm) | ||||||
|  |         select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case( | ||||||
|  |             (JournalEntryLineItem.is_debit != is_debit, | ||||||
|  |              JournalEntryLineItem.amount), | ||||||
|  |             else_=-JournalEntryLineItem.amount)))\ | ||||||
|  |             .filter(be(JournalEntryLineItem.original_line_item_id | ||||||
|  |                        == form.id.data)) | ||||||
|  |         offset_total: Decimal | None = db.session.scalar(select_offset_total) | ||||||
|  |         if offset_total is not None and field.data < offset_total: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The amount must not be less than the offset total %(total)s.", | ||||||
|  |                 total=format_amount(offset_total))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LineItemForm(FlaskForm): | ||||||
|  |     """The base form to create or edit a line item.""" | ||||||
|  |     id = IntegerField() | ||||||
|  |     """The existing line item ID.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the currency.""" | ||||||
|  |     original_line_item_id = IntegerField() | ||||||
|  |     """The Id of the original line item.""" | ||||||
|  |     account_code = StringField() | ||||||
|  |     """The account code.""" | ||||||
|  |     description = StringField() | ||||||
|  |     """The description.""" | ||||||
|  |     amount = DecimalField() | ||||||
|  |     """The amount.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         """Constructs a base line item form. | ||||||
|  |  | ||||||
|  |         :param args: The arguments. | ||||||
|  |         :param kwargs: The keyword arguments. | ||||||
|  |         """ | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |         from .journal_entry import JournalEntryForm | ||||||
|  |         self.journal_entry_form: JournalEntryForm | None = None | ||||||
|  |         """The source journal entry form.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def account_text(self) -> str: | ||||||
|  |         """Returns the text representation of the account. | ||||||
|  |  | ||||||
|  |         :return: The text representation of the account. | ||||||
|  |         """ | ||||||
|  |         if self.account_code.data is None: | ||||||
|  |             return "" | ||||||
|  |         account: Account | None = Account.find_by_code(self.account_code.data) | ||||||
|  |         if account is None: | ||||||
|  |             return "" | ||||||
|  |         return str(account) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def __original_line_item(self) -> JournalEntryLineItem | None: | ||||||
|  |         """Returns the original line item. | ||||||
|  |  | ||||||
|  |         :return: The original line item. | ||||||
|  |         """ | ||||||
|  |         if not hasattr(self, "____original_line_item"): | ||||||
|  |             def get_line_item() -> JournalEntryLineItem | None: | ||||||
|  |                 if self.original_line_item_id.data is None: | ||||||
|  |                     return None | ||||||
|  |                 return db.session.get(JournalEntryLineItem, | ||||||
|  |                                       self.original_line_item_id.data) | ||||||
|  |             setattr(self, "____original_line_item", get_line_item()) | ||||||
|  |         return getattr(self, "____original_line_item") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def original_line_item_date(self) -> date | None: | ||||||
|  |         """Returns the text representation of the original line item. | ||||||
|  |  | ||||||
|  |         :return: The text representation of the original line item. | ||||||
|  |         """ | ||||||
|  |         return None if self.__original_line_item is None \ | ||||||
|  |             else self.__original_line_item.journal_entry.date | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def original_line_item_text(self) -> str | None: | ||||||
|  |         """Returns the text representation of the original line item. | ||||||
|  |  | ||||||
|  |         :return: The text representation of the original line item. | ||||||
|  |         """ | ||||||
|  |         return None if self.__original_line_item is None \ | ||||||
|  |             else str(self.__original_line_item) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_need_offset(self) -> bool: | ||||||
|  |         """Returns whether the line item needs offset. | ||||||
|  |  | ||||||
|  |         :return: True if the line item needs offset, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         if self.account_code.data is None: | ||||||
|  |             return False | ||||||
|  |         if self.account_code.data[0] == "1": | ||||||
|  |             if isinstance(self, CreditLineItemForm): | ||||||
|  |                 return False | ||||||
|  |         elif self.account_code.data[0] == "2": | ||||||
|  |             if isinstance(self, DebitLineItemForm): | ||||||
|  |                 return False | ||||||
|  |         else: | ||||||
|  |             return False | ||||||
|  |         account: Account | None = Account.find_by_code(self.account_code.data) | ||||||
|  |         return account is not None and account.is_need_offset | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def offsets(self) -> list[JournalEntryLineItem]: | ||||||
|  |         """Returns the offsets. | ||||||
|  |  | ||||||
|  |         :return: The offsets. | ||||||
|  |         """ | ||||||
|  |         if not hasattr(self, "__offsets"): | ||||||
|  |             def get_offsets() -> list[JournalEntryLineItem]: | ||||||
|  |                 if not self.is_need_offset or self.id.data is None: | ||||||
|  |                     return [] | ||||||
|  |                 return JournalEntryLineItem.query\ | ||||||
|  |                     .filter(JournalEntryLineItem.original_line_item_id | ||||||
|  |                             == self.id.data)\ | ||||||
|  |                     .options(selectinload(JournalEntryLineItem.journal_entry), | ||||||
|  |                              selectinload(JournalEntryLineItem.account), | ||||||
|  |                              selectinload(JournalEntryLineItem.offsets) | ||||||
|  |                              .selectinload( | ||||||
|  |                                  JournalEntryLineItem.journal_entry)).all() | ||||||
|  |             setattr(self, "__offsets", get_offsets()) | ||||||
|  |         return getattr(self, "__offsets") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def offset_total(self) -> Decimal | None: | ||||||
|  |         """Returns the total amount of the offsets. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the offsets. | ||||||
|  |         """ | ||||||
|  |         if not hasattr(self, "__offset_total"): | ||||||
|  |             def get_offset_total(): | ||||||
|  |                 if not self.is_need_offset or self.id.data is None: | ||||||
|  |                     return None | ||||||
|  |                 is_debit: bool = isinstance(self, DebitLineItemForm) | ||||||
|  |                 return sum([x.amount if x.is_debit != is_debit else -x.amount | ||||||
|  |                             for x in self.offsets]) | ||||||
|  |             setattr(self, "__offset_total", get_offset_total()) | ||||||
|  |         return getattr(self, "__offset_total") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def net_balance(self) -> Decimal | None: | ||||||
|  |         """Returns the net balance. | ||||||
|  |  | ||||||
|  |         :return: The net balance. | ||||||
|  |         """ | ||||||
|  |         if not self.is_need_offset or self.id.data is None \ | ||||||
|  |                 or self.amount.data is None: | ||||||
|  |             return None | ||||||
|  |         return self.amount.data - self.offset_total | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def all_errors(self) -> list[str | LazyString]: | ||||||
|  |         """Returns all the errors of the form. | ||||||
|  |  | ||||||
|  |         :return: All the errors of the form. | ||||||
|  |         """ | ||||||
|  |         all_errors: list[str | LazyString] = [] | ||||||
|  |         for key in self.errors: | ||||||
|  |             if key != "csrf_token": | ||||||
|  |                 all_errors.extend(self.errors[key]) | ||||||
|  |         return all_errors | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DebitLineItemForm(LineItemForm): | ||||||
|  |     """The form to create or edit a debit line item.""" | ||||||
|  |     id = IntegerField() | ||||||
|  |     """The existing line item ID.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the currency.""" | ||||||
|  |     original_line_item_id = IntegerField( | ||||||
|  |         validators=[Optional(), | ||||||
|  |                     OriginalLineItemExists(), | ||||||
|  |                     OriginalLineItemOppositeDebitCredit(), | ||||||
|  |                     OriginalLineItemNeedOffset(), | ||||||
|  |                     OriginalLineItemNotOffset()]) | ||||||
|  |     """The ID of the original line item.""" | ||||||
|  |     account_code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[ | ||||||
|  |             ACCOUNT_REQUIRED, | ||||||
|  |             AccountExists(), | ||||||
|  |             IsDebitAccount(lazy_gettext( | ||||||
|  |                 "This account is not for debit line items.")), | ||||||
|  |             SameAccountAsOriginalLineItem(), | ||||||
|  |             KeepAccountWhenHavingOffset(), | ||||||
|  |             NotStartPayableFromDebit()]) | ||||||
|  |     """The account code.""" | ||||||
|  |     description = StringField(filters=[strip_text]) | ||||||
|  |     """The description.""" | ||||||
|  |     amount = DecimalField( | ||||||
|  |         validators=[PositiveAmount(), | ||||||
|  |                     NotExceedingOriginalLineItemNetBalance(), | ||||||
|  |                     NotLessThanOffsetTotal()]) | ||||||
|  |     """The amount.""" | ||||||
|  |  | ||||||
|  |     def populate_obj(self, obj: JournalEntryLineItem) -> None: | ||||||
|  |         """Populates the form data into a line item object. | ||||||
|  |  | ||||||
|  |         :param obj: The line item object. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         is_new: bool = obj.id is None | ||||||
|  |         if is_new: | ||||||
|  |             obj.id = new_id(JournalEntryLineItem) | ||||||
|  |         obj.original_line_item_id = self.original_line_item_id.data | ||||||
|  |         obj.account_id = Account.find_by_code(self.account_code.data).id | ||||||
|  |         obj.description = self.description.data | ||||||
|  |         obj.is_debit = True | ||||||
|  |         obj.amount = self.amount.data | ||||||
|  |         if is_new: | ||||||
|  |             current_user_pk: int = get_current_user_pk() | ||||||
|  |             obj.created_by_id = current_user_pk | ||||||
|  |             obj.updated_by_id = current_user_pk | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CreditLineItemForm(LineItemForm): | ||||||
|  |     """The form to create or edit a credit line item.""" | ||||||
|  |     id = IntegerField() | ||||||
|  |     """The existing line item ID.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the currency.""" | ||||||
|  |     original_line_item_id = IntegerField( | ||||||
|  |         validators=[Optional(), | ||||||
|  |                     OriginalLineItemExists(), | ||||||
|  |                     OriginalLineItemOppositeDebitCredit(), | ||||||
|  |                     OriginalLineItemNeedOffset(), | ||||||
|  |                     OriginalLineItemNotOffset()]) | ||||||
|  |     """The ID of the original line item.""" | ||||||
|  |     account_code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[ | ||||||
|  |             ACCOUNT_REQUIRED, | ||||||
|  |             AccountExists(), | ||||||
|  |             IsCreditAccount(lazy_gettext( | ||||||
|  |                 "This account is not for credit line items.")), | ||||||
|  |             SameAccountAsOriginalLineItem(), | ||||||
|  |             KeepAccountWhenHavingOffset(), | ||||||
|  |             NotStartReceivableFromCredit()]) | ||||||
|  |     """The account code.""" | ||||||
|  |     description = StringField(filters=[strip_text]) | ||||||
|  |     """The description.""" | ||||||
|  |     amount = DecimalField( | ||||||
|  |         validators=[PositiveAmount(), | ||||||
|  |                     NotExceedingOriginalLineItemNetBalance(), | ||||||
|  |                     NotLessThanOffsetTotal()]) | ||||||
|  |     """The amount.""" | ||||||
|  |  | ||||||
|  |     def populate_obj(self, obj: JournalEntryLineItem) -> None: | ||||||
|  |         """Populates the form data into a line item object. | ||||||
|  |  | ||||||
|  |         :param obj: The line item object. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         is_new: bool = obj.id is None | ||||||
|  |         if is_new: | ||||||
|  |             obj.id = new_id(JournalEntryLineItem) | ||||||
|  |         obj.original_line_item_id = self.original_line_item_id.data | ||||||
|  |         obj.account_id = Account.find_by_code(self.account_code.data).id | ||||||
|  |         obj.description = self.description.data | ||||||
|  |         obj.is_debit = False | ||||||
|  |         obj.amount = self.amount.data | ||||||
|  |         if is_new: | ||||||
|  |             current_user_pk: int = get_current_user_pk() | ||||||
|  |             obj.created_by_id = current_user_pk | ||||||
|  |             obj.updated_by_id = current_user_pk | ||||||
							
								
								
									
										95
									
								
								src/accounting/journal_entry/forms/reorder.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/accounting/journal_entry/forms/reorder.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The reorder forms for the journal entry management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import request | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.models import JournalEntry | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def sort_journal_entries_in(journal_entry_date: date, | ||||||
|  |                             exclude: int | None = None) -> None: | ||||||
|  |     """Sorts the journal entries under a date after changing the date or | ||||||
|  |     deleting a journal entry. | ||||||
|  |  | ||||||
|  |     :param journal_entry_date: The date of the journal entry. | ||||||
|  |     :param exclude: The journal entry ID to exclude. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     conditions: list[sa.BinaryExpression] \ | ||||||
|  |         = [JournalEntry.date == journal_entry_date] | ||||||
|  |     if exclude is not None: | ||||||
|  |         conditions.append(JournalEntry.id != exclude) | ||||||
|  |     journal_entries: list[JournalEntry] = JournalEntry.query\ | ||||||
|  |         .filter(*conditions)\ | ||||||
|  |         .order_by(JournalEntry.no).all() | ||||||
|  |     for i in range(len(journal_entries)): | ||||||
|  |         if journal_entries[i].no != i + 1: | ||||||
|  |             journal_entries[i].no = i + 1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class JournalEntryReorderForm: | ||||||
|  |     """The form to reorder the journal entries.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, journal_entry_date: date): | ||||||
|  |         """Constructs the form to reorder the journal entries in a day. | ||||||
|  |  | ||||||
|  |         :param journal_entry_date: The date. | ||||||
|  |         """ | ||||||
|  |         self.date: date = journal_entry_date | ||||||
|  |         self.is_modified: bool = False | ||||||
|  |  | ||||||
|  |     def save_order(self) -> None: | ||||||
|  |         """Saves the order of the account. | ||||||
|  |  | ||||||
|  |         :return: | ||||||
|  |         """ | ||||||
|  |         journal_entries: list[JournalEntry] = JournalEntry.query\ | ||||||
|  |             .filter(JournalEntry.date == self.date).all() | ||||||
|  |  | ||||||
|  |         # Collects the specified order. | ||||||
|  |         orders: dict[JournalEntry, int] = {} | ||||||
|  |         for journal_entry in journal_entries: | ||||||
|  |             if f"{journal_entry.id}-no" in request.form: | ||||||
|  |                 try: | ||||||
|  |                     orders[journal_entry] \ | ||||||
|  |                         = int(request.form[f"{journal_entry.id}-no"]) | ||||||
|  |                 except ValueError: | ||||||
|  |                     pass | ||||||
|  |  | ||||||
|  |         # Missing and invalid orders are appended to the end. | ||||||
|  |         missing: list[JournalEntry] \ | ||||||
|  |             = [x for x in journal_entries if x not in orders] | ||||||
|  |         if len(missing) > 0: | ||||||
|  |             next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1 | ||||||
|  |             for journal_entry in missing: | ||||||
|  |                 orders[journal_entry] = next_no | ||||||
|  |  | ||||||
|  |         # Sort by the specified order first, and their original order. | ||||||
|  |         journal_entries.sort(key=lambda x: (orders[x], x.no)) | ||||||
|  |  | ||||||
|  |         # Update the orders. | ||||||
|  |         with db.session.no_autoflush: | ||||||
|  |             for i in range(len(journal_entries)): | ||||||
|  |                 if journal_entries[i].no != i + 1: | ||||||
|  |                     journal_entries[i].no = i + 1 | ||||||
|  |                     self.is_modified = True | ||||||
							
								
								
									
										82
									
								
								src/accounting/journal_entry/template_filters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/accounting/journal_entry/template_filters.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | |||||||
|  | # The Mia! Accounting Flask 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 Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The utilities for the journal entry management. | ||||||
|  |  | ||||||
|  | """ | ||||||
							
								
								
									
										49
									
								
								src/accounting/journal_entry/utils/account_option.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/accounting/journal_entry/utils/account_option.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The account option for the journal entry management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from accounting.models import Account | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AccountOption: | ||||||
|  |     """An account option.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, account: Account): | ||||||
|  |         """Constructs an account option. | ||||||
|  |  | ||||||
|  |         :param account: The account. | ||||||
|  |         """ | ||||||
|  |         self.id: str = account.id | ||||||
|  |         """The account ID.""" | ||||||
|  |         self.code: str = account.code | ||||||
|  |         """The account code.""" | ||||||
|  |         self.query_values: list[str] = account.query_values | ||||||
|  |         """The values to be queried.""" | ||||||
|  |         self.__str: str = str(account) | ||||||
|  |         """The string representation of the account option.""" | ||||||
|  |         self.is_in_use: bool = False | ||||||
|  |         """True if this account is in use, or False otherwise.""" | ||||||
|  |         self.is_need_offset: bool = account.is_need_offset | ||||||
|  |         """True if this account needs offset, or False otherwise.""" | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         """Returns the string representation of the account option. | ||||||
|  |  | ||||||
|  |         :return: The string representation of the account option. | ||||||
|  |         """ | ||||||
|  |         return self.__str | ||||||
							
								
								
									
										344
									
								
								src/accounting/journal_entry/utils/description_editor.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										344
									
								
								src/accounting/journal_entry/utils/description_editor.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,344 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The description editor. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import re | ||||||
|  | import typing as t | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.models import Account, JournalEntryLineItem | ||||||
|  | from accounting.utils.options import options, Recurring | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DescriptionAccount: | ||||||
|  |     """An account for a description tag.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, account: Account, freq: int): | ||||||
|  |         """Constructs an account for a description tag. | ||||||
|  |  | ||||||
|  |         :param account: The account. | ||||||
|  |         :param freq: The frequency of the tag with the account. | ||||||
|  |         """ | ||||||
|  |         self.account: Account = account | ||||||
|  |         """The account.""" | ||||||
|  |         self.id: int = account.id | ||||||
|  |         """The account ID.""" | ||||||
|  |         self.code: str = account.code | ||||||
|  |         """The account code.""" | ||||||
|  |         self.freq: int = freq | ||||||
|  |         """The frequency of the tag with the account.""" | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         """Returns the string representation of the account. | ||||||
|  |  | ||||||
|  |         :return: The string representation of the account. | ||||||
|  |         """ | ||||||
|  |         return str(self.account) | ||||||
|  |  | ||||||
|  |     def add_freq(self, freq: int) -> None: | ||||||
|  |         """Adds the frequency of an account. | ||||||
|  |  | ||||||
|  |         :param freq: The frequency of the tag name with the account. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.freq = self.freq + freq | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DescriptionTag: | ||||||
|  |     """A description tag.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, name: str): | ||||||
|  |         """Constructs a description tag. | ||||||
|  |  | ||||||
|  |         :param name: The tag name. | ||||||
|  |         """ | ||||||
|  |         self.name: str = name | ||||||
|  |         """The tag name.""" | ||||||
|  |         self.__account_dict: dict[int, DescriptionAccount] = {} | ||||||
|  |         """The accounts that come with the tag, in the order of their | ||||||
|  |         frequency.""" | ||||||
|  |         self.freq: int = 0 | ||||||
|  |         """The frequency of the tag.""" | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         """Returns the string representation of the tag. | ||||||
|  |  | ||||||
|  |         :return: The string representation of the tag. | ||||||
|  |         """ | ||||||
|  |         return self.name | ||||||
|  |  | ||||||
|  |     def add_account(self, account: Account, freq: int): | ||||||
|  |         """Adds an account. | ||||||
|  |  | ||||||
|  |         :param account: The associated account. | ||||||
|  |         :param freq: The frequency of the tag name with the account. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.__account_dict[account.id] = DescriptionAccount(account, freq) | ||||||
|  |         self.freq = self.freq + freq | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def accounts(self) -> list[DescriptionAccount]: | ||||||
|  |         """Returns the accounts by the order of their frequencies. | ||||||
|  |  | ||||||
|  |         :return: The accounts by the order of their frequencies. | ||||||
|  |         """ | ||||||
|  |         return sorted(self.__account_dict.values(), key=lambda x: -x.freq) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def account_codes(self) -> list[str]: | ||||||
|  |         """Returns the account codes by the order of their frequencies. | ||||||
|  |  | ||||||
|  |         :return: The account codes by the order of their frequencies. | ||||||
|  |         """ | ||||||
|  |         return [x.code for x in self.accounts] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DescriptionType: | ||||||
|  |     """A description type""" | ||||||
|  |  | ||||||
|  |     def __init__(self, type_id: t.Literal["general", "travel", "bus"]): | ||||||
|  |         """Constructs a description type. | ||||||
|  |  | ||||||
|  |         :param type_id: The type ID, either "general", "travel", or "bus". | ||||||
|  |         """ | ||||||
|  |         self.id: t.Literal["general", "travel", "bus"] = type_id | ||||||
|  |         """The type ID.""" | ||||||
|  |         self.__tag_dict: dict[str, DescriptionTag] = {} | ||||||
|  |         """A dictionary from the tag name to their corresponding tag.""" | ||||||
|  |  | ||||||
|  |     def add_tag(self, name: str, account: Account, freq: int) -> None: | ||||||
|  |         """Adds a tag. | ||||||
|  |  | ||||||
|  |         :param name: The tag name. | ||||||
|  |         :param account: The associated account. | ||||||
|  |         :param freq: The frequency of the tag name with the account. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         if name not in self.__tag_dict: | ||||||
|  |             self.__tag_dict[name] = DescriptionTag(name) | ||||||
|  |         self.__tag_dict[name].add_account(account, freq) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def tags(self) -> list[DescriptionTag]: | ||||||
|  |         """Returns the tags by the order of their frequencies. | ||||||
|  |  | ||||||
|  |         :return: The tags by the order of their frequencies. | ||||||
|  |         """ | ||||||
|  |         return sorted(self.__tag_dict.values(), key=lambda x: -x.freq) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DescriptionRecurring: | ||||||
|  |     """A recurring transaction.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, name: str, account: Account, description_template: str): | ||||||
|  |         """Constructs a recurring transaction. | ||||||
|  |  | ||||||
|  |         :param name: The name. | ||||||
|  |         :param description_template: The description template. | ||||||
|  |         :param account: The account. | ||||||
|  |         """ | ||||||
|  |         self.name: str = name | ||||||
|  |         self.account: DescriptionAccount = DescriptionAccount(account, 0) | ||||||
|  |         self.description_template: str = description_template | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def account_codes(self) -> list[str]: | ||||||
|  |         """Returns the account codes by the order of their frequencies. | ||||||
|  |  | ||||||
|  |         :return: The account codes by the order of their frequencies. | ||||||
|  |         """ | ||||||
|  |         return [self.account.code] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DescriptionDebitCredit: | ||||||
|  |     """The description on debit or credit.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, debit_credit: t.Literal["debit", "credit"]): | ||||||
|  |         """Constructs the description on debit or credit. | ||||||
|  |  | ||||||
|  |         :param debit_credit: Either "debit" or "credit". | ||||||
|  |         """ | ||||||
|  |         self.debit_credit: t.Literal["debit", "credit"] = debit_credit | ||||||
|  |         """Either debit or credit.""" | ||||||
|  |         self.general: DescriptionType = DescriptionType("general") | ||||||
|  |         """The general tags.""" | ||||||
|  |         self.travel: DescriptionType = DescriptionType("travel") | ||||||
|  |         """The travel tags.""" | ||||||
|  |         self.bus: DescriptionType = DescriptionType("bus") | ||||||
|  |         """The bus tags.""" | ||||||
|  |         self.__type_dict: dict[t.Literal["general", "travel", "bus"], | ||||||
|  |                                DescriptionType] \ | ||||||
|  |             = {x.id: x for x in {self.general, self.travel, self.bus}} | ||||||
|  |         """A dictionary from the type ID to the corresponding tags.""" | ||||||
|  |         self.recurring: list[DescriptionRecurring] = [] | ||||||
|  |         """The recurring transactions.""" | ||||||
|  |  | ||||||
|  |     def add_tag(self, tag_type: t.Literal["general", "travel", "bus"], | ||||||
|  |                 name: str, account: Account, freq: int) -> None: | ||||||
|  |         """Adds a tag. | ||||||
|  |  | ||||||
|  |         :param tag_type: The tag type, either "general", "travel", or "bus". | ||||||
|  |         :param name: The name. | ||||||
|  |         :param account: The associated account. | ||||||
|  |         :param freq: The frequency of the tag name with the account. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.__type_dict[tag_type].add_tag(name, account, freq) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def accounts(self) -> list[DescriptionAccount]: | ||||||
|  |         """Returns the suggested accounts of all tags in the description editor | ||||||
|  |         in debit or credit, in their frequency order. | ||||||
|  |  | ||||||
|  |         :return: The suggested accounts of all tags, in their frequency order. | ||||||
|  |         """ | ||||||
|  |         accounts: dict[int, DescriptionAccount] = {} | ||||||
|  |         freq: dict[int, int] = {} | ||||||
|  |         for tag_type in self.__type_dict.values(): | ||||||
|  |             for tag in tag_type.tags: | ||||||
|  |                 for account in tag.accounts: | ||||||
|  |                     accounts[account.id] = account | ||||||
|  |                     if account.id not in freq: | ||||||
|  |                         freq[account.id] = 0 | ||||||
|  |                     freq[account.id] \ | ||||||
|  |                         = freq[account.id] + account.freq | ||||||
|  |         for recurring in self.recurring: | ||||||
|  |             accounts[recurring.account.id] = recurring.account | ||||||
|  |             if recurring.account.id not in freq: | ||||||
|  |                 freq[recurring.account.id] = 0 | ||||||
|  |         return [accounts[y] for y in sorted(freq.keys(), | ||||||
|  |                                             key=lambda x: -freq[x])] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DescriptionEditor: | ||||||
|  |     """The description editor.""" | ||||||
|  |  | ||||||
|  |     def __init__(self): | ||||||
|  |         """Constructs the description editor.""" | ||||||
|  |         self.debit: DescriptionDebitCredit = DescriptionDebitCredit("debit") | ||||||
|  |         """The debit tags.""" | ||||||
|  |         self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit") | ||||||
|  |         """The credit tags.""" | ||||||
|  |         self.__init_tags() | ||||||
|  |         self.__init_recurring() | ||||||
|  |  | ||||||
|  |     def __init_tags(self): | ||||||
|  |         """Initializes the tags. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         debit_credit: sa.Label = sa.case( | ||||||
|  |             (JournalEntryLineItem.is_debit, "debit"), | ||||||
|  |             else_="credit").label("debit_credit") | ||||||
|  |         tag_type: sa.Label = sa.case( | ||||||
|  |             (JournalEntryLineItem.description.like("_%—_%—_%→_%"), "bus"), | ||||||
|  |             (sa.or_(JournalEntryLineItem.description.like("_%—_%→_%"), | ||||||
|  |                     JournalEntryLineItem.description.like("_%—_%↔_%")), | ||||||
|  |              "travel"), | ||||||
|  |             else_="general").label("tag_type") | ||||||
|  |         tag: sa.Label = get_prefix(JournalEntryLineItem.description, "—")\ | ||||||
|  |             .label("tag") | ||||||
|  |         select: sa.Select = sa.Select(debit_credit, tag_type, tag, | ||||||
|  |                                       JournalEntryLineItem.account_id, | ||||||
|  |                                       sa.func.count().label("freq"))\ | ||||||
|  |             .filter(JournalEntryLineItem.description.is_not(None), | ||||||
|  |                     JournalEntryLineItem.description.like("_%—_%"), | ||||||
|  |                     JournalEntryLineItem.original_line_item_id.is_(None))\ | ||||||
|  |             .group_by(debit_credit, tag_type, tag, | ||||||
|  |                       JournalEntryLineItem.account_id) | ||||||
|  |         result: list[sa.Row] = db.session.execute(select).all() | ||||||
|  |         accounts: dict[int, Account] \ | ||||||
|  |             = {x.id: x for x in Account.query | ||||||
|  |                .filter(Account.id.in_({x.account_id for x in result})).all()} | ||||||
|  |         debit_credit_dict: dict[t.Literal["debit", "credit"], | ||||||
|  |                                 DescriptionDebitCredit] \ | ||||||
|  |             = {x.debit_credit: x for x in {self.debit, self.credit}} | ||||||
|  |         for row in result: | ||||||
|  |             debit_credit_dict[row.debit_credit].add_tag( | ||||||
|  |                 row.tag_type, row.tag, accounts[row.account_id], row.freq) | ||||||
|  |  | ||||||
|  |     def __init_recurring(self) -> None: | ||||||
|  |         """Initializes the recurring transactions. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         recurring: Recurring = options.recurring | ||||||
|  |         accounts: dict[str, Account] \ | ||||||
|  |             = self.__get_accounts(recurring.codes) | ||||||
|  |         self.debit.recurring \ | ||||||
|  |             = [DescriptionRecurring(x.name, accounts[x.account_code], | ||||||
|  |                                     x.description_template) | ||||||
|  |                for x in recurring.expenses] | ||||||
|  |         self.credit.recurring \ | ||||||
|  |             = [DescriptionRecurring(x.name, accounts[x.account_code], | ||||||
|  |                                     x.description_template) | ||||||
|  |                for x in recurring.incomes] | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def __get_accounts(codes: set[str]) -> dict[str, Account]: | ||||||
|  |         """Finds and returns the accounts by codes. | ||||||
|  |  | ||||||
|  |         :param codes: The account codes. | ||||||
|  |         :return: The account. | ||||||
|  |         """ | ||||||
|  |         if len(codes) == 0: | ||||||
|  |             return {} | ||||||
|  |  | ||||||
|  |         def get_condition(code0: str) -> sa.BinaryExpression: | ||||||
|  |             m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0) | ||||||
|  |             assert m is not None,\ | ||||||
|  |                 f"Malformed account code \"{code0}\" for regular transactions." | ||||||
|  |             return sa.and_(Account.base_code == m.group(1), | ||||||
|  |                            Account.no == int(m.group(2))) | ||||||
|  |  | ||||||
|  |         conditions: list[sa.BinaryExpression] \ | ||||||
|  |             = [get_condition(x) for x in codes] | ||||||
|  |         accounts: dict[str, Account] \ | ||||||
|  |             = {x.code: x for x in | ||||||
|  |                Account.query.filter(sa.or_(*conditions)).all()} | ||||||
|  |         for code in codes: | ||||||
|  |             assert code in accounts,\ | ||||||
|  |                 f"Unknown account \"{code}\" for regular transactions." | ||||||
|  |         return accounts | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_prefix(string: str | sa.Column, separator: str | sa.Column) \ | ||||||
|  |         -> sa.Function: | ||||||
|  |     """Returns the SQL function to find the prefix of a string. | ||||||
|  |  | ||||||
|  |     :param string: The string. | ||||||
|  |     :param separator: The separator. | ||||||
|  |     :return: The position of the substring, starting from 1. | ||||||
|  |     """ | ||||||
|  |     return sa.func.substr(string, 0, get_position(string, separator)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_position(string: str | sa.Column, substring: str | sa.Column) \ | ||||||
|  |         -> sa.Function: | ||||||
|  |     """Returns the SQL function to find the position of a substring. | ||||||
|  |  | ||||||
|  |     :param string: The string. | ||||||
|  |     :param substring: The substring. | ||||||
|  |     :return: The position of the substring, starting from 1. | ||||||
|  |     """ | ||||||
|  |     if db.engine.name == "postgresql": | ||||||
|  |         return sa.func.strpos(string, substring) | ||||||
|  |     return sa.func.instr(string, substring) | ||||||
							
								
								
									
										39
									
								
								src/accounting/journal_entry/utils/offset_alias.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/accounting/journal_entry/utils/offset_alias.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/15 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The SQLAlchemy alias for the offset items. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import typing as t | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  |  | ||||||
|  | from accounting.models import JournalEntryLineItem | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def offset_alias() -> sa.Alias: | ||||||
|  |     """Returns the SQLAlchemy alias for the offset items. | ||||||
|  |  | ||||||
|  |     :return: The SQLAlchemy alias for the offset items. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def as_from(model_cls: t.Any) -> sa.FromClause: | ||||||
|  |         return model_cls | ||||||
|  |  | ||||||
|  |     def as_alias(alias: t.Any) -> sa.Alias: | ||||||
|  |         return alias | ||||||
|  |  | ||||||
|  |     return as_alias(sa.alias(as_from(JournalEntryLineItem), name="offset")) | ||||||
							
								
								
									
										336
									
								
								src/accounting/journal_entry/utils/operators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										336
									
								
								src/accounting/journal_entry/utils/operators.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,336 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The operators for different journal entry types. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import typing as t | ||||||
|  | from abc import ABC, abstractmethod | ||||||
|  |  | ||||||
|  | from flask import render_template, request, abort | ||||||
|  | from flask_wtf import FlaskForm | ||||||
|  |  | ||||||
|  | from accounting.models import JournalEntry | ||||||
|  | from accounting.template_globals import default_currency_code | ||||||
|  | from accounting.utils.journal_entry_types import JournalEntryType | ||||||
|  | from accounting.journal_entry.forms import JournalEntryForm, \ | ||||||
|  |     CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \ | ||||||
|  |     TransferJournalEntryForm | ||||||
|  | from accounting.journal_entry.forms.line_item import LineItemForm | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class JournalEntryOperator(ABC): | ||||||
|  |     """The base journal entry operator.""" | ||||||
|  |     CHECK_ORDER: int = -1 | ||||||
|  |     """The order when checking the journal entry operator.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     @abstractmethod | ||||||
|  |     def form(self) -> t.Type[JournalEntryForm]: | ||||||
|  |         """Returns the form class. | ||||||
|  |  | ||||||
|  |         :return: The form class. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def render_create_template(self, form: FlaskForm) -> str: | ||||||
|  |         """Renders the template for the form to create a journal entry. | ||||||
|  |  | ||||||
|  |         :param form: The journal entry form. | ||||||
|  |         :return: the form to create a journal entry. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def render_detail_template(self, journal_entry: JournalEntry) -> str: | ||||||
|  |         """Renders the template for the detail page. | ||||||
|  |  | ||||||
|  |         :param journal_entry: The journal entry. | ||||||
|  |         :return: the detail page. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def render_edit_template(self, journal_entry: JournalEntry, | ||||||
|  |                              form: FlaskForm) -> str: | ||||||
|  |         """Renders the template for the form to edit a journal entry. | ||||||
|  |  | ||||||
|  |         :param journal_entry: The journal entry. | ||||||
|  |         :param form: The form. | ||||||
|  |         :return: the form to edit a journal entry. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def is_my_type(self, journal_entry: JournalEntry) -> bool: | ||||||
|  |         """Checks and returns whether the journal entry belongs to the type. | ||||||
|  |  | ||||||
|  |         :param journal_entry: The journal entry. | ||||||
|  |         :return: True if the journal entry belongs to the type, or False | ||||||
|  |             otherwise. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def _line_item_template(self) -> str: | ||||||
|  |         """Renders and returns the template for the line item sub-form. | ||||||
|  |  | ||||||
|  |         :return: The template for the line item sub-form. | ||||||
|  |         """ | ||||||
|  |         return render_template( | ||||||
|  |             "accounting/journal-entry/include/form-line-item.html", | ||||||
|  |             currency_index="CURRENCY_INDEX", | ||||||
|  |             debit_credit="DEBIT_CREDIT", | ||||||
|  |             line_item_index="LINE_ITEM_INDEX", | ||||||
|  |             form=LineItemForm()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CashReceiptJournalEntry(JournalEntryOperator): | ||||||
|  |     """A cash receipt journal entry.""" | ||||||
|  |     CHECK_ORDER: int = 2 | ||||||
|  |     """The order when checking the journal entry operator.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def form(self) -> t.Type[JournalEntryForm]: | ||||||
|  |         """Returns the form class. | ||||||
|  |  | ||||||
|  |         :return: The form class. | ||||||
|  |         """ | ||||||
|  |         return CashReceiptJournalEntryForm | ||||||
|  |  | ||||||
|  |     def render_create_template(self, form: CashReceiptJournalEntryForm) -> str: | ||||||
|  |         """Renders the template for the form to create a journal entry. | ||||||
|  |  | ||||||
|  |         :param form: The journal entry form. | ||||||
|  |         :return: the form to create a journal entry. | ||||||
|  |         """ | ||||||
|  |         return render_template( | ||||||
|  |             "accounting/journal-entry/receipt/create.html", | ||||||
|  |             form=form, | ||||||
|  |             journal_entry_type=JournalEntryType.CASH_RECEIPT, | ||||||
|  |             currency_template=self.__currency_template, | ||||||
|  |             line_item_template=self._line_item_template) | ||||||
|  |  | ||||||
|  |     def render_detail_template(self, journal_entry: JournalEntry) -> str: | ||||||
|  |         """Renders the template for the detail page. | ||||||
|  |  | ||||||
|  |         :param journal_entry: The journal entry. | ||||||
|  |         :return: the detail page. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/journal-entry/receipt/detail.html", | ||||||
|  |                                obj=journal_entry) | ||||||
|  |  | ||||||
|  |     def render_edit_template(self, journal_entry: JournalEntry, | ||||||
|  |                              form: CashReceiptJournalEntryForm) -> str: | ||||||
|  |         """Renders the template for the form to edit a journal entry. | ||||||
|  |  | ||||||
|  |         :param journal_entry: The journal entry. | ||||||
|  |         :param form: The form. | ||||||
|  |         :return: the form to edit a journal entry. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/journal-entry/receipt/edit.html", | ||||||
|  |                                journal_entry=journal_entry, form=form, | ||||||
|  |                                currency_template=self.__currency_template, | ||||||
|  |                                line_item_template=self._line_item_template) | ||||||
|  |  | ||||||
|  |     def is_my_type(self, journal_entry: JournalEntry) -> bool: | ||||||
|  |         """Checks and returns whether the journal entry belongs to the type. | ||||||
|  |  | ||||||
|  |         :param journal_entry: The journal entry. | ||||||
|  |         :return: True if the journal entry belongs to the type, or False | ||||||
|  |             otherwise. | ||||||
|  |         """ | ||||||
|  |         return journal_entry.is_cash_receipt | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def __currency_template(self) -> str: | ||||||
|  |         """Renders and returns the template for the currency sub-form. | ||||||
|  |  | ||||||
|  |         :return: The template for the currency sub-form. | ||||||
|  |         """ | ||||||
|  |         return render_template( | ||||||
|  |             "accounting/journal-entry/receipt/include/form-currency.html", | ||||||
|  |             currency_index="CURRENCY_INDEX", | ||||||
|  |             currency_code_data=default_currency_code(), | ||||||
|  |             credit_total="-") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CashDisbursementJournalEntry(JournalEntryOperator): | ||||||
|  |     """A cash disbursement journal entry.""" | ||||||
|  |     CHECK_ORDER: int = 1 | ||||||
|  |     """The order when checking the journal entry operator.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def form(self) -> t.Type[JournalEntryForm]: | ||||||
|  |         """Returns the form class. | ||||||
|  |  | ||||||
|  |         :return: The form class. | ||||||
|  |         """ | ||||||
|  |         return CashDisbursementJournalEntryForm | ||||||
|  |  | ||||||
|  |     def render_create_template(self, form: CashDisbursementJournalEntryForm) \ | ||||||
|  |             -> str: | ||||||
|  |         """Renders the template for the form to create a journal entry. | ||||||
|  |  | ||||||
|  |         :param form: The journal entry form. | ||||||
|  |         :return: the form to create a journal entry. | ||||||
|  |         """ | ||||||
|  |         return render_template( | ||||||
|  |             "accounting/journal-entry/disbursement/create.html", | ||||||
|  |             form=form, | ||||||
|  |             journal_entry_type=JournalEntryType.CASH_DISBURSEMENT, | ||||||
|  |             currency_template=self.__currency_template, | ||||||
|  |             line_item_template=self._line_item_template) | ||||||
|  |  | ||||||
|  |     def render_detail_template(self, journal_entry: JournalEntry) -> str: | ||||||
|  |         """Renders the template for the detail page. | ||||||
|  |  | ||||||
|  |         :param journal_entry: The journal entry. | ||||||
|  |         :return: the detail page. | ||||||
|  |         """ | ||||||
|  |         return render_template( | ||||||
|  |             "accounting/journal-entry/disbursement/detail.html", | ||||||
|  |             obj=journal_entry) | ||||||
|  |  | ||||||
|  |     def render_edit_template(self, journal_entry: JournalEntry, | ||||||
|  |                              form: CashDisbursementJournalEntryForm) -> str: | ||||||
|  |         """Renders the template for the form to edit a journal entry. | ||||||
|  |  | ||||||
|  |         :param journal_entry: The journal entry. | ||||||
|  |         :param form: The form. | ||||||
|  |         :return: the form to edit a journal entry. | ||||||
|  |         """ | ||||||
|  |         return render_template( | ||||||
|  |             "accounting/journal-entry/disbursement/edit.html", | ||||||
|  |             journal_entry=journal_entry, form=form, | ||||||
|  |             currency_template=self.__currency_template, | ||||||
|  |             line_item_template=self._line_item_template) | ||||||
|  |  | ||||||
|  |     def is_my_type(self, journal_entry: JournalEntry) -> bool: | ||||||
|  |         """Checks and returns whether the journal entry belongs to the type. | ||||||
|  |  | ||||||
|  |         :param journal_entry: The journal entry. | ||||||
|  |         :return: True if the journal entry belongs to the type, or False | ||||||
|  |             otherwise. | ||||||
|  |         """ | ||||||
|  |         return journal_entry.is_cash_disbursement | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def __currency_template(self) -> str: | ||||||
|  |         """Renders and returns the template for the currency sub-form. | ||||||
|  |  | ||||||
|  |         :return: The template for the currency sub-form. | ||||||
|  |         """ | ||||||
|  |         return render_template( | ||||||
|  |             "accounting/journal-entry/disbursement/include/form-currency.html", | ||||||
|  |             currency_index="CURRENCY_INDEX", | ||||||
|  |             currency_code_data=default_currency_code(), | ||||||
|  |             debit_total="-") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransferJournalEntry(JournalEntryOperator): | ||||||
|  |     """A transfer journal entry.""" | ||||||
|  |     CHECK_ORDER: int = 3 | ||||||
|  |     """The order when checking the journal entry operator.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def form(self) -> t.Type[JournalEntryForm]: | ||||||
|  |         """Returns the form class. | ||||||
|  |  | ||||||
|  |         :return: The form class. | ||||||
|  |         """ | ||||||
|  |         return TransferJournalEntryForm | ||||||
|  |  | ||||||
|  |     def render_create_template(self, form: TransferJournalEntryForm) -> str: | ||||||
|  |         """Renders the template for the form to create a journal entry. | ||||||
|  |  | ||||||
|  |         :param form: The journal entry form. | ||||||
|  |         :return: the form to create a journal entry. | ||||||
|  |         """ | ||||||
|  |         return render_template( | ||||||
|  |             "accounting/journal-entry/transfer/create.html", | ||||||
|  |             form=form, | ||||||
|  |             journal_entry_type=JournalEntryType.TRANSFER, | ||||||
|  |             currency_template=self.__currency_template, | ||||||
|  |             line_item_template=self._line_item_template) | ||||||
|  |  | ||||||
|  |     def render_detail_template(self, journal_entry: JournalEntry) -> str: | ||||||
|  |         """Renders the template for the detail page. | ||||||
|  |  | ||||||
|  |         :param journal_entry: The journal entry. | ||||||
|  |         :return: the detail page. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/journal-entry/transfer/detail.html", | ||||||
|  |                                obj=journal_entry) | ||||||
|  |  | ||||||
|  |     def render_edit_template(self, journal_entry: JournalEntry, | ||||||
|  |                              form: TransferJournalEntryForm) -> str: | ||||||
|  |         """Renders the template for the form to edit a journal entry. | ||||||
|  |  | ||||||
|  |         :param journal_entry: The journal entry. | ||||||
|  |         :param form: The form. | ||||||
|  |         :return: the form to edit a journal entry. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/journal-entry/transfer/edit.html", | ||||||
|  |                                journal_entry=journal_entry, form=form, | ||||||
|  |                                currency_template=self.__currency_template, | ||||||
|  |                                line_item_template=self._line_item_template) | ||||||
|  |  | ||||||
|  |     def is_my_type(self, journal_entry: JournalEntry) -> bool: | ||||||
|  |         """Checks and returns whether the journal entry belongs to the type. | ||||||
|  |  | ||||||
|  |         :param journal_entry: The journal entry. | ||||||
|  |         :return: True if the journal entry belongs to the type, or False | ||||||
|  |             otherwise. | ||||||
|  |         """ | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def __currency_template(self) -> str: | ||||||
|  |         """Renders and returns the template for the currency sub-form. | ||||||
|  |  | ||||||
|  |         :return: The template for the currency sub-form. | ||||||
|  |         """ | ||||||
|  |         return render_template( | ||||||
|  |             "accounting/journal-entry/transfer/include/form-currency.html", | ||||||
|  |             currency_index="CURRENCY_INDEX", | ||||||
|  |             currency_code_data=default_currency_code(), | ||||||
|  |             debit_total="-", credit_total="-") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | JOURNAL_ENTRY_TYPE_TO_OP: dict[JournalEntryType, JournalEntryOperator] \ | ||||||
|  |     = {JournalEntryType.CASH_RECEIPT: CashReceiptJournalEntry(), | ||||||
|  |        JournalEntryType.CASH_DISBURSEMENT: CashDisbursementJournalEntry(), | ||||||
|  |        JournalEntryType.TRANSFER: TransferJournalEntry()} | ||||||
|  | """The map from the journal entry types to their operators.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_journal_entry_op(journal_entry: JournalEntry, | ||||||
|  |                          is_check_as: bool = False) -> JournalEntryOperator: | ||||||
|  |     """Returns the journal entry operator that may be specified in the "as" | ||||||
|  |     query parameter.  If it is not specified, check the journal entry type from | ||||||
|  |     the journal entry. | ||||||
|  |  | ||||||
|  |     :param journal_entry: The journal entry. | ||||||
|  |     :param is_check_as: True to check the "as" parameter, or False otherwise. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     if is_check_as and "as" in request.args: | ||||||
|  |         type_dict: dict[str, JournalEntryType] \ | ||||||
|  |             = {x.value: x for x in JournalEntryType} | ||||||
|  |         if request.args["as"] not in type_dict: | ||||||
|  |             abort(404) | ||||||
|  |         return JOURNAL_ENTRY_TYPE_TO_OP[type_dict[request.args["as"]]] | ||||||
|  |     for journal_entry_type in sorted(JOURNAL_ENTRY_TYPE_TO_OP.values(), | ||||||
|  |                                      key=lambda x: x.CHECK_ORDER): | ||||||
|  |         if journal_entry_type.is_my_type(journal_entry): | ||||||
|  |             return journal_entry_type | ||||||
							
								
								
									
										84
									
								
								src/accounting/journal_entry/utils/original_line_items.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/accounting/journal_entry/utils/original_line_items.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The selectable original line items. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from sqlalchemy.orm import selectinload | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.models import Account, JournalEntry, JournalEntryLineItem | ||||||
|  | from accounting.utils.cast import be | ||||||
|  | from .offset_alias import offset_alias | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_selectable_original_line_items( | ||||||
|  |         line_item_id_on_form: set[int], is_payable: bool, | ||||||
|  |         is_receivable: bool) -> list[JournalEntryLineItem]: | ||||||
|  |     """Queries and returns the selectable original line items, with their net | ||||||
|  |     balances.  The offset amounts of the form is excluded. | ||||||
|  |  | ||||||
|  |     :param line_item_id_on_form: The ID of the line items on the form. | ||||||
|  |     :param is_payable: True to check the payable original line items, or False | ||||||
|  |         otherwise. | ||||||
|  |     :param is_receivable: True to check the receivable original line items, or | ||||||
|  |         False otherwise. | ||||||
|  |     :return: The selectable original line items, with their net balances. | ||||||
|  |     """ | ||||||
|  |     assert is_payable or is_receivable | ||||||
|  |     offset: sa.Alias = offset_alias() | ||||||
|  |     net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case( | ||||||
|  |         (offset.c.id.in_(line_item_id_on_form), 0), | ||||||
|  |         (be(offset.c.is_debit == JournalEntryLineItem.is_debit), | ||||||
|  |          offset.c.amount), | ||||||
|  |         else_=-offset.c.amount))).label("net_balance") | ||||||
|  |     conditions: list[sa.BinaryExpression] = [Account.is_need_offset] | ||||||
|  |     sub_conditions: list[sa.BinaryExpression] = [] | ||||||
|  |     if is_payable: | ||||||
|  |         sub_conditions.append(sa.and_(Account.base_code.startswith("2"), | ||||||
|  |                                       sa.not_(JournalEntryLineItem.is_debit))) | ||||||
|  |     if is_receivable: | ||||||
|  |         sub_conditions.append(sa.and_(Account.base_code.startswith("1"), | ||||||
|  |                                       JournalEntryLineItem.is_debit)) | ||||||
|  |     conditions.append(sa.or_(*sub_conditions)) | ||||||
|  |     select_net_balances: sa.Select \ | ||||||
|  |         = sa.select(JournalEntryLineItem.id, net_balance)\ | ||||||
|  |         .join(Account)\ | ||||||
|  |         .join(offset, be(JournalEntryLineItem.id | ||||||
|  |                          == offset.c.original_line_item_id), | ||||||
|  |               isouter=True)\ | ||||||
|  |         .filter(*conditions)\ | ||||||
|  |         .group_by(JournalEntryLineItem.id)\ | ||||||
|  |         .having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0)) | ||||||
|  |     net_balances: dict[int, Decimal] \ | ||||||
|  |         = {x.id: x.net_balance | ||||||
|  |            for x in db.session.execute(select_net_balances).all()} | ||||||
|  |     line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\ | ||||||
|  |         .filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\ | ||||||
|  |         .join(JournalEntry)\ | ||||||
|  |         .order_by(JournalEntry.date, JournalEntryLineItem.is_debit, | ||||||
|  |                   JournalEntryLineItem.no)\ | ||||||
|  |         .options(selectinload(JournalEntryLineItem.currency), | ||||||
|  |                  selectinload(JournalEntryLineItem.account), | ||||||
|  |                  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 | ||||||
							
								
								
									
										238
									
								
								src/accounting/journal_entry/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										238
									
								
								src/accounting/journal_entry/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,238 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The views for the journal entry management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date | ||||||
|  | from urllib.parse import parse_qsl, urlencode | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import Blueprint, render_template, session, redirect, request, \ | ||||||
|  |     flash, url_for | ||||||
|  | from werkzeug.datastructures import ImmutableMultiDict | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import lazy_gettext | ||||||
|  | from accounting.models import JournalEntry | ||||||
|  | from accounting.utils.cast import s | ||||||
|  | from accounting.utils.flash_errors import flash_form_errors | ||||||
|  | from accounting.utils.next_uri import inherit_next, or_next | ||||||
|  | from accounting.utils.permission import has_permission, can_view, can_edit | ||||||
|  | from accounting.utils.journal_entry_types import JournalEntryType | ||||||
|  | from accounting.utils.user import get_current_user_pk | ||||||
|  | from .forms import sort_journal_entries_in, JournalEntryReorderForm | ||||||
|  | from .template_filters import with_type, to_transfer, format_amount_input, \ | ||||||
|  |     text2html | ||||||
|  | from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \ | ||||||
|  |     get_journal_entry_op | ||||||
|  |  | ||||||
|  | bp: Blueprint = Blueprint("journal-entry", __name__) | ||||||
|  | """The view blueprint for the journal entry management.""" | ||||||
|  | bp.add_app_template_filter(with_type, "accounting_journal_entry_with_type") | ||||||
|  | bp.add_app_template_filter(to_transfer, "accounting_journal_entry_to_transfer") | ||||||
|  | bp.add_app_template_filter(format_amount_input, | ||||||
|  |                            "accounting_journal_entry_format_amount_input") | ||||||
|  | bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("/create/<journalEntryType:journal_entry_type>", endpoint="create") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str: | ||||||
|  |     """Shows the form to add a journal entry. | ||||||
|  |  | ||||||
|  |     :param journal_entry_type: The journal entry type. | ||||||
|  |     :return: The form to add a journal entry. | ||||||
|  |     """ | ||||||
|  |     journal_entry_op: JournalEntryOperator \ | ||||||
|  |         = JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type] | ||||||
|  |     form: journal_entry_op.form | ||||||
|  |     if "form" in session: | ||||||
|  |         form = journal_entry_op.form( | ||||||
|  |             ImmutableMultiDict(parse_qsl(session["form"]))) | ||||||
|  |         del session["form"] | ||||||
|  |         form.validate() | ||||||
|  |     else: | ||||||
|  |         form = journal_entry_op.form() | ||||||
|  |         form.date.data = date.today() | ||||||
|  |     return journal_entry_op.render_create_template(form) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("/store/<journalEntryType:journal_entry_type>", endpoint="store") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect: | ||||||
|  |     """Adds a journal entry. | ||||||
|  |  | ||||||
|  |     :param journal_entry_type: The journal entry type. | ||||||
|  |     :return: The redirection to the journal entry detail on success, or the | ||||||
|  |         journal entry creation form on error. | ||||||
|  |     """ | ||||||
|  |     journal_entry_op: JournalEntryOperator \ | ||||||
|  |         = JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type] | ||||||
|  |     form: journal_entry_op.form = journal_entry_op.form(request.form) | ||||||
|  |     if not form.validate(): | ||||||
|  |         flash_form_errors(form) | ||||||
|  |         session["form"] = urlencode(list(request.form.items())) | ||||||
|  |         return redirect(inherit_next(with_type( | ||||||
|  |             url_for("accounting.journal-entry.create", | ||||||
|  |                     journal_entry_type=journal_entry_type)))) | ||||||
|  |     journal_entry: JournalEntry = JournalEntry() | ||||||
|  |     form.populate_obj(journal_entry) | ||||||
|  |     db.session.add(journal_entry) | ||||||
|  |     db.session.commit() | ||||||
|  |     flash(s(lazy_gettext("The journal entry is added successfully.")), | ||||||
|  |           "success") | ||||||
|  |     return redirect(inherit_next(__get_detail_uri(journal_entry))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("/<journalEntry:journal_entry>", endpoint="detail") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def show_journal_entry_detail(journal_entry: JournalEntry) -> str: | ||||||
|  |     """Shows the journal entry detail. | ||||||
|  |  | ||||||
|  |     :param journal_entry: The journal entry. | ||||||
|  |     :return: The detail. | ||||||
|  |     """ | ||||||
|  |     journal_entry_op: JournalEntryOperator \ | ||||||
|  |         = get_journal_entry_op(journal_entry) | ||||||
|  |     return journal_entry_op.render_detail_template(journal_entry) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("/<journalEntry:journal_entry>/edit", endpoint="edit") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str: | ||||||
|  |     """Shows the form to edit a journal entry. | ||||||
|  |  | ||||||
|  |     :param journal_entry: The journal entry. | ||||||
|  |     :return: The form to edit the journal entry. | ||||||
|  |     """ | ||||||
|  |     journal_entry_op: JournalEntryOperator \ | ||||||
|  |         = get_journal_entry_op(journal_entry, is_check_as=True) | ||||||
|  |     form: journal_entry_op.form | ||||||
|  |     if "form" in session: | ||||||
|  |         form = journal_entry_op.form( | ||||||
|  |             ImmutableMultiDict(parse_qsl(session["form"]))) | ||||||
|  |         del session["form"] | ||||||
|  |         form.obj = journal_entry | ||||||
|  |         form.validate() | ||||||
|  |     else: | ||||||
|  |         form = journal_entry_op.form(obj=journal_entry) | ||||||
|  |     return journal_entry_op.render_edit_template(journal_entry, form) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("/<journalEntry:journal_entry>/update", endpoint="update") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def update_journal_entry(journal_entry: JournalEntry) -> redirect: | ||||||
|  |     """Updates a journal entry. | ||||||
|  |  | ||||||
|  |     :param journal_entry: The journal entry. | ||||||
|  |     :return: The redirection to the journal entry detail on success, or the | ||||||
|  |         journal entry edit form on error. | ||||||
|  |     """ | ||||||
|  |     journal_entry_op: JournalEntryOperator \ | ||||||
|  |         = get_journal_entry_op(journal_entry, is_check_as=True) | ||||||
|  |     form: journal_entry_op.form = journal_entry_op.form(request.form) | ||||||
|  |     form.obj = journal_entry | ||||||
|  |     if not form.validate(): | ||||||
|  |         flash_form_errors(form) | ||||||
|  |         session["form"] = urlencode(list(request.form.items())) | ||||||
|  |         return redirect(inherit_next(with_type( | ||||||
|  |             url_for("accounting.journal-entry.edit", | ||||||
|  |                     journal_entry=journal_entry)))) | ||||||
|  |     with db.session.no_autoflush: | ||||||
|  |         form.populate_obj(journal_entry) | ||||||
|  |     if not form.is_modified: | ||||||
|  |         flash(s(lazy_gettext("The journal entry was not modified.")), | ||||||
|  |               "success") | ||||||
|  |         return redirect(inherit_next(__get_detail_uri(journal_entry))) | ||||||
|  |     journal_entry.updated_by_id = get_current_user_pk() | ||||||
|  |     journal_entry.updated_at = sa.func.now() | ||||||
|  |     db.session.commit() | ||||||
|  |     flash(s(lazy_gettext("The journal entry is updated successfully.")), | ||||||
|  |           "success") | ||||||
|  |     return redirect(inherit_next(__get_detail_uri(journal_entry))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("/<journalEntry:journal_entry>/delete", endpoint="delete") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def delete_journal_entry(journal_entry: JournalEntry) -> redirect: | ||||||
|  |     """Deletes a journal entry. | ||||||
|  |  | ||||||
|  |     :param journal_entry: The journal entry. | ||||||
|  |     :return: The redirection to the journal entry list on success, or the | ||||||
|  |         journal entry detail on error. | ||||||
|  |     """ | ||||||
|  |     if not journal_entry.can_delete: | ||||||
|  |         flash(s(lazy_gettext("The journal entry cannot be deleted.")), "error") | ||||||
|  |         return redirect(inherit_next(__get_detail_uri(journal_entry))) | ||||||
|  |     journal_entry.delete() | ||||||
|  |     sort_journal_entries_in(journal_entry.date, journal_entry.id) | ||||||
|  |     db.session.commit() | ||||||
|  |     flash(s(lazy_gettext("The journal entry is deleted successfully.")), | ||||||
|  |           "success") | ||||||
|  |     return redirect(or_next(__get_default_page_uri())) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("/dates/<date:journal_entry_date>", endpoint="order") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def show_journal_entry_order(journal_entry_date: date) -> str: | ||||||
|  |     """Shows the order of the journal entries in a same date. | ||||||
|  |  | ||||||
|  |     :param journal_entry_date: The date. | ||||||
|  |     :return: The order of the journal entries in the date. | ||||||
|  |     """ | ||||||
|  |     journal_entries: list[JournalEntry] = JournalEntry.query \ | ||||||
|  |         .filter(JournalEntry.date == journal_entry_date) \ | ||||||
|  |         .order_by(JournalEntry.no).all() | ||||||
|  |     return render_template("accounting/journal-entry/order.html", | ||||||
|  |                            date=journal_entry_date, list=journal_entries) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("/dates/<date:journal_entry_date>", endpoint="sort") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def sort_journal_entries(journal_entry_date: date) -> redirect: | ||||||
|  |     """Reorders the journal entries in a date. | ||||||
|  |  | ||||||
|  |     :param journal_entry_date: The date. | ||||||
|  |     :return: The redirection to the incoming account or the account list.  The | ||||||
|  |         reordering operation does not fail. | ||||||
|  |     """ | ||||||
|  |     form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date) | ||||||
|  |     form.save_order() | ||||||
|  |     if not form.is_modified: | ||||||
|  |         flash(s(lazy_gettext("The order was not modified.")), "success") | ||||||
|  |         return redirect(or_next(__get_default_page_uri())) | ||||||
|  |     db.session.commit() | ||||||
|  |     flash(s(lazy_gettext("The order is updated successfully.")), "success") | ||||||
|  |     return redirect(or_next(__get_default_page_uri())) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_detail_uri(journal_entry: JournalEntry) -> str: | ||||||
|  |     """Returns the detail URI of a journal entry. | ||||||
|  |  | ||||||
|  |     :param journal_entry: The journal entry. | ||||||
|  |     :return: The detail URI of the journal entry. | ||||||
|  |     """ | ||||||
|  |     return url_for("accounting.journal-entry.detail", | ||||||
|  |                    journal_entry=journal_entry) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_default_page_uri() -> str: | ||||||
|  |     """Returns the URI for the default page. | ||||||
|  |  | ||||||
|  |     :return: The URI for the default page. | ||||||
|  |     """ | ||||||
|  |     return url_for("accounting.report.default") | ||||||
| @@ -21,11 +21,12 @@ from __future__ import annotations | |||||||
|  |  | ||||||
| import re | import re | ||||||
| import typing as t | import typing as t | ||||||
|  | from datetime import date | ||||||
| from decimal import Decimal | from decimal import Decimal | ||||||
|  |  | ||||||
| import sqlalchemy as sa | import sqlalchemy as sa | ||||||
| from flask import current_app | from babel import Locale | ||||||
| from flask_babel import get_locale | from flask_babel import get_locale, get_babel | ||||||
| from sqlalchemy import text | from sqlalchemy import text | ||||||
|  |  | ||||||
| from accounting import db | from accounting import db | ||||||
| @@ -52,7 +53,7 @@ class BaseAccount(db.Model): | |||||||
|  |  | ||||||
|         :return: The string representation of the base account. |         :return: The string representation of the base account. | ||||||
|         """ |         """ | ||||||
|         return F"{self.code} {self.title}" |         return f"{self.code} {self.title.title()}" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def title(self) -> str: |     def title(self) -> str: | ||||||
| @@ -60,11 +61,11 @@ class BaseAccount(db.Model): | |||||||
|  |  | ||||||
|         :return: The title in the current locale. |         :return: The title in the current locale. | ||||||
|         """ |         """ | ||||||
|         current_locale = str(get_locale()) |         current_locale: Locale = get_locale() | ||||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: |         if current_locale == get_babel().instance.default_locale: | ||||||
|             return self.title_l10n |             return self.title_l10n | ||||||
|         for l10n in self.l10n: |         for l10n in self.l10n: | ||||||
|             if l10n.locale == current_locale: |             if l10n.locale == str(current_locale): | ||||||
|                 return l10n.title |                 return l10n.title | ||||||
|         return self.title_l10n |         return self.title_l10n | ||||||
|  |  | ||||||
| @@ -113,8 +114,8 @@ class Account(db.Model): | |||||||
|     """The account number under the base account.""" |     """The account number under the base account.""" | ||||||
|     title_l10n = db.Column("title", db.String, nullable=False) |     title_l10n = db.Column("title", db.String, nullable=False) | ||||||
|     """The title.""" |     """The title.""" | ||||||
|     is_pay_off_needed = db.Column(db.Boolean, nullable=False, default=False) |     is_need_offset = db.Column(db.Boolean, nullable=False, default=False) | ||||||
|     """Whether the entries of this account need pay-off.""" |     """Whether the journal entry line items of this account need offset.""" | ||||||
|     created_at = db.Column(db.DateTime(timezone=True), nullable=False, |     created_at = db.Column(db.DateTime(timezone=True), nullable=False, | ||||||
|                            server_default=db.func.now()) |                            server_default=db.func.now()) | ||||||
|     """The time of creation.""" |     """The time of creation.""" | ||||||
| @@ -138,20 +139,15 @@ class Account(db.Model): | |||||||
|     l10n = db.relationship("AccountL10n", back_populates="account", |     l10n = db.relationship("AccountL10n", back_populates="account", | ||||||
|                            lazy=False) |                            lazy=False) | ||||||
|     """The localized titles.""" |     """The localized titles.""" | ||||||
|     entries = db.relationship("JournalEntry", back_populates="account") |     line_items = db.relationship("JournalEntryLineItem", | ||||||
|     """The journal entries.""" |                                  back_populates="account") | ||||||
|  |     """The journal entry line items.""" | ||||||
|  |  | ||||||
|     __CASH = "1111-001" |     CASH_CODE: str = "1111-001" | ||||||
|     """The code of the cash account,""" |     """The code of the cash account,""" | ||||||
|     __RECEIVABLE = "1141-001" |     ACCUMULATED_CHANGE_CODE: str = "3351-001" | ||||||
|     """The code of the receivable account,""" |  | ||||||
|     __PAYABLE = "2141-001" |  | ||||||
|     """The code of the payable account,""" |  | ||||||
|     __ACCUMULATED_CHANGE = "3351-001" |  | ||||||
|     """The code of the accumulated-change account,""" |     """The code of the accumulated-change account,""" | ||||||
|     __BROUGHT_FORWARD = "3352-001" |     NET_CHANGE_CODE: str = "3353-001" | ||||||
|     """The code of the brought-forward account,""" |  | ||||||
|     __NET_CHANGE = "3353-001" |  | ||||||
|     """The code of the net-change account,""" |     """The code of the net-change account,""" | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
| @@ -159,7 +155,7 @@ class Account(db.Model): | |||||||
|  |  | ||||||
|         :return: The string representation of this account. |         :return: The string representation of this account. | ||||||
|         """ |         """ | ||||||
|         return F"{self.base_code}-{self.no:03d} {self.title}" |         return f"{self.base_code}-{self.no:03d} {self.title.title()}" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def code(self) -> str: |     def code(self) -> str: | ||||||
| @@ -167,7 +163,7 @@ class Account(db.Model): | |||||||
|  |  | ||||||
|         :return: The code. |         :return: The code. | ||||||
|         """ |         """ | ||||||
|         return F"{self.base_code}-{self.no:03d}" |         return f"{self.base_code}-{self.no:03d}" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def title(self) -> str: |     def title(self) -> str: | ||||||
| @@ -175,11 +171,11 @@ class Account(db.Model): | |||||||
|  |  | ||||||
|         :return: The title in the current locale. |         :return: The title in the current locale. | ||||||
|         """ |         """ | ||||||
|         current_locale = str(get_locale()) |         current_locale: Locale = get_locale() | ||||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: |         if current_locale == get_babel().instance.default_locale: | ||||||
|             return self.title_l10n |             return self.title_l10n | ||||||
|         for l10n in self.l10n: |         for l10n in self.l10n: | ||||||
|             if l10n.locale == current_locale: |             if l10n.locale == str(current_locale): | ||||||
|                 return l10n.title |                 return l10n.title | ||||||
|         return self.title_l10n |         return self.title_l10n | ||||||
|  |  | ||||||
| @@ -193,34 +189,71 @@ class Account(db.Model): | |||||||
|         if self.title_l10n is None: |         if self.title_l10n is None: | ||||||
|             self.title_l10n = value |             self.title_l10n = value | ||||||
|             return |             return | ||||||
|         current_locale = str(get_locale()) |         current_locale: Locale = get_locale() | ||||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: |         if current_locale == get_babel().instance.default_locale: | ||||||
|             self.title_l10n = value |             self.title_l10n = value | ||||||
|             return |             return | ||||||
|         for l10n in self.l10n: |         for l10n in self.l10n: | ||||||
|             if l10n.locale == current_locale: |             if l10n.locale == str(current_locale): | ||||||
|                 l10n.title = value |                 l10n.title = value | ||||||
|                 return |                 return | ||||||
|         self.l10n.append(AccountL10n(locale=current_locale, title=value)) |         self.l10n.append(AccountL10n(locale=str(current_locale), title=value)) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def is_in_use(self) -> bool: |     def is_real(self) -> bool: | ||||||
|         """Returns whether the account is in use. |         """Returns whether the account is a real account. | ||||||
|  |  | ||||||
|         :return: True if the account is in use, or False otherwise. |         :return: True if the account is a real account, or False otherwise. | ||||||
|         """ |         """ | ||||||
|         if not hasattr(self, "__is_in_use"): |         return self.base_code[0] in {"1", "2", "3"} | ||||||
|             setattr(self, "__is_in_use", len(self.entries) > 0) |  | ||||||
|         return getattr(self, "__is_in_use") |  | ||||||
|  |  | ||||||
|     @is_in_use.setter |     @property | ||||||
|     def is_in_use(self, is_in_use: bool) -> None: |     def is_nominal(self) -> bool: | ||||||
|         """Sets whether the account is in use. |         """Returns whether the account is a nominal account. | ||||||
|  |  | ||||||
|  |         :return: True if the account is a nominal account, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         return not self.is_real | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def query_values(self) -> list[str]: | ||||||
|  |         """Returns the values to be queried. | ||||||
|  |  | ||||||
|  |         :return: The values to be queried. | ||||||
|  |         """ | ||||||
|  |         return [self.code, self.title_l10n] + [x.title for x in self.l10n] | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_modified(self) -> bool: | ||||||
|  |         """Returns whether a product account was modified. | ||||||
|  |  | ||||||
|  |         :return: True if modified, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         if db.session.is_modified(self): | ||||||
|  |             return True | ||||||
|  |         for l10n in self.l10n: | ||||||
|  |             if db.session.is_modified(l10n): | ||||||
|  |                 return True | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     @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. | ||||||
|  |  | ||||||
|         :param is_in_use: True if the account is in use, or False otherwise. |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         setattr(self, "__is_in_use", is_in_use) |         AccountL10n.query.filter(AccountL10n.account == self).delete() | ||||||
|  |         cls: t.Type[t.Self] = self.__class__ | ||||||
|  |         cls.query.filter(cls.id == self.id).delete() | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def find_by_code(cls, code: str) -> t.Self | None: |     def find_by_code(cls, code: str) -> t.Self | None: | ||||||
| @@ -236,13 +269,15 @@ class Account(db.Model): | |||||||
|                                 cls.no == int(m.group(2))).first() |                                 cls.no == int(m.group(2))).first() | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def debit(cls) -> list[t.Self]: |     def selectable_debit(cls) -> list[t.Self]: | ||||||
|         """Returns the debit accounts. |         """Returns the selectable debit accounts. | ||||||
|  |         Payable line items can not start from debit. | ||||||
|  |  | ||||||
|         :return: The debit accounts. |         :return: The selectable debit accounts. | ||||||
|         """ |         """ | ||||||
|         return cls.query.filter(sa.or_(cls.base_code.startswith("1"), |         return cls.query.filter(sa.or_(cls.base_code.startswith("1"), | ||||||
|                                        cls.base_code.startswith("2"), |                                        sa.and_(cls.base_code.startswith("2"), | ||||||
|  |                                                sa.not_(cls.is_need_offset)), | ||||||
|                                        cls.base_code.startswith("3"), |                                        cls.base_code.startswith("3"), | ||||||
|                                        cls.base_code.startswith("5"), |                                        cls.base_code.startswith("5"), | ||||||
|                                        cls.base_code.startswith("6"), |                                        cls.base_code.startswith("6"), | ||||||
| @@ -257,12 +292,14 @@ class Account(db.Model): | |||||||
|             .order_by(cls.base_code, cls.no).all() |             .order_by(cls.base_code, cls.no).all() | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def credit(cls) -> list[t.Self]: |     def selectable_credit(cls) -> list[t.Self]: | ||||||
|         """Returns the debit accounts. |         """Returns the selectable debit accounts. | ||||||
|  |         Receivable line items can not start from credit. | ||||||
|  |  | ||||||
|         :return: The debit accounts. |         :return: The selectable debit accounts. | ||||||
|         """ |         """ | ||||||
|         return cls.query.filter(sa.or_(cls.base_code.startswith("1"), |         return cls.query.filter(sa.or_(sa.and_(cls.base_code.startswith("1"), | ||||||
|  |                                                sa.not_(cls.is_need_offset)), | ||||||
|                                        cls.base_code.startswith("2"), |                                        cls.base_code.startswith("2"), | ||||||
|                                        cls.base_code.startswith("3"), |                                        cls.base_code.startswith("3"), | ||||||
|                                        cls.base_code.startswith("4"), |                                        cls.base_code.startswith("4"), | ||||||
| @@ -276,37 +313,13 @@ class Account(db.Model): | |||||||
|                                 cls.base_code != "3353")\ |                                 cls.base_code != "3353")\ | ||||||
|             .order_by(cls.base_code, cls.no).all() |             .order_by(cls.base_code, cls.no).all() | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def query_values(self) -> list[str]: |  | ||||||
|         """Returns the values to be queried. |  | ||||||
|  |  | ||||||
|         :return: The values to be queried. |  | ||||||
|         """ |  | ||||||
|         return [self.code, self.title_l10n] + [x.title for x in self.l10n] |  | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def cash(cls) -> t.Self: |     def cash(cls) -> t.Self: | ||||||
|         """Returns the cash account. |         """Returns the cash account. | ||||||
|  |  | ||||||
|         :return: The cash account |         :return: The cash account | ||||||
|         """ |         """ | ||||||
|         return cls.find_by_code(cls.__CASH) |         return cls.find_by_code(cls.CASH_CODE) | ||||||
|  |  | ||||||
|     @classmethod |  | ||||||
|     def receivable(cls) -> t.Self: |  | ||||||
|         """Returns the receivable account. |  | ||||||
|  |  | ||||||
|         :return: The receivable account |  | ||||||
|         """ |  | ||||||
|         return cls.find_by_code(cls.__RECEIVABLE) |  | ||||||
|  |  | ||||||
|     @classmethod |  | ||||||
|     def payable(cls) -> t.Self: |  | ||||||
|         """Returns the payable account. |  | ||||||
|  |  | ||||||
|         :return: The payable account |  | ||||||
|         """ |  | ||||||
|         return cls.find_by_code(cls.__PAYABLE) |  | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def accumulated_change(cls) -> t.Self: |     def accumulated_change(cls) -> t.Self: | ||||||
| @@ -314,45 +327,7 @@ class Account(db.Model): | |||||||
|  |  | ||||||
|         :return: The accumulated-change account |         :return: The accumulated-change account | ||||||
|         """ |         """ | ||||||
|         return cls.find_by_code(cls.__ACCUMULATED_CHANGE) |         return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE) | ||||||
|  |  | ||||||
|     @classmethod |  | ||||||
|     def brought_forward(cls) -> t.Self: |  | ||||||
|         """Returns the brought-forward account. |  | ||||||
|  |  | ||||||
|         :return: The brought-forward account |  | ||||||
|         """ |  | ||||||
|         return cls.find_by_code(cls.__BROUGHT_FORWARD) |  | ||||||
|  |  | ||||||
|     @classmethod |  | ||||||
|     def net_change(cls) -> t.Self: |  | ||||||
|         """Returns the net-change account. |  | ||||||
|  |  | ||||||
|         :return: The net-change account |  | ||||||
|         """ |  | ||||||
|         return cls.find_by_code(cls.__NET_CHANGE) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def is_modified(self) -> bool: |  | ||||||
|         """Returns whether a product account was modified. |  | ||||||
|  |  | ||||||
|         :return: True if modified, or False otherwise. |  | ||||||
|         """ |  | ||||||
|         if db.session.is_modified(self): |  | ||||||
|             return True |  | ||||||
|         for l10n in self.l10n: |  | ||||||
|             if db.session.is_modified(l10n): |  | ||||||
|                 return True |  | ||||||
|         return False |  | ||||||
|  |  | ||||||
|     def delete(self) -> None: |  | ||||||
|         """Deletes this account. |  | ||||||
|  |  | ||||||
|         :return: None. |  | ||||||
|         """ |  | ||||||
|         AccountL10n.query.filter(AccountL10n.account == self).delete() |  | ||||||
|         cls: t.Type[t.Self] = self.__class__ |  | ||||||
|         cls.query.filter(cls.id == self.id).delete() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AccountL10n(db.Model): | class AccountL10n(db.Model): | ||||||
| @@ -403,15 +378,16 @@ class Currency(db.Model): | |||||||
|     l10n = db.relationship("CurrencyL10n", back_populates="currency", |     l10n = db.relationship("CurrencyL10n", back_populates="currency", | ||||||
|                            lazy=False) |                            lazy=False) | ||||||
|     """The localized names.""" |     """The localized names.""" | ||||||
|     entries = db.relationship("JournalEntry", back_populates="currency") |     line_items = db.relationship("JournalEntryLineItem", | ||||||
|     """The journal entries.""" |                                  back_populates="currency") | ||||||
|  |     """The journal entry line items.""" | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         """Returns the string representation of the currency. |         """Returns the string representation of the currency. | ||||||
|  |  | ||||||
|         :return: The string representation of the currency. |         :return: The string representation of the currency. | ||||||
|         """ |         """ | ||||||
|         return F"{self.name} ({self.code})" |         return f"{self.name.title()} ({self.code})" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def name(self) -> str: |     def name(self) -> str: | ||||||
| @@ -419,11 +395,11 @@ class Currency(db.Model): | |||||||
|  |  | ||||||
|         :return: The name in the current locale. |         :return: The name in the current locale. | ||||||
|         """ |         """ | ||||||
|         current_locale = str(get_locale()) |         current_locale: Locale = get_locale() | ||||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: |         if current_locale == get_babel().instance.default_locale: | ||||||
|             return self.name_l10n |             return self.name_l10n | ||||||
|         for l10n in self.l10n: |         for l10n in self.l10n: | ||||||
|             if l10n.locale == current_locale: |             if l10n.locale == str(current_locale): | ||||||
|                 return l10n.name |                 return l10n.name | ||||||
|         return self.name_l10n |         return self.name_l10n | ||||||
|  |  | ||||||
| @@ -437,15 +413,15 @@ class Currency(db.Model): | |||||||
|         if self.name_l10n is None: |         if self.name_l10n is None: | ||||||
|             self.name_l10n = value |             self.name_l10n = value | ||||||
|             return |             return | ||||||
|         current_locale = str(get_locale()) |         current_locale: Locale = get_locale() | ||||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: |         if current_locale == get_babel().instance.default_locale: | ||||||
|             self.name_l10n = value |             self.name_l10n = value | ||||||
|             return |             return | ||||||
|         for l10n in self.l10n: |         for l10n in self.l10n: | ||||||
|             if l10n.locale == current_locale: |             if l10n.locale == str(current_locale): | ||||||
|                 l10n.name = value |                 l10n.name = value | ||||||
|                 return |                 return | ||||||
|         self.l10n.append(CurrencyL10n(locale=current_locale, name=value)) |         self.l10n.append(CurrencyL10n(locale=str(current_locale), name=value)) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def is_modified(self) -> bool: |     def is_modified(self) -> bool: | ||||||
| @@ -460,6 +436,17 @@ class Currency(db.Model): | |||||||
|                 return True |                 return True | ||||||
|         return False |         return False | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def can_delete(self) -> bool: | ||||||
|  |         """Returns whether the currency can be deleted. | ||||||
|  |  | ||||||
|  |         :return: True if the currency can be deleted, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         from accounting.template_globals import default_currency_code | ||||||
|  |         if self.code == default_currency_code(): | ||||||
|  |             return False | ||||||
|  |         return len(self.line_items) == 0 | ||||||
|  |  | ||||||
|     def delete(self) -> None: |     def delete(self) -> None: | ||||||
|         """Deletes the currency. |         """Deletes the currency. | ||||||
|  |  | ||||||
| @@ -487,23 +474,23 @@ class CurrencyL10n(db.Model): | |||||||
|     """The localized name.""" |     """The localized name.""" | ||||||
|  |  | ||||||
|  |  | ||||||
| class TransactionCurrency: | class JournalEntryCurrency: | ||||||
|     """A currency in a transaction.""" |     """A currency in a journal entry.""" | ||||||
|  |  | ||||||
|     def __init__(self, code: str, debit: list[JournalEntry], |     def __init__(self, code: str, debit: list[JournalEntryLineItem], | ||||||
|                  credit: list[JournalEntry]): |                  credit: list[JournalEntryLineItem]): | ||||||
|         """Constructs the currency in the transaction. |         """Constructs the currency in the journal entry. | ||||||
|  |  | ||||||
|         :param code: The currency code. |         :param code: The currency code. | ||||||
|         :param debit: The debit entries. |         :param debit: The debit line items. | ||||||
|         :param credit: The credit entries. |         :param credit: The credit line items. | ||||||
|         """ |         """ | ||||||
|         self.code: str = code |         self.code: str = code | ||||||
|         """The currency code.""" |         """The currency code.""" | ||||||
|         self.debit: list[JournalEntry] = debit |         self.debit: list[JournalEntryLineItem] = debit | ||||||
|         """The debit entries.""" |         """The debit line items.""" | ||||||
|         self.credit: list[JournalEntry] = credit |         self.credit: list[JournalEntryLineItem] = credit | ||||||
|         """The credit entries.""" |         """The credit line items.""" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def name(self) -> str: |     def name(self) -> str: | ||||||
| @@ -515,28 +502,28 @@ class TransactionCurrency: | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def debit_total(self) -> Decimal: |     def debit_total(self) -> Decimal: | ||||||
|         """Returns the total amount of the debit journal entries. |         """Returns the total amount of the debit line items. | ||||||
|  |  | ||||||
|         :return: The total amount of the debit journal entries. |         :return: The total amount of the debit line items. | ||||||
|         """ |         """ | ||||||
|         return sum([x.amount for x in self.debit]) |         return sum([x.amount for x in self.debit]) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def credit_total(self) -> str: |     def credit_total(self) -> str: | ||||||
|         """Returns the total amount of the credit journal entries. |         """Returns the total amount of the credit line items. | ||||||
|  |  | ||||||
|         :return: The total amount of the credit journal entries. |         :return: The total amount of the credit line items. | ||||||
|         """ |         """ | ||||||
|         return sum([x.amount for x in self.credit]) |         return sum([x.amount for x in self.credit]) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Transaction(db.Model): | class JournalEntry(db.Model): | ||||||
|     """A transaction.""" |     """A journal entry.""" | ||||||
|     __tablename__ = "accounting_transactions" |     __tablename__ = "accounting_journal_entries" | ||||||
|     """The table name.""" |     """The table name.""" | ||||||
|     id = db.Column(db.Integer, nullable=False, primary_key=True, |     id = db.Column(db.Integer, nullable=False, primary_key=True, | ||||||
|                    autoincrement=False) |                    autoincrement=False) | ||||||
|     """The transaction ID.""" |     """The journal entry ID.""" | ||||||
|     date = db.Column(db.Date, nullable=False) |     date = db.Column(db.Date, nullable=False) | ||||||
|     """The date.""" |     """The date.""" | ||||||
|     no = db.Column(db.Integer, nullable=False, default=text("1")) |     no = db.Column(db.Integer, nullable=False, default=text("1")) | ||||||
| @@ -563,132 +550,155 @@ class Transaction(db.Model): | |||||||
|     """The ID of the updator.""" |     """The ID of the updator.""" | ||||||
|     updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) |     updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) | ||||||
|     """The updator.""" |     """The updator.""" | ||||||
|     entries = db.relationship("JournalEntry", back_populates="transaction") |     line_items = db.relationship("JournalEntryLineItem", | ||||||
|     """The journal entries.""" |                                  back_populates="journal_entry") | ||||||
|  |     """The line items.""" | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         """Returns the string representation of this transaction. |         """Returns the string representation of this journal entry. | ||||||
|  |  | ||||||
|         :return: The string representation of this transaction. |         :return: The string representation of this journal entry. | ||||||
|         """ |         """ | ||||||
|         if self.is_cash_expense: |         if self.is_cash_disbursement: | ||||||
|             return gettext("Cash Expense Transaction#%(id)s", id=self.id) |             return gettext("Cash Disbursement Journal Entry#%(id)s", | ||||||
|         if self.is_cash_income: |                            id=self.id) | ||||||
|             return gettext("Cash Income Transaction#%(id)s", id=self.id) |         if self.is_cash_receipt: | ||||||
|         return gettext("Transfer Transaction#%(id)s", id=self.id) |             return gettext("Cash Receipt Journal Entry#%(id)s", id=self.id) | ||||||
|  |         return gettext("Transfer Journal Entry#%(id)s", id=self.id) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def currencies(self) -> list[TransactionCurrency]: |     def currencies(self) -> list[JournalEntryCurrency]: | ||||||
|         """Returns the journal entries categorized by their currencies. |         """Returns the line items categorized by their currencies. | ||||||
|  |  | ||||||
|         :return: The currency categories. |         :return: The currency categories. | ||||||
|         """ |         """ | ||||||
|         entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no) |         line_items: list[JournalEntryLineItem] = sorted(self.line_items, | ||||||
|  |                                                         key=lambda x: x.no) | ||||||
|         codes: list[str] = [] |         codes: list[str] = [] | ||||||
|         by_currency: dict[str, list[JournalEntry]] = {} |         by_currency: dict[str, list[JournalEntryLineItem]] = {} | ||||||
|         for entry in entries: |         for line_item in line_items: | ||||||
|             if entry.currency_code not in by_currency: |             if line_item.currency_code not in by_currency: | ||||||
|                 codes.append(entry.currency_code) |                 codes.append(line_item.currency_code) | ||||||
|                 by_currency[entry.currency_code] = [] |                 by_currency[line_item.currency_code] = [] | ||||||
|             by_currency[entry.currency_code].append(entry) |             by_currency[line_item.currency_code].append(line_item) | ||||||
|         return [TransactionCurrency(code=x, |         return [JournalEntryCurrency(code=x, | ||||||
|                                     debit=[y for y in by_currency[x] |                                      debit=[y for y in by_currency[x] | ||||||
|                                            if y.is_debit], |                                             if y.is_debit], | ||||||
|                                     credit=[y for y in by_currency[x] |                                      credit=[y for y in by_currency[x] | ||||||
|                                             if not y.is_debit]) |                                              if not y.is_debit]) | ||||||
|                 for x in codes] |                 for x in codes] | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def is_cash_income(self) -> bool: |     def is_cash_receipt(self) -> bool: | ||||||
|         """Returns whether this is a cash income transaction. |         """Returns whether this is a cash receipt journal entry. | ||||||
|  |  | ||||||
|         :return: True if this is a cash income transaction, or False otherwise. |         :return: True if this is a cash receipt journal entry, or False | ||||||
|  |             otherwise. | ||||||
|         """ |         """ | ||||||
|         for currency in self.currencies: |         for currency in self.currencies: | ||||||
|             if len(currency.debit) > 1: |             if len(currency.debit) > 1: | ||||||
|                 return False |                 return False | ||||||
|             if currency.debit[0].account.code != "1111-001": |             if currency.debit[0].account.code != Account.CASH_CODE: | ||||||
|                 return False |                 return False | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def is_cash_expense(self) -> bool: |     def is_cash_disbursement(self) -> bool: | ||||||
|         """Returns whether this is a cash expense transaction. |         """Returns whether this is a cash disbursement journal entry. | ||||||
|  |  | ||||||
|         :return: True if this is a cash expense transaction, or False |         :return: True if this is a cash disbursement journal entry, or False | ||||||
|             otherwise. |             otherwise. | ||||||
|         """ |         """ | ||||||
|         for currency in self.currencies: |         for currency in self.currencies: | ||||||
|             if len(currency.credit) > 1: |             if len(currency.credit) > 1: | ||||||
|                 return False |                 return False | ||||||
|             if currency.credit[0].account.code != "1111-001": |             if currency.credit[0].account.code != Account.CASH_CODE: | ||||||
|  |                 return False | ||||||
|  |         return 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 False | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
|     def delete(self) -> None: |     def delete(self) -> None: | ||||||
|         """Deletes the transaction. |         """Deletes the journal entry. | ||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         JournalEntry.query\ |         JournalEntryLineItem.query\ | ||||||
|             .filter(JournalEntry.transaction_id == self.id).delete() |             .filter(JournalEntryLineItem.journal_entry_id == self.id).delete() | ||||||
|         db.session.delete(self) |         db.session.delete(self) | ||||||
|  |  | ||||||
|  |  | ||||||
| class JournalEntry(db.Model): | class JournalEntryLineItem(db.Model): | ||||||
|     """An accounting journal entry.""" |     """A line item in the journal entry.""" | ||||||
|     __tablename__ = "accounting_journal_entries" |     __tablename__ = "accounting_journal_entry_line_items" | ||||||
|     """The table name.""" |     """The table name.""" | ||||||
|     id = db.Column(db.Integer, nullable=False, primary_key=True, |     id = db.Column(db.Integer, nullable=False, primary_key=True, | ||||||
|                    autoincrement=False) |                    autoincrement=False) | ||||||
|     """The entry ID.""" |     """The line item ID.""" | ||||||
|     transaction_id = db.Column(db.Integer, |     journal_entry_id = db.Column(db.Integer, | ||||||
|                                db.ForeignKey(Transaction.id, |                                  db.ForeignKey(JournalEntry.id, | ||||||
|                                              onupdate="CASCADE", |                                                onupdate="CASCADE", | ||||||
|                                              ondelete="CASCADE"), |                                                ondelete="CASCADE"), | ||||||
|                                nullable=False) |                                  nullable=False) | ||||||
|     """The transaction ID.""" |     """The journal entry ID.""" | ||||||
|     transaction = db.relationship(Transaction, back_populates="entries") |     journal_entry = db.relationship(JournalEntry, back_populates="line_items") | ||||||
|     """The transaction.""" |     """The journal entry.""" | ||||||
|     is_debit = db.Column(db.Boolean, nullable=False) |     is_debit = db.Column(db.Boolean, nullable=False) | ||||||
|     """True for a debit entry, or False for a credit entry.""" |     """True for a debit line item, or False for a credit line item.""" | ||||||
|     no = db.Column(db.Integer, nullable=False) |     no = db.Column(db.Integer, nullable=False) | ||||||
|     """The entry number under the transaction and debit or credit.""" |     """The line item number under the journal entry and debit or credit.""" | ||||||
|     pay_off_target_id = db.Column(db.Integer, |     original_line_item_id = db.Column(db.Integer, | ||||||
|                                   db.ForeignKey(id, onupdate="CASCADE"), |                                       db.ForeignKey(id, onupdate="CASCADE"), | ||||||
|                                   nullable=True) |                                       nullable=True) | ||||||
|     """The ID of the pay-off target entry.""" |     """The ID of the original line item.""" | ||||||
|     pay_off_target = db.relationship("JournalEntry", back_populates="pay_off", |     original_line_item = db.relationship("JournalEntryLineItem", | ||||||
|                                      remote_side=id, passive_deletes=True) |                                          back_populates="offsets", | ||||||
|     """The pay-off target entry.""" |                                          remote_side=id, passive_deletes=True) | ||||||
|     pay_off = db.relationship("JournalEntry", back_populates="pay_off_target") |     """The original line item.""" | ||||||
|     """The pay-off entries.""" |     offsets = db.relationship("JournalEntryLineItem", | ||||||
|  |                               back_populates="original_line_item") | ||||||
|  |     """The offset items.""" | ||||||
|     currency_code = db.Column(db.String, |     currency_code = db.Column(db.String, | ||||||
|                               db.ForeignKey(Currency.code, onupdate="CASCADE"), |                               db.ForeignKey(Currency.code, onupdate="CASCADE"), | ||||||
|                               nullable=False) |                               nullable=False) | ||||||
|     """The currency code.""" |     """The currency code.""" | ||||||
|     currency = db.relationship(Currency, back_populates="entries") |     currency = db.relationship(Currency, back_populates="line_items") | ||||||
|     """The currency.""" |     """The currency.""" | ||||||
|     account_id = db.Column(db.Integer, |     account_id = db.Column(db.Integer, | ||||||
|                            db.ForeignKey(Account.id, |                            db.ForeignKey(Account.id, | ||||||
|                                          onupdate="CASCADE"), |                                          onupdate="CASCADE"), | ||||||
|                            nullable=False) |                            nullable=False) | ||||||
|     """The account ID.""" |     """The account ID.""" | ||||||
|     account = db.relationship(Account, back_populates="entries", lazy=False) |     account = db.relationship(Account, back_populates="line_items", lazy=False) | ||||||
|     """The account.""" |     """The account.""" | ||||||
|     summary = db.Column(db.String, nullable=True) |     description = db.Column(db.String, nullable=True) | ||||||
|     """The summary.""" |     """The description.""" | ||||||
|     amount = db.Column(db.Numeric(14, 2), nullable=False) |     amount = db.Column(db.Numeric(14, 2), nullable=False) | ||||||
|     """The amount.""" |     """The amount.""" | ||||||
|  |  | ||||||
|     @property |     def __str__(self) -> str: | ||||||
|     def eid(self) -> int | None: |         """Returns the string representation of the line item. | ||||||
|         """Returns the journal entry ID.  This is the alternative name of the |  | ||||||
|         ID field, to work with WTForms. |  | ||||||
|  |  | ||||||
|         :return: The journal entry ID. |         :return: The string representation of the line item. | ||||||
|         """ |         """ | ||||||
|         return self.id |         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 |     @property | ||||||
|     def account_code(self) -> str: |     def account_code(self) -> str: | ||||||
| @@ -697,3 +707,101 @@ class JournalEntry(db.Model): | |||||||
|         :return: The account code. |         :return: The account code. | ||||||
|         """ |         """ | ||||||
|         return self.account.code |         return self.account.code | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def debit(self) -> Decimal | None: | ||||||
|  |         """Returns the debit amount. | ||||||
|  |  | ||||||
|  |         :return: The debit amount, or None if this is not a debit line item. | ||||||
|  |         """ | ||||||
|  |         return self.amount if self.is_debit else None | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_need_offset(self) -> bool: | ||||||
|  |         """Returns whether the line item needs offset. | ||||||
|  |  | ||||||
|  |         :return: True if the line item needs offset, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         if not self.account.is_need_offset: | ||||||
|  |             return False | ||||||
|  |         if self.account.base_code[0] == "1" and not self.is_debit: | ||||||
|  |             return False | ||||||
|  |         if self.account.base_code[0] == "2" and self.is_debit: | ||||||
|  |             return False | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def credit(self) -> Decimal | None: | ||||||
|  |         """Returns the credit amount. | ||||||
|  |  | ||||||
|  |         :return: The credit amount, or None if this is not a credit line item. | ||||||
|  |         """ | ||||||
|  |         return None if self.is_debit else self.amount | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def net_balance(self) -> Decimal: | ||||||
|  |         """Returns the net balance. | ||||||
|  |  | ||||||
|  |         :return: The net balance. | ||||||
|  |         """ | ||||||
|  |         if not hasattr(self, "__net_balance"): | ||||||
|  |             setattr(self, "__net_balance", self.amount + sum( | ||||||
|  |                 [x.amount if x.is_debit == self.is_debit else -x.amount | ||||||
|  |                  for x in self.offsets])) | ||||||
|  |         return getattr(self, "__net_balance") | ||||||
|  |  | ||||||
|  |     @net_balance.setter | ||||||
|  |     def net_balance(self, net_balance: Decimal) -> None: | ||||||
|  |         """Sets the net balance. | ||||||
|  |  | ||||||
|  |         :param net_balance: The net balance. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         setattr(self, "__net_balance", net_balance) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def query_values(self) -> list[str]: | ||||||
|  |         """Returns the values to be queried. | ||||||
|  |  | ||||||
|  |         :return: The values to be queried. | ||||||
|  |         """ | ||||||
|  |         def format_amount(value: Decimal) -> str: | ||||||
|  |             whole: int = int(value) | ||||||
|  |             frac: Decimal = (value - whole).normalize() | ||||||
|  |             return str(whole) + str(abs(frac))[1:] | ||||||
|  |  | ||||||
|  |         return ["{}/{}/{}".format(self.journal_entry.date.year, | ||||||
|  |                                   self.journal_entry.date.month, | ||||||
|  |                                   self.journal_entry.date.day), | ||||||
|  |                 "" if self.description is None else self.description, | ||||||
|  |                 format_amount(self.amount)] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Option(db.Model): | ||||||
|  |     """An option.""" | ||||||
|  |     __tablename__ = "accounting_options" | ||||||
|  |     """The table name.""" | ||||||
|  |     name = db.Column(db.String, nullable=False, primary_key=True) | ||||||
|  |     """The name.""" | ||||||
|  |     value = db.Column(db.Text, nullable=False) | ||||||
|  |     """The option value.""" | ||||||
|  |     created_at = db.Column(db.DateTime(timezone=True), nullable=False, | ||||||
|  |                            server_default=db.func.now()) | ||||||
|  |     """The time of creation.""" | ||||||
|  |     created_by_id = db.Column(db.Integer, | ||||||
|  |                               db.ForeignKey(user_pk_column, | ||||||
|  |                                             onupdate="CASCADE"), | ||||||
|  |                               nullable=False) | ||||||
|  |     """The ID of the creator.""" | ||||||
|  |     created_by = db.relationship(user_cls, foreign_keys=created_by_id) | ||||||
|  |     """The creator.""" | ||||||
|  |     updated_at = db.Column(db.DateTime(timezone=True), nullable=False, | ||||||
|  |                            server_default=db.func.now()) | ||||||
|  |     """The time of last update.""" | ||||||
|  |     updated_by_id = db.Column(db.Integer, | ||||||
|  |                               db.ForeignKey(user_pk_column, | ||||||
|  |                                             onupdate="CASCADE"), | ||||||
|  |                               nullable=False) | ||||||
|  |     """The ID of the updator.""" | ||||||
|  |     updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) | ||||||
|  |     """The updator.""" | ||||||
|   | |||||||
							
								
								
									
										30
									
								
								src/accounting/option/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/accounting/option/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | |||||||
|  | # The Mia! Accounting Flask 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 option management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from flask import Blueprint | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def init_app(bp: Blueprint) -> None: | ||||||
|  |     """Initialize the application. | ||||||
|  |  | ||||||
|  |     :param bp: The blueprint of the accounting application. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     from .views import bp as option_bp | ||||||
|  |     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 Flask 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 Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The views for the option management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from urllib.parse import parse_qsl, urlencode | ||||||
|  |  | ||||||
|  | from flask import Blueprint, render_template, redirect, session, request, \ | ||||||
|  |     flash, url_for | ||||||
|  | from werkzeug.datastructures import ImmutableMultiDict | ||||||
|  |  | ||||||
|  | from accounting.locale import lazy_gettext | ||||||
|  | from accounting.utils.cast import s | ||||||
|  | from accounting.utils.flash_errors import flash_form_errors | ||||||
|  | from accounting.utils.next_uri import inherit_next | ||||||
|  | from accounting.utils.options import options | ||||||
|  | from accounting.utils.permission import has_permission, can_admin | ||||||
|  | from .forms import OptionForm | ||||||
|  |  | ||||||
|  | bp: Blueprint = Blueprint("option", __name__) | ||||||
|  | """The view blueprint for the currency management.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("", endpoint="detail") | ||||||
|  | @has_permission(can_admin) | ||||||
|  | def show_options() -> str: | ||||||
|  |     """Shows the options. | ||||||
|  |  | ||||||
|  |     :return: The options. | ||||||
|  |     """ | ||||||
|  |     return render_template("accounting/option/detail.html", obj=options) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("edit", endpoint="edit") | ||||||
|  | @has_permission(can_admin) | ||||||
|  | def show_option_form() -> str: | ||||||
|  |     """Shows the option form. | ||||||
|  |  | ||||||
|  |     :return: The option form. | ||||||
|  |     """ | ||||||
|  |     form: OptionForm | ||||||
|  |     if "form" in session: | ||||||
|  |         form = OptionForm(ImmutableMultiDict(parse_qsl(session["form"]))) | ||||||
|  |         del session["form"] | ||||||
|  |         form.validate() | ||||||
|  |     else: | ||||||
|  |         form = OptionForm(obj=options) | ||||||
|  |     return render_template("accounting/option/form.html", form=form) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("update", endpoint="update") | ||||||
|  | @has_permission(can_admin) | ||||||
|  | def update_options() -> redirect: | ||||||
|  |     """Updates the options. | ||||||
|  |  | ||||||
|  |     :return: The redirection to the option form. | ||||||
|  |     """ | ||||||
|  |     form = OptionForm(request.form) | ||||||
|  |     if not form.validate(): | ||||||
|  |         flash_form_errors(form) | ||||||
|  |         session["form"] = urlencode(list(request.form.items())) | ||||||
|  |         return redirect(inherit_next(url_for("accounting.option.edit"))) | ||||||
|  |     form.populate_obj(options) | ||||||
|  |     if not options.is_modified: | ||||||
|  |         flash(s(lazy_gettext("The settings were not modified.")), "success") | ||||||
|  |         return redirect(inherit_next(url_for("accounting.option.detail"))) | ||||||
|  |     options.commit() | ||||||
|  |     flash(s(lazy_gettext("The settings are saved successfully.")), "success") | ||||||
|  |     return redirect(inherit_next(url_for("accounting.option.detail"))) | ||||||
							
								
								
									
										35
									
								
								src/accounting/report/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/accounting/report/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The report management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from flask import Flask, Blueprint | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def init_app(app: Flask, bp: Blueprint) -> None: | ||||||
|  |     """Initialize the application. | ||||||
|  |  | ||||||
|  |     :param app: The Flask application. | ||||||
|  |     :param bp: The blueprint of the accounting application. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     from .converters import PeriodConverter, IncomeExpensesAccountConverter | ||||||
|  |     app.url_map.converters["period"] = PeriodConverter | ||||||
|  |     app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter | ||||||
|  |  | ||||||
|  |     from .views import bp as report_bp | ||||||
|  |     bp.register_blueprint(report_bp, url_prefix="/reports") | ||||||
							
								
								
									
										79
									
								
								src/accounting/report/converters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								src/accounting/report/converters.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The path converters for the report management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import re | ||||||
|  |  | ||||||
|  | from flask import abort | ||||||
|  | from werkzeug.routing import BaseConverter | ||||||
|  |  | ||||||
|  | from accounting.models import Account | ||||||
|  | from accounting.utils.current_account import CurrentAccount | ||||||
|  | from .period import Period, get_period | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PeriodConverter(BaseConverter): | ||||||
|  |     """The supplier converter to convert the period specification from and to | ||||||
|  |     the corresponding period in the routes.""" | ||||||
|  |  | ||||||
|  |     def to_python(self, value: str) -> Period: | ||||||
|  |         """Converts a period specification to a period. | ||||||
|  |  | ||||||
|  |         :param value: The period specification. | ||||||
|  |         :return: The corresponding period. | ||||||
|  |         """ | ||||||
|  |         try: | ||||||
|  |             return get_period(value) | ||||||
|  |         except ValueError: | ||||||
|  |             abort(404) | ||||||
|  |  | ||||||
|  |     def to_url(self, value: Period) -> str: | ||||||
|  |         """Converts a period to its specification. | ||||||
|  |  | ||||||
|  |         :param value: The period. | ||||||
|  |         :return: Its specification. | ||||||
|  |         """ | ||||||
|  |         return value.spec | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IncomeExpensesAccountConverter(BaseConverter): | ||||||
|  |     """The supplier converter to convert the income and expenses log pseudo | ||||||
|  |     account code from and to the corresponding pseudo account in the routes.""" | ||||||
|  |  | ||||||
|  |     def to_python(self, value: str) -> CurrentAccount: | ||||||
|  |         """Converts an account code to an account. | ||||||
|  |  | ||||||
|  |         :param value: The account code. | ||||||
|  |         :return: The corresponding account. | ||||||
|  |         """ | ||||||
|  |         if value == CurrentAccount.CURRENT_AL_CODE: | ||||||
|  |             return CurrentAccount.current_assets_and_liabilities() | ||||||
|  |         if not re.match("^[12][12]", value): | ||||||
|  |             abort(404) | ||||||
|  |         account: Account | None = Account.find_by_code(value) | ||||||
|  |         if account is None: | ||||||
|  |             abort(404) | ||||||
|  |         return CurrentAccount(account) | ||||||
|  |  | ||||||
|  |     def to_url(self, value: CurrentAccount) -> str: | ||||||
|  |         """Converts an account to account code. | ||||||
|  |  | ||||||
|  |         :param value: The account. | ||||||
|  |         :return: Its code. | ||||||
|  |         """ | ||||||
|  |         return value.code | ||||||
							
								
								
									
										22
									
								
								src/accounting/report/period/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/accounting/report/period/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The period utility. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from .chooser import PeriodChooser | ||||||
|  | from .parser import get_period | ||||||
|  | from .period import Period | ||||||
							
								
								
									
										97
									
								
								src/accounting/report/period/chooser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								src/accounting/report/period/chooser.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The period chooser. | ||||||
|  |  | ||||||
|  | This file is largely taken from the NanoParma ERP project, first written in | ||||||
|  | 2021/9/16 by imacat (imacat@nanoparma.com). | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import typing as t | ||||||
|  | from datetime import date | ||||||
|  |  | ||||||
|  | from accounting.models import JournalEntry | ||||||
|  | from .period import Period | ||||||
|  | from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \ | ||||||
|  |     LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PeriodChooser: | ||||||
|  |     """The period chooser.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, get_url: t.Callable[[Period], str]): | ||||||
|  |         """Constructs a period chooser. | ||||||
|  |  | ||||||
|  |         :param get_url: The callback to return the URL of the current report in | ||||||
|  |             a period. | ||||||
|  |         """ | ||||||
|  |         self.__get_url: t.Callable[[Period], str] = get_url | ||||||
|  |         """The callback to return the URL of the current report in a period.""" | ||||||
|  |  | ||||||
|  |         # Shortcut periods | ||||||
|  |         self.this_month_url: str = get_url(ThisMonth()) | ||||||
|  |         """The URL for this month.""" | ||||||
|  |         self.last_month_url: str = get_url(LastMonth()) | ||||||
|  |         """The URL for last month.""" | ||||||
|  |         self.since_last_month_url: str = get_url(SinceLastMonth()) | ||||||
|  |         """The URL since last mint.""" | ||||||
|  |         self.this_year_url: str = get_url(ThisYear()) | ||||||
|  |         """The URL for this year.""" | ||||||
|  |         self.last_year_url: str = get_url(LastYear()) | ||||||
|  |         """The URL for last year.""" | ||||||
|  |         self.today_url: str = get_url(Today()) | ||||||
|  |         """The URL for today.""" | ||||||
|  |         self.yesterday_url: str = get_url(Yesterday()) | ||||||
|  |         """The URL for yesterday.""" | ||||||
|  |         self.all_url: str = get_url(AllTime()) | ||||||
|  |         """The URL for all period.""" | ||||||
|  |         self.url_template: str = get_url(TemplatePeriod()) | ||||||
|  |         """The URL template.""" | ||||||
|  |  | ||||||
|  |         first: JournalEntry | None \ | ||||||
|  |             = JournalEntry.query.order_by(JournalEntry.date).first() | ||||||
|  |         start: date | None = None if first is None else first.date | ||||||
|  |  | ||||||
|  |         # Attributes | ||||||
|  |         self.data_start: date | None = start | ||||||
|  |         """The start of the data.""" | ||||||
|  |         self.has_data: bool = start is not None | ||||||
|  |         """Whether there is any data.""" | ||||||
|  |         self.has_last_month: bool = False | ||||||
|  |         """Where there is data in last month.""" | ||||||
|  |         self.has_last_year: bool = False | ||||||
|  |         """Whether there is data in last year.""" | ||||||
|  |         self.has_yesterday: bool = False | ||||||
|  |         """Whether there is data in yesterday.""" | ||||||
|  |         self.available_years: list[int] = [] | ||||||
|  |         """The available years.""" | ||||||
|  |  | ||||||
|  |         if self.has_data: | ||||||
|  |             today: date = date.today() | ||||||
|  |             self.has_last_month = start < date(today.year, today.month, 1) | ||||||
|  |             self.has_last_year = start.year < today.year | ||||||
|  |             self.has_yesterday = start < today | ||||||
|  |             if start.year < today.year - 1: | ||||||
|  |                 self.available_years \ | ||||||
|  |                     = reversed(range(start.year, today.year - 1)) | ||||||
|  |  | ||||||
|  |     def year_url(self, year: int) -> str: | ||||||
|  |         """Returns the period URL of a year. | ||||||
|  |  | ||||||
|  |         :param year: The year | ||||||
|  |         :return: The period URL of the year. | ||||||
|  |         """ | ||||||
|  |         return self.__get_url(YearPeriod(year)) | ||||||
							
								
								
									
										179
									
								
								src/accounting/report/period/description.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								src/accounting/report/period/description.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The period description composer. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date, timedelta | ||||||
|  |  | ||||||
|  | from accounting.locale import gettext | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_desc(start: date | None, end: date | None) -> str: | ||||||
|  |     """Returns the period description. | ||||||
|  |  | ||||||
|  |     :param start: The start of the period. | ||||||
|  |     :param end: The end of the period. | ||||||
|  |     :return: The period description. | ||||||
|  |     """ | ||||||
|  |     if start is None and end is None: | ||||||
|  |         return gettext("for all time") | ||||||
|  |     if start is None: | ||||||
|  |         return __get_until_desc(end) | ||||||
|  |     if end is None: | ||||||
|  |         return __get_since_desc(start) | ||||||
|  |     try: | ||||||
|  |         return __get_year_desc(start, end) | ||||||
|  |     except ValueError: | ||||||
|  |         pass | ||||||
|  |     try: | ||||||
|  |         return __get_month_desc(start, end) | ||||||
|  |     except ValueError: | ||||||
|  |         pass | ||||||
|  |     return __get_day_desc(start, end) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_since_desc(start: date) -> str: | ||||||
|  |     """Returns the description without the end day. | ||||||
|  |  | ||||||
|  |     :param start: The start of the period. | ||||||
|  |     :return: The description without the end day. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def get_start_desc() -> str: | ||||||
|  |         """Returns the description of the start day. | ||||||
|  |  | ||||||
|  |         :return: The description of the start day. | ||||||
|  |         """ | ||||||
|  |         if start.month == 1 and start.day == 1: | ||||||
|  |             return str(start.year) | ||||||
|  |         if start.day == 1: | ||||||
|  |             return __format_month(start) | ||||||
|  |         return __format_day(start) | ||||||
|  |  | ||||||
|  |     return gettext("since %(start)s", start=get_start_desc()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_until_desc(end: date) -> str: | ||||||
|  |     """Returns the description without the start day. | ||||||
|  |  | ||||||
|  |     :param end: The end of the period. | ||||||
|  |     :return: The description without the start day. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def get_end_desc() -> str: | ||||||
|  |         """Returns the description of the end day. | ||||||
|  |  | ||||||
|  |         :return: The description of the end day. | ||||||
|  |         """ | ||||||
|  |         if end.month == 12 and end.day == 31: | ||||||
|  |             return str(end.year) | ||||||
|  |         if (end + timedelta(days=1)).day == 1: | ||||||
|  |             return __format_month(end) | ||||||
|  |         return __format_day(end) | ||||||
|  |  | ||||||
|  |     return gettext("until %(end)s", end=get_end_desc()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_year_desc(start: date, end: date) -> str: | ||||||
|  |     """Returns the description as a year range. | ||||||
|  |  | ||||||
|  |     :param start: The start of the period. | ||||||
|  |     :param end: The end of the period. | ||||||
|  |     :return: The description as a year range. | ||||||
|  |     :raise ValueError: The period is not a year range. | ||||||
|  |     """ | ||||||
|  |     if start.month != 1 or start.day != 1 \ | ||||||
|  |             or end.month != 12 or end.day != 31: | ||||||
|  |         raise ValueError | ||||||
|  |     start_text: str = str(start.year) | ||||||
|  |     if start.year == end.year: | ||||||
|  |         return __get_in_desc(start_text) | ||||||
|  |     return __get_from_to_desc(start_text, str(end.year)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_month_desc(start: date, end: date) -> str: | ||||||
|  |     """Returns the description as a month range. | ||||||
|  |  | ||||||
|  |     :param start: The start of the period. | ||||||
|  |     :param end: The end of the period. | ||||||
|  |     :return: The description as a month range. | ||||||
|  |     :raise ValueError: The period is not a month range. | ||||||
|  |     """ | ||||||
|  |     if start.day != 1 or (end + timedelta(days=1)).day != 1: | ||||||
|  |         raise ValueError | ||||||
|  |     start_text: str = __format_month(start) | ||||||
|  |     if start.year == end.year and start.month == end.month: | ||||||
|  |         return __get_in_desc(start_text) | ||||||
|  |     if start.year == end.year: | ||||||
|  |         return __get_from_to_desc(start_text, str(end.month)) | ||||||
|  |     return __get_from_to_desc(start_text, __format_month(end)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_day_desc(start: date, end: date) -> str: | ||||||
|  |     """Returns the description as a day range. | ||||||
|  |  | ||||||
|  |     :param start: The start of the period. | ||||||
|  |     :param end: The end of the period. | ||||||
|  |     :return: The description as a day range. | ||||||
|  |     :raise ValueError: The period is a month or year range. | ||||||
|  |     """ | ||||||
|  |     start_text: str = __format_day(start) | ||||||
|  |     if start == end: | ||||||
|  |         return __get_in_desc(start_text) | ||||||
|  |     if start.year == end.year and start.month == end.month: | ||||||
|  |         return __get_from_to_desc(start_text, str(end.day)) | ||||||
|  |     if start.year == end.year: | ||||||
|  |         end_month_day: str = f"{end.month}/{end.day}" | ||||||
|  |         return __get_from_to_desc(start_text, end_month_day) | ||||||
|  |     return __get_from_to_desc(start_text, __format_day(end)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __format_month(month: date) -> str: | ||||||
|  |     """Formats a month. | ||||||
|  |  | ||||||
|  |     :param month: The month. | ||||||
|  |     :return: The formatted month. | ||||||
|  |     """ | ||||||
|  |     return f"{month.year}/{month.month}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __format_day(day: date) -> str: | ||||||
|  |     """Formats a day. | ||||||
|  |  | ||||||
|  |     :param day: The day. | ||||||
|  |     :return: The formatted day. | ||||||
|  |     """ | ||||||
|  |     return f"{day.year}/{day.month}/{day.day}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_in_desc(period: str) -> str: | ||||||
|  |     """Returns the description of a whole year, month, or day. | ||||||
|  |  | ||||||
|  |     :param period: The time period. | ||||||
|  |     :return: The description of a whole year, month, or day. | ||||||
|  |     """ | ||||||
|  |     return gettext("in %(period)s", period=period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_from_to_desc(start: str, end: str) -> str: | ||||||
|  |     """Returns the description of a separated start and end. | ||||||
|  |  | ||||||
|  |     :param start: The start. | ||||||
|  |     :param end: The end. | ||||||
|  |     :return: The description of the separated start and end. | ||||||
|  |     """ | ||||||
|  |     return gettext("in %(start)s-%(end)s", start=start, end=end) | ||||||
							
								
								
									
										31
									
								
								src/accounting/report/period/month_end.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/accounting/report/period/month_end.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The utility to return the end of a month. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import calendar | ||||||
|  | from datetime import date | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def month_end(day: date) -> date: | ||||||
|  |     """Returns the end day of month for a date. | ||||||
|  |  | ||||||
|  |     :param day: The date. | ||||||
|  |     :return: The end day of the month of that day. | ||||||
|  |     """ | ||||||
|  |     last_day: int = calendar.monthrange(day.year, day.month)[1] | ||||||
|  |     return date(day.year, day.month, last_day) | ||||||
							
								
								
									
										119
									
								
								src/accounting/report/period/parser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								src/accounting/report/period/parser.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,119 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The period specification parser. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import calendar | ||||||
|  | import re | ||||||
|  | import typing as t | ||||||
|  | from datetime import date | ||||||
|  |  | ||||||
|  | from .period import Period | ||||||
|  | from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \ | ||||||
|  |     LastYear, Today, Yesterday, AllTime | ||||||
|  |  | ||||||
|  | DATE_SPEC_RE: str = r"(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?" | ||||||
|  | """The regular expression of a date specification.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_period(spec: str | None = None) -> Period: | ||||||
|  |     """Returns a period instance. | ||||||
|  |  | ||||||
|  |     :param spec: The period specification, or omit for the default. | ||||||
|  |     :return: The period instance. | ||||||
|  |     :raise ValueError: When the period specification is invalid. | ||||||
|  |     """ | ||||||
|  |     if spec is None: | ||||||
|  |         return ThisMonth() | ||||||
|  |     named_periods: dict[str, t.Type[t.Callable[[], Period]]] = { | ||||||
|  |         "this-month": lambda: ThisMonth(), | ||||||
|  |         "last-month": lambda: LastMonth(), | ||||||
|  |         "since-last-month": lambda: SinceLastMonth(), | ||||||
|  |         "this-year": lambda: ThisYear(), | ||||||
|  |         "last-year": lambda: LastYear(), | ||||||
|  |         "today": lambda: Today(), | ||||||
|  |         "yesterday": lambda: Yesterday(), | ||||||
|  |         "all-time": lambda: AllTime(), | ||||||
|  |     } | ||||||
|  |     if spec in named_periods: | ||||||
|  |         return named_periods[spec]() | ||||||
|  |     start, end = __parse_spec(spec) | ||||||
|  |     if start is not None and end is not None and start > end: | ||||||
|  |         raise ValueError | ||||||
|  |     return Period(start, end) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __parse_spec(text: str) -> tuple[date | None, date | None]: | ||||||
|  |     """Parses the period specification. | ||||||
|  |  | ||||||
|  |     :param text: The period specification. | ||||||
|  |     :return: The start and end day of the period.  The start and end day | ||||||
|  |         may be None. | ||||||
|  |     :raise ValueError: When the date is invalid. | ||||||
|  |     """ | ||||||
|  |     if text == "-": | ||||||
|  |         return None, None | ||||||
|  |     m = re.match(f"^{DATE_SPEC_RE}$", text) | ||||||
|  |     if m is not None: | ||||||
|  |         return __get_start(m[1], m[2], m[3]), \ | ||||||
|  |                __get_end(m[1], m[2], m[3]) | ||||||
|  |     m = re.match(f"^{DATE_SPEC_RE}-$", text) | ||||||
|  |     if m is not None: | ||||||
|  |         return __get_start(m[1], m[2], m[3]), None | ||||||
|  |     m = re.match(f"-{DATE_SPEC_RE}$", text) | ||||||
|  |     if m is not None: | ||||||
|  |         return None, __get_end(m[1], m[2], m[3]) | ||||||
|  |     m = re.match(f"^{DATE_SPEC_RE}-{DATE_SPEC_RE}$", text) | ||||||
|  |     if m is not None: | ||||||
|  |         return __get_start(m[1], m[2], m[3]), \ | ||||||
|  |                __get_end(m[4], m[5], m[6]) | ||||||
|  |     raise ValueError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_start(year: str, month: str | None, day: str | None) -> date: | ||||||
|  |     """Returns the start of the period from the date representation. | ||||||
|  |  | ||||||
|  |     :param year: The year. | ||||||
|  |     :param month: The month, if any. | ||||||
|  |     :param day: The day, if any. | ||||||
|  |     :return: The start of the period. | ||||||
|  |     :raise ValueError: When the date is invalid. | ||||||
|  |     """ | ||||||
|  |     if day is not None: | ||||||
|  |         return date(int(year), int(month), int(day)) | ||||||
|  |     if month is not None: | ||||||
|  |         return date(int(year), int(month), 1) | ||||||
|  |     return date(int(year), 1, 1) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_end(year: str, month: str | None, day: str | None) -> date: | ||||||
|  |     """Returns the end of the period from the date representation. | ||||||
|  |  | ||||||
|  |     :param year: The year. | ||||||
|  |     :param month: The month, if any. | ||||||
|  |     :param day: The day, if any. | ||||||
|  |     :return: The end of the period. | ||||||
|  |     :raise ValueError: When the date is invalid. | ||||||
|  |     """ | ||||||
|  |     if day is not None: | ||||||
|  |         return date(int(year), int(month), int(day)) | ||||||
|  |     if month is not None: | ||||||
|  |         year_n: int = int(year) | ||||||
|  |         month_n: int = int(month) | ||||||
|  |         day_n: int = calendar.monthrange(year_n, month_n)[1] | ||||||
|  |         return date(year_n, month_n, day_n) | ||||||
|  |     return date(int(year), 12, 31) | ||||||
							
								
								
									
										129
									
								
								src/accounting/report/period/period.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								src/accounting/report/period/period.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,129 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The date period. | ||||||
|  |  | ||||||
|  | This file is largely taken from the NanoParma ERP project, first written in | ||||||
|  | 2021/9/16 by imacat (imacat@nanoparma.com). | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import typing as t | ||||||
|  | from datetime import date, timedelta | ||||||
|  |  | ||||||
|  | from .description import get_desc | ||||||
|  | from .month_end import month_end | ||||||
|  | from .specification import get_spec | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Period: | ||||||
|  |     """A date period.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, start: date | None, end: date | None): | ||||||
|  |         """Constructs a new date period. | ||||||
|  |  | ||||||
|  |         :param start: The start date, or None from the very beginning. | ||||||
|  |         :param end: The end date, or None till no end. | ||||||
|  |         """ | ||||||
|  |         self.start: date | None = start | ||||||
|  |         """The start of the period.""" | ||||||
|  |         self.end: date | None = end | ||||||
|  |         """The end of the period.""" | ||||||
|  |         self.is_default: bool = False | ||||||
|  |         """Whether the is the default period.""" | ||||||
|  |         self.is_this_month: bool = False | ||||||
|  |         """Whether the period is this month.""" | ||||||
|  |         self.is_last_month: bool = False | ||||||
|  |         """Whether the period is last month.""" | ||||||
|  |         self.is_since_last_month: bool = False | ||||||
|  |         """Whether the period is since last month.""" | ||||||
|  |         self.is_this_year: bool = False | ||||||
|  |         """Whether the period is this year.""" | ||||||
|  |         self.is_last_year: bool = False | ||||||
|  |         """Whether the period is last year.""" | ||||||
|  |         self.is_today: bool = False | ||||||
|  |         """Whether the period is today.""" | ||||||
|  |         self.is_yesterday: bool = False | ||||||
|  |         """Whether the period is yesterday.""" | ||||||
|  |         self.is_all: bool = start is None and end is None | ||||||
|  |         """Whether the period is all time.""" | ||||||
|  |         self.spec: str = "" | ||||||
|  |         """The period specification.""" | ||||||
|  |         self.desc: str = "" | ||||||
|  |         """The text description.""" | ||||||
|  |         self.is_a_month: bool = False | ||||||
|  |         """Whether the period is a whole month.""" | ||||||
|  |         self.is_type_month: bool = False | ||||||
|  |         """Whether the period is for the month chooser.""" | ||||||
|  |         self.is_a_year: bool = False | ||||||
|  |         """Whether the period is a whole year.""" | ||||||
|  |         self.is_a_day: bool = False | ||||||
|  |         """Whether the period is a single day.""" | ||||||
|  |         self._set_properties() | ||||||
|  |  | ||||||
|  |     def _set_properties(self) -> None: | ||||||
|  |         """Sets the following properties. | ||||||
|  |  | ||||||
|  |         * self.spec | ||||||
|  |         * self.desc | ||||||
|  |         * self.is_a_month | ||||||
|  |         * self.is_type_month | ||||||
|  |         * self.is_a_year | ||||||
|  |         * self.is_a_day | ||||||
|  |  | ||||||
|  |         Override this method to set the properties in the subclasses, to skip | ||||||
|  |         the calculation. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.spec = get_spec(self.start, self.end) | ||||||
|  |         self.desc = get_desc(self.start, self.end) | ||||||
|  |         if self.start is None or self.end is None: | ||||||
|  |             return | ||||||
|  |         self.is_a_month = self.start.day == 1 \ | ||||||
|  |             and self.end == month_end(self.start) | ||||||
|  |         self.is_type_month = self.is_a_month | ||||||
|  |         self.is_a_year = self.start == date(self.start.year, 1, 1) \ | ||||||
|  |             and self.end == date(self.start.year, 12, 31) | ||||||
|  |         self.is_a_day = self.start == self.end | ||||||
|  |  | ||||||
|  |     def is_year(self, year: int) -> bool: | ||||||
|  |         """Returns whether the period is the specific year period. | ||||||
|  |  | ||||||
|  |         :param year: The year. | ||||||
|  |         :return: True if the period is the year period, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         if not self.is_a_year: | ||||||
|  |             return False | ||||||
|  |         return self.start.year == year | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_type_arbitrary(self) -> bool: | ||||||
|  |         """Returns whether this period is an arbitrary period. | ||||||
|  |  | ||||||
|  |         :return: True if this is an arbitrary period, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         return not self.is_type_month and not self.is_a_year \ | ||||||
|  |             and not self.is_a_day | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def before(self) -> t.Self | None: | ||||||
|  |         """Returns the period before this period. | ||||||
|  |  | ||||||
|  |         :return: The period before this period. | ||||||
|  |         """ | ||||||
|  |         if self.start is None: | ||||||
|  |             return None | ||||||
|  |         return Period(None, self.start - timedelta(days=1)) | ||||||
							
								
								
									
										168
									
								
								src/accounting/report/period/shortcuts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								src/accounting/report/period/shortcuts.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,168 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The named shortcut periods. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date, timedelta | ||||||
|  |  | ||||||
|  | from accounting.locale import gettext | ||||||
|  | from .month_end import month_end | ||||||
|  | from .period import Period | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ThisMonth(Period): | ||||||
|  |     """The period of this month.""" | ||||||
|  |     def __init__(self): | ||||||
|  |         today: date = date.today() | ||||||
|  |         this_month_start: date = date(today.year, today.month, 1) | ||||||
|  |         super().__init__(this_month_start, month_end(today)) | ||||||
|  |         self.is_default = True | ||||||
|  |         self.is_this_month = True | ||||||
|  |  | ||||||
|  |     def _set_properties(self) -> None: | ||||||
|  |         self.spec = "this-month" | ||||||
|  |         self.desc = gettext("This Month") | ||||||
|  |         self.is_a_month = True | ||||||
|  |         self.is_type_month = True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LastMonth(Period): | ||||||
|  |     """The period of this month.""" | ||||||
|  |     def __init__(self): | ||||||
|  |         today: date = date.today() | ||||||
|  |         year: int = today.year | ||||||
|  |         month: int = today.month - 1 | ||||||
|  |         if month < 1: | ||||||
|  |             year = year - 1 | ||||||
|  |             month = 12 | ||||||
|  |         start: date = date(year, month, 1) | ||||||
|  |         super().__init__(start, month_end(start)) | ||||||
|  |         self.is_last_month = True | ||||||
|  |  | ||||||
|  |     def _set_properties(self) -> None: | ||||||
|  |         self.spec = "last-month" | ||||||
|  |         self.desc = gettext("Last Month") | ||||||
|  |         self.is_a_month = True | ||||||
|  |         self.is_type_month = True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SinceLastMonth(Period): | ||||||
|  |     """The period of this month.""" | ||||||
|  |     def __init__(self): | ||||||
|  |         today: date = date.today() | ||||||
|  |         year: int = today.year | ||||||
|  |         month: int = today.month - 1 | ||||||
|  |         if month < 1: | ||||||
|  |             year = year - 1 | ||||||
|  |             month = 12 | ||||||
|  |         start: date = date(year, month, 1) | ||||||
|  |         super().__init__(start, None) | ||||||
|  |         self.is_since_last_month = True | ||||||
|  |  | ||||||
|  |     def _set_properties(self) -> None: | ||||||
|  |         self.spec = "since-last-month" | ||||||
|  |         self.desc = gettext("Since Last Month") | ||||||
|  |         self.is_type_month = True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ThisYear(Period): | ||||||
|  |     """The period of this year.""" | ||||||
|  |     def __init__(self): | ||||||
|  |         year: int = date.today().year | ||||||
|  |         start: date = date(year, 1, 1) | ||||||
|  |         end: date = date(year, 12, 31) | ||||||
|  |         super().__init__(start, end) | ||||||
|  |         self.is_this_year = True | ||||||
|  |  | ||||||
|  |     def _set_properties(self) -> None: | ||||||
|  |         self.spec = "this-year" | ||||||
|  |         self.desc = gettext("This Year") | ||||||
|  |         self.is_a_year = True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LastYear(Period): | ||||||
|  |     """The period of last year.""" | ||||||
|  |     def __init__(self): | ||||||
|  |         year: int = date.today().year | ||||||
|  |         start: date = date(year - 1, 1, 1) | ||||||
|  |         end: date = date(year - 1, 12, 31) | ||||||
|  |         super().__init__(start, end) | ||||||
|  |         self.is_last_year = True | ||||||
|  |  | ||||||
|  |     def _set_properties(self) -> None: | ||||||
|  |         self.spec = "last-year" | ||||||
|  |         self.desc = gettext("Last Year") | ||||||
|  |         self.is_a_year = True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Today(Period): | ||||||
|  |     """The period of today.""" | ||||||
|  |     def __init__(self): | ||||||
|  |         today: date = date.today() | ||||||
|  |         super().__init__(today, today) | ||||||
|  |         self.is_today = True | ||||||
|  |  | ||||||
|  |     def _set_properties(self) -> None: | ||||||
|  |         self.spec = "today" | ||||||
|  |         self.desc = gettext("Today") | ||||||
|  |         self.is_a_day = True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Yesterday(Period): | ||||||
|  |     """The period of yesterday.""" | ||||||
|  |     def __init__(self): | ||||||
|  |         yesterday: date = date.today() - timedelta(days=1) | ||||||
|  |         super().__init__(yesterday, yesterday) | ||||||
|  |         self.is_yesterday = True | ||||||
|  |  | ||||||
|  |     def _set_properties(self) -> None: | ||||||
|  |         self.spec = "yesterday" | ||||||
|  |         self.desc = gettext("Yesterday") | ||||||
|  |         self.is_a_day = True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AllTime(Period): | ||||||
|  |     """The period of all time.""" | ||||||
|  |     def __init__(self): | ||||||
|  |         super().__init__(None, None) | ||||||
|  |         self.is_all = True | ||||||
|  |  | ||||||
|  |     def _set_properties(self) -> None: | ||||||
|  |         self.spec = "all-time" | ||||||
|  |         self.desc = gettext("All") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TemplatePeriod(Period): | ||||||
|  |     """The period template.""" | ||||||
|  |     def __init__(self): | ||||||
|  |         super().__init__(None, None) | ||||||
|  |  | ||||||
|  |     def _set_properties(self) -> None: | ||||||
|  |         self.spec = "PERIOD" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class YearPeriod(Period): | ||||||
|  |     """A year period.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, year: int): | ||||||
|  |         """Constructs a year period. | ||||||
|  |  | ||||||
|  |         :param year: The year. | ||||||
|  |         """ | ||||||
|  |         start: date = date(year, 1, 1) | ||||||
|  |         end: date = date(year, 12, 31) | ||||||
|  |         super().__init__(start, end) | ||||||
							
								
								
									
										120
									
								
								src/accounting/report/period/specification.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								src/accounting/report/period/specification.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,120 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The period specification composer. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date, timedelta | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_spec(start: date | None, end: date | None) -> str: | ||||||
|  |     """Returns the period specification. | ||||||
|  |  | ||||||
|  |     :param start: The start of the period. | ||||||
|  |     :param end: The end of the period. | ||||||
|  |     :return: The period specification. | ||||||
|  |     """ | ||||||
|  |     if start is None and end is None: | ||||||
|  |         return "-" | ||||||
|  |     if end is None: | ||||||
|  |         return __get_since_spec(start) | ||||||
|  |     if start is None: | ||||||
|  |         return __get_until_spec(end) | ||||||
|  |     try: | ||||||
|  |         return __get_year_spec(start, end) | ||||||
|  |     except ValueError: | ||||||
|  |         pass | ||||||
|  |     try: | ||||||
|  |         return __get_month_spec(start, end) | ||||||
|  |     except ValueError: | ||||||
|  |         pass | ||||||
|  |     return __get_day_spec(start, end) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_since_spec(start: date) -> str: | ||||||
|  |     """Returns the period specification without the end day. | ||||||
|  |  | ||||||
|  |     :param start: The start of the period. | ||||||
|  |     :return: The period specification without the end day | ||||||
|  |     """ | ||||||
|  |     if start.month == 1 and start.day == 1: | ||||||
|  |         return start.strftime("%Y-") | ||||||
|  |     if start.day == 1: | ||||||
|  |         return start.strftime("%Y-%m-") | ||||||
|  |     return start.strftime("%Y-%m-%d-") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_until_spec(end: date) -> str: | ||||||
|  |     """Returns the period specification without the start day. | ||||||
|  |  | ||||||
|  |     :param end: The end of the period. | ||||||
|  |     :return: The period specification without the start day | ||||||
|  |     """ | ||||||
|  |     if end.month == 12 and end.day == 31: | ||||||
|  |         return end.strftime("-%Y") | ||||||
|  |     if (end + timedelta(days=1)).day == 1: | ||||||
|  |         return end.strftime("-%Y-%m") | ||||||
|  |     return end.strftime("-%Y-%m-%d") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_year_spec(start: date, end: date) -> str: | ||||||
|  |     """Returns the period specification as a year range. | ||||||
|  |  | ||||||
|  |     :param start: The start of the period. | ||||||
|  |     :param end: The end of the period. | ||||||
|  |     :return: The period specification as a year range. | ||||||
|  |     :raise ValueError: The period is not a year range. | ||||||
|  |     """ | ||||||
|  |     if start.month != 1 or start.day != 1 \ | ||||||
|  |             or end.month != 12 or end.day != 31: | ||||||
|  |         raise ValueError | ||||||
|  |     start_spec: str = start.strftime("%Y") | ||||||
|  |     if start.year == end.year: | ||||||
|  |         return start_spec | ||||||
|  |     end_spec: str = end.strftime("%Y") | ||||||
|  |     return f"{start_spec}-{end_spec}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_month_spec(start: date, end: date) -> str: | ||||||
|  |     """Returns the period specification as a month range. | ||||||
|  |  | ||||||
|  |     :param start: The start of the period. | ||||||
|  |     :param end: The end of the period. | ||||||
|  |     :return: The period specification as a month range. | ||||||
|  |     :raise ValueError: The period is not a month range. | ||||||
|  |     """ | ||||||
|  |     if start.day != 1 or (end + timedelta(days=1)).day != 1: | ||||||
|  |         raise ValueError | ||||||
|  |     start_spec: str = start.strftime("%Y-%m") | ||||||
|  |     if start.year == end.year and start.month == end.month: | ||||||
|  |         return start_spec | ||||||
|  |     end_spec: str = end.strftime("%Y-%m") | ||||||
|  |     return f"{start_spec}-{end_spec}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_day_spec(start: date, end: date) -> str: | ||||||
|  |     """Returns the period specification as a day range. | ||||||
|  |  | ||||||
|  |     :param start: The start of the period. | ||||||
|  |     :param end: The end of the period. | ||||||
|  |     :return: The period specification as a day range. | ||||||
|  |     :raise ValueError: The period is a month or year range. | ||||||
|  |     """ | ||||||
|  |     start_spec: str = start.strftime("%Y-%m-%d") | ||||||
|  |     if start == end: | ||||||
|  |         return start_spec | ||||||
|  |     end_spec: str = end.strftime("%Y-%m-%d") | ||||||
|  |     return f"{start_spec}-{end_spec}" | ||||||
							
								
								
									
										26
									
								
								src/accounting/report/reports/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/accounting/report/reports/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The reports. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from .balance_sheet import BalanceSheet | ||||||
|  | from .income_expenses import IncomeExpenses | ||||||
|  | from .income_statement import IncomeStatement | ||||||
|  | from .journal import Journal | ||||||
|  | from .ledger import Ledger | ||||||
|  | from .search import Search | ||||||
|  | from .trial_balance import TrialBalance | ||||||
							
								
								
									
										474
									
								
								src/accounting/report/reports/balance_sheet.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										474
									
								
								src/accounting/report/reports/balance_sheet.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,474 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The balance sheet. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import render_template, Response | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import gettext | ||||||
|  | from accounting.models import Currency, BaseAccount, Account, 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)\ | ||||||
|  |             .order_by(Account.base_code, Account.no) | ||||||
|  |         account_balances: list[sa.Row] \ | ||||||
|  |             = db.session.execute(select_balance).all() | ||||||
|  |         self.__all_accounts: list[Account] = Account.query\ | ||||||
|  |             .filter(sa.or_(Account.id.in_({x.id for x in account_balances}), | ||||||
|  |                            Account.base_code == "3351", | ||||||
|  |                            Account.base_code == "3353")).all() | ||||||
|  |         account_by_id: dict[int, Account] \ | ||||||
|  |             = {x.id: x for x in self.__all_accounts} | ||||||
|  |         self.accounts: list[ReportAccount] \ | ||||||
|  |             = [ReportAccount(account=account_by_id[x.id], | ||||||
|  |                              amount=x.balance, | ||||||
|  |                              url=ledger_url(self.__currency, | ||||||
|  |                                             account_by_id[x.id], | ||||||
|  |                                             self.__period)) | ||||||
|  |                for x in account_balances] | ||||||
|  |         self.__add_accumulated() | ||||||
|  |         self.__add_current_period() | ||||||
|  |         self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no)) | ||||||
|  |         for balance in self.accounts: | ||||||
|  |             if not balance.account.base_code.startswith("1"): | ||||||
|  |                 balance.amount = -balance.amount | ||||||
|  |         return self.accounts | ||||||
|  |  | ||||||
|  |     def __add_accumulated(self) -> None: | ||||||
|  |         """Adds the accumulated profit or loss to the balances. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.__add_owner_s_equity(Account.ACCUMULATED_CHANGE_CODE, | ||||||
|  |                                   self.__query_accumulated(), | ||||||
|  |                                   self.__period) | ||||||
|  |  | ||||||
|  |     def __query_accumulated(self) -> Decimal | None: | ||||||
|  |         """Queries and returns the accumulated profit or loss. | ||||||
|  |  | ||||||
|  |         :return: The accumulated profit or loss. | ||||||
|  |         """ | ||||||
|  |         if self.__period.start is None: | ||||||
|  |             return None | ||||||
|  |         conditions: list[sa.BinaryExpression] \ | ||||||
|  |             = [JournalEntryLineItem.currency_code == self.__currency.code, | ||||||
|  |                JournalEntry.date < self.__period.start] | ||||||
|  |         return self.__query_balance(conditions) | ||||||
|  |  | ||||||
|  |     def __add_current_period(self) -> None: | ||||||
|  |         """Adds the accumulated profit or loss to the balances. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.__add_owner_s_equity(Account.NET_CHANGE_CODE, | ||||||
|  |                                   self.__query_current_period(), | ||||||
|  |                                   self.__period) | ||||||
|  |  | ||||||
|  |     def __query_current_period(self) -> Decimal | None: | ||||||
|  |         """Queries and returns the net income or loss for current period. | ||||||
|  |  | ||||||
|  |         :return: The net income or loss for current period. | ||||||
|  |         """ | ||||||
|  |         conditions: list[sa.BinaryExpression] \ | ||||||
|  |             = [JournalEntryLineItem.currency_code == self.__currency.code] | ||||||
|  |         if self.__period.start is not None: | ||||||
|  |             conditions.append(JournalEntry.date >= self.__period.start) | ||||||
|  |         if self.__period.end is not None: | ||||||
|  |             conditions.append(JournalEntry.date <= self.__period.end) | ||||||
|  |         return self.__query_balance(conditions) | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def __query_balance(conditions: list[sa.BinaryExpression])\ | ||||||
|  |             -> Decimal: | ||||||
|  |         """Queries the balance. | ||||||
|  |  | ||||||
|  |         :param conditions: The SQL conditions for the balance. | ||||||
|  |         :return: The balance. | ||||||
|  |         """ | ||||||
|  |         conditions.extend([sa.not_(Account.base_code.startswith(x)) | ||||||
|  |                            for x in {"1", "2", "3"}]) | ||||||
|  |         balance_func: sa.Function = sa.func.sum(sa.case( | ||||||
|  |             (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), | ||||||
|  |             else_=-JournalEntryLineItem.amount)) | ||||||
|  |         select_balance: sa.Select = sa.select(balance_func)\ | ||||||
|  |             .join(JournalEntry).join(Account).filter(*conditions) | ||||||
|  |         return db.session.scalar(select_balance) | ||||||
|  |  | ||||||
|  |     def __add_owner_s_equity(self, code: str, amount: Decimal | None, | ||||||
|  |                              period: Period) -> None: | ||||||
|  |         """Adds an owner's equity balance. | ||||||
|  |  | ||||||
|  |         :param code: The code of the account to add. | ||||||
|  |         :param amount: The amount. | ||||||
|  |         :param period: The period. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         if amount is None: | ||||||
|  |             return | ||||||
|  |         url: str = income_statement_url(self.__currency, period) | ||||||
|  |         # There is an existing balance. | ||||||
|  |         account_balance_by_code: dict[str, ReportAccount] \ | ||||||
|  |             = {x.account.code: x for x in self.accounts} | ||||||
|  |         if code in account_balance_by_code: | ||||||
|  |             balance: ReportAccount = account_balance_by_code[code] | ||||||
|  |             balance.amount = balance.amount + amount | ||||||
|  |             balance.url = url | ||||||
|  |             return | ||||||
|  |         # Add a new balance | ||||||
|  |         account_by_code: dict[str, Account] \ | ||||||
|  |             = {x.code: x for x in self.__all_accounts} | ||||||
|  |         self.accounts.append(ReportAccount(account=account_by_code[code], | ||||||
|  |                                            amount=amount, | ||||||
|  |                                            url=url)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CSVHalfRow: | ||||||
|  |     """A half row in the CSV.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, title: str | None, amount: Decimal | None): | ||||||
|  |         """The constructs a half row in the CSV. | ||||||
|  |  | ||||||
|  |         :param title: The title. | ||||||
|  |         :param amount: The amount. | ||||||
|  |         """ | ||||||
|  |         self.title: str | None = title | ||||||
|  |         """The title.""" | ||||||
|  |         self.amount: Decimal | None = amount | ||||||
|  |         """The amount.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CSVRow(BaseCSVRow): | ||||||
|  |     """A row in the CSV.""" | ||||||
|  |  | ||||||
|  |     def __init__(self): | ||||||
|  |         """Constructs a row in the CSV.""" | ||||||
|  |         self.asset_title: str | None = None | ||||||
|  |         """The title of the asset.""" | ||||||
|  |         self.asset_amount: Decimal | None = None | ||||||
|  |         """The amount of the asset.""" | ||||||
|  |         self.liability_title: str | None = None | ||||||
|  |         """The title of the liability.""" | ||||||
|  |         self.liability_amount: Decimal | None = None | ||||||
|  |         """The amount of the liability.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def values(self) -> list[str | Decimal | None]: | ||||||
|  |         """Returns the values of the row. | ||||||
|  |  | ||||||
|  |         :return: The values of the row. | ||||||
|  |         """ | ||||||
|  |         return [self.asset_title, self.asset_amount, | ||||||
|  |                 self.liability_title, self.liability_amount] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PageParams(BasePageParams): | ||||||
|  |     """The HTML page parameters.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, currency: Currency, | ||||||
|  |                  period: Period, | ||||||
|  |                  has_data: bool, | ||||||
|  |                  assets: Section, | ||||||
|  |                  liabilities: Section, | ||||||
|  |                  owner_s_equity: Section): | ||||||
|  |         """Constructs the HTML page parameters. | ||||||
|  |  | ||||||
|  |         :param currency: The currency. | ||||||
|  |         :param period: The period. | ||||||
|  |         :param has_data: True if there is any data, or False otherwise. | ||||||
|  |         :param assets: The assets. | ||||||
|  |         :param liabilities: The liabilities. | ||||||
|  |         :param owner_s_equity: The owner's equity. | ||||||
|  |         """ | ||||||
|  |         self.currency: Currency = currency | ||||||
|  |         """The currency.""" | ||||||
|  |         self.period: Period = period | ||||||
|  |         """The period.""" | ||||||
|  |         self.__has_data: bool = has_data | ||||||
|  |         """True if there is any data, or False otherwise.""" | ||||||
|  |         self.assets: Section = assets | ||||||
|  |         """The assets.""" | ||||||
|  |         self.liabilities: Section = liabilities | ||||||
|  |         """The liabilities.""" | ||||||
|  |         self.owner_s_equity: Section = owner_s_equity | ||||||
|  |         """The owner's equity.""" | ||||||
|  |         self.period_chooser: PeriodChooser = PeriodChooser( | ||||||
|  |             lambda x: balance_sheet_url(currency, x)) | ||||||
|  |         """The period chooser.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def has_data(self) -> bool: | ||||||
|  |         """Returns whether there is any data on the page. | ||||||
|  |  | ||||||
|  |         :return: True if there is any data, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         return self.__has_data | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def report_chooser(self) -> ReportChooser: | ||||||
|  |         """Returns the report chooser. | ||||||
|  |  | ||||||
|  |         :return: The report chooser. | ||||||
|  |         """ | ||||||
|  |         return ReportChooser(ReportType.BALANCE_SHEET, | ||||||
|  |                              currency=self.currency, | ||||||
|  |                              period=self.period) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def currency_options(self) -> list[OptionLink]: | ||||||
|  |         """Returns the currency options. | ||||||
|  |  | ||||||
|  |         :return: The currency options. | ||||||
|  |         """ | ||||||
|  |         return self._get_currency_options( | ||||||
|  |             lambda x: balance_sheet_url(x, self.period), self.currency) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BalanceSheet(BaseReport): | ||||||
|  |     """The balance sheet.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, currency: Currency, period: Period): | ||||||
|  |         """Constructs a balance sheet. | ||||||
|  |  | ||||||
|  |         :param currency: The currency. | ||||||
|  |         :param period: The period. | ||||||
|  |         """ | ||||||
|  |         self.__currency: Currency = currency | ||||||
|  |         """The currency.""" | ||||||
|  |         self.__period: Period = period | ||||||
|  |         """The period.""" | ||||||
|  |         self.__has_data: bool | ||||||
|  |         """True if there is any data, or False otherwise.""" | ||||||
|  |         self.__assets: Section | ||||||
|  |         """The assets.""" | ||||||
|  |         self.__liabilities: Section | ||||||
|  |         """The liabilities.""" | ||||||
|  |         self.__owner_s_equity: Section | ||||||
|  |         """The owner's equity.""" | ||||||
|  |         self.__set_data() | ||||||
|  |  | ||||||
|  |     def __set_data(self) -> None: | ||||||
|  |         """Queries and sets assets, the liabilities, and the owner's equity | ||||||
|  |         sections in the balance sheet. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         balances: list[ReportAccount] = AccountCollector( | ||||||
|  |             self.__currency, self.__period).accounts | ||||||
|  |  | ||||||
|  |         titles: list[BaseAccount] = BaseAccount.query\ | ||||||
|  |             .filter(BaseAccount.code.in_({"1", "2", "3"})).all() | ||||||
|  |         subtitles: list[BaseAccount] = BaseAccount.query\ | ||||||
|  |             .filter(BaseAccount.code.in_({x.account.base_code[:2] | ||||||
|  |                                           for x in balances})).all() | ||||||
|  |  | ||||||
|  |         sections: dict[str, Section] = {x.code: Section(x) for x in titles} | ||||||
|  |         subsections: dict[str, Subsection] = {x.code: Subsection(x) | ||||||
|  |                                               for x in subtitles} | ||||||
|  |         for subsection in subsections.values(): | ||||||
|  |             sections[subsection.title.code[0]].subsections.append(subsection) | ||||||
|  |         for balance in balances: | ||||||
|  |             subsections[balance.account.base_code[:2]].accounts.append(balance) | ||||||
|  |  | ||||||
|  |         self.__has_data = len(balances) > 0 | ||||||
|  |         self.__assets = sections["1"] | ||||||
|  |         self.__liabilities = sections["2"] | ||||||
|  |         self.__owner_s_equity = sections["3"] | ||||||
|  |  | ||||||
|  |     def csv(self) -> Response: | ||||||
|  |         """Returns the report as CSV for download. | ||||||
|  |  | ||||||
|  |         :return: The response of the report for download. | ||||||
|  |         """ | ||||||
|  |         filename: str = "balance-sheet-{currency}-{period}.csv"\ | ||||||
|  |             .format(currency=self.__currency.code, | ||||||
|  |                     period=period_spec(self.__period)) | ||||||
|  |         return csv_download(filename, self.__get_csv_rows()) | ||||||
|  |  | ||||||
|  |     def __get_csv_rows(self) -> list[CSVRow]: | ||||||
|  |         """Composes and returns the CSV rows. | ||||||
|  |  | ||||||
|  |         :return: The CSV rows. | ||||||
|  |         """ | ||||||
|  |         asset_rows: list[CSVHalfRow] = self.__section_csv_rows(self.__assets) | ||||||
|  |         liability_rows: list[CSVHalfRow] = [] | ||||||
|  |         liability_rows.extend(self.__section_csv_rows(self.__liabilities)) | ||||||
|  |         liability_rows.append(CSVHalfRow(gettext("Total"), | ||||||
|  |                                          self.__liabilities.total)) | ||||||
|  |         liability_rows.append(CSVHalfRow(None, None)) | ||||||
|  |         liability_rows.extend(self.__section_csv_rows(self.__owner_s_equity)) | ||||||
|  |         liability_rows.append(CSVHalfRow(gettext("Total"), | ||||||
|  |                                          self.__owner_s_equity.total)) | ||||||
|  |         rows: list[CSVRow] = [CSVRow() for _ in | ||||||
|  |                               range(max(len(asset_rows), len(liability_rows)))] | ||||||
|  |         for i in range(len(rows)): | ||||||
|  |             if i < len(asset_rows): | ||||||
|  |                 rows[i].asset_title = asset_rows[i].title | ||||||
|  |                 rows[i].asset_amount = asset_rows[i].amount | ||||||
|  |             if i < len(liability_rows) and liability_rows[i].title is not None: | ||||||
|  |                 rows[i].liability_title = liability_rows[i].title | ||||||
|  |                 rows[i].liability_amount = liability_rows[i].amount | ||||||
|  |         total: CSVRow = CSVRow() | ||||||
|  |         total.asset_title = gettext("Total") | ||||||
|  |         total.asset_amount = self.__assets.total | ||||||
|  |         total.liability_title = gettext("Total") | ||||||
|  |         total.liability_amount \ | ||||||
|  |             = self.__liabilities.total + self.__owner_s_equity.total | ||||||
|  |         rows.append(total) | ||||||
|  |         return rows | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def __section_csv_rows(section: Section) -> list[CSVHalfRow]: | ||||||
|  |         """Gathers the CSV rows for a section. | ||||||
|  |  | ||||||
|  |         :param section: The section. | ||||||
|  |         :return: The CSV rows for the section. | ||||||
|  |         """ | ||||||
|  |         rows: list[CSVHalfRow] \ | ||||||
|  |             = [CSVHalfRow(section.title.title.title(), None)] | ||||||
|  |         for subsection in section.subsections: | ||||||
|  |             rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None)) | ||||||
|  |             for account in subsection.accounts: | ||||||
|  |                 rows.append(CSVHalfRow(f"  {str(account.account).title()}", | ||||||
|  |                                        account.amount)) | ||||||
|  |         return rows | ||||||
|  |  | ||||||
|  |     def html(self) -> str: | ||||||
|  |         """Composes and returns the report as HTML. | ||||||
|  |  | ||||||
|  |         :return: The report as HTML. | ||||||
|  |         """ | ||||||
|  |         params: PageParams = PageParams(currency=self.__currency, | ||||||
|  |                                         period=self.__period, | ||||||
|  |                                         has_data=self.__has_data, | ||||||
|  |                                         assets=self.__assets, | ||||||
|  |                                         liabilities=self.__liabilities, | ||||||
|  |                                         owner_s_equity=self.__owner_s_equity) | ||||||
|  |         return render_template("accounting/report/balance-sheet.html", | ||||||
|  |                                report=params) | ||||||
							
								
								
									
										460
									
								
								src/accounting/report/reports/income_expenses.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										460
									
								
								src/accounting/report/reports/income_expenses.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,460 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The income and expenses log. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import url_for, render_template, Response | ||||||
|  | from sqlalchemy.orm import selectinload | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import gettext | ||||||
|  | from accounting.models import Currency, Account, JournalEntry, \ | ||||||
|  |     JournalEntryLineItem | ||||||
|  | from accounting.report.period import Period, PeriodChooser | ||||||
|  | from accounting.report.utils.base_page_params import BasePageParams | ||||||
|  | from accounting.report.utils.base_report import BaseReport | ||||||
|  | from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ | ||||||
|  |     period_spec | ||||||
|  | from accounting.report.utils.option_link import OptionLink | ||||||
|  | from accounting.report.utils.report_chooser import ReportChooser | ||||||
|  | from accounting.report.utils.report_type import ReportType | ||||||
|  | from accounting.report.utils.urls import income_expenses_url | ||||||
|  | from accounting.utils.cast import be | ||||||
|  | from accounting.utils.current_account import CurrentAccount | ||||||
|  | from accounting.utils.pagination import Pagination | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReportLineItem: | ||||||
|  |     """A line item in the report.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, line_item: JournalEntryLineItem | None = None): | ||||||
|  |         """Constructs the line item in the report. | ||||||
|  |  | ||||||
|  |         :param line_item: The journal entry line item. | ||||||
|  |         """ | ||||||
|  |         self.is_brought_forward: bool = False | ||||||
|  |         """Whether this is the brought-forward line item.""" | ||||||
|  |         self.is_total: bool = False | ||||||
|  |         """Whether this is the total line item.""" | ||||||
|  |         self.date: date | None = None | ||||||
|  |         """The date.""" | ||||||
|  |         self.account: Account | None = None | ||||||
|  |         """The account.""" | ||||||
|  |         self.description: str | None = None | ||||||
|  |         """The description.""" | ||||||
|  |         self.income: Decimal | None = None | ||||||
|  |         """The income amount.""" | ||||||
|  |         self.expense: Decimal | None = None | ||||||
|  |         """The expense amount.""" | ||||||
|  |         self.balance: Decimal | None = None | ||||||
|  |         """The balance.""" | ||||||
|  |         self.note: str | None = None | ||||||
|  |         """The note.""" | ||||||
|  |         self.url: str | None = None | ||||||
|  |         """The URL to the journal entry line item.""" | ||||||
|  |         if line_item is not None: | ||||||
|  |             self.date = line_item.journal_entry.date | ||||||
|  |             self.account = line_item.account | ||||||
|  |             self.description = line_item.description | ||||||
|  |             self.income = None if line_item.is_debit else line_item.amount | ||||||
|  |             self.expense = line_item.amount if line_item.is_debit else None | ||||||
|  |             self.note = line_item.journal_entry.note | ||||||
|  |             self.url = url_for("accounting.journal-entry.detail", | ||||||
|  |                                journal_entry=line_item.journal_entry) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LineItemCollector: | ||||||
|  |     """The line item collector.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, currency: Currency, account: CurrentAccount, | ||||||
|  |                  period: Period): | ||||||
|  |         """Constructs the line item collector. | ||||||
|  |  | ||||||
|  |         :param currency: The currency. | ||||||
|  |         :param account: The account. | ||||||
|  |         :param period: The period. | ||||||
|  |         """ | ||||||
|  |         self.__currency: Currency = currency | ||||||
|  |         """The currency.""" | ||||||
|  |         self.__account: CurrentAccount = account | ||||||
|  |         """The account.""" | ||||||
|  |         self.__period: Period = period | ||||||
|  |         """The period""" | ||||||
|  |         self.brought_forward: ReportLineItem | None | ||||||
|  |         """The brought-forward line item.""" | ||||||
|  |         self.line_items: list[ReportLineItem] | ||||||
|  |         """The line items.""" | ||||||
|  |         self.total: ReportLineItem | None | ||||||
|  |         """The total line item.""" | ||||||
|  |         self.brought_forward = self.__get_brought_forward() | ||||||
|  |         self.line_items = self.__query_line_items() | ||||||
|  |         self.total = self.__get_total() | ||||||
|  |         self.__populate_balance() | ||||||
|  |  | ||||||
|  |     def __get_brought_forward(self) -> ReportLineItem | None: | ||||||
|  |         """Queries, composes and returns the brought-forward line item. | ||||||
|  |  | ||||||
|  |         :return: The brought-forward line item, or None if the period starts | ||||||
|  |             from the beginning. | ||||||
|  |         """ | ||||||
|  |         if self.__period.start is None: | ||||||
|  |             return None | ||||||
|  |         balance_func: sa.Function = sa.func.sum(sa.case( | ||||||
|  |             (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), | ||||||
|  |             else_=-JournalEntryLineItem.amount)) | ||||||
|  |         select: sa.Select = sa.Select(balance_func)\ | ||||||
|  |             .join(JournalEntry).join(Account)\ | ||||||
|  |             .filter(be(JournalEntryLineItem.currency_code | ||||||
|  |                        == self.__currency.code), | ||||||
|  |                     self.__account_condition, | ||||||
|  |                     JournalEntry.date < self.__period.start) | ||||||
|  |         balance: int | None = db.session.scalar(select) | ||||||
|  |         if balance is None: | ||||||
|  |             return None | ||||||
|  |         line_item: ReportLineItem = ReportLineItem() | ||||||
|  |         line_item.is_brought_forward = True | ||||||
|  |         line_item.date = self.__period.start | ||||||
|  |         line_item.account = Account.accumulated_change() | ||||||
|  |         line_item.description = gettext("Brought forward") | ||||||
|  |         if balance > 0: | ||||||
|  |             line_item.income = balance | ||||||
|  |         elif balance < 0: | ||||||
|  |             line_item.expense = -balance | ||||||
|  |         line_item.balance = balance | ||||||
|  |         return line_item | ||||||
|  |  | ||||||
|  |     def __query_line_items(self) -> list[ReportLineItem]: | ||||||
|  |         """Queries and returns the line items. | ||||||
|  |  | ||||||
|  |         :return: The line items. | ||||||
|  |         """ | ||||||
|  |         conditions: list[sa.BinaryExpression] \ | ||||||
|  |             = [JournalEntryLineItem.currency_code == self.__currency.code, | ||||||
|  |                self.__account_condition] | ||||||
|  |         if self.__period.start is not None: | ||||||
|  |             conditions.append(JournalEntry.date >= self.__period.start) | ||||||
|  |         if self.__period.end is not None: | ||||||
|  |             conditions.append(JournalEntry.date <= self.__period.end) | ||||||
|  |         journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\ | ||||||
|  |             join(JournalEntryLineItem).join(Account).filter(*conditions) | ||||||
|  |  | ||||||
|  |         return [ReportLineItem(x) | ||||||
|  |                 for x in JournalEntryLineItem.query | ||||||
|  |                 .join(JournalEntry).join(Account) | ||||||
|  |                 .filter(JournalEntryLineItem.journal_entry_id | ||||||
|  |                         .in_(journal_entry_with_account), | ||||||
|  |                         JournalEntryLineItem.currency_code | ||||||
|  |                         == self.__currency.code, | ||||||
|  |                         sa.not_(self.__account_condition)) | ||||||
|  |                 .order_by(JournalEntry.date, | ||||||
|  |                           JournalEntry.no, | ||||||
|  |                           JournalEntryLineItem.is_debit, | ||||||
|  |                           JournalEntryLineItem.no) | ||||||
|  |                 .options(selectinload(JournalEntryLineItem.account), | ||||||
|  |                          selectinload(JournalEntryLineItem.journal_entry))] | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def __account_condition(self) -> sa.BinaryExpression: | ||||||
|  |         if self.__account.code == CurrentAccount.CURRENT_AL_CODE: | ||||||
|  |             return CurrentAccount.sql_condition() | ||||||
|  |         return Account.id == self.__account.id | ||||||
|  |  | ||||||
|  |     def __get_total(self) -> ReportLineItem | None: | ||||||
|  |         """Composes the total line item. | ||||||
|  |  | ||||||
|  |         :return: The total line item, or None if there is no data. | ||||||
|  |         """ | ||||||
|  |         if self.brought_forward is None and len(self.line_items) == 0: | ||||||
|  |             return None | ||||||
|  |         line_item: ReportLineItem = ReportLineItem() | ||||||
|  |         line_item.is_total = True | ||||||
|  |         line_item.description = gettext("Total") | ||||||
|  |         line_item.income = sum([x.income for x in self.line_items | ||||||
|  |                                 if x.income is not None]) | ||||||
|  |         line_item.expense = sum([x.expense for x in self.line_items | ||||||
|  |                                  if x.expense is not None]) | ||||||
|  |         line_item.balance = line_item.income - line_item.expense | ||||||
|  |         if self.brought_forward is not None: | ||||||
|  |             line_item.balance \ | ||||||
|  |                 = self.brought_forward.balance + line_item.balance | ||||||
|  |         return line_item | ||||||
|  |  | ||||||
|  |     def __populate_balance(self) -> None: | ||||||
|  |         """Populates the balance of the line items. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         balance: Decimal = 0 if self.brought_forward is None \ | ||||||
|  |             else self.brought_forward.balance | ||||||
|  |         for line_item in self.line_items: | ||||||
|  |             if line_item.income is not None: | ||||||
|  |                 balance = balance + line_item.income | ||||||
|  |             if line_item.expense is not None: | ||||||
|  |                 balance = balance - line_item.expense | ||||||
|  |             line_item.balance = balance | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CSVRow(BaseCSVRow): | ||||||
|  |     """A row in the CSV.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, journal_entry_date: date | str | None, | ||||||
|  |                  account: str | None, | ||||||
|  |                  description: str | None, | ||||||
|  |                  income: str | Decimal | None, | ||||||
|  |                  expense: str | Decimal | None, | ||||||
|  |                  balance: str | Decimal | None, | ||||||
|  |                  note: str | None): | ||||||
|  |         """Constructs a row in the CSV. | ||||||
|  |  | ||||||
|  |         :param journal_entry_date: The journal entry date. | ||||||
|  |         :param account: The account. | ||||||
|  |         :param description: The description. | ||||||
|  |         :param income: The income. | ||||||
|  |         :param expense: The expense. | ||||||
|  |         :param balance: The balance. | ||||||
|  |         :param note: The note. | ||||||
|  |         """ | ||||||
|  |         self.date: date | str | None = journal_entry_date | ||||||
|  |         """The date.""" | ||||||
|  |         self.account: str | None = account | ||||||
|  |         """The account.""" | ||||||
|  |         self.description: str | None = description | ||||||
|  |         """The description.""" | ||||||
|  |         self.income: str | Decimal | None = income | ||||||
|  |         """The income.""" | ||||||
|  |         self.expense: str | Decimal | None = expense | ||||||
|  |         """The expense.""" | ||||||
|  |         self.balance: str | Decimal | None = balance | ||||||
|  |         """The balance.""" | ||||||
|  |         self.note: str | None = note | ||||||
|  |         """The note.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def values(self) -> list[str | Decimal | None]: | ||||||
|  |         """Returns the values of the row. | ||||||
|  |  | ||||||
|  |         :return: The values of the row. | ||||||
|  |         """ | ||||||
|  |         return [self.date, self.account, self.description, | ||||||
|  |                 self.income, self.expense, self.balance, self.note] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PageParams(BasePageParams): | ||||||
|  |     """The HTML page parameters.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, currency: Currency, | ||||||
|  |                  account: CurrentAccount, | ||||||
|  |                  period: Period, | ||||||
|  |                  has_data: bool, | ||||||
|  |                  pagination: Pagination[ReportLineItem], | ||||||
|  |                  brought_forward: ReportLineItem | None, | ||||||
|  |                  line_items: list[ReportLineItem], | ||||||
|  |                  total: ReportLineItem | None): | ||||||
|  |         """Constructs the HTML page parameters. | ||||||
|  |  | ||||||
|  |         :param currency: The currency. | ||||||
|  |         :param account: The account. | ||||||
|  |         :param period: The period. | ||||||
|  |         :param has_data: True if there is any data, or False otherwise. | ||||||
|  |         :param brought_forward: The brought-forward line item. | ||||||
|  |         :param line_items: The line items. | ||||||
|  |         :param total: The total line item. | ||||||
|  |         """ | ||||||
|  |         self.currency: Currency = currency | ||||||
|  |         """The currency.""" | ||||||
|  |         self.account: CurrentAccount = account | ||||||
|  |         """The account.""" | ||||||
|  |         self.period: Period = period | ||||||
|  |         """The period.""" | ||||||
|  |         self.__has_data: bool = has_data | ||||||
|  |         """True if there is any data, or False otherwise.""" | ||||||
|  |         self.pagination: Pagination[ReportLineItem] = pagination | ||||||
|  |         """The pagination.""" | ||||||
|  |         self.brought_forward: ReportLineItem | None = brought_forward | ||||||
|  |         """The brought-forward line item.""" | ||||||
|  |         self.line_items: list[ReportLineItem] = line_items | ||||||
|  |         """The line items.""" | ||||||
|  |         self.total: ReportLineItem | None = total | ||||||
|  |         """The total line item.""" | ||||||
|  |         self.period_chooser: PeriodChooser = PeriodChooser( | ||||||
|  |             lambda x: income_expenses_url(currency, account, x)) | ||||||
|  |         """The period chooser.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def has_data(self) -> bool: | ||||||
|  |         """Returns whether there is any data on the page. | ||||||
|  |  | ||||||
|  |         :return: True if there is any data, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         return self.__has_data | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def report_chooser(self) -> ReportChooser: | ||||||
|  |         """Returns the report chooser. | ||||||
|  |  | ||||||
|  |         :return: The report chooser. | ||||||
|  |         """ | ||||||
|  |         if self.account.account is None: | ||||||
|  |             return ReportChooser(ReportType.INCOME_EXPENSES, | ||||||
|  |                                  currency=self.currency, | ||||||
|  |                                  account=Account.cash(), | ||||||
|  |                                  period=self.period) | ||||||
|  |         return ReportChooser(ReportType.INCOME_EXPENSES, | ||||||
|  |                              currency=self.currency, | ||||||
|  |                              account=self.account.account, | ||||||
|  |                              period=self.period) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def currency_options(self) -> list[OptionLink]: | ||||||
|  |         """Returns the currency options. | ||||||
|  |  | ||||||
|  |         :return: The currency options. | ||||||
|  |         """ | ||||||
|  |         return self._get_currency_options( | ||||||
|  |             lambda x: income_expenses_url(x, self.account, self.period), | ||||||
|  |             self.currency) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def account_options(self) -> list[OptionLink]: | ||||||
|  |         """Returns the account options. | ||||||
|  |  | ||||||
|  |         :return: The account options. | ||||||
|  |         """ | ||||||
|  |         current_al: CurrentAccount \ | ||||||
|  |             = CurrentAccount.current_assets_and_liabilities() | ||||||
|  |         options: list[OptionLink] \ | ||||||
|  |             = [OptionLink(str(current_al), | ||||||
|  |                           income_expenses_url(self.currency, current_al, | ||||||
|  |                                               self.period), | ||||||
|  |                           self.account.id == 0)] | ||||||
|  |         in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\ | ||||||
|  |             .join(Account)\ | ||||||
|  |             .filter(be(JournalEntryLineItem.currency_code | ||||||
|  |                        == self.currency.code), | ||||||
|  |                     CurrentAccount.sql_condition())\ | ||||||
|  |             .group_by(JournalEntryLineItem.account_id) | ||||||
|  |         options.extend([OptionLink(str(x), | ||||||
|  |                                    income_expenses_url( | ||||||
|  |                                        self.currency, | ||||||
|  |                                        CurrentAccount(x), | ||||||
|  |                                        self.period), | ||||||
|  |                                    x.id == self.account.id) | ||||||
|  |                         for x in Account.query.filter(Account.id.in_(in_use)) | ||||||
|  |                        .order_by(Account.base_code, Account.no).all()]) | ||||||
|  |         return options | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IncomeExpenses(BaseReport): | ||||||
|  |     """The income and expenses log.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, currency: Currency, account: CurrentAccount, | ||||||
|  |                  period: Period): | ||||||
|  |         """Constructs an income and expenses log. | ||||||
|  |  | ||||||
|  |         :param currency: The currency. | ||||||
|  |         :param account: The account. | ||||||
|  |         :param period: The period. | ||||||
|  |         """ | ||||||
|  |         self.__currency: Currency = currency | ||||||
|  |         """The currency.""" | ||||||
|  |         self.__account: CurrentAccount = account | ||||||
|  |         """The account.""" | ||||||
|  |         self.__period: Period = period | ||||||
|  |         """The period.""" | ||||||
|  |         collector: LineItemCollector = LineItemCollector( | ||||||
|  |             self.__currency, self.__account, self.__period) | ||||||
|  |         self.__brought_forward: ReportLineItem | None \ | ||||||
|  |             = collector.brought_forward | ||||||
|  |         """The brought-forward line item.""" | ||||||
|  |         self.__line_items: list[ReportLineItem] = collector.line_items | ||||||
|  |         """The line items.""" | ||||||
|  |         self.__total: ReportLineItem | None = collector.total | ||||||
|  |         """The total line item.""" | ||||||
|  |  | ||||||
|  |     def csv(self) -> Response: | ||||||
|  |         """Returns the report as CSV for download. | ||||||
|  |  | ||||||
|  |         :return: The response of the report for download. | ||||||
|  |         """ | ||||||
|  |         filename: str = "income-expenses-{currency}-{account}-{period}.csv"\ | ||||||
|  |             .format(currency=self.__currency.code, account=self.__account.code, | ||||||
|  |                     period=period_spec(self.__period)) | ||||||
|  |         return csv_download(filename, self.__get_csv_rows()) | ||||||
|  |  | ||||||
|  |     def __get_csv_rows(self) -> list[CSVRow]: | ||||||
|  |         """Composes and returns the CSV rows. | ||||||
|  |  | ||||||
|  |         :return: The CSV rows. | ||||||
|  |         """ | ||||||
|  |         rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"), | ||||||
|  |                                      gettext("Description"), gettext("Income"), | ||||||
|  |                                      gettext("Expense"), gettext("Balance"), | ||||||
|  |                                      gettext("Note"))] | ||||||
|  |         if self.__brought_forward is not None: | ||||||
|  |             rows.append(CSVRow(self.__brought_forward.date, | ||||||
|  |                                str(self.__brought_forward.account).title(), | ||||||
|  |                                self.__brought_forward.description, | ||||||
|  |                                self.__brought_forward.income, | ||||||
|  |                                self.__brought_forward.expense, | ||||||
|  |                                self.__brought_forward.balance, | ||||||
|  |                                None)) | ||||||
|  |         rows.extend([CSVRow(x.date, str(x.account).title(), x.description, | ||||||
|  |                             x.income, x.expense, x.balance, x.note) | ||||||
|  |                      for x in self.__line_items]) | ||||||
|  |         if self.__total is not None: | ||||||
|  |             rows.append(CSVRow(gettext("Total"), None, None, | ||||||
|  |                                self.__total.income, self.__total.expense, | ||||||
|  |                                self.__total.balance, None)) | ||||||
|  |         return rows | ||||||
|  |  | ||||||
|  |     def html(self) -> str: | ||||||
|  |         """Composes and returns the report as HTML. | ||||||
|  |  | ||||||
|  |         :return: The report as HTML. | ||||||
|  |         """ | ||||||
|  |         all_line_items: list[ReportLineItem] = [] | ||||||
|  |         if self.__brought_forward is not None: | ||||||
|  |             all_line_items.append(self.__brought_forward) | ||||||
|  |         all_line_items.extend(self.__line_items) | ||||||
|  |         if self.__total is not None: | ||||||
|  |             all_line_items.append(self.__total) | ||||||
|  |         pagination: Pagination[ReportLineItem] \ | ||||||
|  |             = Pagination[ReportLineItem](all_line_items, is_reversed=True) | ||||||
|  |         page_line_items: list[ReportLineItem] = pagination.list | ||||||
|  |         has_data: bool = len(page_line_items) > 0 | ||||||
|  |         brought_forward: ReportLineItem | None = None | ||||||
|  |         if len(page_line_items) > 0 and page_line_items[0].is_brought_forward: | ||||||
|  |             brought_forward = page_line_items[0] | ||||||
|  |             page_line_items = page_line_items[1:] | ||||||
|  |         total: ReportLineItem | None = None | ||||||
|  |         if len(page_line_items) > 0 and page_line_items[-1].is_total: | ||||||
|  |             total = page_line_items[-1] | ||||||
|  |             page_line_items = page_line_items[:-1] | ||||||
|  |         params: PageParams = PageParams(currency=self.__currency, | ||||||
|  |                                         account=self.__account, | ||||||
|  |                                         period=self.__period, | ||||||
|  |                                         has_data=has_data, | ||||||
|  |                                         pagination=pagination, | ||||||
|  |                                         brought_forward=brought_forward, | ||||||
|  |                                         line_items=page_line_items, | ||||||
|  |                                         total=total) | ||||||
|  |         return render_template("accounting/report/income-expenses.html", | ||||||
|  |                                report=params) | ||||||
							
								
								
									
										325
									
								
								src/accounting/report/reports/income_statement.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										325
									
								
								src/accounting/report/reports/income_statement.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,325 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The income statement. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import render_template, Response | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import gettext | ||||||
|  | from accounting.models import Currency, BaseAccount, Account, JournalEntry, \ | ||||||
|  |     JournalEntryLineItem | ||||||
|  | from accounting.report.period import Period, PeriodChooser | ||||||
|  | from accounting.report.utils.base_page_params import BasePageParams | ||||||
|  | from accounting.report.utils.base_report import BaseReport | ||||||
|  | from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ | ||||||
|  |     period_spec | ||||||
|  | from accounting.report.utils.option_link import OptionLink | ||||||
|  | from accounting.report.utils.report_chooser import ReportChooser | ||||||
|  | from accounting.report.utils.report_type import ReportType | ||||||
|  | from accounting.report.utils.urls import ledger_url, income_statement_url | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReportAccount: | ||||||
|  |     """An account in the report.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, account: Account, amount: Decimal, url: str): | ||||||
|  |         """Constructs an account in the report. | ||||||
|  |  | ||||||
|  |         :param account: The account. | ||||||
|  |         :param amount: The amount. | ||||||
|  |         :param url: The URL to the ledger of the account. | ||||||
|  |         """ | ||||||
|  |         self.account: Account = account | ||||||
|  |         """The account.""" | ||||||
|  |         self.amount: Decimal = amount | ||||||
|  |         """The amount of the account.""" | ||||||
|  |         self.url: str = url | ||||||
|  |         """The URL to the ledger of the account.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AccumulatedTotal: | ||||||
|  |     """An accumulated total.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, title: str): | ||||||
|  |         """Constructs an accumulated total. | ||||||
|  |  | ||||||
|  |         :param title: The title. | ||||||
|  |         """ | ||||||
|  |         self.title: str = title | ||||||
|  |         """The account.""" | ||||||
|  |         self.amount: Decimal = Decimal("0") | ||||||
|  |         """The amount of the account.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Subsection: | ||||||
|  |     """A subsection.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, title: BaseAccount): | ||||||
|  |         """Constructs a subsection. | ||||||
|  |  | ||||||
|  |         :param title: The title account. | ||||||
|  |         """ | ||||||
|  |         self.title: BaseAccount = title | ||||||
|  |         """The title account.""" | ||||||
|  |         self.accounts: list[ReportAccount] = [] | ||||||
|  |         """The accounts in the subsection.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def total(self) -> Decimal: | ||||||
|  |         """Returns the total of the subsection. | ||||||
|  |  | ||||||
|  |         :return: The total of the subsection. | ||||||
|  |         """ | ||||||
|  |         return sum([x.amount for x in self.accounts]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Section: | ||||||
|  |     """A section.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, title: BaseAccount, accumulated_title: str): | ||||||
|  |         """Constructs a section. | ||||||
|  |  | ||||||
|  |         :param title: The title account. | ||||||
|  |         :param accumulated_title: The title for the accumulated total. | ||||||
|  |         """ | ||||||
|  |         self.title: BaseAccount = title | ||||||
|  |         """The title account.""" | ||||||
|  |         self.subsections: list[Subsection] = [] | ||||||
|  |         """The subsections in the section.""" | ||||||
|  |         self.accumulated: AccumulatedTotal \ | ||||||
|  |             = AccumulatedTotal(accumulated_title) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def total(self) -> Decimal: | ||||||
|  |         """Returns the total of the section. | ||||||
|  |  | ||||||
|  |         :return: The total of the section. | ||||||
|  |         """ | ||||||
|  |         return sum([x.total for x in self.subsections]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CSVRow(BaseCSVRow): | ||||||
|  |     """A row in the CSV.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, text: str | None, amount: str | Decimal | None): | ||||||
|  |         """Constructs a row in the CSV. | ||||||
|  |  | ||||||
|  |         :param text: The text. | ||||||
|  |         :param amount: The amount. | ||||||
|  |         """ | ||||||
|  |         self.text: str | None = text | ||||||
|  |         """The text.""" | ||||||
|  |         self.amount: str | Decimal | None = amount | ||||||
|  |         """The amount.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def values(self) -> list[str | Decimal | None]: | ||||||
|  |         """Returns the values of the row. | ||||||
|  |  | ||||||
|  |         :return: The values of the row. | ||||||
|  |         """ | ||||||
|  |         return [self.text, self.amount] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PageParams(BasePageParams): | ||||||
|  |     """The HTML page parameters.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, currency: Currency, | ||||||
|  |                  period: Period, | ||||||
|  |                  has_data: bool, | ||||||
|  |                  sections: list[Section], ): | ||||||
|  |         """Constructs the HTML page parameters. | ||||||
|  |  | ||||||
|  |         :param currency: The currency. | ||||||
|  |         :param period: The period. | ||||||
|  |         :param has_data: True if there is any data, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         self.currency: Currency = currency | ||||||
|  |         """The currency.""" | ||||||
|  |         self.period: Period = period | ||||||
|  |         """The period.""" | ||||||
|  |         self.__has_data: bool = has_data | ||||||
|  |         """True if there is any data, or False otherwise.""" | ||||||
|  |         self.sections: list[Section] = sections | ||||||
|  |         """The sections in the income statement.""" | ||||||
|  |         self.period_chooser: PeriodChooser = PeriodChooser( | ||||||
|  |             lambda x: income_statement_url(currency, x)) | ||||||
|  |         """The period chooser.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def has_data(self) -> bool: | ||||||
|  |         """Returns whether there is any data on the page. | ||||||
|  |  | ||||||
|  |         :return: True if there is any data, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         return self.__has_data | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def report_chooser(self) -> ReportChooser: | ||||||
|  |         """Returns the report chooser. | ||||||
|  |  | ||||||
|  |         :return: The report chooser. | ||||||
|  |         """ | ||||||
|  |         return ReportChooser(ReportType.INCOME_STATEMENT, | ||||||
|  |                              currency=self.currency, | ||||||
|  |                              period=self.period) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def currency_options(self) -> list[OptionLink]: | ||||||
|  |         """Returns the currency options. | ||||||
|  |  | ||||||
|  |         :return: The currency options. | ||||||
|  |         """ | ||||||
|  |         return self._get_currency_options( | ||||||
|  |             lambda x: income_statement_url(x, self.period), self.currency) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IncomeStatement(BaseReport): | ||||||
|  |     """The income statement.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, currency: Currency, period: Period): | ||||||
|  |         """Constructs an income statement. | ||||||
|  |  | ||||||
|  |         :param currency: The currency. | ||||||
|  |         :param period: The period. | ||||||
|  |         """ | ||||||
|  |         self.__currency: Currency = currency | ||||||
|  |         """The currency.""" | ||||||
|  |         self.__period: Period = period | ||||||
|  |         """The period.""" | ||||||
|  |         self.__has_data: bool | ||||||
|  |         """True if there is any data, or False otherwise.""" | ||||||
|  |         self.__sections: list[Section] | ||||||
|  |         """The sections.""" | ||||||
|  |         self.__set_data() | ||||||
|  |  | ||||||
|  |     def __set_data(self) -> None: | ||||||
|  |         """Queries and sets data sections in the income statement. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         balances: list[ReportAccount] = self.__query_balances() | ||||||
|  |  | ||||||
|  |         titles: list[BaseAccount] = BaseAccount.query\ | ||||||
|  |             .filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all() | ||||||
|  |         subtitles: list[BaseAccount] = BaseAccount.query\ | ||||||
|  |             .filter(BaseAccount.code.in_({x.account.base_code[:2] | ||||||
|  |                                           for x in balances})).all() | ||||||
|  |  | ||||||
|  |         total_titles: dict[str, str] \ | ||||||
|  |             = {"4": gettext("total operating revenue"), | ||||||
|  |                "5": gettext("gross income"), | ||||||
|  |                "6": gettext("operating income"), | ||||||
|  |                "7": gettext("before tax income"), | ||||||
|  |                "8": gettext("after tax income"), | ||||||
|  |                "9": gettext("net income or loss for current period")} | ||||||
|  |  | ||||||
|  |         sections: dict[str, Section] \ | ||||||
|  |             = {x.code: Section(x, total_titles[x.code]) for x in titles} | ||||||
|  |         subsections: dict[str, Subsection] \ | ||||||
|  |             = {x.code: Subsection(x) for x in subtitles} | ||||||
|  |         for subsection in subsections.values(): | ||||||
|  |             sections[subsection.title.code[0]].subsections.append(subsection) | ||||||
|  |         for balance in balances: | ||||||
|  |             subsections[balance.account.base_code[:2]].accounts.append(balance) | ||||||
|  |  | ||||||
|  |         self.__has_data = len(balances) > 0 | ||||||
|  |         self.__sections = sorted(sections.values(), key=lambda x: x.title.code) | ||||||
|  |         total: Decimal = Decimal("0") | ||||||
|  |         for section in self.__sections: | ||||||
|  |             total = total + section.total | ||||||
|  |             section.accumulated.amount = total | ||||||
|  |  | ||||||
|  |     def __query_balances(self) -> list[ReportAccount]: | ||||||
|  |         """Queries and returns the balances. | ||||||
|  |  | ||||||
|  |         :return: The balances. | ||||||
|  |         """ | ||||||
|  |         sub_conditions: list[sa.BinaryExpression] \ | ||||||
|  |             = [Account.base_code.startswith(str(x)) for x in range(4, 10)] | ||||||
|  |         conditions: list[sa.BinaryExpression] \ | ||||||
|  |             = [JournalEntryLineItem.currency_code == self.__currency.code, | ||||||
|  |                sa.or_(*sub_conditions)] | ||||||
|  |         if self.__period.start is not None: | ||||||
|  |             conditions.append(JournalEntry.date >= self.__period.start) | ||||||
|  |         if self.__period.end is not None: | ||||||
|  |             conditions.append(JournalEntry.date <= self.__period.end) | ||||||
|  |         balance_func: sa.Function = sa.func.sum(sa.case( | ||||||
|  |             (JournalEntryLineItem.is_debit, -JournalEntryLineItem.amount), | ||||||
|  |             else_=JournalEntryLineItem.amount)).label("balance") | ||||||
|  |         select_balances: sa.Select = sa.select(Account.id, balance_func)\ | ||||||
|  |             .join(JournalEntry).join(Account)\ | ||||||
|  |             .filter(*conditions)\ | ||||||
|  |             .group_by(Account.id)\ | ||||||
|  |             .order_by(Account.base_code, Account.no) | ||||||
|  |         balances: list[sa.Row] = db.session.execute(select_balances).all() | ||||||
|  |         accounts: dict[int, Account] \ | ||||||
|  |             = {x.id: x for x in Account.query | ||||||
|  |                .filter(Account.id.in_([x.id for x in balances])).all()} | ||||||
|  |         return [ReportAccount(account=accounts[x.id], | ||||||
|  |                               amount=x.balance, | ||||||
|  |                               url=ledger_url(self.__currency, | ||||||
|  |                                              accounts[x.id], | ||||||
|  |                                              self.__period)) | ||||||
|  |                 for x in balances] | ||||||
|  |  | ||||||
|  |     def csv(self) -> Response: | ||||||
|  |         """Returns the report as CSV for download. | ||||||
|  |  | ||||||
|  |         :return: The response of the report for download. | ||||||
|  |         """ | ||||||
|  |         filename: str = "income-statement-{currency}-{period}.csv"\ | ||||||
|  |             .format(currency=self.__currency.code, | ||||||
|  |                     period=period_spec(self.__period)) | ||||||
|  |         return csv_download(filename, self.__get_csv_rows()) | ||||||
|  |  | ||||||
|  |     def __get_csv_rows(self) -> list[CSVRow]: | ||||||
|  |         """Composes and returns the CSV rows. | ||||||
|  |  | ||||||
|  |         :return: The CSV rows. | ||||||
|  |         """ | ||||||
|  |         total_str: str = gettext("Total") | ||||||
|  |         rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))] | ||||||
|  |         for section in self.__sections: | ||||||
|  |             rows.append(CSVRow(str(section.title).title(), None)) | ||||||
|  |             for subsection in section.subsections: | ||||||
|  |                 rows.append(CSVRow(f" {str(subsection.title).title()}", None)) | ||||||
|  |                 for account in subsection.accounts: | ||||||
|  |                     rows.append(CSVRow(f"  {str(account.account).title()}", | ||||||
|  |                                        account.amount)) | ||||||
|  |                 rows.append(CSVRow(f" {total_str}", subsection.total)) | ||||||
|  |             rows.append(CSVRow(section.accumulated.title.title(), | ||||||
|  |                                section.accumulated.amount)) | ||||||
|  |             rows.append(CSVRow(None, None)) | ||||||
|  |         rows = rows[:-1] | ||||||
|  |         return rows | ||||||
|  |  | ||||||
|  |     def html(self) -> str: | ||||||
|  |         """Composes and returns the report as HTML. | ||||||
|  |  | ||||||
|  |         :return: The report as HTML. | ||||||
|  |         """ | ||||||
|  |         params: PageParams = PageParams(currency=self.__currency, | ||||||
|  |                                         period=self.__period, | ||||||
|  |                                         has_data=self.__has_data, | ||||||
|  |                                         sections=self.__sections) | ||||||
|  |         return render_template("accounting/report/income-statement.html", | ||||||
|  |                                report=params) | ||||||
							
								
								
									
										220
									
								
								src/accounting/report/reports/journal.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								src/accounting/report/reports/journal.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,220 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The journal. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import render_template, Response | ||||||
|  | from sqlalchemy.orm import selectinload | ||||||
|  |  | ||||||
|  | from accounting.locale import gettext | ||||||
|  | from accounting.models import Currency, Account, JournalEntry, \ | ||||||
|  |     JournalEntryLineItem | ||||||
|  | from accounting.report.period import Period, PeriodChooser | ||||||
|  | from accounting.report.utils.base_page_params import BasePageParams | ||||||
|  | from accounting.report.utils.base_report import BaseReport | ||||||
|  | from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ | ||||||
|  |     period_spec | ||||||
|  | from accounting.report.utils.report_chooser import ReportChooser | ||||||
|  | from accounting.report.utils.report_type import ReportType | ||||||
|  | from accounting.report.utils.urls import journal_url | ||||||
|  | from accounting.utils.pagination import Pagination | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReportLineItem: | ||||||
|  |     """A line item in the report.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, line_item: JournalEntryLineItem): | ||||||
|  |         """Constructs the line item in the report. | ||||||
|  |  | ||||||
|  |         :param line_item: The journal entry line item. | ||||||
|  |         """ | ||||||
|  |         self.line_item: JournalEntryLineItem = line_item | ||||||
|  |         """The journal entry line item.""" | ||||||
|  |         self.journal_entry: JournalEntry = line_item.journal_entry | ||||||
|  |         """The journal entry.""" | ||||||
|  |         self.currency: Currency = line_item.currency | ||||||
|  |         """The account.""" | ||||||
|  |         self.account: Account = line_item.account | ||||||
|  |         """The account.""" | ||||||
|  |         self.description: str | None = line_item.description | ||||||
|  |         """The description.""" | ||||||
|  |         self.debit: Decimal | None = line_item.debit | ||||||
|  |         """The debit amount.""" | ||||||
|  |         self.credit: Decimal | None = line_item.credit | ||||||
|  |         """The credit amount.""" | ||||||
|  |         self.amount: Decimal = line_item.amount | ||||||
|  |         """The amount.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CSVRow(BaseCSVRow): | ||||||
|  |     """A row in the CSV.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, journal_entry_date: str | date, | ||||||
|  |                  currency: str, | ||||||
|  |                  account: str, | ||||||
|  |                  description: str | None, | ||||||
|  |                  debit: str | Decimal | None, | ||||||
|  |                  credit: str | Decimal | None, | ||||||
|  |                  note: str | None): | ||||||
|  |         """Constructs a row in the CSV. | ||||||
|  |  | ||||||
|  |         :param journal_entry_date: The journal entry date. | ||||||
|  |         :param description: The description. | ||||||
|  |         :param debit: The debit amount. | ||||||
|  |         :param credit: The credit amount. | ||||||
|  |         :param note: The note. | ||||||
|  |         """ | ||||||
|  |         self.date: str | date = journal_entry_date | ||||||
|  |         """The date.""" | ||||||
|  |         self.currency: str = currency | ||||||
|  |         """The currency.""" | ||||||
|  |         self.account: str = account | ||||||
|  |         """The account.""" | ||||||
|  |         self.description: str | None = description | ||||||
|  |         """The description.""" | ||||||
|  |         self.debit: str | Decimal | None = debit | ||||||
|  |         """The debit amount.""" | ||||||
|  |         self.credit: str | Decimal | None = credit | ||||||
|  |         """The credit amount.""" | ||||||
|  |         self.note: str | None = note | ||||||
|  |         """The note.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def values(self) -> list[str | Decimal | None]: | ||||||
|  |         """Returns the values of the row. | ||||||
|  |  | ||||||
|  |         :return: The values of the row. | ||||||
|  |         """ | ||||||
|  |         return [self.date, self.currency, self.account, self.description, | ||||||
|  |                 self.debit, self.credit, self.note] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PageParams(BasePageParams): | ||||||
|  |     """The HTML page parameters.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, period: Period, | ||||||
|  |                  pagination: Pagination[JournalEntryLineItem], | ||||||
|  |                  line_items: list[JournalEntryLineItem]): | ||||||
|  |         """Constructs the HTML page parameters. | ||||||
|  |  | ||||||
|  |         :param period: The period. | ||||||
|  |         :param line_items: The line items. | ||||||
|  |         """ | ||||||
|  |         self.period: Period = period | ||||||
|  |         """The period.""" | ||||||
|  |         self.pagination: Pagination[JournalEntryLineItem] = pagination | ||||||
|  |         """The pagination.""" | ||||||
|  |         self.line_items: list[JournalEntryLineItem] = line_items | ||||||
|  |         """The line items.""" | ||||||
|  |         self.period_chooser: PeriodChooser = PeriodChooser( | ||||||
|  |             lambda x: journal_url(x)) | ||||||
|  |         """The period chooser.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def has_data(self) -> bool: | ||||||
|  |         """Returns whether there is any data on the page. | ||||||
|  |  | ||||||
|  |         :return: True if there is any data, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         return len(self.line_items) > 0 | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def report_chooser(self) -> ReportChooser: | ||||||
|  |         """Returns the report chooser. | ||||||
|  |  | ||||||
|  |         :return: The report chooser. | ||||||
|  |         """ | ||||||
|  |         return ReportChooser(ReportType.JOURNAL, | ||||||
|  |                              period=self.period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]: | ||||||
|  |     """Composes and returns the CSV rows from the line items. | ||||||
|  |  | ||||||
|  |     :param line_items: The line items. | ||||||
|  |     :return: The CSV rows. | ||||||
|  |     """ | ||||||
|  |     rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"), | ||||||
|  |                                  gettext("Account"), gettext("Description"), | ||||||
|  |                                  gettext("Debit"), gettext("Credit"), | ||||||
|  |                                  gettext("Note"))] | ||||||
|  |     rows.extend([CSVRow(x.journal_entry.date, x.currency.code, | ||||||
|  |                         str(x.account).title(), x.description, | ||||||
|  |                         x.debit, x.credit, x.journal_entry.note) | ||||||
|  |                  for x in line_items]) | ||||||
|  |     return rows | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Journal(BaseReport): | ||||||
|  |     """The journal.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, period: Period): | ||||||
|  |         """Constructs a journal. | ||||||
|  |  | ||||||
|  |         :param period: The period. | ||||||
|  |         """ | ||||||
|  |         self.__period: Period = period | ||||||
|  |         """The period.""" | ||||||
|  |         self.__line_items: list[JournalEntryLineItem] \ | ||||||
|  |             = self.__query_line_items() | ||||||
|  |         """The line items.""" | ||||||
|  |  | ||||||
|  |     def __query_line_items(self) -> list[JournalEntryLineItem]: | ||||||
|  |         """Queries and returns the line items. | ||||||
|  |  | ||||||
|  |         :return: The line items. | ||||||
|  |         """ | ||||||
|  |         conditions: list[sa.BinaryExpression] = [] | ||||||
|  |         if self.__period.start is not None: | ||||||
|  |             conditions.append(JournalEntry.date >= self.__period.start) | ||||||
|  |         if self.__period.end is not None: | ||||||
|  |             conditions.append(JournalEntry.date <= self.__period.end) | ||||||
|  |         return JournalEntryLineItem.query.join(JournalEntry)\ | ||||||
|  |             .filter(*conditions)\ | ||||||
|  |             .order_by(JournalEntry.date, | ||||||
|  |                       JournalEntry.no, | ||||||
|  |                       JournalEntryLineItem.is_debit.desc(), | ||||||
|  |                       JournalEntryLineItem.no)\ | ||||||
|  |             .options(selectinload(JournalEntryLineItem.account), | ||||||
|  |                      selectinload(JournalEntryLineItem.currency), | ||||||
|  |                      selectinload(JournalEntryLineItem.journal_entry)).all() | ||||||
|  |  | ||||||
|  |     def csv(self) -> Response: | ||||||
|  |         """Returns the report as CSV for download. | ||||||
|  |  | ||||||
|  |         :return: The response of the report for download. | ||||||
|  |         """ | ||||||
|  |         filename: str = f"journal-{period_spec(self.__period)}.csv" | ||||||
|  |         return csv_download(filename, get_csv_rows(self.__line_items)) | ||||||
|  |  | ||||||
|  |     def html(self) -> str: | ||||||
|  |         """Composes and returns the report as HTML. | ||||||
|  |  | ||||||
|  |         :return: The report as HTML. | ||||||
|  |         """ | ||||||
|  |         pagination: Pagination[JournalEntryLineItem] \ | ||||||
|  |             = Pagination[JournalEntryLineItem](self.__line_items, | ||||||
|  |                                                is_reversed=True) | ||||||
|  |         params: PageParams = PageParams(period=self.__period, | ||||||
|  |                                         pagination=pagination, | ||||||
|  |                                         line_items=pagination.list) | ||||||
|  |         return render_template("accounting/report/journal.html", | ||||||
|  |                                report=params) | ||||||
							
								
								
									
										417
									
								
								src/accounting/report/reports/ledger.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										417
									
								
								src/accounting/report/reports/ledger.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,417 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The ledger. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import url_for, render_template, Response | ||||||
|  | from sqlalchemy.orm import selectinload | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import gettext | ||||||
|  | from accounting.models import Currency, Account, JournalEntry, \ | ||||||
|  |     JournalEntryLineItem | ||||||
|  | from accounting.report.period import Period, PeriodChooser | ||||||
|  | from accounting.report.utils.base_page_params import BasePageParams | ||||||
|  | from accounting.report.utils.base_report import BaseReport | ||||||
|  | from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ | ||||||
|  |     period_spec | ||||||
|  | from accounting.report.utils.option_link import OptionLink | ||||||
|  | from accounting.report.utils.report_chooser import ReportChooser | ||||||
|  | from accounting.report.utils.report_type import ReportType | ||||||
|  | from accounting.report.utils.urls import ledger_url | ||||||
|  | from accounting.utils.cast import be | ||||||
|  | from accounting.utils.pagination import Pagination | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReportLineItem: | ||||||
|  |     """A line item in the report.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, line_item: JournalEntryLineItem | None = None): | ||||||
|  |         """Constructs the line item in the report. | ||||||
|  |  | ||||||
|  |         :param line_item: The journal entry line item. | ||||||
|  |         """ | ||||||
|  |         self.is_brought_forward: bool = False | ||||||
|  |         """Whether this is the brought-forward line item.""" | ||||||
|  |         self.is_total: bool = False | ||||||
|  |         """Whether this is the total line item.""" | ||||||
|  |         self.date: date | None = None | ||||||
|  |         """The date.""" | ||||||
|  |         self.description: str | None = None | ||||||
|  |         """The description.""" | ||||||
|  |         self.debit: Decimal | None = None | ||||||
|  |         """The debit amount.""" | ||||||
|  |         self.credit: Decimal | None = None | ||||||
|  |         """The credit amount.""" | ||||||
|  |         self.balance: Decimal | None = None | ||||||
|  |         """The balance.""" | ||||||
|  |         self.note: str | None = None | ||||||
|  |         """The note.""" | ||||||
|  |         self.url: str | None = None | ||||||
|  |         """The URL to the journal entry line item.""" | ||||||
|  |         if line_item is not None: | ||||||
|  |             self.date = line_item.journal_entry.date | ||||||
|  |             self.description = line_item.description | ||||||
|  |             self.debit = line_item.amount if line_item.is_debit else None | ||||||
|  |             self.credit = None if line_item.is_debit else line_item.amount | ||||||
|  |             self.note = line_item.journal_entry.note | ||||||
|  |             self.url = url_for("accounting.journal-entry.detail", | ||||||
|  |                                journal_entry=line_item.journal_entry) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LineItemCollector: | ||||||
|  |     """The line item collector.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, currency: Currency, account: Account, period: Period): | ||||||
|  |         """Constructs the line item collector. | ||||||
|  |  | ||||||
|  |         :param currency: The currency. | ||||||
|  |         :param account: The account. | ||||||
|  |         :param period: The period. | ||||||
|  |         """ | ||||||
|  |         self.__currency: Currency = currency | ||||||
|  |         """The currency.""" | ||||||
|  |         self.__account: Account = account | ||||||
|  |         """The account.""" | ||||||
|  |         self.__period: Period = period | ||||||
|  |         """The period""" | ||||||
|  |         self.brought_forward: ReportLineItem | None | ||||||
|  |         """The brought-forward line item.""" | ||||||
|  |         self.line_items: list[ReportLineItem] | ||||||
|  |         """The line items.""" | ||||||
|  |         self.total: ReportLineItem | None | ||||||
|  |         """The total line item.""" | ||||||
|  |         self.brought_forward = self.__get_brought_forward() | ||||||
|  |         self.line_items = self.__query_line_items() | ||||||
|  |         self.total = self.__get_total() | ||||||
|  |         self.__populate_balance() | ||||||
|  |  | ||||||
|  |     def __get_brought_forward(self) -> ReportLineItem | None: | ||||||
|  |         """Queries, composes and returns the brought-forward line item. | ||||||
|  |  | ||||||
|  |         :return: The brought-forward line item, or None if the report starts | ||||||
|  |             from the beginning. | ||||||
|  |         """ | ||||||
|  |         if self.__period.start is None: | ||||||
|  |             return None | ||||||
|  |         if self.__account.is_nominal: | ||||||
|  |             return None | ||||||
|  |         balance_func: sa.Function = sa.func.sum(sa.case( | ||||||
|  |             (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), | ||||||
|  |             else_=-JournalEntryLineItem.amount)) | ||||||
|  |         select: sa.Select = sa.Select(balance_func).join(JournalEntry)\ | ||||||
|  |             .filter(be(JournalEntryLineItem.currency_code | ||||||
|  |                        == self.__currency.code), | ||||||
|  |                     be(JournalEntryLineItem.account_id | ||||||
|  |                        == self.__account.id), | ||||||
|  |                     JournalEntry.date < self.__period.start) | ||||||
|  |         balance: int | None = db.session.scalar(select) | ||||||
|  |         if balance is None: | ||||||
|  |             return None | ||||||
|  |         line_item: ReportLineItem = ReportLineItem() | ||||||
|  |         line_item.is_brought_forward = True | ||||||
|  |         line_item.date = self.__period.start | ||||||
|  |         line_item.description = gettext("Brought forward") | ||||||
|  |         if balance > 0: | ||||||
|  |             line_item.debit = balance | ||||||
|  |         elif balance < 0: | ||||||
|  |             line_item.credit = -balance | ||||||
|  |         line_item.balance = balance | ||||||
|  |         return line_item | ||||||
|  |  | ||||||
|  |     def __query_line_items(self) -> list[ReportLineItem]: | ||||||
|  |         """Queries and returns the line items. | ||||||
|  |  | ||||||
|  |         :return: The line items. | ||||||
|  |         """ | ||||||
|  |         conditions: list[sa.BinaryExpression] \ | ||||||
|  |             = [JournalEntryLineItem.currency_code == self.__currency.code, | ||||||
|  |                JournalEntryLineItem.account_id == self.__account.id] | ||||||
|  |         if self.__period.start is not None: | ||||||
|  |             conditions.append(JournalEntry.date >= self.__period.start) | ||||||
|  |         if self.__period.end is not None: | ||||||
|  |             conditions.append(JournalEntry.date <= self.__period.end) | ||||||
|  |         return [ReportLineItem(x) for x in JournalEntryLineItem.query | ||||||
|  |                 .join(JournalEntry) | ||||||
|  |                 .filter(*conditions) | ||||||
|  |                 .order_by(JournalEntry.date, | ||||||
|  |                           JournalEntry.no, | ||||||
|  |                           JournalEntryLineItem.is_debit.desc(), | ||||||
|  |                           JournalEntryLineItem.no) | ||||||
|  |                 .options(selectinload(JournalEntryLineItem.journal_entry)) | ||||||
|  |                 .all()] | ||||||
|  |  | ||||||
|  |     def __get_total(self) -> ReportLineItem | None: | ||||||
|  |         """Composes the total line item. | ||||||
|  |  | ||||||
|  |         :return: The total line item, or None if there is no data. | ||||||
|  |         """ | ||||||
|  |         if self.brought_forward is None and len(self.line_items) == 0: | ||||||
|  |             return None | ||||||
|  |         line_item: ReportLineItem = ReportLineItem() | ||||||
|  |         line_item.is_total = True | ||||||
|  |         line_item.description = gettext("Total") | ||||||
|  |         line_item.debit = sum([x.debit for x in self.line_items | ||||||
|  |                                if x.debit is not None]) | ||||||
|  |         line_item.credit = sum([x.credit for x in self.line_items | ||||||
|  |                                 if x.credit is not None]) | ||||||
|  |         line_item.balance = line_item.debit - line_item.credit | ||||||
|  |         if self.brought_forward is not None: | ||||||
|  |             line_item.balance \ | ||||||
|  |                 = self.brought_forward.balance + line_item.balance | ||||||
|  |         return line_item | ||||||
|  |  | ||||||
|  |     def __populate_balance(self) -> None: | ||||||
|  |         """Populates the balance of the line items. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         if self.__account.is_nominal: | ||||||
|  |             return None | ||||||
|  |         balance: Decimal = 0 if self.brought_forward is None \ | ||||||
|  |             else self.brought_forward.balance | ||||||
|  |         for line_item in self.line_items: | ||||||
|  |             if line_item.debit is not None: | ||||||
|  |                 balance = balance + line_item.debit | ||||||
|  |             if line_item.credit is not None: | ||||||
|  |                 balance = balance - line_item.credit | ||||||
|  |             line_item.balance = balance | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CSVRow(BaseCSVRow): | ||||||
|  |     """A row in the CSV.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, journal_entry_date: date | str | None, | ||||||
|  |                  description: str | None, | ||||||
|  |                  debit: str | Decimal | None, | ||||||
|  |                  credit: str | Decimal | None, | ||||||
|  |                  balance: str | Decimal | None, | ||||||
|  |                  note: str | None): | ||||||
|  |         """Constructs a row in the CSV. | ||||||
|  |  | ||||||
|  |         :param journal_entry_date: The journal entry date. | ||||||
|  |         :param description: The description. | ||||||
|  |         :param debit: The debit amount. | ||||||
|  |         :param credit: The credit amount. | ||||||
|  |         :param balance: The balance. | ||||||
|  |         :param note: The note. | ||||||
|  |         """ | ||||||
|  |         self.date: date | str | None = journal_entry_date | ||||||
|  |         """The date.""" | ||||||
|  |         self.description: str | None = description | ||||||
|  |         """The description.""" | ||||||
|  |         self.debit: str | Decimal | None = debit | ||||||
|  |         """The debit amount.""" | ||||||
|  |         self.credit: str | Decimal | None = credit | ||||||
|  |         """The credit amount.""" | ||||||
|  |         self.balance: str | Decimal | None = balance | ||||||
|  |         """The balance.""" | ||||||
|  |         self.note: str | None = note | ||||||
|  |         """The note.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def values(self) -> list[str | Decimal | None]: | ||||||
|  |         """Returns the values of the row. | ||||||
|  |  | ||||||
|  |         :return: The values of the row. | ||||||
|  |         """ | ||||||
|  |         return [self.date, self.description, | ||||||
|  |                 self.debit, self.credit, self.balance, self.note] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PageParams(BasePageParams): | ||||||
|  |     """The HTML page parameters.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, currency: Currency, | ||||||
|  |                  account: Account, | ||||||
|  |                  period: Period, | ||||||
|  |                  has_data: bool, | ||||||
|  |                  pagination: Pagination[ReportLineItem], | ||||||
|  |                  brought_forward: ReportLineItem | None, | ||||||
|  |                  line_items: list[ReportLineItem], | ||||||
|  |                  total: ReportLineItem | None): | ||||||
|  |         """Constructs the HTML page parameters. | ||||||
|  |  | ||||||
|  |         :param currency: The currency. | ||||||
|  |         :param account: The account. | ||||||
|  |         :param period: The period. | ||||||
|  |         :param has_data: True if there is any data, or False otherwise. | ||||||
|  |         :param brought_forward: The brought-forward line item. | ||||||
|  |         :param line_items: The line items. | ||||||
|  |         :param total: The total line item. | ||||||
|  |         """ | ||||||
|  |         self.currency: Currency = currency | ||||||
|  |         """The currency.""" | ||||||
|  |         self.account: Account = account | ||||||
|  |         """The account.""" | ||||||
|  |         self.period: Period = period | ||||||
|  |         """The period.""" | ||||||
|  |         self.__has_data: bool = has_data | ||||||
|  |         """True if there is any data, or False otherwise.""" | ||||||
|  |         self.pagination: Pagination[ReportLineItem] = pagination | ||||||
|  |         """The pagination.""" | ||||||
|  |         self.brought_forward: ReportLineItem | None = brought_forward | ||||||
|  |         """The brought-forward line item.""" | ||||||
|  |         self.line_items: list[ReportLineItem] = line_items | ||||||
|  |         """The line items.""" | ||||||
|  |         self.total: ReportLineItem | None = total | ||||||
|  |         """The total line item.""" | ||||||
|  |         self.period_chooser: PeriodChooser = PeriodChooser( | ||||||
|  |             lambda x: ledger_url(currency, account, x)) | ||||||
|  |         """The period chooser.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def has_data(self) -> bool: | ||||||
|  |         """Returns whether there is any data on the page. | ||||||
|  |  | ||||||
|  |         :return: True if there is any data, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         return self.__has_data | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def report_chooser(self) -> ReportChooser: | ||||||
|  |         """Returns the report chooser. | ||||||
|  |  | ||||||
|  |         :return: The report chooser. | ||||||
|  |         """ | ||||||
|  |         return ReportChooser(ReportType.LEDGER, | ||||||
|  |                              currency=self.currency, | ||||||
|  |                              account=self.account, | ||||||
|  |                              period=self.period) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def currency_options(self) -> list[OptionLink]: | ||||||
|  |         """Returns the currency options. | ||||||
|  |  | ||||||
|  |         :return: The currency options. | ||||||
|  |         """ | ||||||
|  |         return self._get_currency_options( | ||||||
|  |             lambda x: ledger_url(x, self.account, self.period), self.currency) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def account_options(self) -> list[OptionLink]: | ||||||
|  |         """Returns the account options. | ||||||
|  |  | ||||||
|  |         :return: The account options. | ||||||
|  |         """ | ||||||
|  |         in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\ | ||||||
|  |             .filter(be(JournalEntryLineItem.currency_code | ||||||
|  |                        == self.currency.code))\ | ||||||
|  |             .group_by(JournalEntryLineItem.account_id) | ||||||
|  |         return [OptionLink(str(x), ledger_url(self.currency, x, self.period), | ||||||
|  |                            x.id == self.account.id) | ||||||
|  |                 for x in Account.query.filter(Account.id.in_(in_use)) | ||||||
|  |                 .order_by(Account.base_code, Account.no).all()] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Ledger(BaseReport): | ||||||
|  |     """The ledger.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, currency: Currency, account: Account, period: Period): | ||||||
|  |         """Constructs a ledger. | ||||||
|  |  | ||||||
|  |         :param currency: The currency. | ||||||
|  |         :param account: The account. | ||||||
|  |         :param period: The period. | ||||||
|  |         """ | ||||||
|  |         self.__currency: Currency = currency | ||||||
|  |         """The currency.""" | ||||||
|  |         self.__account: Account = account | ||||||
|  |         """The account.""" | ||||||
|  |         self.__period: Period = period | ||||||
|  |         """The period.""" | ||||||
|  |         collector: LineItemCollector = LineItemCollector( | ||||||
|  |             self.__currency, self.__account, self.__period) | ||||||
|  |         self.__brought_forward: ReportLineItem | None \ | ||||||
|  |             = collector.brought_forward | ||||||
|  |         """The brought-forward line item.""" | ||||||
|  |         self.__line_items: list[ReportLineItem] = collector.line_items | ||||||
|  |         """The line items.""" | ||||||
|  |         self.__total: ReportLineItem | None = collector.total | ||||||
|  |         """The total line item.""" | ||||||
|  |  | ||||||
|  |     def csv(self) -> Response: | ||||||
|  |         """Returns the report as CSV for download. | ||||||
|  |  | ||||||
|  |         :return: The response of the report for download. | ||||||
|  |         """ | ||||||
|  |         filename: str = "ledger-{currency}-{account}-{period}.csv"\ | ||||||
|  |             .format(currency=self.__currency.code, account=self.__account.code, | ||||||
|  |                     period=period_spec(self.__period)) | ||||||
|  |         return csv_download(filename, self.__get_csv_rows()) | ||||||
|  |  | ||||||
|  |     def __get_csv_rows(self) -> list[CSVRow]: | ||||||
|  |         """Composes and returns the CSV rows. | ||||||
|  |  | ||||||
|  |         :return: The CSV rows. | ||||||
|  |         """ | ||||||
|  |         rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Description"), | ||||||
|  |                                      gettext("Debit"), gettext("Credit"), | ||||||
|  |                                      gettext("Balance"), gettext("Note"))] | ||||||
|  |         if self.__brought_forward is not None: | ||||||
|  |             rows.append(CSVRow(self.__brought_forward.date, | ||||||
|  |                                self.__brought_forward.description, | ||||||
|  |                                self.__brought_forward.debit, | ||||||
|  |                                self.__brought_forward.credit, | ||||||
|  |                                self.__brought_forward.balance, | ||||||
|  |                                None)) | ||||||
|  |         rows.extend([CSVRow(x.date, x.description, | ||||||
|  |                             x.debit, x.credit, x.balance, x.note) | ||||||
|  |                      for x in self.__line_items]) | ||||||
|  |         if self.__total is not None: | ||||||
|  |             rows.append(CSVRow(gettext("Total"), None, | ||||||
|  |                                self.__total.debit, self.__total.credit, | ||||||
|  |                                self.__total.balance, None)) | ||||||
|  |         return rows | ||||||
|  |  | ||||||
|  |     def html(self) -> str: | ||||||
|  |         """Composes and returns the report as HTML. | ||||||
|  |  | ||||||
|  |         :return: The report as HTML. | ||||||
|  |         """ | ||||||
|  |         all_line_items: list[ReportLineItem] = [] | ||||||
|  |         if self.__brought_forward is not None: | ||||||
|  |             all_line_items.append(self.__brought_forward) | ||||||
|  |         all_line_items.extend(self.__line_items) | ||||||
|  |         if self.__total is not None: | ||||||
|  |             all_line_items.append(self.__total) | ||||||
|  |         pagination: Pagination[ReportLineItem] \ | ||||||
|  |             = Pagination[ReportLineItem](all_line_items, is_reversed=True) | ||||||
|  |         page_line_items: list[ReportLineItem] = pagination.list | ||||||
|  |         has_data: bool = len(page_line_items) > 0 | ||||||
|  |         brought_forward: ReportLineItem | None = None | ||||||
|  |         if len(page_line_items) > 0 and page_line_items[0].is_brought_forward: | ||||||
|  |             brought_forward = page_line_items[0] | ||||||
|  |             page_line_items = page_line_items[1:] | ||||||
|  |         total: ReportLineItem | None = None | ||||||
|  |         if len(page_line_items) > 0 and page_line_items[-1].is_total: | ||||||
|  |             total = page_line_items[-1] | ||||||
|  |             page_line_items = page_line_items[:-1] | ||||||
|  |         params: PageParams = PageParams(currency=self.__currency, | ||||||
|  |                                         account=self.__account, | ||||||
|  |                                         period=self.__period, | ||||||
|  |                                         has_data=has_data, | ||||||
|  |                                         pagination=pagination, | ||||||
|  |                                         brought_forward=brought_forward, | ||||||
|  |                                         line_items=page_line_items, | ||||||
|  |                                         total=total) | ||||||
|  |         return render_template("accounting/report/ledger.html", | ||||||
|  |                                report=params) | ||||||
							
								
								
									
										216
									
								
								src/accounting/report/reports/search.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										216
									
								
								src/accounting/report/reports/search.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,216 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The search. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import datetime | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import Response, render_template, request | ||||||
|  | from sqlalchemy.orm import selectinload | ||||||
|  |  | ||||||
|  | from accounting.locale import gettext | ||||||
|  | from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \ | ||||||
|  |     JournalEntry, JournalEntryLineItem | ||||||
|  | from accounting.report.utils.base_page_params import BasePageParams | ||||||
|  | from accounting.report.utils.base_report import BaseReport | ||||||
|  | from accounting.report.utils.csv_export import csv_download | ||||||
|  | from accounting.report.utils.report_chooser import ReportChooser | ||||||
|  | from accounting.report.utils.report_type import ReportType | ||||||
|  | from accounting.utils.cast import be | ||||||
|  | from accounting.utils.pagination import Pagination | ||||||
|  | from accounting.utils.query import parse_query_keywords | ||||||
|  | from .journal import get_csv_rows | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LineItemCollector: | ||||||
|  |     """The line item collector.""" | ||||||
|  |  | ||||||
|  |     def __init__(self): | ||||||
|  |         """Constructs the line item collector.""" | ||||||
|  |         self.line_items: list[JournalEntryLineItem] = self.__query_line_items() | ||||||
|  |         """The line items.""" | ||||||
|  |  | ||||||
|  |     def __query_line_items(self) -> list[JournalEntryLineItem]: | ||||||
|  |         """Queries and returns the line items. | ||||||
|  |  | ||||||
|  |         :return: The line items. | ||||||
|  |         """ | ||||||
|  |         keywords: list[str] = parse_query_keywords(request.args.get("q")) | ||||||
|  |         if len(keywords) == 0: | ||||||
|  |             return [] | ||||||
|  |         conditions: list[sa.BinaryExpression] = [] | ||||||
|  |         for k in keywords: | ||||||
|  |             sub_conditions: list[sa.BinaryExpression] \ | ||||||
|  |                 = [JournalEntryLineItem.description.icontains(k), | ||||||
|  |                    JournalEntryLineItem.account_id.in_( | ||||||
|  |                        self.__get_account_condition(k)), | ||||||
|  |                    JournalEntryLineItem.currency_code.in_( | ||||||
|  |                        self.__get_currency_condition(k)), | ||||||
|  |                    JournalEntryLineItem.journal_entry_id.in_( | ||||||
|  |                        self.__get_journal_entry_condition(k))] | ||||||
|  |             try: | ||||||
|  |                 sub_conditions.append( | ||||||
|  |                     JournalEntryLineItem.amount == Decimal(k)) | ||||||
|  |             except ArithmeticError: | ||||||
|  |                 pass | ||||||
|  |             conditions.append(sa.or_(*sub_conditions)) | ||||||
|  |         return JournalEntryLineItem.query.join(JournalEntry)\ | ||||||
|  |             .filter(*conditions)\ | ||||||
|  |             .order_by(JournalEntry.date, | ||||||
|  |                       JournalEntry.no, | ||||||
|  |                       JournalEntryLineItem.is_debit, | ||||||
|  |                       JournalEntryLineItem.no)\ | ||||||
|  |             .options(selectinload(JournalEntryLineItem.account), | ||||||
|  |                      selectinload(JournalEntryLineItem.currency), | ||||||
|  |                      selectinload(JournalEntryLineItem.journal_entry)).all() | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def __get_account_condition(k: str) -> sa.Select: | ||||||
|  |         """Composes and returns the condition to filter the account. | ||||||
|  |  | ||||||
|  |         :param k: The keyword. | ||||||
|  |         :return: The condition to filter the account. | ||||||
|  |         """ | ||||||
|  |         code: sa.BinaryExpression = Account.base_code + "-" \ | ||||||
|  |             + sa.func.substr("000" + sa.cast(Account.no, sa.String), | ||||||
|  |                              sa.func.char_length(sa.cast(Account.no, | ||||||
|  |                                                          sa.String)) + 1) | ||||||
|  |         select_l10n: sa.Select = sa.select(AccountL10n.account_id)\ | ||||||
|  |             .filter(AccountL10n.title.icontains(k)) | ||||||
|  |         conditions: list[sa.BinaryExpression] \ | ||||||
|  |             = [Account.base_code.contains(k), | ||||||
|  |                Account.title_l10n.icontains(k), | ||||||
|  |                code.contains(k), | ||||||
|  |                Account.id.in_(select_l10n)] | ||||||
|  |         if k in gettext("Needs Offset"): | ||||||
|  |             conditions.append(Account.is_need_offset) | ||||||
|  |         return sa.select(Account.id).filter(sa.or_(*conditions)) | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def __get_currency_condition(k: str) -> sa.Select: | ||||||
|  |         """Composes and returns the condition to filter the currency. | ||||||
|  |  | ||||||
|  |         :param k: The keyword. | ||||||
|  |         :return: The condition to filter the currency. | ||||||
|  |         """ | ||||||
|  |         select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\ | ||||||
|  |             .filter(CurrencyL10n.name.icontains(k)) | ||||||
|  |         return sa.select(Currency.code).filter( | ||||||
|  |             sa.or_(Currency.code.icontains(k), | ||||||
|  |                    Currency.name_l10n.icontains(k), | ||||||
|  |                    Currency.code.in_(select_l10n))) | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def __get_journal_entry_condition(k: str) -> sa.Select: | ||||||
|  |         """Composes and returns the condition to filter the journal entry. | ||||||
|  |  | ||||||
|  |         :param k: The keyword. | ||||||
|  |         :return: The condition to filter the journal entry. | ||||||
|  |         """ | ||||||
|  |         conditions: list[sa.BinaryExpression] \ | ||||||
|  |             = [JournalEntry.note.icontains(k)] | ||||||
|  |         journal_entry_date: datetime | ||||||
|  |         try: | ||||||
|  |             journal_entry_date = datetime.strptime(k, "%Y") | ||||||
|  |             conditions.append( | ||||||
|  |                 be(sa.extract("year", JournalEntry.date) | ||||||
|  |                    == journal_entry_date.year)) | ||||||
|  |         except ValueError: | ||||||
|  |             pass | ||||||
|  |         try: | ||||||
|  |             journal_entry_date = datetime.strptime(k, "%Y/%m") | ||||||
|  |             conditions.append(sa.and_( | ||||||
|  |                 sa.extract("year", JournalEntry.date) | ||||||
|  |                 == journal_entry_date.year, | ||||||
|  |                 sa.extract("month", JournalEntry.date) | ||||||
|  |                 == journal_entry_date.month)) | ||||||
|  |         except ValueError: | ||||||
|  |             pass | ||||||
|  |         try: | ||||||
|  |             journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d") | ||||||
|  |             conditions.append(sa.and_( | ||||||
|  |                 sa.extract("month", JournalEntry.date) | ||||||
|  |                 == journal_entry_date.month, | ||||||
|  |                 sa.extract("day", JournalEntry.date) | ||||||
|  |                 == journal_entry_date.day)) | ||||||
|  |         except ValueError: | ||||||
|  |             pass | ||||||
|  |         return sa.select(JournalEntry.id).filter(sa.or_(*conditions)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PageParams(BasePageParams): | ||||||
|  |     """The HTML page parameters.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, pagination: Pagination[JournalEntryLineItem], | ||||||
|  |                  line_items: list[JournalEntryLineItem]): | ||||||
|  |         """Constructs the HTML page parameters. | ||||||
|  |  | ||||||
|  |         :param line_items: The search result line items. | ||||||
|  |         """ | ||||||
|  |         self.pagination: Pagination[JournalEntryLineItem] = pagination | ||||||
|  |         """The pagination.""" | ||||||
|  |         self.line_items: list[JournalEntryLineItem] = line_items | ||||||
|  |         """The line items.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def has_data(self) -> bool: | ||||||
|  |         """Returns whether there is any data on the page. | ||||||
|  |  | ||||||
|  |         :return: True if there is any data, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         return len(self.line_items) > 0 | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def report_chooser(self) -> ReportChooser: | ||||||
|  |         """Returns the report chooser. | ||||||
|  |  | ||||||
|  |         :return: The report chooser. | ||||||
|  |         """ | ||||||
|  |         return ReportChooser(ReportType.SEARCH) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Search(BaseReport): | ||||||
|  |     """The search.""" | ||||||
|  |  | ||||||
|  |     def __init__(self): | ||||||
|  |         """Constructs a search.""" | ||||||
|  |         self.__line_items: list[JournalEntryLineItem] \ | ||||||
|  |             = LineItemCollector().line_items | ||||||
|  |         """The line items.""" | ||||||
|  |  | ||||||
|  |     def csv(self) -> Response: | ||||||
|  |         """Returns the report as CSV for download. | ||||||
|  |  | ||||||
|  |         :return: The response of the report for download. | ||||||
|  |         """ | ||||||
|  |         filename: str = "search-{q}.csv".format(q=request.args["q"]) | ||||||
|  |         return csv_download(filename, get_csv_rows(self.__line_items)) | ||||||
|  |  | ||||||
|  |     def html(self) -> str: | ||||||
|  |         """Composes and returns the report as HTML. | ||||||
|  |  | ||||||
|  |         :return: The report as HTML. | ||||||
|  |         """ | ||||||
|  |         pagination: Pagination[JournalEntryLineItem] \ | ||||||
|  |             = Pagination[JournalEntryLineItem](self.__line_items, | ||||||
|  |                                                is_reversed=True) | ||||||
|  |         params: PageParams = PageParams(pagination=pagination, | ||||||
|  |                                         line_items=pagination.list) | ||||||
|  |         return render_template("accounting/report/search.html", | ||||||
|  |                                report=params) | ||||||
							
								
								
									
										242
									
								
								src/accounting/report/reports/trial_balance.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										242
									
								
								src/accounting/report/reports/trial_balance.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,242 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The trial balance. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import Response, render_template | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import gettext | ||||||
|  | from accounting.models import Currency, Account, 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)\ | ||||||
|  |             .order_by(Account.base_code, Account.no) | ||||||
|  |         balances: list[sa.Row] = db.session.execute(select_balances).all() | ||||||
|  |         accounts: dict[int, Account] \ | ||||||
|  |             = {x.id: x for x in Account.query | ||||||
|  |                .filter(Account.id.in_([x.id for x in balances])).all()} | ||||||
|  |         self.__accounts = [ReportAccount(account=accounts[x.id], | ||||||
|  |                                          amount=x.balance, | ||||||
|  |                                          url=ledger_url(self.__currency, | ||||||
|  |                                                         accounts[x.id], | ||||||
|  |                                                         self.__period)) | ||||||
|  |                            for x in balances] | ||||||
|  |         self.__total = Total( | ||||||
|  |             sum([x.debit for x in self.__accounts if x.debit is not None]), | ||||||
|  |             sum([x.credit for x in self.__accounts if x.credit is not None])) | ||||||
|  |  | ||||||
|  |     def csv(self) -> Response: | ||||||
|  |         """Returns the report as CSV for download. | ||||||
|  |  | ||||||
|  |         :return: The response of the report for download. | ||||||
|  |         """ | ||||||
|  |         filename: str = "trial-balance-{currency}-{period}.csv"\ | ||||||
|  |             .format(currency=self.__currency.code, | ||||||
|  |                     period=period_spec(self.__period)) | ||||||
|  |         return csv_download(filename, self.__get_csv_rows()) | ||||||
|  |  | ||||||
|  |     def __get_csv_rows(self) -> list[CSVRow]: | ||||||
|  |         """Composes and returns the CSV rows. | ||||||
|  |  | ||||||
|  |         :return: The CSV rows. | ||||||
|  |         """ | ||||||
|  |         rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"), | ||||||
|  |                                      gettext("Credit"))] | ||||||
|  |         rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit) | ||||||
|  |                      for x in self.__accounts]) | ||||||
|  |         rows.append(CSVRow(gettext("Total"), self.__total.debit, | ||||||
|  |                            self.__total.credit)) | ||||||
|  |         return rows | ||||||
|  |  | ||||||
|  |     def html(self) -> str: | ||||||
|  |         """Composes and returns the report as HTML. | ||||||
|  |  | ||||||
|  |         :return: The report as HTML. | ||||||
|  |         """ | ||||||
|  |         params: PageParams = PageParams(currency=self.__currency, | ||||||
|  |                                         period=self.__period, | ||||||
|  |                                         accounts=self.__accounts, | ||||||
|  |                                         total=self.__total) | ||||||
|  |         return render_template("accounting/report/trial-balance.html", | ||||||
|  |                                report=params) | ||||||
							
								
								
									
										37
									
								
								src/accounting/report/template_filters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/accounting/report/template_filters.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The template filters for the reports. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | from accounting.template_filters import format_amount as core_format_amount | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def format_amount(value: Decimal | None) -> str | None: | ||||||
|  |     """Formats an amount for the report. | ||||||
|  |  | ||||||
|  |     :param value: The amount. | ||||||
|  |     :return: The formatted amount text. | ||||||
|  |     """ | ||||||
|  |     if value is None: | ||||||
|  |         return "" | ||||||
|  |     is_negative: bool = value < 0 | ||||||
|  |     formatted: str = core_format_amount(abs(value)) | ||||||
|  |     if is_negative: | ||||||
|  |         formatted = f"({formatted})" | ||||||
|  |     return formatted | ||||||
							
								
								
									
										19
									
								
								src/accounting/report/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/accounting/report/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The utilities for the reports. | ||||||
|  |  | ||||||
|  | """ | ||||||
							
								
								
									
										88
									
								
								src/accounting/report/utils/base_page_params.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								src/accounting/report/utils/base_page_params.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The page parameters of a report. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import typing as t | ||||||
|  | from abc import ABC, abstractmethod | ||||||
|  | from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \ | ||||||
|  |     urlunparse | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import request | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.models import Currency, JournalEntryLineItem | ||||||
|  | from accounting.utils.journal_entry_types import JournalEntryType | ||||||
|  | from .option_link import OptionLink | ||||||
|  | from .report_chooser import ReportChooser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BasePageParams(ABC): | ||||||
|  |     """The base HTML page parameters class.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     @abstractmethod | ||||||
|  |     def has_data(self) -> bool: | ||||||
|  |         """Returns whether there is any data on the page. | ||||||
|  |  | ||||||
|  |         :return: True if there is any data, or False otherwise. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     @abstractmethod | ||||||
|  |     def report_chooser(self) -> ReportChooser: | ||||||
|  |         """Returns the report chooser. | ||||||
|  |  | ||||||
|  |         :return: The report chooser. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def journal_entry_types(self) -> t.Type[JournalEntryType]: | ||||||
|  |         """Returns the journal entry types. | ||||||
|  |  | ||||||
|  |         :return: The journal entry types. | ||||||
|  |         """ | ||||||
|  |         return JournalEntryType | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def csv_uri(self) -> str: | ||||||
|  |         uri: str = request.full_path if request.query_string \ | ||||||
|  |             else request.path | ||||||
|  |         uri_p: ParseResult = urlparse(uri) | ||||||
|  |         params: list[tuple[str, str]] = parse_qsl(uri_p.query) | ||||||
|  |         params = [x for x in params if x[0] != "as"] | ||||||
|  |         params.append(("as", "csv")) | ||||||
|  |         parts: list[str] = list(uri_p) | ||||||
|  |         parts[4] = urlencode(params) | ||||||
|  |         return urlunparse(parts) | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def _get_currency_options(get_url: t.Callable[[Currency], str], | ||||||
|  |                               active_currency: Currency) -> list[OptionLink]: | ||||||
|  |         """Returns the currency options. | ||||||
|  |  | ||||||
|  |         :param get_url: The callback to return the URL of a currency. | ||||||
|  |         :param active_currency: The active currency. | ||||||
|  |         :return: The currency options. | ||||||
|  |         """ | ||||||
|  |         in_use: set[str] = set(db.session.scalars( | ||||||
|  |             sa.select(JournalEntryLineItem.currency_code) | ||||||
|  |             .group_by(JournalEntryLineItem.currency_code)).all()) | ||||||
|  |         return [OptionLink(str(x), get_url(x), x.code == active_currency.code) | ||||||
|  |                 for x in Currency.query.filter(Currency.code.in_(in_use)) | ||||||
|  |                 .order_by(Currency.code).all()] | ||||||
							
								
								
									
										40
									
								
								src/accounting/report/utils/base_report.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/accounting/report/utils/base_report.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The base report. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from abc import ABC, abstractmethod | ||||||
|  |  | ||||||
|  | from flask import Response | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BaseReport(ABC): | ||||||
|  |     """The base report class.""" | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def csv(self) -> Response: | ||||||
|  |         """Returns the report as CSV for download. | ||||||
|  |  | ||||||
|  |         :return: The response of the report for download. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def html(self) -> str: | ||||||
|  |         """Composes and returns the report as HTML. | ||||||
|  |  | ||||||
|  |         :return: The report as HTML. | ||||||
|  |         """ | ||||||
							
								
								
									
										108
									
								
								src/accounting/report/utils/csv_export.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/accounting/report/utils/csv_export.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The utilities to export the report as CSV for download. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import csv | ||||||
|  | from abc import ABC, abstractmethod | ||||||
|  | from datetime import timedelta, date | ||||||
|  | from decimal import Decimal | ||||||
|  | from io import StringIO | ||||||
|  |  | ||||||
|  | from flask import Response | ||||||
|  |  | ||||||
|  | from accounting.report.period import Period | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BaseCSVRow(ABC): | ||||||
|  |     """The base CSV row.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     @abstractmethod | ||||||
|  |     def values(self) -> list[str | Decimal | None]: | ||||||
|  |         """Returns the values of the row. | ||||||
|  |  | ||||||
|  |         :return: The values of the row. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response: | ||||||
|  |     """Exports the data rows as a CSV file for download. | ||||||
|  |  | ||||||
|  |     :param filename: The download file name. | ||||||
|  |     :param rows: The data rows. | ||||||
|  |     :return: The response for download the CSV file. | ||||||
|  |     """ | ||||||
|  |     with StringIO() as fp: | ||||||
|  |         writer = csv.writer(fp) | ||||||
|  |         writer.writerows([x.values for x in rows]) | ||||||
|  |         fp.seek(0) | ||||||
|  |         response: Response = Response(fp.read(), mimetype="text/csv") | ||||||
|  |         response.headers["Content-Disposition"] \ | ||||||
|  |             = f"attachment; filename={filename}" | ||||||
|  |         return response | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def period_spec(period: Period) -> str: | ||||||
|  |     """Constructs the period specification to be used in the filename. | ||||||
|  |  | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The period specification to be used in the filename. | ||||||
|  |     """ | ||||||
|  |     start: str | None = __get_start_str(period.start) | ||||||
|  |     end: str | None = __get_end_str(period.end) | ||||||
|  |     if period.start is None and period.end is None: | ||||||
|  |         return "all-time" | ||||||
|  |     if start == end: | ||||||
|  |         return start | ||||||
|  |     if period.start is None: | ||||||
|  |         return f"until-{end}" | ||||||
|  |     if period.end is None: | ||||||
|  |         return f"since-{start}" | ||||||
|  |     return f"{start}-{end}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_start_str(start: date | None) -> str | None: | ||||||
|  |     """Returns the string representation of the start date. | ||||||
|  |  | ||||||
|  |     :param start: The start date. | ||||||
|  |     :return: The string representation of the start date, or None if the start | ||||||
|  |         date is None. | ||||||
|  |     """ | ||||||
|  |     if start is None: | ||||||
|  |         return None | ||||||
|  |     if start.month == 1 and start.day == 1: | ||||||
|  |         return str(start.year) | ||||||
|  |     if start.day == 1: | ||||||
|  |         return start.strftime("%Y%m") | ||||||
|  |     return start.strftime("%Y%m%d") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_end_str(end: date | None) -> str | None: | ||||||
|  |     """Returns the string representation of the end date. | ||||||
|  |  | ||||||
|  |     :param end: The end date. | ||||||
|  |     :return: The string representation of the end date, or None if the end | ||||||
|  |         date is None. | ||||||
|  |     """ | ||||||
|  |     if end is None: | ||||||
|  |         return None | ||||||
|  |     if end.month == 12 and end.day == 31: | ||||||
|  |         return str(end.year) | ||||||
|  |     if (end + timedelta(days=1)).day == 1: | ||||||
|  |         return end.strftime("%Y%m") | ||||||
|  |     return end.strftime("%Y%m%d") | ||||||
							
								
								
									
										41
									
								
								src/accounting/report/utils/option_link.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/accounting/report/utils/option_link.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The option link. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OptionLink: | ||||||
|  |     """An option link.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, title: str, url: str, is_active: bool, | ||||||
|  |                  fa_icon: str | None = None): | ||||||
|  |         """Constructs an option link. | ||||||
|  |  | ||||||
|  |         :param title: The title. | ||||||
|  |         :param url: The URL. | ||||||
|  |         :param is_active: True if active, or False otherwise | ||||||
|  |         :param fa_icon: The font-awesome icon, if any. | ||||||
|  |         """ | ||||||
|  |         self.title: str = title | ||||||
|  |         """The title.""" | ||||||
|  |         self.url: str = url | ||||||
|  |         """The URL.""" | ||||||
|  |         self.is_active: bool = is_active | ||||||
|  |         """True if active, or False otherwise.""" | ||||||
|  |         self.fa_icon: str | None = fa_icon | ||||||
|  |         """The font-awesome icon, if any.""" | ||||||
							
								
								
									
										159
									
								
								src/accounting/report/utils/report_chooser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								src/accounting/report/utils/report_chooser.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The report chooser. | ||||||
|  |  | ||||||
|  | This file is largely taken from the NanoParma ERP project, first written in | ||||||
|  | 2021/9/16 by imacat (imacat@nanoparma.com). | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import re | ||||||
|  | import typing as t | ||||||
|  |  | ||||||
|  | from flask_babel import LazyString | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import gettext | ||||||
|  | from accounting.models import Currency, Account | ||||||
|  | from accounting.report.period import Period, get_period | ||||||
|  | from accounting.template_globals import default_currency_code | ||||||
|  | from accounting.utils.current_account import CurrentAccount | ||||||
|  | from .option_link import OptionLink | ||||||
|  | from .report_type import ReportType | ||||||
|  | from .urls import journal_url, ledger_url, income_expenses_url, \ | ||||||
|  |     trial_balance_url, income_statement_url, balance_sheet_url | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReportChooser: | ||||||
|  |     """The report chooser.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, active_report: ReportType, | ||||||
|  |                  period: Period | None = None, | ||||||
|  |                  currency: Currency | None = None, | ||||||
|  |                  account: Account | None = None): | ||||||
|  |         """Constructs the report chooser. | ||||||
|  |  | ||||||
|  |         :param active_report: The active report. | ||||||
|  |         :param period: The period. | ||||||
|  |         :param currency: The currency. | ||||||
|  |         :param account: The account. | ||||||
|  |         """ | ||||||
|  |         self.__active_report: ReportType = active_report | ||||||
|  |         """The currently active report.""" | ||||||
|  |         self.__period: Period = get_period() if period is None else period | ||||||
|  |         """The period.""" | ||||||
|  |         self.__currency: Currency = db.session.get( | ||||||
|  |             Currency, default_currency_code()) \ | ||||||
|  |             if currency is None else currency | ||||||
|  |         """The currency.""" | ||||||
|  |         self.__account: Account = Account.cash() if account is None \ | ||||||
|  |             else account | ||||||
|  |         """The currency.""" | ||||||
|  |         self.__reports: list[OptionLink] = [] | ||||||
|  |         """The links to the reports.""" | ||||||
|  |         self.current_report: str | LazyString = "" | ||||||
|  |         """The title of the current report.""" | ||||||
|  |         self.is_search: bool = active_report == ReportType.SEARCH | ||||||
|  |         """Whether the current report is the search page.""" | ||||||
|  |         self.__reports.append(self.__journal) | ||||||
|  |         self.__reports.append(self.__ledger) | ||||||
|  |         self.__reports.append(self.__income_expenses) | ||||||
|  |         self.__reports.append(self.__trial_balance) | ||||||
|  |         self.__reports.append(self.__income_statement) | ||||||
|  |         self.__reports.append(self.__balance_sheet) | ||||||
|  |         for report in self.__reports: | ||||||
|  |             if report.is_active: | ||||||
|  |                 self.current_report = report.title | ||||||
|  |         if self.is_search: | ||||||
|  |             self.current_report = gettext("Search") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def __journal(self) -> OptionLink: | ||||||
|  |         """Returns the journal. | ||||||
|  |  | ||||||
|  |         :return: The journal. | ||||||
|  |         """ | ||||||
|  |         return OptionLink(gettext("Journal"), journal_url(self.__period), | ||||||
|  |                           self.__active_report == ReportType.JOURNAL, | ||||||
|  |                           fa_icon="fa-solid fa-book") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def __ledger(self) -> OptionLink: | ||||||
|  |         """Returns the ledger. | ||||||
|  |  | ||||||
|  |         :return: The ledger. | ||||||
|  |         """ | ||||||
|  |         return OptionLink(gettext("Ledger"), | ||||||
|  |                           ledger_url(self.__currency, self.__account, | ||||||
|  |                                      self.__period), | ||||||
|  |                           self.__active_report == ReportType.LEDGER, | ||||||
|  |                           fa_icon="fa-solid fa-clipboard") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def __income_expenses(self) -> OptionLink: | ||||||
|  |         """Returns the income and expenses log. | ||||||
|  |  | ||||||
|  |         :return: The income and expenses log. | ||||||
|  |         """ | ||||||
|  |         account: Account = self.__account | ||||||
|  |         if not re.match(r"[12][12]", account.base_code): | ||||||
|  |             account: Account = Account.cash() | ||||||
|  |         return OptionLink(gettext("Income and Expenses Log"), | ||||||
|  |                           income_expenses_url(self.__currency, | ||||||
|  |                                               CurrentAccount(account), | ||||||
|  |                                               self.__period), | ||||||
|  |                           self.__active_report == ReportType.INCOME_EXPENSES, | ||||||
|  |                           fa_icon="fa-solid fa-money-bill-wave") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def __trial_balance(self) -> OptionLink: | ||||||
|  |         """Returns the trial balance. | ||||||
|  |  | ||||||
|  |         :return: The trial balance. | ||||||
|  |         """ | ||||||
|  |         return OptionLink(gettext("Trial Balance"), | ||||||
|  |                           trial_balance_url(self.__currency, self.__period), | ||||||
|  |                           self.__active_report == ReportType.TRIAL_BALANCE, | ||||||
|  |                           fa_icon="fa-solid fa-scale-unbalanced") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def __income_statement(self) -> OptionLink: | ||||||
|  |         """Returns the income statement. | ||||||
|  |  | ||||||
|  |         :return: The income statement. | ||||||
|  |         """ | ||||||
|  |         return OptionLink(gettext("Income Statement"), | ||||||
|  |                           income_statement_url(self.__currency, self.__period), | ||||||
|  |                           self.__active_report == ReportType.INCOME_STATEMENT, | ||||||
|  |                           fa_icon="fa-solid fa-file-invoice-dollar") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def __balance_sheet(self) -> OptionLink: | ||||||
|  |         """Returns the balance sheet. | ||||||
|  |  | ||||||
|  |         :return: The balance sheet. | ||||||
|  |         """ | ||||||
|  |         return OptionLink(gettext("Balance Sheet"), | ||||||
|  |                           balance_sheet_url(self.__currency, self.__period), | ||||||
|  |                           self.__active_report == ReportType.BALANCE_SHEET, | ||||||
|  |                           fa_icon="fa-solid fa-scale-balanced") | ||||||
|  |  | ||||||
|  |     def __iter__(self) -> t.Iterator[OptionLink]: | ||||||
|  |         """Returns the iteration of the reports. | ||||||
|  |  | ||||||
|  |         :return: The iteration of the reports. | ||||||
|  |         """ | ||||||
|  |         return iter(self.__reports) | ||||||
							
								
								
									
										38
									
								
								src/accounting/report/utils/report_type.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/accounting/report/utils/report_type.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The report types. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from enum import Enum | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReportType(Enum): | ||||||
|  |     """The report types.""" | ||||||
|  |     JOURNAL: str = "journal" | ||||||
|  |     """The journal.""" | ||||||
|  |     LEDGER: str = "ledger" | ||||||
|  |     """The ledger.""" | ||||||
|  |     INCOME_EXPENSES: str = "income-expenses" | ||||||
|  |     """The income and expenses log.""" | ||||||
|  |     TRIAL_BALANCE: str = "trial-balance" | ||||||
|  |     """The trial balance.""" | ||||||
|  |     INCOME_STATEMENT: str = "income-statement" | ||||||
|  |     """The income statement.""" | ||||||
|  |     BALANCE_SHEET: str = "balance-sheet" | ||||||
|  |     """The balance sheet.""" | ||||||
|  |     SEARCH: str = "search" | ||||||
|  |     """The search.""" | ||||||
							
								
								
									
										118
									
								
								src/accounting/report/utils/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								src/accounting/report/utils/urls.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,118 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The utilities to get the ledger URL. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from flask import url_for | ||||||
|  |  | ||||||
|  | from accounting.models import Currency, Account | ||||||
|  | from accounting.report.period import Period | ||||||
|  | from accounting.template_globals import default_currency_code | ||||||
|  | from accounting.utils.current_account import CurrentAccount | ||||||
|  | from accounting.utils.options import options | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def journal_url(period: Period) \ | ||||||
|  |         -> str: | ||||||
|  |     """Returns the URL of a journal. | ||||||
|  |  | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The URL of the journal. | ||||||
|  |     """ | ||||||
|  |     if period.is_default: | ||||||
|  |         return url_for("accounting.report.journal-default") | ||||||
|  |     return url_for("accounting.report.journal", period=period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def ledger_url(currency: Currency, account: Account, period: Period) \ | ||||||
|  |         -> str: | ||||||
|  |     """Returns the URL of a ledger. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param account: The account. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The URL of the ledger. | ||||||
|  |     """ | ||||||
|  |     if period.is_default: | ||||||
|  |         return url_for("accounting.report.ledger-default", | ||||||
|  |                        currency=currency, account=account) | ||||||
|  |     return url_for("accounting.report.ledger", | ||||||
|  |                    currency=currency, account=account, | ||||||
|  |                    period=period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def income_expenses_url(currency: Currency, account: CurrentAccount, | ||||||
|  |                         period: Period) -> str: | ||||||
|  |     """Returns the URL of an income and expenses log. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param account: The account. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The URL of the income and expenses log. | ||||||
|  |     """ | ||||||
|  |     if currency.code == default_currency_code() \ | ||||||
|  |             and account.code == options.default_ie_account_code \ | ||||||
|  |             and period.is_default: | ||||||
|  |         return url_for("accounting.report.default") | ||||||
|  |     if period.is_default: | ||||||
|  |         return url_for("accounting.report.income-expenses-default", | ||||||
|  |                        currency=currency, account=account) | ||||||
|  |     return url_for("accounting.report.income-expenses", | ||||||
|  |                    currency=currency, account=account, | ||||||
|  |                    period=period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def trial_balance_url(currency: Currency, period: Period) -> str: | ||||||
|  |     """Returns the URL of a trial balance. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The URL of the trial balance. | ||||||
|  |     """ | ||||||
|  |     if period.is_default: | ||||||
|  |         return url_for("accounting.report.trial-balance-default", | ||||||
|  |                        currency=currency) | ||||||
|  |     return url_for("accounting.report.trial-balance", | ||||||
|  |                    currency=currency, period=period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def income_statement_url(currency: Currency, period: Period) -> str: | ||||||
|  |     """Returns the URL of an income statement. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The URL of the income statement. | ||||||
|  |     """ | ||||||
|  |     if period.is_default: | ||||||
|  |         return url_for("accounting.report.income-statement-default", | ||||||
|  |                        currency=currency) | ||||||
|  |     return url_for("accounting.report.income-statement", | ||||||
|  |                    currency=currency, period=period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def balance_sheet_url(currency: Currency, period: Period) -> str: | ||||||
|  |     """Returns the URL of a balance sheet. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The URL of the balance sheet. | ||||||
|  |     """ | ||||||
|  |     if period.is_default: | ||||||
|  |         return url_for("accounting.report.balance-sheet-default", | ||||||
|  |                        currency=currency) | ||||||
|  |     return url_for("accounting.report.balance-sheet", | ||||||
|  |                    currency=currency, period=period) | ||||||
							
								
								
									
										299
									
								
								src/accounting/report/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										299
									
								
								src/accounting/report/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,299 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The views for the report management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from flask import Blueprint, request, Response | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.models import Currency, Account | ||||||
|  | from accounting.report.period import Period, get_period | ||||||
|  | from accounting.template_globals import default_currency_code | ||||||
|  | from accounting.utils.current_account import CurrentAccount | ||||||
|  | from accounting.utils.options import options | ||||||
|  | from accounting.utils.permission import has_permission, can_view | ||||||
|  | from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ | ||||||
|  |     IncomeStatement, BalanceSheet, Search | ||||||
|  | from .template_filters import format_amount | ||||||
|  |  | ||||||
|  | bp: Blueprint = Blueprint("report", __name__) | ||||||
|  | """The view blueprint for the reports.""" | ||||||
|  | bp.add_app_template_filter(format_amount, "accounting_report_format_amount") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("", endpoint="default") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_default_report() -> str | Response: | ||||||
|  |     """Returns the income and expenses log in the default period. | ||||||
|  |  | ||||||
|  |     :return: The income and expenses log in the default period. | ||||||
|  |     """ | ||||||
|  |     return __get_income_expenses( | ||||||
|  |         db.session.get(Currency, default_currency_code()), | ||||||
|  |         options.default_ie_account, | ||||||
|  |         get_period()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("journal", endpoint="journal-default") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_default_journal() -> str | Response: | ||||||
|  |     """Returns the journal in the default period. | ||||||
|  |  | ||||||
|  |     :return: The journal in the default period. | ||||||
|  |     """ | ||||||
|  |     return __get_journal(get_period()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("journal/<period:period>", endpoint="journal") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_journal(period: Period) -> str | Response: | ||||||
|  |     """Returns the journal. | ||||||
|  |  | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The journal in the period. | ||||||
|  |     """ | ||||||
|  |     return __get_journal(period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_journal(period: Period) -> str | Response: | ||||||
|  |     """Returns the journal. | ||||||
|  |  | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The journal in the period. | ||||||
|  |     """ | ||||||
|  |     report: Journal = Journal(period) | ||||||
|  |     if "as" in request.args and request.args["as"] == "csv": | ||||||
|  |         return report.csv() | ||||||
|  |     return report.html() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("ledger/<currency:currency>/<account:account>", | ||||||
|  |         endpoint="ledger-default") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_default_ledger(currency: Currency, account: Account) -> str | Response: | ||||||
|  |     """Returns the ledger in the default period. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param account: The account. | ||||||
|  |     :return: The ledger in the default period. | ||||||
|  |     """ | ||||||
|  |     return __get_ledger(currency, account, get_period()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("ledger/<currency:currency>/<account:account>/<period:period>", | ||||||
|  |         endpoint="ledger") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_ledger(currency: Currency, account: Account, period: Period) \ | ||||||
|  |         -> str | Response: | ||||||
|  |     """Returns the ledger. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param account: The account. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The ledger in the period. | ||||||
|  |     """ | ||||||
|  |     return __get_ledger(currency, account, period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_ledger(currency: Currency, account: Account, period: Period) \ | ||||||
|  |         -> str | Response: | ||||||
|  |     """Returns the ledger. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param account: The account. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The ledger in the period. | ||||||
|  |     """ | ||||||
|  |     report: Ledger = Ledger(currency, account, period) | ||||||
|  |     if "as" in request.args and request.args["as"] == "csv": | ||||||
|  |         return report.csv() | ||||||
|  |     return report.html() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("income-expenses/<currency:currency>/<ieAccount:account>", | ||||||
|  |         endpoint="income-expenses-default") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_default_income_expenses(currency: Currency, account: CurrentAccount) \ | ||||||
|  |         -> str | Response: | ||||||
|  |     """Returns the income and expenses log in the default period. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param account: The account. | ||||||
|  |     :return: The income and expenses log in the default period. | ||||||
|  |     """ | ||||||
|  |     return __get_income_expenses(currency, account, get_period()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get( | ||||||
|  |     "income-expenses/<currency:currency>/<ieAccount:account>/<period:period>", | ||||||
|  |     endpoint="income-expenses") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_income_expenses(currency: Currency, account: CurrentAccount, | ||||||
|  |                         period: Period) -> str | Response: | ||||||
|  |     """Returns the income and expenses log. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param account: The account. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The income and expenses log in the period. | ||||||
|  |     """ | ||||||
|  |     return __get_income_expenses(currency, account, period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_income_expenses(currency: Currency, account: CurrentAccount, | ||||||
|  |                           period: Period) -> str | Response: | ||||||
|  |     """Returns the income and expenses log. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param account: The account. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The income and expenses log in the period. | ||||||
|  |     """ | ||||||
|  |     report: IncomeExpenses = IncomeExpenses(currency, account, period) | ||||||
|  |     if "as" in request.args and request.args["as"] == "csv": | ||||||
|  |         return report.csv() | ||||||
|  |     return report.html() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("trial-balance/<currency:currency>", | ||||||
|  |         endpoint="trial-balance-default") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_default_trial_balance(currency: Currency) -> str | Response: | ||||||
|  |     """Returns the trial balance in the default period. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :return: The trial balance in the default period. | ||||||
|  |     """ | ||||||
|  |     return __get_trial_balance(currency, get_period()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("trial-balance/<currency:currency>/<period:period>", | ||||||
|  |         endpoint="trial-balance") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_trial_balance(currency: Currency, period: Period) -> str | Response: | ||||||
|  |     """Returns the trial balance. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The trial balance in the period. | ||||||
|  |     """ | ||||||
|  |     return __get_trial_balance(currency, period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_trial_balance(currency: Currency, period: Period) -> str | Response: | ||||||
|  |     """Returns the trial balance. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The trial balance in the period. | ||||||
|  |     """ | ||||||
|  |     report: TrialBalance = TrialBalance(currency, period) | ||||||
|  |     if "as" in request.args and request.args["as"] == "csv": | ||||||
|  |         return report.csv() | ||||||
|  |     return report.html() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("income-statement/<currency:currency>", | ||||||
|  |         endpoint="income-statement-default") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_default_income_statement(currency: Currency) -> str | Response: | ||||||
|  |     """Returns the income statement in the default period. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :return: The income statement in the default period. | ||||||
|  |     """ | ||||||
|  |     return __get_income_statement(currency, get_period()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("income-statement/<currency:currency>/<period:period>", | ||||||
|  |         endpoint="income-statement") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_income_statement(currency: Currency, period: Period) -> str | Response: | ||||||
|  |     """Returns the income statement. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The income statement in the period. | ||||||
|  |     """ | ||||||
|  |     return __get_income_statement(currency, period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_income_statement(currency: Currency, period: Period) \ | ||||||
|  |         -> str | Response: | ||||||
|  |     """Returns the income statement. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The income statement in the period. | ||||||
|  |     """ | ||||||
|  |     report: IncomeStatement = IncomeStatement(currency, period) | ||||||
|  |     if "as" in request.args and request.args["as"] == "csv": | ||||||
|  |         return report.csv() | ||||||
|  |     return report.html() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("balance-sheet/<currency:currency>", | ||||||
|  |         endpoint="balance-sheet-default") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_default_balance_sheet(currency: Currency) -> str | Response: | ||||||
|  |     """Returns the balance sheet in the default period. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :return: The balance sheet in the default period. | ||||||
|  |     """ | ||||||
|  |     return __get_balance_sheet(currency, get_period()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("balance-sheet/<currency:currency>/<period:period>", | ||||||
|  |         endpoint="balance-sheet") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def get_balance_sheet(currency: Currency, period: Period) \ | ||||||
|  |         -> str | Response: | ||||||
|  |     """Returns the balance sheet. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The balance sheet in the period. | ||||||
|  |     """ | ||||||
|  |     return __get_balance_sheet(currency, period) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_balance_sheet(currency: Currency, period: Period) \ | ||||||
|  |         -> str | Response: | ||||||
|  |     """Returns the balance sheet. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :param period: The period. | ||||||
|  |     :return: The balance sheet in the period. | ||||||
|  |     """ | ||||||
|  |     report: BalanceSheet = BalanceSheet(currency, period) | ||||||
|  |     if "as" in request.args and request.args["as"] == "csv": | ||||||
|  |         return report.csv() | ||||||
|  |     return report.html() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("search", endpoint="search") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def search() -> str | Response: | ||||||
|  |     """Returns the search result. | ||||||
|  |  | ||||||
|  |     :return: The search result. | ||||||
|  |     """ | ||||||
|  |     report: Search = Search() | ||||||
|  |     if "as" in request.args and request.args["as"] == "csv": | ||||||
|  |         return report.csv() | ||||||
|  |     return report.html() | ||||||
| @@ -24,16 +24,6 @@ | |||||||
| .accounting-clickable { | .accounting-clickable { | ||||||
|     cursor: pointer; |     cursor: pointer; | ||||||
| } | } | ||||||
| .btn-group .btn .accounting-search-input { |  | ||||||
|     min-height: calc(1em + .5rem + 2px); |  | ||||||
|     padding: 0 0.5rem; |  | ||||||
| } |  | ||||||
| .btn-group .btn .accounting-search-label button { |  | ||||||
|     border: none; |  | ||||||
|     background-color: transparent; |  | ||||||
|     color: inherit; |  | ||||||
|     padding-right: 0; |  | ||||||
| } |  | ||||||
| .form-floating > textarea.form-control { | .form-floating > textarea.form-control { | ||||||
|     height: 6rem; |     height: 6rem; | ||||||
| } | } | ||||||
| @@ -41,6 +31,64 @@ | |||||||
|     color: #141619; |     color: #141619; | ||||||
|     background-color: #D3D3D4; |     background-color: #D3D3D4; | ||||||
| } | } | ||||||
|  | .form-control.accounting-disabled { | ||||||
|  |     background-color: #e9ecef; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** The toolbar */ | ||||||
|  | .accounting-toolbar { | ||||||
|  |     display: flex; | ||||||
|  | } | ||||||
|  | .accounting-toolbar .input-group > .input-group-text { | ||||||
|  |     padding: 0; | ||||||
|  |     background-color: transparent; | ||||||
|  |     color: inherit; | ||||||
|  |     border: 0; | ||||||
|  | } | ||||||
|  | .accounting-toolbar .input-group > .input-group-text > button { | ||||||
|  |     background-color: transparent; | ||||||
|  |     color: inherit; | ||||||
|  |     border: 0; | ||||||
|  | } | ||||||
|  | .accounting-toolbar form.btn > .form-control { | ||||||
|  |     min-height: calc(1.5em + 2px); | ||||||
|  |     padding-top: 0.1rem; | ||||||
|  |     padding-bottom: 0.1rem; | ||||||
|  | } | ||||||
|  | @media(min-width: 768px) { | ||||||
|  |     .accounting-toolbar > .btn, .accounting-toolbar > .btn-group > .btn { | ||||||
|  |         border-radius: 0; | ||||||
|  |     } | ||||||
|  |     .accounting-toolbar > .btn:first-child, .accounting-toolbar > .btn-group:first-child > .btn { | ||||||
|  |         border-top-left-radius: 0.375rem; | ||||||
|  |         border-bottom-left-radius: 0.375rem; | ||||||
|  |     } | ||||||
|  |     .accounting-toolbar > .btn:last-child, .accounting-toolbar > .btn-group:last-child > .btn { | ||||||
|  |         border-top-right-radius: 0.375rem; | ||||||
|  |         border-bottom-right-radius: 0.375rem; | ||||||
|  |     } | ||||||
|  |     .accounting-toolbar .btn.input-group { | ||||||
|  |         width: 16rem; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @media(max-width:767px) { | ||||||
|  |     .accounting-toolbar > .btn:not(form), .accounting-toolbar > .btn-group > .btn { | ||||||
|  |         height: 3.2rem; | ||||||
|  |         width: 3.2rem; | ||||||
|  |         border-radius: 50%; | ||||||
|  |         margin-right: 0.5rem; | ||||||
|  |     } | ||||||
|  |     .accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn { | ||||||
|  |         padding-top: 0.7rem; | ||||||
|  |     } | ||||||
|  |     .accounting-toolbar > form.btn { | ||||||
|  |         width: 12rem; | ||||||
|  |         height: 2.6rem; | ||||||
|  |         border-radius: 0.375rem; | ||||||
|  |         margin-top: 0.3rem; | ||||||
|  |         margin-left: 1rem; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| /** The card layout */ | /** The card layout */ | ||||||
| .accounting-card { | .accounting-card { | ||||||
| @@ -58,6 +106,42 @@ | |||||||
|     font-size: 1.4rem; |     font-size: 1.4rem; | ||||||
|     color: #373b3e; |     color: #373b3e; | ||||||
| } | } | ||||||
|  | .accounting-sheet { | ||||||
|  |     padding: 2em 1.5em; | ||||||
|  |     margin: 1em; | ||||||
|  |     background-color: #F8F9FA; | ||||||
|  |     box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); | ||||||
|  | } | ||||||
|  | .accounting-sheet h2 { | ||||||
|  |     border-bottom: thick double slategray; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Links between objects */ | ||||||
|  | .accounting-original-line-item { | ||||||
|  |     border-top: thin solid darkslategray; | ||||||
|  |     padding: 0.2rem 0.5rem; | ||||||
|  | } | ||||||
|  | .accounting-original-line-item a { | ||||||
|  |     color: inherit; | ||||||
|  |     text-decoration: none; | ||||||
|  | } | ||||||
|  | .accounting-original-line-item a:hover { | ||||||
|  |     color: inherit; | ||||||
|  | } | ||||||
|  | .accounting-offset-line-items { | ||||||
|  |     border-top: thin solid darkslategray; | ||||||
|  |     padding: 0.2rem 0.5rem; | ||||||
|  | } | ||||||
|  | .accounting-offset-line-items ul li { | ||||||
|  |     list-style: none; | ||||||
|  | } | ||||||
|  | .accounting-offset-line-items ul li a { | ||||||
|  |     color: inherit; | ||||||
|  |     text-decoration: none; | ||||||
|  | } | ||||||
|  | .accounting-offset-line-items ul li a:hover { | ||||||
|  |     color: inherit; | ||||||
|  | } | ||||||
|  |  | ||||||
| /** The option selector */ | /** The option selector */ | ||||||
| .accounting-selector-list { | .accounting-selector-list { | ||||||
| @@ -65,49 +149,195 @@ | |||||||
|     overflow-y: scroll; |     overflow-y: scroll; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** The transaction management */ | /** The journal entry management */ | ||||||
| .accounting-currency-control { | .accounting-currency-control { | ||||||
|     background-color: transparent; |     background-color: transparent; | ||||||
| } | } | ||||||
| .accounting-currency-content { | .accounting-currency-content { | ||||||
|     width: calc(100% - 3rem); |     width: calc(100% - 3rem); | ||||||
| } | } | ||||||
| .accounting-entry-content { | .accounting-line-item-content { | ||||||
|     width: calc(100% - 3rem); |     width: calc(100% - 3rem); | ||||||
|     background-color: transparent; |     background-color: transparent; | ||||||
| } | } | ||||||
| .accounting-entry-control { |  | ||||||
|     border-color: transparent; |  | ||||||
| } |  | ||||||
| .accounting-transaction-card { |  | ||||||
|     padding: 2em 1.5em; |  | ||||||
|     margin: 1em; |  | ||||||
|     background-color: #F8F9FA; |  | ||||||
|     box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); |  | ||||||
| } |  | ||||||
| .accounting-transaction-card h2 { |  | ||||||
|     border-bottom: thick double slategray; |  | ||||||
| } |  | ||||||
| .accounting-list-group-stripped .list-group-item:nth-child(2n+1) { | .accounting-list-group-stripped .list-group-item:nth-child(2n+1) { | ||||||
|     background-color: #f2f2f2; |     background-color: #f2f2f2; | ||||||
| } | } | ||||||
| .accounting-list-group-stripped .list-group-item-success:nth-child(2n+1) { |  | ||||||
|     background-color: #c7dbd2; |  | ||||||
| } |  | ||||||
| .accounting-list-group-hover .list-group-item:hover { | .accounting-list-group-hover .list-group-item:hover { | ||||||
|     background-color: #ececec; |     background-color: #ececec; | ||||||
| } | } | ||||||
| .accounting-transaction-entry { | .accounting-journal-entry-line-item { | ||||||
|     border: none; |     border: none; | ||||||
| } | } | ||||||
| .accounting-transaction-entry-header { | .accounting-journal-entry-line-item-header { | ||||||
|     font-weight: bolder; |     font-weight: bolder; | ||||||
|     border-bottom: thick double slategray; |     border-bottom: thick double slategray; | ||||||
| } | } | ||||||
| .list-group-item.accounting-transaction-entry-total { | .list-group-item.accounting-journal-entry-line-item-total { | ||||||
|     font-weight: bolder; |     font-weight: bolder; | ||||||
|     border-top: thick double slategray; |     border-top: thick double slategray; | ||||||
| } | } | ||||||
|  | .accounting-line-item-editor-original-line-item-content { | ||||||
|  |     width: calc(100% - 3rem); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* The report table */ | ||||||
|  | .accounting-report-table-header, .accounting-report-table-footer { | ||||||
|  |     font-size: 1.2rem; | ||||||
|  |     font-weight: bolder; | ||||||
|  | } | ||||||
|  | .accounting-report-table-header { | ||||||
|  |     border-bottom: thin solid slategray; | ||||||
|  | } | ||||||
|  | .accounting-report-table-footer { | ||||||
|  |     font-style: italic; | ||||||
|  |     border-top: thin solid slategray; | ||||||
|  | } | ||||||
|  | .accounting-report-table-row { | ||||||
|  |     display: grid; | ||||||
|  | } | ||||||
|  | a.accounting-report-table-row { | ||||||
|  |     color: inherit; | ||||||
|  |     text-decoration: none; | ||||||
|  | } | ||||||
|  | .accounting-report-table-row > div { | ||||||
|  |     padding: .5rem; | ||||||
|  | } | ||||||
|  | .accounting-report-table .accounting-amount { | ||||||
|  |     text-align: right; | ||||||
|  | } | ||||||
|  | .accounting-report-table-body .accounting-amount { | ||||||
|  |     font-style: italic; | ||||||
|  | } | ||||||
|  | .accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) { | ||||||
|  |     background-color: #f2f2f2; | ||||||
|  | } | ||||||
|  | .accounting-report-table-body .accounting-report-table-row:hover { | ||||||
|  |     background-color: rgba(0, 0, 0, 0.075); | ||||||
|  | } | ||||||
|  | .accounting-journal-table .accounting-report-table-row { | ||||||
|  |     grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr; | ||||||
|  | } | ||||||
|  | .accounting-ledger-real-table .accounting-report-table-row { | ||||||
|  |     grid-template-columns: 1fr 4fr 1fr 1fr 1fr; | ||||||
|  | } | ||||||
|  | .accounting-ledger-real-table .accounting-report-table-footer .accounting-report-table-row { | ||||||
|  |     grid-template-columns: 5fr 1fr 1fr 1fr; | ||||||
|  | } | ||||||
|  | .accounting-ledger-nominal-table .accounting-report-table-row { | ||||||
|  |     grid-template-columns: 1fr 4fr 1fr 1fr; | ||||||
|  | } | ||||||
|  | .accounting-ledger-nominal-table .accounting-report-table-footer .accounting-report-table-row { | ||||||
|  |     grid-template-columns: 5fr 1fr 1fr; | ||||||
|  | } | ||||||
|  | .accounting-income-expenses-table .accounting-report-table-row { | ||||||
|  |     grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr; | ||||||
|  | } | ||||||
|  | .accounting-income-expenses-table .accounting-report-table-footer .accounting-report-table-row { | ||||||
|  |     grid-template-columns: 7fr 1fr 1fr 1fr; | ||||||
|  | } | ||||||
|  | .accounting-trial-balance-table .accounting-report-table-header { | ||||||
|  |     border-bottom: thick double slategray; | ||||||
|  | } | ||||||
|  | .accounting-trial-balance-table .accounting-report-table-footer { | ||||||
|  |     border-top: thick double slategray; | ||||||
|  | } | ||||||
|  | .accounting-trial-balance-table .accounting-report-table-row { | ||||||
|  |     grid-template-columns: 3fr 1fr 1fr; | ||||||
|  | } | ||||||
|  | .accounting-income-statement-table .accounting-report-table-body { | ||||||
|  |     border-top: thick double slategray; | ||||||
|  |     border-bottom: thick double slategray; | ||||||
|  | } | ||||||
|  | .accounting-income-statement-table .accounting-report-table-row { | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: space-between; | ||||||
|  | } | ||||||
|  | .accounting-income-statement-table .accounting-report-table-header .accounting-report-table-row { | ||||||
|  |     display: block; | ||||||
|  | } | ||||||
|  | .accounting-income-statement-section, .accounting-income-statement-total { | ||||||
|  |     font-size: 1.2rem; | ||||||
|  |     font-weight: bolder; | ||||||
|  | } | ||||||
|  | .accounting-income-statement-subsection, .accounting-income-statement-subtotal { | ||||||
|  |     font-size: 1.1rem; | ||||||
|  | } | ||||||
|  | .accounting-income-statement-subtotal { | ||||||
|  |     border-top: thin solid darkslategray; | ||||||
|  | } | ||||||
|  | /* Indents */ | ||||||
|  | .accounting-income-statement-subsection { | ||||||
|  |     margin-left: 0.5rem; | ||||||
|  |     margin-right: 0.5rem; | ||||||
|  | } | ||||||
|  | .accounting-income-statement-account, .accounting-income-statement-subtotal { | ||||||
|  |     margin-left: 1rem; | ||||||
|  |     margin-right: 1rem; | ||||||
|  | } | ||||||
|  | /* A visual blank line between categories */ | ||||||
|  | .accounting-income-statement-section { | ||||||
|  |     margin-top: 2rem; | ||||||
|  | } | ||||||
|  | .accounting-income-statement-section:first-child { | ||||||
|  |     margin-top: 0; | ||||||
|  | } | ||||||
|  | .accounting-income-statement-total { | ||||||
|  |     margin-bottom: 2rem; | ||||||
|  | } | ||||||
|  | .accounting-income-statement-total:last-child { | ||||||
|  |     margin-bottom: 0; | ||||||
|  | } | ||||||
|  | .accounting-balance-sheet-section, .accounting-balance-sheet-total { | ||||||
|  |     font-size: 1.2rem; | ||||||
|  |     font-weight: bolder; | ||||||
|  | } | ||||||
|  | .accounting-balance-sheet-section { | ||||||
|  |     border-bottom: thick double darkslategray; | ||||||
|  | } | ||||||
|  | .accounting-balance-sheet-total { | ||||||
|  |     border-top: thick double darkslategray; | ||||||
|  | } | ||||||
|  | .accounting-balance-sheet-subtotal { | ||||||
|  |     font-size: 1.1rem; | ||||||
|  |     font-weight: bolder; | ||||||
|  |     border-top: thick double darkslategray; | ||||||
|  | } | ||||||
|  | .accounting-balance-sheet-account { | ||||||
|  |     margin-left: 0.5rem; | ||||||
|  | } | ||||||
|  | .accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount { | ||||||
|  |     font-style: italic; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* The accounting report */ | ||||||
|  | .accounting-mobile-journal-credit { | ||||||
|  |     padding-left: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* The description editor */ | ||||||
|  | .accounting-description-editor-buttons .btn { | ||||||
|  |     margin-bottom: 0.3rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* The order of the journal entries in a same day */ | ||||||
|  | .accounting-journal-entry-order-item, .accounting-journal-entry-order-item:hover { | ||||||
|  |     color: inherit; | ||||||
|  |     text-decoration: none; | ||||||
|  | } | ||||||
|  | .accounting-journal-entry-order-item-currency { | ||||||
|  |     margin-left: 0.5rem; | ||||||
|  |     border-top: thin solid lightgray; | ||||||
|  |     margin-top: 0.2rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* The illustration of the description template for the recurring transactions */ | ||||||
|  | .accounting-recurring-description-template-illustration p { | ||||||
|  |     margin: 0.2rem 0; | ||||||
|  | } | ||||||
|  | .accounting-recurring-description-template-illustration ul { | ||||||
|  |     margin: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
| /* The Material Design text field (floating form control in Bootstrap) */ | /* The Material Design text field (floating form control in Bootstrap) */ | ||||||
| .accounting-material-text-field { | .accounting-material-text-field { | ||||||
| @@ -133,7 +363,7 @@ | |||||||
| .accounting-material-fab { | .accounting-material-fab { | ||||||
|     position: fixed; |     position: fixed; | ||||||
|     right: 2rem; |     right: 2rem; | ||||||
|     bottom: 1rem; |     bottom: 2rem; | ||||||
|     z-index: 10; |     z-index: 10; | ||||||
|     flex-direction: column-reverse; |     flex-direction: column-reverse; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -20,164 +20,415 @@ | |||||||
| /* Author: imacat@mail.imacat.idv.tw (imacat) | /* Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
|  * First written: 2023/2/1 |  * First written: 2023/2/1 | ||||||
|  */ |  */ | ||||||
|  | "use strict"; | ||||||
|  |  | ||||||
| // Initializes the page JavaScript. | // Initializes the page JavaScript. | ||||||
| document.addEventListener("DOMContentLoaded", function () { | document.addEventListener("DOMContentLoaded", () => { | ||||||
|     initializeBaseAccountSelector(); |     AccountForm.initialize(); | ||||||
|     document.getElementById("accounting-base-code") |  | ||||||
|         .onchange = validateBase; |  | ||||||
|     document.getElementById("accounting-title") |  | ||||||
|         .onchange = validateTitle; |  | ||||||
|     document.getElementById("accounting-form") |  | ||||||
|         .onsubmit = validateForm; |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Initializes the base account selector. |  * The account form. | ||||||
|  * |  * | ||||||
|  * @private |  * @private | ||||||
|  */ |  */ | ||||||
| function initializeBaseAccountSelector() { | class AccountForm { | ||||||
|     const selector = document.getElementById("accounting-base-selector-modal"); |  | ||||||
|     const base = document.getElementById("accounting-base"); |     /** | ||||||
|     const baseCode = document.getElementById("accounting-base-code"); |      * The base account selector | ||||||
|     const baseContent = document.getElementById("accounting-base-content"); |      * @type {BaseAccountSelector} | ||||||
|     const options = Array.from(document.getElementsByClassName("accounting-base-option")); |      */ | ||||||
|     const btnClear = document.getElementById("accounting-btn-clear-base"); |     #baseAccountSelector; | ||||||
|     selector.addEventListener("show.bs.modal", function () { |  | ||||||
|         base.classList.add("accounting-not-empty"); |     /** | ||||||
|         options.forEach(function (item) { |      * The form element | ||||||
|             item.classList.remove("active"); |      * @type {HTMLFormElement} | ||||||
|         }); |      */ | ||||||
|         const selected = document.getElementById("accounting-base-option-" + baseCode.value); |     #formElement; | ||||||
|         if (selected !== null) { |  | ||||||
|             selected.classList.add("active"); |     /** | ||||||
|         } |      * The control of the base account | ||||||
|     }); |      * @type {HTMLDivElement} | ||||||
|     selector.addEventListener("hidden.bs.modal", function () { |      */ | ||||||
|         if (baseCode.value === "") { |     #baseControl; | ||||||
|             base.classList.remove("accounting-not-empty"); |  | ||||||
|         } |     /** | ||||||
|     }); |      * The input of the base account | ||||||
|     options.forEach(function (option) { |      * @type {HTMLInputElement} | ||||||
|         option.onclick = function () { |      */ | ||||||
|             baseCode.value = option.dataset.code; |     #baseCode; | ||||||
|             baseContent.innerText = option.dataset.content; |  | ||||||
|             btnClear.classList.add("btn-danger"); |     /** | ||||||
|             btnClear.classList.remove("btn-secondary") |      * The base account | ||||||
|             btnClear.disabled = false; |      * @type {HTMLDivElement} | ||||||
|             validateBase(); |      */ | ||||||
|             bootstrap.Modal.getInstance(selector).hide(); |     #base; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message for the base account | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #baseError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The title | ||||||
|  |      * @type {HTMLInputElement} | ||||||
|  |      */ | ||||||
|  |     #title; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message of the title | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #titleError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The control of the is-need-offset option | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #isNeedOffsetControl; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The is-need-offset option | ||||||
|  |      * @type {HTMLInputElement} | ||||||
|  |      */ | ||||||
|  |     #isNeedOffset; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs the account form. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     constructor() { | ||||||
|  |         this.#baseAccountSelector = new BaseAccountSelector(this); | ||||||
|  |         this.#formElement = document.getElementById("accounting-form"); | ||||||
|  |         this.#baseControl = document.getElementById("accounting-base-control"); | ||||||
|  |         this.#baseCode = document.getElementById("accounting-base-code"); | ||||||
|  |         this.#base = document.getElementById("accounting-base"); | ||||||
|  |         this.#baseError = document.getElementById("accounting-base-error"); | ||||||
|  |         this.#title = document.getElementById("accounting-title"); | ||||||
|  |         this.#titleError = document.getElementById("accounting-title-error"); | ||||||
|  |         this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control"); | ||||||
|  |         this.#isNeedOffset = document.getElementById("accounting-is-need-offset"); | ||||||
|  |         this.#formElement.onsubmit = () => { | ||||||
|  |             return this.#validate(); | ||||||
|  |         }; | ||||||
|  |         this.#baseControl.onclick = () => { | ||||||
|  |             this.#baseControl.classList.add("accounting-not-empty"); | ||||||
|  |             this.#baseAccountSelector.onOpen(); | ||||||
|         }; |         }; | ||||||
|     }); |  | ||||||
|     btnClear.onclick = function () { |  | ||||||
|         baseCode.value = ""; |  | ||||||
|         baseContent.innerText = ""; |  | ||||||
|         btnClear.classList.add("btn-secondary") |  | ||||||
|         btnClear.classList.remove("btn-danger"); |  | ||||||
|         btnClear.disabled = true; |  | ||||||
|         validateBase(); |  | ||||||
|         bootstrap.Modal.getInstance(selector).hide(); |  | ||||||
|     } |     } | ||||||
|     initializeBaseAccountQuery(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |     /** | ||||||
|  * Initializes the query on the base account options. |      * Returns the base code. | ||||||
|  * |      * | ||||||
|  * @private |      * @return {string|null} | ||||||
|  */ |      */ | ||||||
| function initializeBaseAccountQuery() { |     get baseCode() { | ||||||
|     const query = document.getElementById("accounting-base-selector-query"); |         return this.#baseCode.value === ""? null: this.#baseCode.value; | ||||||
|     const optionList = document.getElementById("accounting-base-option-list"); |     } | ||||||
|     const options = Array.from(document.getElementsByClassName("accounting-base-option")); |  | ||||||
|     const queryNoResult = document.getElementById("accounting-base-option-no-result"); |     /** | ||||||
|     query.addEventListener("input", function () { |      * The callback when the base account selector is closed. | ||||||
|         if (query.value === "") { |      * | ||||||
|             options.forEach(function (option) { |      */ | ||||||
|                 option.classList.remove("d-none"); |     onBaseAccountSelectorClosed() { | ||||||
|             }); |         if (this.#baseCode.value === "") { | ||||||
|             optionList.classList.remove("d-none"); |             this.#baseControl.classList.remove("accounting-not-empty"); | ||||||
|             queryNoResult.classList.add("d-none"); |  | ||||||
|             return |  | ||||||
|         } |         } | ||||||
|         let hasAnyMatched = false; |     } | ||||||
|         options.forEach(function (option) { |  | ||||||
|             const queryValues = JSON.parse(option.dataset.queryValues); |     /** | ||||||
|             let isMatched = false; |      * Saves the selected base account. | ||||||
|             for (const queryValue of queryValues) { |      * | ||||||
|                 if (queryValue.includes(query.value)) { |      * @param account {BaseAccountOption} the selected base account | ||||||
|                     isMatched = true; |      */ | ||||||
|                     break; |     saveBaseAccount(account) { | ||||||
|                 } |         this.#baseCode.value = account.code; | ||||||
|             } |         this.#base.innerText = account.text; | ||||||
|             if (isMatched) { |         if (["1", "2", "3"].includes(account.code.substring(0, 1))) { | ||||||
|                 option.classList.remove("d-none"); |             this.#isNeedOffsetControl.classList.remove("d-none"); | ||||||
|                 hasAnyMatched = true; |             this.#isNeedOffset.disabled = false; | ||||||
|             } else { |  | ||||||
|                 option.classList.add("d-none"); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|         if (!hasAnyMatched) { |  | ||||||
|             optionList.classList.add("d-none"); |  | ||||||
|             queryNoResult.classList.remove("d-none"); |  | ||||||
|         } else { |         } else { | ||||||
|             optionList.classList.remove("d-none"); |             this.#isNeedOffsetControl.classList.add("d-none"); | ||||||
|             queryNoResult.classList.add("d-none"); |             this.#isNeedOffset.disabled = true; | ||||||
|  |             this.#isNeedOffset.checked = false; | ||||||
|         } |         } | ||||||
|     }); |         this.#validateBase(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Clears the base account. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     clearBaseAccount() { | ||||||
|  |         this.#baseCode.value = ""; | ||||||
|  |         this.#base.innerText = ""; | ||||||
|  |         this.#validateBase(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the form. | ||||||
|  |      * | ||||||
|  |      * @returns {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validate() { | ||||||
|  |         let isValid = true; | ||||||
|  |         isValid = this.#validateBase() && isValid; | ||||||
|  |         isValid = this.#validateTitle() && isValid; | ||||||
|  |         return isValid; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the base account. | ||||||
|  |      * | ||||||
|  |      * @returns {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateBase() { | ||||||
|  |         if (this.#baseCode.value === "") { | ||||||
|  |             this.#baseControl.classList.add("is-invalid"); | ||||||
|  |             this.#baseError.innerText = A_("Please select the base account."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         this.#baseControl.classList.remove("is-invalid"); | ||||||
|  |         this.#baseError.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the title. | ||||||
|  |      * | ||||||
|  |      * @returns {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateTitle() { | ||||||
|  |         this.#title.value = this.#title.value.trim(); | ||||||
|  |         if (this.#title.value === "") { | ||||||
|  |             this.#title.classList.add("is-invalid"); | ||||||
|  |             this.#titleError.innerText = A_("Please fill in the title."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         this.#title.classList.remove("is-invalid"); | ||||||
|  |         this.#titleError.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account form | ||||||
|  |      * @type {AccountForm} the form | ||||||
|  |      */ | ||||||
|  |     static #form; | ||||||
|  |  | ||||||
|  |     static initialize() { | ||||||
|  |         this.#form = new AccountForm(); | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Validates the form. |  * The base account selector. | ||||||
|  * |  * | ||||||
|  * @returns {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  * @private | ||||||
|  */ |  */ | ||||||
| function validateForm() { | class BaseAccountSelector { | ||||||
|     let isValid = true; |  | ||||||
|     isValid = validateBase() && isValid; |     /** | ||||||
|     isValid = validateTitle() && isValid; |      * The account form | ||||||
|     return isValid; |      * @type {AccountForm} | ||||||
|  |      */ | ||||||
|  |     form; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The selector modal | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #modal; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The query input | ||||||
|  |      * @type {HTMLInputElement} | ||||||
|  |      */ | ||||||
|  |     #query; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message when the query has no result | ||||||
|  |      * @type {HTMLParagraphElement} | ||||||
|  |      */ | ||||||
|  |     #queryNoResult; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The option list | ||||||
|  |      * @type {HTMLUListElement} | ||||||
|  |      */ | ||||||
|  |     #optionList; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The options | ||||||
|  |      * @type {BaseAccountOption[]} | ||||||
|  |      */ | ||||||
|  |     #options; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The button to clear the base account value | ||||||
|  |      * @type {HTMLButtonElement} | ||||||
|  |      */ | ||||||
|  |     #clearButton; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs the base account selector. | ||||||
|  |      * | ||||||
|  |      * @param form {AccountForm} the form | ||||||
|  |      */ | ||||||
|  |     constructor(form) { | ||||||
|  |         this.form = form; | ||||||
|  |         const prefix = "accounting-base-selector"; | ||||||
|  |         this.#modal = document.getElementById(`${prefix}-modal`); | ||||||
|  |         this.#query = document.getElementById(`${prefix}-query`); | ||||||
|  |         this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`); | ||||||
|  |         this.#optionList = document.getElementById(`${prefix}-option-list`); | ||||||
|  |         this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(this, element)); | ||||||
|  |         this.#clearButton = document.getElementById(`${prefix}-clear`); | ||||||
|  |  | ||||||
|  |         this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed()); | ||||||
|  |         this.#query.oninput = () => this.#filterOptions(); | ||||||
|  |         this.#clearButton.onclick = () => this.form.clearBaseAccount(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Filters the options. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #filterOptions() { | ||||||
|  |         let isAnyMatched = false; | ||||||
|  |         for (const option of this.#options) { | ||||||
|  |             if (option.isMatched(this.#query.value)) { | ||||||
|  |                 option.setShown(true); | ||||||
|  |                 isAnyMatched = true; | ||||||
|  |             } else { | ||||||
|  |                 option.setShown(false); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         if (!isAnyMatched) { | ||||||
|  |             this.#optionList.classList.add("d-none"); | ||||||
|  |             this.#queryNoResult.classList.remove("d-none"); | ||||||
|  |         } else { | ||||||
|  |             this.#optionList.classList.remove("d-none"); | ||||||
|  |             this.#queryNoResult.classList.add("d-none"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The callback when the base account selector is shown. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     onOpen() { | ||||||
|  |         this.#query.value = ""; | ||||||
|  |         this.#filterOptions(); | ||||||
|  |         for (const option of this.#options) { | ||||||
|  |             option.setActive(option.code === this.form.baseCode); | ||||||
|  |         } | ||||||
|  |         if (this.form.baseCode === null) { | ||||||
|  |             this.#clearButton.classList.add("btn-secondary") | ||||||
|  |             this.#clearButton.classList.remove("btn-danger"); | ||||||
|  |             this.#clearButton.disabled = true; | ||||||
|  |         } else { | ||||||
|  |             this.#clearButton.classList.add("btn-danger"); | ||||||
|  |             this.#clearButton.classList.remove("btn-secondary") | ||||||
|  |             this.#clearButton.disabled = false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Validates the base account. |  * A base account option. | ||||||
|  * |  * | ||||||
|  * @returns {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  */ | ||||||
| function validateBase() { | class BaseAccountOption { | ||||||
|     const field = document.getElementById("accounting-base-code"); |  | ||||||
|     const error = document.getElementById("accounting-base-code-error"); |     /** | ||||||
|     const displayField = document.getElementById("accounting-base"); |      * The base account selector | ||||||
|     field.value = field.value.trim(); |      * @type {BaseAccountSelector} | ||||||
|     if (field.value === "") { |      */ | ||||||
|         displayField.classList.add("is-invalid"); |     #selector; | ||||||
|         error.innerText = A_("Please select the base account."); |  | ||||||
|  |     /** | ||||||
|  |      * The element | ||||||
|  |      * @type {HTMLLIElement} | ||||||
|  |      */ | ||||||
|  |     #element; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account code | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     code; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account text | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     text; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The values to query against | ||||||
|  |      * @type {string[]} | ||||||
|  |      */ | ||||||
|  |     #queryValues; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs the account in the base account selector. | ||||||
|  |      * | ||||||
|  |      * @param selector {BaseAccountSelector} the base account selector | ||||||
|  |      * @param element {HTMLLIElement} the element | ||||||
|  |      */ | ||||||
|  |     constructor(selector, element) { | ||||||
|  |         this.#selector = selector; | ||||||
|  |         this.#element = element; | ||||||
|  |         this.code = element.dataset.code; | ||||||
|  |         this.text = element.dataset.text; | ||||||
|  |         this.#queryValues = JSON.parse(element.dataset.queryValues); | ||||||
|  |  | ||||||
|  |         this.#element.onclick = () => this.#selector.form.saveBaseAccount(this); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns whether the account matches the query. | ||||||
|  |      * | ||||||
|  |      * @param query {string} the query term | ||||||
|  |      * @return {boolean} true if the option matches, or false otherwise | ||||||
|  |      */ | ||||||
|  |     isMatched(query) { | ||||||
|  |         if (query === "") { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         for (const queryValue of this.#queryValues) { | ||||||
|  |             if (queryValue.toLowerCase().includes(query.toLowerCase())) { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|         return false; |         return false; | ||||||
|     } |     } | ||||||
|     displayField.classList.remove("is-invalid"); |  | ||||||
|     error.innerText = ""; |  | ||||||
|     return true; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |     /** | ||||||
|  * Validates the title. |      * Sets whether the option is shown. | ||||||
|  * |      * | ||||||
|  * @returns {boolean} true if valid, or false otherwise |      * @param isShown {boolean} true to show, or false otherwise | ||||||
|  * @private |      */ | ||||||
|  */ |     setShown(isShown) { | ||||||
| function validateTitle() { |         if (isShown) { | ||||||
|     const field = document.getElementById("accounting-title"); |             this.#element.classList.remove("d-none"); | ||||||
|     const error = document.getElementById("accounting-title-error"); |         } else { | ||||||
|     field.value = field.value.trim(); |             this.#element.classList.add("d-none"); | ||||||
|     if (field.value === "") { |         } | ||||||
|         field.classList.add("is-invalid"); |     } | ||||||
|         error.innerText = A_("Please fill in the title."); |  | ||||||
|         return false; |     /** | ||||||
|  |      * Sets whether the option is active. | ||||||
|  |      * | ||||||
|  |      * @param isActive {boolean} true if active, or false otherwise | ||||||
|  |      */ | ||||||
|  |     setActive(isActive) { | ||||||
|  |         if (isActive) { | ||||||
|  |             this.#element.classList.add("active"); | ||||||
|  |         } else { | ||||||
|  |             this.#element.classList.remove("active"); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|     field.classList.remove("is-invalid"); |  | ||||||
|     error.innerText = ""; |  | ||||||
|     return true; |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -20,12 +20,13 @@ | |||||||
| /* Author: imacat@mail.imacat.idv.tw (imacat) | /* Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
|  * First written: 2023/2/2 |  * First written: 2023/2/2 | ||||||
|  */ |  */ | ||||||
|  | "use strict"; | ||||||
|  |  | ||||||
| // Initializes the page JavaScript. | // Initializes the page JavaScript. | ||||||
| document.addEventListener("DOMContentLoaded", function () { | document.addEventListener("DOMContentLoaded", () => { | ||||||
|     const list = document.getElementById("accounting-order-list"); |     const list = document.getElementById("accounting-order-list"); | ||||||
|     if (list !== null) { |     if (list !== null) { | ||||||
|         const onReorder = function () { |         const onReorder = () => { | ||||||
|             const accounts = Array.from(list.children); |             const accounts = Array.from(list.children); | ||||||
|             for (let i = 0; i < accounts.length; i++) { |             for (let i = 0; i < accounts.length; i++) { | ||||||
|                 const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no"); |                 const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no"); | ||||||
|   | |||||||
| @@ -20,155 +20,155 @@ | |||||||
| /* Author: imacat@mail.imacat.idv.tw (imacat) | /* Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
|  * First written: 2023/2/6 |  * First written: 2023/2/6 | ||||||
|  */ |  */ | ||||||
|  | "use strict"; | ||||||
|  |  | ||||||
| // Initializes the page JavaScript. | // Initializes the page JavaScript. | ||||||
| document.addEventListener("DOMContentLoaded", function () { | document.addEventListener("DOMContentLoaded", () => { | ||||||
|     document.getElementById("accounting-code") |     CurrencyForm.initialize(); | ||||||
|         .onchange = validateCode; |  | ||||||
|     document.getElementById("accounting-name") |  | ||||||
|         .onchange = validateName; |  | ||||||
|     document.getElementById("accounting-form") |  | ||||||
|         .onsubmit = validateForm; |  | ||||||
| }); | }); | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * The asynchronous validation result |  * The currency form. | ||||||
|  * @type {object} |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| let isAsyncValid = {}; |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the form. |  | ||||||
|  * |  | ||||||
|  * @returns {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateForm() { |  | ||||||
|     isAsyncValid = { |  | ||||||
|         "code": false, |  | ||||||
|         "_sync": false, |  | ||||||
|     }; |  | ||||||
|     let isValid = true; |  | ||||||
|     isValid = validateCode() && isValid; |  | ||||||
|     isValid = validateName() && isValid; |  | ||||||
|     isAsyncValid["_sync"] = isValid; |  | ||||||
|     submitFormIfAllAsyncValid(); |  | ||||||
|     return false; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Submits the form if the whole form passed the asynchronous |  | ||||||
|  * validations. |  | ||||||
|  * |  * | ||||||
|  * @private |  * @private | ||||||
|  */ |  */ | ||||||
| function submitFormIfAllAsyncValid() { | class CurrencyForm { | ||||||
|     let isValid = true; |  | ||||||
|     Object.keys(isAsyncValid).forEach(function (key) { |  | ||||||
|         isValid = isAsyncValid[key] && isValid; |  | ||||||
|     }); |  | ||||||
|     if (isValid) { |  | ||||||
|         document.getElementById("accounting-form").submit() |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |     /** | ||||||
|  * Validates the code. |      * The form. | ||||||
|  * |      * @type {HTMLFormElement} | ||||||
|  * @param changeEvent {Event} the change event, if invoked from onchange |      */ | ||||||
|  * @returns {boolean} true if valid, or false otherwise |     #formElement; | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateCode(changeEvent = null) { |  | ||||||
|     const key = "code"; |  | ||||||
|     const isSubmission = changeEvent === null; |  | ||||||
|     let hasAsyncValidation = false; |  | ||||||
|     const field = document.getElementById("accounting-code"); |  | ||||||
|     const error = document.getElementById("accounting-code-error"); |  | ||||||
|     field.value = field.value.trim(); |  | ||||||
|     if (field.value === "") { |  | ||||||
|         field.classList.add("is-invalid"); |  | ||||||
|         error.innerText = A_("Please fill in the code."); |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
|     const blocklist = JSON.parse(field.dataset.blocklist); |  | ||||||
|     if (blocklist.includes(field.value)) { |  | ||||||
|         field.classList.add("is-invalid"); |  | ||||||
|         error.innerText = A_("This code is not available."); |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
|     if (!field.value.match(/^[A-Z]{3}$/)) { |  | ||||||
|         field.classList.add("is-invalid"); |  | ||||||
|         error.innerText = A_("Code can only be composed of 3 upper-cased letters."); |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
|     const original = field.dataset.original; |  | ||||||
|     if (original === "" || field.value !== original) { |  | ||||||
|         hasAsyncValidation = true; |  | ||||||
|         validateAsyncCodeIsDuplicated(isSubmission, key); |  | ||||||
|     } |  | ||||||
|     if (!hasAsyncValidation) { |  | ||||||
|         isAsyncValid[key] = true; |  | ||||||
|         field.classList.remove("is-invalid"); |  | ||||||
|         error.innerText = ""; |  | ||||||
|     } |  | ||||||
|     return true; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |     /** | ||||||
|  * Validates asynchronously whether the code is duplicated. |      * The code | ||||||
|  * The boolean validation result is stored in isAsyncValid[key]. |      * @type {HTMLInputElement} | ||||||
|  * |      */ | ||||||
|  * @param isSubmission {boolean} whether this is invoked from a form submission |     #code; | ||||||
|  * @param key {string} the key to store the result in isAsyncValid |  | ||||||
|  * @private |     /** | ||||||
|  */ |      * The error message of the code | ||||||
| function validateAsyncCodeIsDuplicated(isSubmission, key) { |      * @type {HTMLDivElement} | ||||||
|     const field = document.getElementById("accounting-code"); |      */ | ||||||
|     const error = document.getElementById("accounting-code-error"); |     #codeError; | ||||||
|     const url = field.dataset.existsUrl; |  | ||||||
|     const onLoad = function () { |     /** | ||||||
|         if (this.status === 200) { |      * The name | ||||||
|             const result = JSON.parse(this.responseText); |      * @type {HTMLInputElement} | ||||||
|             if (result["exists"]) { |      */ | ||||||
|                 field.classList.add("is-invalid"); |     #name; | ||||||
|                 error.innerText = A_("Code conflicts with another currency."); |  | ||||||
|                 if (isSubmission) { |     /** | ||||||
|                     isAsyncValid[key] = false; |      * The error message of the name | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #nameError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs the currency form. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     constructor() { | ||||||
|  |         this.#formElement = document.getElementById("accounting-form"); | ||||||
|  |         this.#code = document.getElementById("accounting-code"); | ||||||
|  |         this.#codeError = document.getElementById("accounting-code-error"); | ||||||
|  |         this.#name = document.getElementById("accounting-name"); | ||||||
|  |         this.#nameError = document.getElementById("accounting-name-error"); | ||||||
|  |         this.#code.onchange = () => { | ||||||
|  |             this.#validateCode().then(); | ||||||
|  |         }; | ||||||
|  |         this.#name.onchange = () => { | ||||||
|  |             this.#validateName(); | ||||||
|  |         }; | ||||||
|  |         this.#formElement.onsubmit = () => { | ||||||
|  |             this.#validate().then((isValid) => { | ||||||
|  |                 if (isValid) { | ||||||
|  |                     this.#formElement.submit(); | ||||||
|                 } |                 } | ||||||
|                 return; |             }); | ||||||
|             } |             return false; | ||||||
|             field.classList.remove("is-invalid"); |         }; | ||||||
|             error.innerText = ""; |     } | ||||||
|             if (isSubmission) { |  | ||||||
|                 isAsyncValid[key] = true; |     /** | ||||||
|                 submitFormIfAllAsyncValid(); |      * Validates the form. | ||||||
|  |      * | ||||||
|  |      * @returns {Promise<boolean>} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     async #validate() { | ||||||
|  |         let isValid = true; | ||||||
|  |         isValid = await this.#validateCode() && isValid; | ||||||
|  |         isValid = this.#validateName() && isValid; | ||||||
|  |         return isValid; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the code. | ||||||
|  |      * | ||||||
|  |      * @param changeEvent {Event} the change event, if invoked from onchange | ||||||
|  |      * @returns {Promise<boolean>} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     async #validateCode(changeEvent = null) { | ||||||
|  |         this.#code.value = this.#code.value.trim(); | ||||||
|  |         if (this.#code.value === "") { | ||||||
|  |             this.#code.classList.add("is-invalid"); | ||||||
|  |             this.#codeError.innerText = A_("Please fill in the code."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         const blocklist = JSON.parse(this.#code.dataset.blocklist); | ||||||
|  |         if (blocklist.includes(this.#code.value)) { | ||||||
|  |             this.#code.classList.add("is-invalid"); | ||||||
|  |             this.#codeError.innerText = A_("This code is not available."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         if (!this.#code.value.match(/^[A-Z]{3}$/)) { | ||||||
|  |             this.#code.classList.add("is-invalid"); | ||||||
|  |             this.#codeError.innerText = A_("Code can only be composed of 3 upper-cased letters."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         const original = this.#code.dataset.original; | ||||||
|  |         if (original === "" || this.#code.value !== original) { | ||||||
|  |             const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value)); | ||||||
|  |             const data = await response.json(); | ||||||
|  |             if (data["exists"]) { | ||||||
|  |                 this.#code.classList.add("is-invalid"); | ||||||
|  |                 this.#codeError.innerText = A_("Code conflicts with another currency."); | ||||||
|  |                 return false; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     }; |         this.#code.classList.remove("is-invalid"); | ||||||
|     const request = new XMLHttpRequest(); |         this.#codeError.innerText = ""; | ||||||
|     request.onload = onLoad; |         return true; | ||||||
|     request.open("GET", url + "?q=" + encodeURIComponent(field.value)); |     } | ||||||
|     request.send(); |  | ||||||
| } |     /** | ||||||
|  |      * Validates the name. | ||||||
| /** |      * | ||||||
|  * Validates the name. |      * @returns {boolean} true if valid, or false otherwise | ||||||
|  * |      */ | ||||||
|  * @returns {boolean} true if valid, or false otherwise |     #validateName() { | ||||||
|  * @private |         this.#name.value = this.#name.value.trim(); | ||||||
|  */ |         if (this.#name.value === "") { | ||||||
| function validateName() { |             this.#name.classList.add("is-invalid"); | ||||||
|     const field = document.getElementById("accounting-name"); |             this.#nameError.innerText = A_("Please fill in the name."); | ||||||
|     const error = document.getElementById("accounting-name-error"); |             return false; | ||||||
|     field.value = field.value.trim(); |         } | ||||||
|     if (field.value === "") { |         this.#name.classList.remove("is-invalid"); | ||||||
|         field.classList.add("is-invalid"); |         this.#nameError.innerText = ""; | ||||||
|         error.innerText = A_("Please fill in the name."); |         return true; | ||||||
|         return false; |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The form | ||||||
|  |      * @type {CurrencyForm} | ||||||
|  |      */ | ||||||
|  |     static #form; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the currency form. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     static initialize() { | ||||||
|  |         this.#form = new CurrencyForm(); | ||||||
|     } |     } | ||||||
|     field.classList.remove("is-invalid"); |  | ||||||
|     error.innerText = ""; |  | ||||||
|     return true; |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										1194
									
								
								src/accounting/static/js/description-editor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1194
									
								
								src/accounting/static/js/description-editor.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -20,6 +20,7 @@ | |||||||
| /* Author: imacat@mail.imacat.idv.tw (imacat) | /* Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
|  * First written: 2023/2/3 |  * First written: 2023/2/3 | ||||||
|  */ |  */ | ||||||
|  | "use strict"; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Initializes the drag-and-drop reordering on a list. |  * Initializes the drag-and-drop reordering on a list. | ||||||
| @@ -42,21 +43,21 @@ function initializeDragAndDropReordering(list, onReorder) { | |||||||
| function initializeMouseDragAndDropReordering(list, onReorder) { | function initializeMouseDragAndDropReordering(list, onReorder) { | ||||||
|     const items = Array.from(list.children); |     const items = Array.from(list.children); | ||||||
|     let dragged = null; |     let dragged = null; | ||||||
|     items.forEach(function (item) { |     for (const item of items) { | ||||||
|         item.draggable = true; |         item.draggable = true; | ||||||
|         item.addEventListener("dragstart", function () { |         item.addEventListener("dragstart", () => { | ||||||
|             dragged = item; |             dragged = item; | ||||||
|             dragged.classList.add("accounting-dragged"); |             dragged.classList.add("accounting-dragged"); | ||||||
|         }); |         }); | ||||||
|         item.addEventListener("dragover", function () { |         item.addEventListener("dragover", () => { | ||||||
|             onDragOver(dragged, item); |             onDragOver(dragged, item); | ||||||
|             onReorder(); |             onReorder(); | ||||||
|         }); |         }); | ||||||
|         item.addEventListener("dragend", function () { |         item.addEventListener("dragend", () => { | ||||||
|             dragged.classList.remove("accounting-dragged"); |             dragged.classList.remove("accounting-dragged"); | ||||||
|             dragged = null; |             dragged = null; | ||||||
|         }); |         }); | ||||||
|     }); |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -68,20 +69,20 @@ function initializeMouseDragAndDropReordering(list, onReorder) { | |||||||
|  */ |  */ | ||||||
| function initializeTouchDragAndDropReordering(list, onReorder) { | function initializeTouchDragAndDropReordering(list, onReorder) { | ||||||
|     const items = Array.from(list.children); |     const items = Array.from(list.children); | ||||||
|     items.forEach(function (item) { |     for (const item of items) { | ||||||
|         item.addEventListener("touchstart", function () { |         item.addEventListener("touchstart", () => { | ||||||
|             item.classList.add("accounting-dragged"); |             item.classList.add("accounting-dragged"); | ||||||
|         }); |         }); | ||||||
|         item.addEventListener("touchmove", function (event) { |         item.addEventListener("touchmove", (event) => { | ||||||
|             const touch = event.targetTouches[0]; |             const touch = event.targetTouches[0]; | ||||||
|             const target = document.elementFromPoint(touch.pageX, touch.pageY); |             const target = document.elementFromPoint(touch.pageX, touch.pageY); | ||||||
|             onDragOver(item, target); |             onDragOver(item, target); | ||||||
|             onReorder(); |             onReorder(); | ||||||
|         }); |         }); | ||||||
|         item.addEventListener("touchend", function () { |         item.addEventListener("touchend", () => { | ||||||
|             item.classList.remove("accounting-dragged"); |             item.classList.remove("accounting-dragged"); | ||||||
|         }); |         }); | ||||||
|     }); |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|   | |||||||
							
								
								
									
										319
									
								
								src/accounting/static/js/journal-entry-account-selector.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								src/accounting/static/js/journal-entry-account-selector.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,319 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * journal-entry-account-selector.js: The JavaScript for the account selector of the journal entry form | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /*  Copyright (c) 2023 imacat. | ||||||
|  |  * | ||||||
|  |  *  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  *  you may not use this file except in compliance with the License. | ||||||
|  |  *  You may obtain a copy of the License at | ||||||
|  |  * | ||||||
|  |  *      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  * | ||||||
|  |  *  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  *  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  |  *  See the License for the specific language governing permissions and | ||||||
|  |  *  limitations under the License. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /* Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
|  |  * First written: 2023/2/28 | ||||||
|  |  */ | ||||||
|  | "use strict"; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The account selector. | ||||||
|  |  * | ||||||
|  |  */ | ||||||
|  | class JournalEntryAccountSelector { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The line item editor | ||||||
|  |      * @type {JournalEntryLineItemEditor} | ||||||
|  |      */ | ||||||
|  |     lineItemEditor; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Either "debit" or "credit" | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #debitCredit; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The button to clear the account | ||||||
|  |      * @type {HTMLButtonElement} | ||||||
|  |      */ | ||||||
|  |     #clearButton | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The query input | ||||||
|  |      * @type {HTMLInputElement} | ||||||
|  |      */ | ||||||
|  |     #query; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message when the query has no result | ||||||
|  |      * @type {HTMLParagraphElement} | ||||||
|  |      */ | ||||||
|  |     #queryNoResult; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The option list | ||||||
|  |      * @type {HTMLUListElement} | ||||||
|  |      */ | ||||||
|  |     #optionList; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The options | ||||||
|  |      * @type {JournalEntryAccountOption[]} | ||||||
|  |      */ | ||||||
|  |     #options; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The more item to show all accounts | ||||||
|  |      * @type {HTMLLIElement} | ||||||
|  |      */ | ||||||
|  |     #more; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Whether to show all accounts | ||||||
|  |      * @type {boolean} | ||||||
|  |      */ | ||||||
|  |     #isShowMore = false; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs an account selector. | ||||||
|  |      * | ||||||
|  |      * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor | ||||||
|  |      * @param debitCredit {string} either "debit" or "credit" | ||||||
|  |      */ | ||||||
|  |     constructor(lineItemEditor, debitCredit) { | ||||||
|  |         this.lineItemEditor = lineItemEditor | ||||||
|  |         this.#debitCredit = debitCredit; | ||||||
|  |         const prefix = "accounting-account-selector-" + debitCredit; | ||||||
|  |         this.#query = document.getElementById(prefix + "-query"); | ||||||
|  |         this.#queryNoResult = document.getElementById(prefix + "-option-no-result"); | ||||||
|  |         this.#optionList = document.getElementById(prefix + "-option-list"); | ||||||
|  |         this.#options = Array.from(document.getElementsByClassName(prefix + "-option")).map((element) => new JournalEntryAccountOption(this, element)); | ||||||
|  |         this.#more = document.getElementById(prefix + "-more"); | ||||||
|  |         this.#clearButton = document.getElementById(prefix + "-btn-clear"); | ||||||
|  |  | ||||||
|  |         this.#more.onclick = () => { | ||||||
|  |             this.#isShowMore = true; | ||||||
|  |             this.#more.classList.add("d-none"); | ||||||
|  |             this.#filterOptions(); | ||||||
|  |         }; | ||||||
|  |         this.#query.oninput = () => this.#filterOptions(); | ||||||
|  |         this.#clearButton.onclick = () => this.lineItemEditor.clearAccount(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Filters the options. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #filterOptions() { | ||||||
|  |         const codesInUse = this.#getCodesUsedInForm(); | ||||||
|  |         let isAnyMatched = false; | ||||||
|  |         for (const option of this.#options) { | ||||||
|  |             if (option.isMatched(this.#isShowMore, codesInUse, this.#query.value)) { | ||||||
|  |                 option.setShown(true); | ||||||
|  |                 isAnyMatched = true; | ||||||
|  |             } else { | ||||||
|  |                 option.setShown(false); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         if (!isAnyMatched) { | ||||||
|  |             this.#optionList.classList.add("d-none"); | ||||||
|  |             this.#queryNoResult.classList.remove("d-none"); | ||||||
|  |         } else { | ||||||
|  |             this.#optionList.classList.remove("d-none"); | ||||||
|  |             this.#queryNoResult.classList.add("d-none"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns the account codes that are used in the form. | ||||||
|  |      * | ||||||
|  |      * @return {string[]} the account codes that are used in the form | ||||||
|  |      */ | ||||||
|  |     #getCodesUsedInForm() { | ||||||
|  |         const inUse = this.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit); | ||||||
|  |         if (this.lineItemEditor.accountCode !== null) { | ||||||
|  |             inUse.push(this.lineItemEditor.accountCode); | ||||||
|  |         } | ||||||
|  |         return inUse | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The callback when the account selector is shown. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     onOpen() { | ||||||
|  |         this.#query.value = ""; | ||||||
|  |         this.#isShowMore = false; | ||||||
|  |         this.#more.classList.remove("d-none"); | ||||||
|  |         this.#filterOptions(); | ||||||
|  |         for (const option of this.#options) { | ||||||
|  |             option.setActive(option.code === this.lineItemEditor.accountCode); | ||||||
|  |         } | ||||||
|  |         if (this.lineItemEditor.accountCode === null) { | ||||||
|  |             this.#clearButton.classList.add("btn-secondary"); | ||||||
|  |             this.#clearButton.classList.remove("btn-danger"); | ||||||
|  |             this.#clearButton.disabled = true; | ||||||
|  |         } else { | ||||||
|  |             this.#clearButton.classList.add("btn-danger"); | ||||||
|  |             this.#clearButton.classList.remove("btn-secondary"); | ||||||
|  |             this.#clearButton.disabled = false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns the account selector instances. | ||||||
|  |      * | ||||||
|  |      * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor | ||||||
|  |      * @return {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}} | ||||||
|  |      */ | ||||||
|  |     static getInstances(lineItemEditor) { | ||||||
|  |         const selectors = {} | ||||||
|  |         const modals = Array.from(document.getElementsByClassName("accounting-account-selector")); | ||||||
|  |         for (const modal of modals) { | ||||||
|  |             selectors[modal.dataset.debitCredit] = new JournalEntryAccountSelector(lineItemEditor, modal.dataset.debitCredit); | ||||||
|  |         } | ||||||
|  |         return selectors; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * An account option | ||||||
|  |  * | ||||||
|  |  */ | ||||||
|  | class JournalEntryAccountOption { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account selector | ||||||
|  |      * @type {JournalEntryAccountSelector} | ||||||
|  |      */ | ||||||
|  |     #selector; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The element | ||||||
|  |      * @type {HTMLLIElement} | ||||||
|  |      */ | ||||||
|  |     #element; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account code | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     code; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account text | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     text; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Whether the account is in use | ||||||
|  |      * @type {boolean} | ||||||
|  |      */ | ||||||
|  |     #isInUse; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Whether line items in the account need offset | ||||||
|  |      * @type {boolean} | ||||||
|  |      */ | ||||||
|  |     isNeedOffset; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The values to query against | ||||||
|  |      * @type {string[]} | ||||||
|  |      */ | ||||||
|  |     #queryValues; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs the account in the account selector. | ||||||
|  |      * | ||||||
|  |      * @param selector {JournalEntryAccountSelector} the account selector | ||||||
|  |      * @param element {HTMLLIElement} the element | ||||||
|  |      */ | ||||||
|  |     constructor(selector, element) { | ||||||
|  |         this.#selector = selector; | ||||||
|  |         this.#element = element; | ||||||
|  |         this.code = element.dataset.code; | ||||||
|  |         this.text = element.dataset.text; | ||||||
|  |         this.#isInUse = element.classList.contains("accounting-account-is-in-use"); | ||||||
|  |         this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset"); | ||||||
|  |         this.#queryValues = JSON.parse(element.dataset.queryValues); | ||||||
|  |  | ||||||
|  |         this.#element.onclick = () => this.#selector.lineItemEditor.saveAccount(this); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns whether the account matches the query. | ||||||
|  |      * | ||||||
|  |      * @param isShowMore {boolean} true to show all accounts, or false to show only those in use | ||||||
|  |      * @param codesInUse {string[]} the account codes that are used in the form | ||||||
|  |      * @param query {string} the query term | ||||||
|  |      * @return {boolean} true if the option matches, or false otherwise | ||||||
|  |      */ | ||||||
|  |     isMatched(isShowMore, codesInUse, query) { | ||||||
|  |         return this.#isInUseMatched(isShowMore, codesInUse) && this.#isQueryMatched(query); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns whether the account matches the "in-use" condition. | ||||||
|  |      * | ||||||
|  |      * @param isShowMore {boolean} true to show all accounts, or false to show only those in use | ||||||
|  |      * @param codesInUse {string[]} the account codes that are used in the form | ||||||
|  |      * @return {boolean} true if the option matches, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #isInUseMatched(isShowMore, codesInUse) { | ||||||
|  |         return isShowMore || this.#isInUse || codesInUse.includes(this.code); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns whether the account matches the query term. | ||||||
|  |      * | ||||||
|  |      * @param query {string} the query term | ||||||
|  |      * @return {boolean} true if the option matches, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #isQueryMatched(query) { | ||||||
|  |         if (query === "") { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         for (const queryValue of this.#queryValues) { | ||||||
|  |             if (queryValue.toLowerCase().includes(query.toLowerCase())) { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Sets whether the option is shown. | ||||||
|  |      * | ||||||
|  |      * @param isShown {boolean} true to show, or false otherwise | ||||||
|  |      */ | ||||||
|  |     setShown(isShown) { | ||||||
|  |         if (isShown) { | ||||||
|  |             this.#element.classList.remove("d-none"); | ||||||
|  |         } else { | ||||||
|  |             this.#element.classList.add("d-none"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Sets whether the option is active. | ||||||
|  |      * | ||||||
|  |      * @param isActive {boolean} true if active, or false otherwise | ||||||
|  |      */ | ||||||
|  |     setActive(isActive) { | ||||||
|  |         if (isActive) { | ||||||
|  |             this.#element.classList.add("active"); | ||||||
|  |         } else { | ||||||
|  |             this.#element.classList.remove("active"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										1121
									
								
								src/accounting/static/js/journal-entry-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1121
									
								
								src/accounting/static/js/journal-entry-form.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										598
									
								
								src/accounting/static/js/journal-entry-line-item-editor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										598
									
								
								src/accounting/static/js/journal-entry-line-item-editor.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,598 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * journal-entry-line-item-editor.js: The JavaScript for the journal entry line item editor | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /*  Copyright (c) 2023 imacat. | ||||||
|  |  * | ||||||
|  |  *  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  *  you may not use this file except in compliance with the License. | ||||||
|  |  *  You may obtain a copy of the License at | ||||||
|  |  * | ||||||
|  |  *      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  * | ||||||
|  |  *  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  *  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  |  *  See the License for the specific language governing permissions and | ||||||
|  |  *  limitations under the License. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /* Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
|  |  * First written: 2023/2/25 | ||||||
|  |  */ | ||||||
|  | "use strict"; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The journal entry line item editor. | ||||||
|  |  * | ||||||
|  |  */ | ||||||
|  | class JournalEntryLineItemEditor { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The journal entry form | ||||||
|  |      * @type {JournalEntryForm} | ||||||
|  |      */ | ||||||
|  |     form; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The journal entry line item editor | ||||||
|  |      * @type {HTMLFormElement} | ||||||
|  |      */ | ||||||
|  |     #element; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The bootstrap modal | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     modal; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Either "debit" or "credit" | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     debitCredit; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The prefix of the HTML ID and class names | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #prefix = "accounting-line-item-editor" | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The container of the original line item | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #originalLineItemContainer; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The control of the original line item | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #originalLineItemControl; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The original line item | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #originalLineItemText; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message of the original line item | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #originalLineItemError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The delete button of the original line item | ||||||
|  |      * @type {HTMLButtonElement} | ||||||
|  |      */ | ||||||
|  |     #originalLineItemDelete; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The control of the description | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #descriptionControl; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The description | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #descriptionText; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message of the description | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #descriptionError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The control of the account | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #accountControl; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #accountText; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message of the account | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #accountError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The amount | ||||||
|  |      * @type {HTMLInputElement} | ||||||
|  |      */ | ||||||
|  |     #amountInput; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message of the amount | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #amountError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The journal entry line item to edit | ||||||
|  |      * @type {LineItemSubForm|null} | ||||||
|  |      */ | ||||||
|  |     lineItem; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The debit or credit sub-form | ||||||
|  |      * @type {DebitCreditSubForm} | ||||||
|  |      */ | ||||||
|  |     #debitCreditSubForm; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Whether the journal entry line item needs offset | ||||||
|  |      * @type {boolean} | ||||||
|  |      */ | ||||||
|  |     isNeedOffset = false; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The ID of the original line item | ||||||
|  |      * @type {string|null} | ||||||
|  |      */ | ||||||
|  |     originalLineItemId = null; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The date of the original line item | ||||||
|  |      * @type {string|null} | ||||||
|  |      */ | ||||||
|  |     originalLineItemDate = null; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The text of the original line item | ||||||
|  |      * @type {string|null} | ||||||
|  |      */ | ||||||
|  |     originalLineItemText = null; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account code | ||||||
|  |      * @type {string|null} | ||||||
|  |      */ | ||||||
|  |     accountCode = null; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account text | ||||||
|  |      * @type {string|null} | ||||||
|  |      */ | ||||||
|  |     accountText = null; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The description | ||||||
|  |      * @type {string|null} | ||||||
|  |      */ | ||||||
|  |     description = null; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The description editors | ||||||
|  |      * @type {{debit: DescriptionEditor, credit: DescriptionEditor}} | ||||||
|  |      */ | ||||||
|  |     #descriptionEditors; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account selectors | ||||||
|  |      * @type {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}} | ||||||
|  |      */ | ||||||
|  |     #accountSelectors; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The original line item selector | ||||||
|  |      * @type {OriginalLineItemSelector} | ||||||
|  |      */ | ||||||
|  |     originalLineItemSelector; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs a new journal entry line item editor. | ||||||
|  |      * | ||||||
|  |      * @param form {JournalEntryForm} the journal entry form | ||||||
|  |      */ | ||||||
|  |     constructor(form) { | ||||||
|  |         this.form = form; | ||||||
|  |         this.#element = document.getElementById(this.#prefix); | ||||||
|  |         this.modal = document.getElementById(this.#prefix + "-modal"); | ||||||
|  |         this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container"); | ||||||
|  |         this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control"); | ||||||
|  |         this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item"); | ||||||
|  |         this.#originalLineItemError = document.getElementById(this.#prefix + "-original-line-item-error"); | ||||||
|  |         this.#originalLineItemDelete = document.getElementById(this.#prefix + "-original-line-item-delete"); | ||||||
|  |         this.#descriptionControl = document.getElementById(this.#prefix + "-description-control"); | ||||||
|  |         this.#descriptionText = document.getElementById(this.#prefix + "-description"); | ||||||
|  |         this.#descriptionError = document.getElementById(this.#prefix + "-description-error"); | ||||||
|  |         this.#accountControl = document.getElementById(this.#prefix + "-account-control"); | ||||||
|  |         this.#accountText = document.getElementById(this.#prefix + "-account"); | ||||||
|  |         this.#accountError = document.getElementById(this.#prefix + "-account-error") | ||||||
|  |         this.#amountInput = document.getElementById(this.#prefix + "-amount"); | ||||||
|  |         this.#amountError = document.getElementById(this.#prefix + "-amount-error"); | ||||||
|  |         this.#descriptionEditors = DescriptionEditor.getInstances(this); | ||||||
|  |         this.#accountSelectors = JournalEntryAccountSelector.getInstances(this); | ||||||
|  |         this.originalLineItemSelector = new OriginalLineItemSelector(this); | ||||||
|  |  | ||||||
|  |         this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen() | ||||||
|  |         this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem(); | ||||||
|  |         this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen(); | ||||||
|  |         this.#accountControl.onclick = () => this.#accountSelectors[this.debitCredit].onOpen(); | ||||||
|  |         this.#amountInput.onchange = () => this.#validateAmount(); | ||||||
|  |         this.#element.onsubmit = () => { | ||||||
|  |             if (this.#validate()) { | ||||||
|  |                 if (this.lineItem === null) { | ||||||
|  |                     this.lineItem = this.#debitCreditSubForm.addLineItem(); | ||||||
|  |                 } | ||||||
|  |                 this.lineItem.save(this); | ||||||
|  |                 bootstrap.Modal.getInstance(this.modal).hide(); | ||||||
|  |             } | ||||||
|  |             return false; | ||||||
|  |         }; | ||||||
|  |         this.modal.addEventListener("hidden.bs.modal", () => this.#debitCreditSubForm.onLineItemEditorClosed()); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns the amount. | ||||||
|  |      * | ||||||
|  |      * @return {string} the amount | ||||||
|  |      */ | ||||||
|  |     get amount() { | ||||||
|  |         return this.#amountInput.value; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns the currency code. | ||||||
|  |      * | ||||||
|  |      * @return {string} the currency code | ||||||
|  |      */ | ||||||
|  |     get currencyCode() { | ||||||
|  |         return this.#debitCreditSubForm.currency.currencyCode; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Saves the original line item from the original line item selector. | ||||||
|  |      * | ||||||
|  |      * @param originalLineItem {OriginalLineItem} the original line item | ||||||
|  |      */ | ||||||
|  |     saveOriginalLineItem(originalLineItem) { | ||||||
|  |         this.isNeedOffset = false; | ||||||
|  |         this.#originalLineItemContainer.classList.remove("d-none"); | ||||||
|  |         this.#originalLineItemControl.classList.add("accounting-not-empty"); | ||||||
|  |         this.originalLineItemId = originalLineItem.id; | ||||||
|  |         this.originalLineItemDate = originalLineItem.date; | ||||||
|  |         this.originalLineItemText = originalLineItem.text; | ||||||
|  |         this.#originalLineItemText.innerText = originalLineItem.text; | ||||||
|  |         this.#setEnableDescriptionAccount(false); | ||||||
|  |         if (originalLineItem.description === "") { | ||||||
|  |             this.#descriptionControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             this.#descriptionControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         this.description = originalLineItem.description === ""? null: originalLineItem.description; | ||||||
|  |         this.#descriptionText.innerText = originalLineItem.description; | ||||||
|  |         this.#accountControl.classList.add("accounting-not-empty"); | ||||||
|  |         this.accountCode = originalLineItem.accountCode; | ||||||
|  |         this.accountText = originalLineItem.accountText; | ||||||
|  |         this.#accountText.innerText = originalLineItem.accountText; | ||||||
|  |         this.#amountInput.value = String(originalLineItem.netBalance); | ||||||
|  |         this.#amountInput.max = String(originalLineItem.netBalance); | ||||||
|  |         this.#amountInput.min = "0"; | ||||||
|  |         this.#validate(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Clears the original line item. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     clearOriginalLineItem() { | ||||||
|  |         this.isNeedOffset = false; | ||||||
|  |         this.#originalLineItemContainer.classList.add("d-none"); | ||||||
|  |         this.#originalLineItemControl.classList.remove("accounting-not-empty"); | ||||||
|  |         this.originalLineItemId = null; | ||||||
|  |         this.originalLineItemDate = null; | ||||||
|  |         this.originalLineItemText = null; | ||||||
|  |         this.#originalLineItemText.innerText = ""; | ||||||
|  |         this.#setEnableDescriptionAccount(true); | ||||||
|  |         this.#accountControl.classList.remove("accounting-not-empty"); | ||||||
|  |         this.accountCode = null; | ||||||
|  |         this.accountText = null; | ||||||
|  |         this.#accountText.innerText = ""; | ||||||
|  |         this.#amountInput.max = ""; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Saves the description from the description editor. | ||||||
|  |      * | ||||||
|  |      * @param description {string} the description | ||||||
|  |      */ | ||||||
|  |     saveDescription(description) { | ||||||
|  |         if (description === "") { | ||||||
|  |             this.#descriptionControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             this.#descriptionControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         this.description = description === ""? null: description; | ||||||
|  |         this.#descriptionText.innerText = description; | ||||||
|  |         this.#validateDescription(); | ||||||
|  |         bootstrap.Modal.getOrCreateInstance(this.modal).show(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Saves the description with the suggested account from the description editor. | ||||||
|  |      * | ||||||
|  |      * @param description {string} the description | ||||||
|  |      * @param accountCode {string} the account code | ||||||
|  |      * @param accountText {string} the account text | ||||||
|  |      * @param isAccountNeedOffset {boolean} true if the line items in the account need offset, or false otherwise | ||||||
|  |      */ | ||||||
|  |     saveDescriptionWithAccount(description, accountCode, accountText, isAccountNeedOffset) { | ||||||
|  |         this.isNeedOffset = isAccountNeedOffset; | ||||||
|  |         this.#accountControl.classList.add("accounting-not-empty"); | ||||||
|  |         this.accountCode = accountCode; | ||||||
|  |         this.accountText = accountText; | ||||||
|  |         this.#accountText.innerText = accountText; | ||||||
|  |         this.#validateAccount(); | ||||||
|  |         this.saveDescription(description) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Clears the account. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     clearAccount() { | ||||||
|  |         this.isNeedOffset = false; | ||||||
|  |         this.#accountControl.classList.remove("accounting-not-empty"); | ||||||
|  |         this.accountCode = null; | ||||||
|  |         this.accountText = null; | ||||||
|  |         this.#accountText.innerText = ""; | ||||||
|  |         this.#validateAccount(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Saves the selected account. | ||||||
|  |      * | ||||||
|  |      * @param account {JournalEntryAccountOption} the selected account | ||||||
|  |      */ | ||||||
|  |     saveAccount(account) { | ||||||
|  |         this.isNeedOffset = account.isNeedOffset; | ||||||
|  |         this.#accountControl.classList.add("accounting-not-empty"); | ||||||
|  |         this.accountCode = account.code; | ||||||
|  |         this.accountText = account.text; | ||||||
|  |         this.#accountText.innerText = account.text; | ||||||
|  |         this.#validateAccount(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the form. | ||||||
|  |      * | ||||||
|  |      * @returns {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validate() { | ||||||
|  |         let isValid = true; | ||||||
|  |         isValid = this.#validateOriginalLineItem() && isValid; | ||||||
|  |         isValid = this.#validateDescription() && isValid; | ||||||
|  |         isValid = this.#validateAccount() && isValid; | ||||||
|  |         isValid = this.#validateAmount() && isValid | ||||||
|  |         return isValid; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the original line item. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      * @private | ||||||
|  |      */ | ||||||
|  |     #validateOriginalLineItem() { | ||||||
|  |         this.#originalLineItemControl.classList.remove("is-invalid"); | ||||||
|  |         this.#originalLineItemError.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the description. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      * @private | ||||||
|  |      */ | ||||||
|  |     #validateDescription() { | ||||||
|  |         this.#descriptionText.classList.remove("is-invalid"); | ||||||
|  |         this.#descriptionError.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the account. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateAccount() { | ||||||
|  |         if (this.accountCode === null) { | ||||||
|  |             this.#accountControl.classList.add("is-invalid"); | ||||||
|  |             this.#accountError.innerText = A_("Please select the account."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         this.#accountControl.classList.remove("is-invalid"); | ||||||
|  |         this.#accountError.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the amount. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      * @private | ||||||
|  |      */ | ||||||
|  |     #validateAmount() { | ||||||
|  |         this.#amountInput.value = this.#amountInput.value.trim(); | ||||||
|  |         this.#amountInput.classList.remove("is-invalid"); | ||||||
|  |         if (this.#amountInput.value === "") { | ||||||
|  |             this.#amountInput.classList.add("is-invalid"); | ||||||
|  |             this.#amountError.innerText = A_("Please fill in the amount."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         const amount =new Decimal(this.#amountInput.value); | ||||||
|  |         if (amount.lessThanOrEqualTo(0)) { | ||||||
|  |             this.#amountInput.classList.add("is-invalid"); | ||||||
|  |             this.#amountError.innerText = A_("Please fill in a positive amount."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         if (this.#amountInput.max !== "") { | ||||||
|  |             if (amount.greaterThan(new Decimal(this.#amountInput.max))) { | ||||||
|  |                 this.#amountInput.classList.add("is-invalid"); | ||||||
|  |                 this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original line item.", {balance: new Decimal(this.#amountInput.max)}); | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         if (this.#amountInput.min !== "") { | ||||||
|  |             const min = new Decimal(this.#amountInput.min); | ||||||
|  |             if (amount.lessThan(min)) { | ||||||
|  |                 this.#amountInput.classList.add("is-invalid"); | ||||||
|  |                 this.#amountError.innerText = A_("The amount must not be less than the offset total %(total)s.", {total: formatDecimal(min)}); | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         this.#amountInput.classList.remove("is-invalid"); | ||||||
|  |         this.#amountError.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The callback when adding a new journal entry line item. | ||||||
|  |      * | ||||||
|  |      * @param debitCredit {DebitCreditSubForm} the debit or credit sub-form | ||||||
|  |      */ | ||||||
|  |     onAddNew(debitCredit) { | ||||||
|  |         this.lineItem = null; | ||||||
|  |         this.#debitCreditSubForm = debitCredit; | ||||||
|  |         this.debitCredit = this.#debitCreditSubForm.debitCredit; | ||||||
|  |         this.isNeedOffset = false; | ||||||
|  |         this.#originalLineItemContainer.classList.add("d-none"); | ||||||
|  |         this.#originalLineItemControl.classList.remove("accounting-not-empty"); | ||||||
|  |         this.#originalLineItemControl.classList.remove("is-invalid"); | ||||||
|  |         this.originalLineItemId = null; | ||||||
|  |         this.originalLineItemDate = null; | ||||||
|  |         this.originalLineItemText = null; | ||||||
|  |         this.#originalLineItemText.innerText = ""; | ||||||
|  |         this.#setEnableDescriptionAccount(true); | ||||||
|  |         this.#descriptionControl.classList.remove("accounting-not-empty"); | ||||||
|  |         this.#descriptionControl.classList.remove("is-invalid"); | ||||||
|  |         this.description = null; | ||||||
|  |         this.#descriptionText.innerText = "" | ||||||
|  |         this.#descriptionError.innerText = "" | ||||||
|  |         this.#accountControl.classList.remove("accounting-not-empty"); | ||||||
|  |         this.#accountControl.classList.remove("is-invalid"); | ||||||
|  |         this.accountCode = null; | ||||||
|  |         this.accountText = null; | ||||||
|  |         this.#accountText.innerText = ""; | ||||||
|  |         this.#accountError.innerText = ""; | ||||||
|  |         this.#amountInput.value = ""; | ||||||
|  |         this.#amountInput.max = ""; | ||||||
|  |         this.#amountInput.min = "0"; | ||||||
|  |         this.#amountInput.classList.remove("is-invalid"); | ||||||
|  |         this.#amountError.innerText = ""; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The callback when editing a journal entry line item. | ||||||
|  |      * | ||||||
|  |      * @param lineItem {LineItemSubForm} the journal entry line item sub-form | ||||||
|  |      */ | ||||||
|  |     onEdit(lineItem) { | ||||||
|  |         this.lineItem = lineItem; | ||||||
|  |         this.#debitCreditSubForm = lineItem.debitCreditSubForm; | ||||||
|  |         this.debitCredit = this.#debitCreditSubForm.debitCredit; | ||||||
|  |         this.isNeedOffset = lineItem.isNeedOffset; | ||||||
|  |         this.originalLineItemId = lineItem.originalLineItemId; | ||||||
|  |         this.originalLineItemDate = lineItem.originalLineItemDate; | ||||||
|  |         this.originalLineItemText = lineItem.originalLineItemText; | ||||||
|  |         this.#originalLineItemText.innerText = this.originalLineItemText; | ||||||
|  |         if (this.originalLineItemId === null) { | ||||||
|  |             this.#originalLineItemContainer.classList.add("d-none"); | ||||||
|  |             this.#originalLineItemControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             this.#originalLineItemContainer.classList.remove("d-none"); | ||||||
|  |             this.#originalLineItemControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null); | ||||||
|  |         this.description = lineItem.description; | ||||||
|  |         if (this.description === null) { | ||||||
|  |             this.#descriptionControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             this.#descriptionControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         this.#descriptionText.innerText = this.description === null? "": this.description; | ||||||
|  |         if (lineItem.accountCode === null) { | ||||||
|  |             this.#accountControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             this.#accountControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         this.accountCode = lineItem.accountCode; | ||||||
|  |         this.accountText = lineItem.accountText; | ||||||
|  |         this.#accountText.innerText = this.accountText; | ||||||
|  |         this.#amountInput.value = lineItem.amount === null? "": String(lineItem.amount); | ||||||
|  |         const maxAmount = this.#getMaxAmount(); | ||||||
|  |         this.#amountInput.max = maxAmount === null? "": maxAmount; | ||||||
|  |         this.#amountInput.min = lineItem.amountMin === null? "": String(lineItem.amountMin); | ||||||
|  |         this.#validate(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Finds out the max amount. | ||||||
|  |      * | ||||||
|  |      * @return {Decimal|null} the max amount | ||||||
|  |      */ | ||||||
|  |     #getMaxAmount() { | ||||||
|  |         if (this.originalLineItemId === null) { | ||||||
|  |             return null; | ||||||
|  |         } | ||||||
|  |         return this.originalLineItemSelector.getNetBalance(this.lineItem, this.form, this.originalLineItemId); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Sets the enable status of the description and account. | ||||||
|  |      * | ||||||
|  |      * @param isEnabled {boolean} true to enable, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #setEnableDescriptionAccount(isEnabled) { | ||||||
|  |         if (isEnabled) { | ||||||
|  |             this.#descriptionControl.dataset.bsToggle = "modal"; | ||||||
|  |             this.#descriptionControl.dataset.bsTarget = "#accounting-description-editor-" + this.#debitCreditSubForm.debitCredit + "-modal"; | ||||||
|  |             this.#descriptionControl.classList.remove("accounting-disabled"); | ||||||
|  |             this.#descriptionControl.classList.add("accounting-clickable"); | ||||||
|  |             this.#accountControl.dataset.bsToggle = "modal"; | ||||||
|  |             this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#debitCreditSubForm.debitCredit + "-modal"; | ||||||
|  |             this.#accountControl.classList.remove("accounting-disabled"); | ||||||
|  |             this.#accountControl.classList.add("accounting-clickable"); | ||||||
|  |         } else { | ||||||
|  |             this.#descriptionControl.dataset.bsToggle = ""; | ||||||
|  |             this.#descriptionControl.dataset.bsTarget = ""; | ||||||
|  |             this.#descriptionControl.classList.add("accounting-disabled"); | ||||||
|  |             this.#descriptionControl.classList.remove("accounting-clickable"); | ||||||
|  |             this.#accountControl.dataset.bsToggle = ""; | ||||||
|  |             this.#accountControl.dataset.bsTarget = ""; | ||||||
|  |             this.#accountControl.classList.add("accounting-disabled"); | ||||||
|  |             this.#accountControl.classList.remove("accounting-clickable"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| /* The Mia! Accounting Flask Project | /* The Mia! Accounting Flask Project | ||||||
|  * transaction-order.js: The JavaScript for the transaction order |  * journal-entry-order.js: The JavaScript for the journal entry order | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| /*  Copyright (c) 2023 imacat. | /*  Copyright (c) 2023 imacat. | ||||||
| @@ -20,12 +20,13 @@ | |||||||
| /* Author: imacat@mail.imacat.idv.tw (imacat) | /* Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
|  * First written: 2023/2/26 |  * First written: 2023/2/26 | ||||||
|  */ |  */ | ||||||
|  | "use strict"; | ||||||
| 
 | 
 | ||||||
| // Initializes the page JavaScript.
 | // Initializes the page JavaScript.
 | ||||||
| document.addEventListener("DOMContentLoaded", function () { | document.addEventListener("DOMContentLoaded", () => { | ||||||
|     const list = document.getElementById("accounting-order-list"); |     const list = document.getElementById("accounting-order-list"); | ||||||
|     if (list !== null) { |     if (list !== null) { | ||||||
|         const onReorder = function () { |         const onReorder = () => { | ||||||
|             const accounts = Array.from(list.children); |             const accounts = Array.from(list.children); | ||||||
|             for (let i = 0; i < accounts.length; i++) { |             for (let i = 0; i < accounts.length; i++) { | ||||||
|                 const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no"); |                 const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no"); | ||||||
| @@ -20,9 +20,10 @@ | |||||||
| /* Author: imacat@mail.imacat.idv.tw (imacat) | /* Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
|  * First written: 2023/2/25 |  * First written: 2023/2/25 | ||||||
|  */ |  */ | ||||||
|  | "use strict"; | ||||||
|  |  | ||||||
| // Initializes the page JavaScript. | // Initializes the page JavaScript. | ||||||
| document.addEventListener("DOMContentLoaded", function () { | document.addEventListener("DOMContentLoaded", () => { | ||||||
|     initializeMaterialFabSpeedDial(); |     initializeMaterialFabSpeedDial(); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @@ -34,7 +35,7 @@ document.addEventListener("DOMContentLoaded", function () { | |||||||
| function initializeMaterialFabSpeedDial() { | function initializeMaterialFabSpeedDial() { | ||||||
|     const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial"); |     const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial"); | ||||||
|     const fab = document.getElementById(btnFab.dataset.target); |     const fab = document.getElementById(btnFab.dataset.target); | ||||||
|     btnFab.onclick = function () { |     btnFab.onclick = () => { | ||||||
|         if (fab.classList.contains("show")) { |         if (fab.classList.contains("show")) { | ||||||
|             fab.classList.remove("show"); |             fab.classList.remove("show"); | ||||||
|         } else { |         } else { | ||||||
|   | |||||||
							
								
								
									
										1036
									
								
								src/accounting/static/js/option-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1036
									
								
								src/accounting/static/js/option-form.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										416
									
								
								src/accounting/static/js/original-line-item-selector.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										416
									
								
								src/accounting/static/js/original-line-item-selector.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,416 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * original-line-item-selector.js: The JavaScript for the original line item selector | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /*  Copyright (c) 2023 imacat. | ||||||
|  |  * | ||||||
|  |  *  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  *  you may not use this file except in compliance with the License. | ||||||
|  |  *  You may obtain a copy of the License at | ||||||
|  |  * | ||||||
|  |  *      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  * | ||||||
|  |  *  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  *  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  |  *  See the License for the specific language governing permissions and | ||||||
|  |  *  limitations under the License. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /* Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
|  |  * First written: 2023/3/10 | ||||||
|  |  */ | ||||||
|  | "use strict"; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The original line item selector. | ||||||
|  |  * | ||||||
|  |  */ | ||||||
|  | class OriginalLineItemSelector { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The line item editor | ||||||
|  |      * @type {JournalEntryLineItemEditor} | ||||||
|  |      */ | ||||||
|  |     lineItemEditor; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The prefix of the HTML ID and class names | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #prefix = "accounting-original-line-item-selector"; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The query input | ||||||
|  |      * @type {HTMLInputElement} | ||||||
|  |      */ | ||||||
|  |     #query; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error message when the query has no result | ||||||
|  |      * @type {HTMLParagraphElement} | ||||||
|  |      */ | ||||||
|  |     #queryNoResult; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The option list | ||||||
|  |      * @type {HTMLUListElement} | ||||||
|  |      */ | ||||||
|  |     #optionList; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The options | ||||||
|  |      * @type {OriginalLineItem[]} | ||||||
|  |      */ | ||||||
|  |     #options; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The options by their ID | ||||||
|  |      * @type {Object.<string, OriginalLineItem>} | ||||||
|  |      */ | ||||||
|  |     #optionById; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The currency code | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #currencyCode; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Either "credit" or "debit" | ||||||
|  |      */ | ||||||
|  |     #debitCredit; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs an original line item selector. | ||||||
|  |      * | ||||||
|  |      * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor | ||||||
|  |      */ | ||||||
|  |     constructor(lineItemEditor) { | ||||||
|  |         this.lineItemEditor = lineItemEditor; | ||||||
|  |         this.#query = document.getElementById(this.#prefix + "-query"); | ||||||
|  |         this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result"); | ||||||
|  |         this.#optionList = document.getElementById(this.#prefix + "-option-list"); | ||||||
|  |         this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalLineItem(this, element)); | ||||||
|  |         this.#optionById = {}; | ||||||
|  |         for (const option of this.#options) { | ||||||
|  |             this.#optionById[option.id] = option; | ||||||
|  |         } | ||||||
|  |         this.#query.oninput = () => this.#filterOptions(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns the net balance for an original line item. | ||||||
|  |      * | ||||||
|  |      * @param currentLineItem {LineItemSubForm} the line item sub-form that is currently editing | ||||||
|  |      * @param form {JournalEntryForm} the journal entry form | ||||||
|  |      * @param originalLineItemId {string} the ID of the original line item | ||||||
|  |      * @return {Decimal} the net balance of the original line item | ||||||
|  |      */ | ||||||
|  |     getNetBalance(currentLineItem, form, originalLineItemId) { | ||||||
|  |         const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem); | ||||||
|  |         let otherOffset = new Decimal(0); | ||||||
|  |         for (const otherLineItem of otherLineItems) { | ||||||
|  |             if (otherLineItem.originalLineItemId === originalLineItemId) { | ||||||
|  |                 const amount = otherLineItem.amount; | ||||||
|  |                 if (amount !== null) { | ||||||
|  |                     otherOffset = otherOffset.plus(amount); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return this.#optionById[originalLineItemId].bareNetBalance.minus(otherOffset); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Updates the net balances, subtracting the offset amounts on the form but the currently editing line item | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #updateNetBalances() { | ||||||
|  |         const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem); | ||||||
|  |         const otherOffsets = {} | ||||||
|  |         for (const otherLineItem of otherLineItems) { | ||||||
|  |             const otherOriginalLineItemId = otherLineItem.originalLineItemId; | ||||||
|  |             const amount = otherLineItem.amount; | ||||||
|  |             if (otherOriginalLineItemId === null || amount === null) { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |             if (!(otherOriginalLineItemId in otherOffsets)) { | ||||||
|  |                 otherOffsets[otherOriginalLineItemId] = new Decimal("0"); | ||||||
|  |             } | ||||||
|  |             otherOffsets[otherOriginalLineItemId] = otherOffsets[otherOriginalLineItemId].plus(amount); | ||||||
|  |         } | ||||||
|  |         for (const option of this.#options) { | ||||||
|  |             if (option.id in otherOffsets) { | ||||||
|  |                 option.updateNetBalance(otherOffsets[option.id]); | ||||||
|  |             } else { | ||||||
|  |                 option.resetNetBalance(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Filters the options. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #filterOptions() { | ||||||
|  |         let isAnyMatched = false; | ||||||
|  |         for (const option of this.#options) { | ||||||
|  |             if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) { | ||||||
|  |                 option.setShown(true); | ||||||
|  |                 isAnyMatched = true; | ||||||
|  |             } else { | ||||||
|  |                 option.setShown(false); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         if (!isAnyMatched) { | ||||||
|  |             this.#optionList.classList.add("d-none"); | ||||||
|  |             this.#queryNoResult.classList.remove("d-none"); | ||||||
|  |         } else { | ||||||
|  |             this.#optionList.classList.remove("d-none"); | ||||||
|  |             this.#queryNoResult.classList.add("d-none"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The callback when the original line item selector is shown. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     onOpen() { | ||||||
|  |         this.#currencyCode = this.lineItemEditor.currencyCode; | ||||||
|  |         this.#debitCredit = this.lineItemEditor.debitCredit; | ||||||
|  |         for (const option of this.#options) { | ||||||
|  |             option.setActive(option.id === this.lineItemEditor.originalLineItemId); | ||||||
|  |         } | ||||||
|  |         this.#query.value = ""; | ||||||
|  |         this.#updateNetBalances(); | ||||||
|  |         this.#filterOptions(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * An original line item. | ||||||
|  |  * | ||||||
|  |  */ | ||||||
|  | class OriginalLineItem { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The original line item selector | ||||||
|  |      * @type {OriginalLineItemSelector} | ||||||
|  |      */ | ||||||
|  |     #selector; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The element | ||||||
|  |      * @type {HTMLLIElement} | ||||||
|  |      */ | ||||||
|  |     #element; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The ID | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     id; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The date | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     date; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Either "debit" or "credit" | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #debitCredit; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The currency code | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     #currencyCode; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account code | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     accountCode; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The account text | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     accountText; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The description | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     description; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The net balance, without the offset amounts on the form | ||||||
|  |      * @type {Decimal} | ||||||
|  |      */ | ||||||
|  |     bareNetBalance; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The net balance | ||||||
|  |      * @type {Decimal} | ||||||
|  |      */ | ||||||
|  |     netBalance; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The text of the net balance | ||||||
|  |      * @type {HTMLSpanElement} | ||||||
|  |      */ | ||||||
|  |     netBalanceText; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The text representation of the original line item | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     text; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The values to query against | ||||||
|  |      * @type {string[]} | ||||||
|  |      */ | ||||||
|  |     #queryValues; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs an original line item. | ||||||
|  |      * | ||||||
|  |      * @param selector {OriginalLineItemSelector} the original line item selector | ||||||
|  |      * @param element {HTMLLIElement} the element | ||||||
|  |      */ | ||||||
|  |     constructor(selector, element) { | ||||||
|  |         this.#selector = selector; | ||||||
|  |         this.#element = element; | ||||||
|  |         this.id = element.dataset.id; | ||||||
|  |         this.date = element.dataset.date; | ||||||
|  |         this.#debitCredit = element.dataset.debitCredit; | ||||||
|  |         this.#currencyCode = element.dataset.currencyCode; | ||||||
|  |         this.accountCode = element.dataset.accountCode; | ||||||
|  |         this.accountText = element.dataset.accountText; | ||||||
|  |         this.description = element.dataset.description; | ||||||
|  |         this.bareNetBalance = new Decimal(element.dataset.netBalance); | ||||||
|  |         this.netBalance = this.bareNetBalance; | ||||||
|  |         this.netBalanceText = document.getElementById("accounting-original-line-item-selector-option-" + this.id + "-net-balance"); | ||||||
|  |         this.text = element.dataset.text; | ||||||
|  |         this.#queryValues = JSON.parse(element.dataset.queryValues); | ||||||
|  |         this.#element.onclick = () => this.#selector.lineItemEditor.saveOriginalLineItem(this); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Resets the net balance to its initial value, without the offset amounts on the form. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     resetNetBalance() { | ||||||
|  |         if (this.netBalance !== this.bareNetBalance) { | ||||||
|  |             this.netBalance = this.bareNetBalance; | ||||||
|  |             this.#updateNetBalanceText(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Updates the net balance with an offset. | ||||||
|  |      * | ||||||
|  |      * @param offset {Decimal} the offset to be added to the net balance | ||||||
|  |      */ | ||||||
|  |     updateNetBalance(offset) { | ||||||
|  |         this.netBalance = this.bareNetBalance.minus(offset); | ||||||
|  |         this.#updateNetBalanceText(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Updates the text display of the net balance. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #updateNetBalanceText() { | ||||||
|  |         this.netBalanceText.innerText = formatDecimal(this.netBalance); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns whether the original matches. | ||||||
|  |      * | ||||||
|  |      * @param debitCredit {string} either "debit" or "credit" | ||||||
|  |      * @param currencyCode {string} the currency code | ||||||
|  |      * @param query {string|null} the query term | ||||||
|  |      */ | ||||||
|  |     isMatched(debitCredit, currencyCode, query = null) { | ||||||
|  |         return this.netBalance.greaterThan(0) | ||||||
|  |             && this.date <= this.#selector.lineItemEditor.form.date | ||||||
|  |             && this.#isDebitCreditMatched(debitCredit) | ||||||
|  |             && this.#currencyCode === currencyCode | ||||||
|  |             && this.#isQueryMatched(query); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns whether the original line item matches the debit or credit. | ||||||
|  |      * | ||||||
|  |      * @param debitCredit {string} either "debit" or credit | ||||||
|  |      * @return {boolean} true if the option matches, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #isDebitCreditMatched(debitCredit) { | ||||||
|  |         return (debitCredit === "debit" && this.#debitCredit === "credit") | ||||||
|  |             || (debitCredit === "credit" && this.#debitCredit === "debit"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns whether the original line item matches the query term. | ||||||
|  |      * | ||||||
|  |      * @param query {string|null} the query term | ||||||
|  |      * @return {boolean} true if the option matches, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #isQueryMatched(query) { | ||||||
|  |         if (query === "") { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         if (this.#getNetBalanceForQuery().includes(query.toLowerCase())) { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         for (const queryValue of this.#queryValues) { | ||||||
|  |             if (queryValue.toLowerCase().includes(query.toLowerCase())) { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Returns the net balance in the format for query match. | ||||||
|  |      * | ||||||
|  |      * @return {string} the net balance in the format for query match | ||||||
|  |      */ | ||||||
|  |     #getNetBalanceForQuery() { | ||||||
|  |         const frac = this.netBalance.modulo(1); | ||||||
|  |         const whole = Number(this.netBalance.minus(frac)); | ||||||
|  |         return String(whole) + String(frac).substring(1); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Sets whether the option is shown. | ||||||
|  |      * | ||||||
|  |      * @param isShown {boolean} true to show, or false otherwise | ||||||
|  |      */ | ||||||
|  |     setShown(isShown) { | ||||||
|  |         if (isShown) { | ||||||
|  |             this.#element.classList.remove("d-none"); | ||||||
|  |         } else { | ||||||
|  |             this.#element.classList.add("d-none"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Sets whether the option is active. | ||||||
|  |      * | ||||||
|  |      * @param isActive {boolean} true if active, or false otherwise | ||||||
|  |      */ | ||||||
|  |     setActive(isActive) { | ||||||
|  |         if (isActive) { | ||||||
|  |             this.#element.classList.add("active"); | ||||||
|  |         } else { | ||||||
|  |             this.#element.classList.remove("active"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										420
									
								
								src/accounting/static/js/period-chooser.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										420
									
								
								src/accounting/static/js/period-chooser.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,420 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * period-chooser.js: The JavaScript for the period chooser | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /*  Copyright (c) 2023 imacat. | ||||||
|  |  * | ||||||
|  |  *  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  *  you may not use this file except in compliance with the License. | ||||||
|  |  *  You may obtain a copy of the License at | ||||||
|  |  * | ||||||
|  |  *      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  * | ||||||
|  |  *  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  *  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  |  *  See the License for the specific language governing permissions and | ||||||
|  |  *  limitations under the License. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /* Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
|  |  * First written: 2023/3/4 | ||||||
|  |  */ | ||||||
|  | "use strict"; | ||||||
|  |  | ||||||
|  | // Initializes the page JavaScript. | ||||||
|  | document.addEventListener("DOMContentLoaded", () => { | ||||||
|  |     PeriodChooser.initialize(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The period chooser. | ||||||
|  |  * | ||||||
|  |  */ | ||||||
|  | class PeriodChooser { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The modal of the period chooser | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     modal; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The tab planes | ||||||
|  |      * @type {{month: MonthTab, year: YearTab, day: DayTab, custom: CustomTab}} | ||||||
|  |      */ | ||||||
|  |     tabPlanes = {}; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs the period chooser. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     constructor() { | ||||||
|  |         const prefix = "accounting-period-chooser"; | ||||||
|  |         this.modal = document.getElementById(prefix + "-modal"); | ||||||
|  |         for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) { | ||||||
|  |             const tab = new cls(this); | ||||||
|  |             this.tabPlanes[tab.tabId()] = tab; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The period chooser. | ||||||
|  |      * @type {PeriodChooser} | ||||||
|  |      */ | ||||||
|  |     static #chooser; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Initializes the period chooser. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     static initialize() { | ||||||
|  |         this.#chooser = new PeriodChooser(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * A tab plane. | ||||||
|  |  * | ||||||
|  |  * @abstract | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | class TabPlane { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The period chooser | ||||||
|  |      * @type {PeriodChooser} | ||||||
|  |      */ | ||||||
|  |     chooser; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The prefix of the HTML ID and class names | ||||||
|  |      * @type {string} | ||||||
|  |      */ | ||||||
|  |     prefix; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The tab | ||||||
|  |      * @type {HTMLSpanElement} | ||||||
|  |      */ | ||||||
|  |     #tab; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The page | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #page; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs a tab plane. | ||||||
|  |      * | ||||||
|  |      * @param chooser {PeriodChooser} the period chooser | ||||||
|  |      */ | ||||||
|  |     constructor(chooser) { | ||||||
|  |         this.chooser = chooser; | ||||||
|  |         this.prefix = "accounting-period-chooser-" + this.tabId(); | ||||||
|  |         this.#tab = document.getElementById(this.prefix + "-tab"); | ||||||
|  |         this.#page = document.getElementById(this.prefix + "-page"); | ||||||
|  |         this.#tab.onclick = () => this.#switchToMe(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The tab ID | ||||||
|  |      * | ||||||
|  |      * @return {string} | ||||||
|  |      * @abstract | ||||||
|  |      */ | ||||||
|  |     tabId() { throw new Error("Method not implemented.") }; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Switches to the tab plane. | ||||||
|  |      * | ||||||
|  |      */ | ||||||
|  |     #switchToMe() { | ||||||
|  |         for (const tabPlane of Object.values(this.chooser.tabPlanes)) { | ||||||
|  |             tabPlane.#tab.classList.remove("active") | ||||||
|  |             tabPlane.#tab.ariaCurrent = "false"; | ||||||
|  |             tabPlane.#page.classList.add("d-none"); | ||||||
|  |             tabPlane.#page.ariaCurrent = "false"; | ||||||
|  |         } | ||||||
|  |         this.#tab.classList.add("active"); | ||||||
|  |         this.#tab.ariaCurrent = "page"; | ||||||
|  |         this.#page.classList.remove("d-none"); | ||||||
|  |         this.#page.ariaCurrent = "page"; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The month tab plane. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | class MonthTab extends TabPlane { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The month chooser. | ||||||
|  |      * @type {tempusDominus.TempusDominus} | ||||||
|  |      */ | ||||||
|  |     #monthChooser | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs a tab plane. | ||||||
|  |      * | ||||||
|  |      * @param chooser {PeriodChooser} the period chooser | ||||||
|  |      */ | ||||||
|  |     constructor(chooser) { | ||||||
|  |         super(chooser); | ||||||
|  |         const monthChooser = document.getElementById(this.prefix + "-chooser"); | ||||||
|  |         if (monthChooser !== null) { | ||||||
|  |             let start = monthChooser.dataset.start; | ||||||
|  |             this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, { | ||||||
|  |                 restrictions: { | ||||||
|  |                     minDate: new Date(start), | ||||||
|  |                 }, | ||||||
|  |                 display: { | ||||||
|  |                     inline: true, | ||||||
|  |                     components: { | ||||||
|  |                         date: false, | ||||||
|  |                         clock: false, | ||||||
|  |                     }, | ||||||
|  |                 }, | ||||||
|  |                 defaultDate: new Date(monthChooser.dataset.default), | ||||||
|  |             }); | ||||||
|  |             monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => { | ||||||
|  |                 const date = e.detail.date; | ||||||
|  |                 const year = date.year; | ||||||
|  |                 const month = date.month + 1; | ||||||
|  |                 const period = month < 10? year + "-0" + month: year + "-" + month; | ||||||
|  |                 window.location = chooser.modal.dataset.urlTemplate | ||||||
|  |                     .replaceAll("PERIOD", period); | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The tab ID | ||||||
|  |      * | ||||||
|  |      * @return {string} | ||||||
|  |      */ | ||||||
|  |     tabId() { | ||||||
|  |         return "month"; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The year tab plane. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | class YearTab extends TabPlane { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The tab ID | ||||||
|  |      * | ||||||
|  |      * @return {string} | ||||||
|  |      */ | ||||||
|  |     tabId() { | ||||||
|  |         return "year"; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The day tab plane. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | class DayTab extends TabPlane { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The day input | ||||||
|  |      * @type {HTMLInputElement} | ||||||
|  |      */ | ||||||
|  |     #date; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error of the date input | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #dateError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs a tab plane. | ||||||
|  |      * | ||||||
|  |      * @param chooser {PeriodChooser} the period chooser | ||||||
|  |      */ | ||||||
|  |     constructor(chooser) { | ||||||
|  |         super(chooser); | ||||||
|  |         this.#date = document.getElementById(this.prefix + "-date"); | ||||||
|  |         this.#dateError = document.getElementById(this.prefix + "-date-error"); | ||||||
|  |         if (this.#date !== null) { | ||||||
|  |             this.#date.onchange = () => { | ||||||
|  |                 if (this.#validateDate()) { | ||||||
|  |                     window.location = chooser.modal.dataset.urlTemplate | ||||||
|  |                         .replaceAll("PERIOD", this.#date.value); | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the date. | ||||||
|  |      * | ||||||
|  |      * @return {boolean} true if valid, or false otherwise | ||||||
|  |      */ | ||||||
|  |     #validateDate() { | ||||||
|  |         if (this.#date.value === "") { | ||||||
|  |             this.#date.classList.add("is-invalid"); | ||||||
|  |             this.#dateError.innerText = A_("Please fill in the date."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         if (this.#date.value < this.#date.min) { | ||||||
|  |             this.#date.classList.add("is-invalid"); | ||||||
|  |             this.#dateError.innerText = A_("The date is too early."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         this.#date.classList.remove("is-invalid"); | ||||||
|  |         this.#dateError.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The tab ID | ||||||
|  |      * | ||||||
|  |      * @return {string} | ||||||
|  |      */ | ||||||
|  |     tabId() { | ||||||
|  |         return "day"; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The custom tab plane. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | class CustomTab extends TabPlane { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The start of the period | ||||||
|  |      * @type {HTMLInputElement} | ||||||
|  |      */ | ||||||
|  |     #start; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error of the start | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #startError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The end of the period | ||||||
|  |      * @type {HTMLInputElement} | ||||||
|  |      */ | ||||||
|  |     #end; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The error of the end | ||||||
|  |      * @type {HTMLDivElement} | ||||||
|  |      */ | ||||||
|  |     #endError; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The confirm button | ||||||
|  |      * @type {HTMLButtonElement} | ||||||
|  |      */ | ||||||
|  |     #conform; | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Constructs a tab plane. | ||||||
|  |      * | ||||||
|  |      * @param chooser {PeriodChooser} the period chooser | ||||||
|  |      */ | ||||||
|  |     constructor(chooser) { | ||||||
|  |         super(chooser); | ||||||
|  |         this.#start = document.getElementById(this.prefix + "-start"); | ||||||
|  |         this.#startError = document.getElementById(this.prefix + "-start-error"); | ||||||
|  |         this.#end = document.getElementById(this.prefix + "-end"); | ||||||
|  |         this.#endError = document.getElementById(this.prefix + "-end-error"); | ||||||
|  |         this.#conform = document.getElementById(this.prefix + "-confirm"); | ||||||
|  |         if (this.#start !== null) { | ||||||
|  |             this.#start.onchange = () => { | ||||||
|  |                 if (this.#validateStart()) { | ||||||
|  |                     this.#end.min = this.#start.value; | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |             this.#end.onchange = () => { | ||||||
|  |                 if (this.#validateEnd()) { | ||||||
|  |                     this.#start.max = this.#end.value; | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |             this.#conform.onclick = () => { | ||||||
|  |                 let isValid = true; | ||||||
|  |                 isValid = this.#validateStart() && isValid; | ||||||
|  |                 isValid = this.#validateEnd() && isValid; | ||||||
|  |                 if (isValid) { | ||||||
|  |                     window.location = chooser.modal.dataset.urlTemplate | ||||||
|  |                         .replaceAll("PERIOD", this.#start.value + "-" + this.#end.value); | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the start of the period. | ||||||
|  |      * | ||||||
|  |      * @returns {boolean} true if valid, or false otherwise | ||||||
|  |      * @private | ||||||
|  |      */ | ||||||
|  |     #validateStart() { | ||||||
|  |         if (this.#start.value === "") { | ||||||
|  |             this.#start.classList.add("is-invalid"); | ||||||
|  |             this.#startError.innerText = A_("Please fill in the start date."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         if (this.#start.value < this.#start.min) { | ||||||
|  |             this.#start.classList.add("is-invalid"); | ||||||
|  |             this.#startError.innerText = A_("The start date is too early."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         if (this.#start.value > this.#start.max) { | ||||||
|  |             this.#start.classList.add("is-invalid"); | ||||||
|  |             this.#startError.innerText = A_("The start date cannot be beyond the end date."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         this.#start.classList.remove("is-invalid"); | ||||||
|  |         this.#startError.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Validates the end of the period. | ||||||
|  |      * | ||||||
|  |      * @returns {boolean} true if valid, or false otherwise | ||||||
|  |      * @private | ||||||
|  |      */ | ||||||
|  |     #validateEnd() { | ||||||
|  |         this.#end.value = this.#end.value.trim(); | ||||||
|  |         if (this.#end.value === "") { | ||||||
|  |             this.#end.classList.add("is-invalid"); | ||||||
|  |             this.#endError.innerText = A_("Please fill in the end date."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         if (this.#end.value < this.#end.min) { | ||||||
|  |             this.#end.classList.add("is-invalid"); | ||||||
|  |             this.#endError.innerText = A_("The end date cannot be beyond the start date."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         this.#end.classList.remove("is-invalid"); | ||||||
|  |         this.#endError.innerText = ""; | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * The tab ID | ||||||
|  |      * | ||||||
|  |      * @return {string} | ||||||
|  |      */ | ||||||
|  |     tabId() { | ||||||
|  |         return "custom"; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,832 +0,0 @@ | |||||||
| /* The Mia! Accounting Flask Project |  | ||||||
|  * transaction-transfer-form.js: The JavaScript for the transfer transaction form |  | ||||||
|  */ |  | ||||||
|  |  | ||||||
| /*  Copyright (c) 2023 imacat. |  | ||||||
|  * |  | ||||||
|  *  Licensed under the Apache License, Version 2.0 (the "License"); |  | ||||||
|  *  you may not use this file except in compliance with the License. |  | ||||||
|  *  You may obtain a copy of the License at |  | ||||||
|  * |  | ||||||
|  *      http://www.apache.org/licenses/LICENSE-2.0 |  | ||||||
|  * |  | ||||||
|  *  Unless required by applicable law or agreed to in writing, software |  | ||||||
|  *  distributed under the License is distributed on an "AS IS" BASIS, |  | ||||||
|  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |  | ||||||
|  *  See the License for the specific language governing permissions and |  | ||||||
|  *  limitations under the License. |  | ||||||
|  */ |  | ||||||
|  |  | ||||||
| /* Author: imacat@mail.imacat.idv.tw (imacat) |  | ||||||
|  * First written: 2023/2/25 |  | ||||||
|  */ |  | ||||||
|  |  | ||||||
| // Initializes the page JavaScript. |  | ||||||
| document.addEventListener("DOMContentLoaded", function () { |  | ||||||
|     initializeCurrencyForms(); |  | ||||||
|     initializeJournalEntries(); |  | ||||||
|     initializeAccountSelectors(); |  | ||||||
|     initializeFormValidation(); |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Escapes the HTML special characters and returns. |  | ||||||
|  * |  | ||||||
|  * @param s {string} the original string |  | ||||||
|  * @returns {string} the string with HTML special character escaped |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function escapeHtml(s) { |  | ||||||
|     return String(s) |  | ||||||
|          .replaceAll("&", "&") |  | ||||||
|          .replaceAll("<", "<") |  | ||||||
|          .replaceAll(">", ">") |  | ||||||
|          .replaceAll("\"", """); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Formats a Decimal number. |  | ||||||
|  * |  | ||||||
|  * @param number {Decimal} the Decimal number |  | ||||||
|  * @returns {string} the formatted Decimal number |  | ||||||
|  */ |  | ||||||
| function formatDecimal(number) { |  | ||||||
|     if (number.equals(new Decimal("0"))) { |  | ||||||
|         return "-"; |  | ||||||
|     } |  | ||||||
|     const frac = number.modulo(1); |  | ||||||
|     const whole = Number(number.minus(frac)).toLocaleString(); |  | ||||||
|     return whole + String(frac).substring(1); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the currency forms. |  | ||||||
|  * |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function initializeCurrencyForms() { |  | ||||||
|     const form = document.getElementById("accounting-form"); |  | ||||||
|     const btnNew = document.getElementById("accounting-btn-new-currency"); |  | ||||||
|     const currencyList = document.getElementById("accounting-currency-list"); |  | ||||||
|     const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency")); |  | ||||||
|     const onReorder = function () { |  | ||||||
|         const currencies = Array.from(currencyList.children); |  | ||||||
|         for (let i = 0; i < currencies.length; i++) { |  | ||||||
|             const no = document.getElementById(currencies[i].dataset.prefix + "-no"); |  | ||||||
|             no.value = String(i + 1); |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
|     btnNew.onclick = function () { |  | ||||||
|         const currencies = Array.from(document.getElementsByClassName("accounting-currency")); |  | ||||||
|         let maxIndex = 0; |  | ||||||
|         currencies.forEach(function (currency) { |  | ||||||
|             const index = parseInt(currency.dataset.index); |  | ||||||
|             if (maxIndex < index) { |  | ||||||
|                 maxIndex = index; |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|         const newIndex = String(maxIndex + 1); |  | ||||||
|         const html = form.dataset.currencyTemplate |  | ||||||
|             .replaceAll("CURRENCY_INDEX", escapeHtml(newIndex)); |  | ||||||
|         currencyList.insertAdjacentHTML("beforeend", html); |  | ||||||
|         const newEntryButtons = Array.from(document.getElementsByClassName("accounting-currency-" + newIndex + "-btn-new-entry")); |  | ||||||
|         const btnDelete = document.getElementById("accounting-btn-delete-currency-" + newIndex); |  | ||||||
|         newEntryButtons.forEach(initializeNewEntryButton); |  | ||||||
|         initializeBtnDeleteCurrency(btnDelete); |  | ||||||
|         resetDeleteCurrencyButtons(); |  | ||||||
|         initializeDragAndDropReordering(currencyList, onReorder); |  | ||||||
|     }; |  | ||||||
|     deleteButtons.forEach(initializeBtnDeleteCurrency); |  | ||||||
|     initializeDragAndDropReordering(currencyList, onReorder); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the button to delete a currency. |  | ||||||
|  * |  | ||||||
|  * @param button {HTMLButtonElement} the button to delete a currency. |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function initializeBtnDeleteCurrency(button) { |  | ||||||
|     const target = document.getElementById(button.dataset.target); |  | ||||||
|     button.onclick = function () { |  | ||||||
|         target.parentElement.removeChild(target); |  | ||||||
|         resetDeleteCurrencyButtons(); |  | ||||||
|     }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Resets the status of the delete currency buttons. |  | ||||||
|  * |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function resetDeleteCurrencyButtons() { |  | ||||||
|     const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency")); |  | ||||||
|     if (buttons.length > 1) { |  | ||||||
|         buttons.forEach(function (button) { |  | ||||||
|             button.classList.remove("d-none"); |  | ||||||
|         }); |  | ||||||
|     } else { |  | ||||||
|         buttons[0].classList.add("d-none"); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the journal entry forms. |  | ||||||
|  * |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function initializeJournalEntries() { |  | ||||||
|     const newButtons = Array.from(document.getElementsByClassName("accounting-btn-new-entry")); |  | ||||||
|     const entryLists = Array.from(document.getElementsByClassName("accounting-entry-list")); |  | ||||||
|     const entries = Array.from(document.getElementsByClassName("accounting-entry")) |  | ||||||
|     const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-entry")); |  | ||||||
|     newButtons.forEach(initializeNewEntryButton); |  | ||||||
|     entryLists.forEach(initializeJournalEntryListReorder); |  | ||||||
|     entries.forEach(initializeJournalEntry); |  | ||||||
|     deleteButtons.forEach(initializeDeleteJournalEntryButton); |  | ||||||
|     initializeJournalEntryFormModal(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the button to add a new journal entry. |  | ||||||
|  * |  | ||||||
|  * @param button {HTMLButtonElement} the button to add a new journal entry |  | ||||||
|  */ |  | ||||||
| function initializeNewEntryButton(button) { |  | ||||||
|     const entryForm = document.getElementById("accounting-entry-form"); |  | ||||||
|     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); |  | ||||||
|     const formAccount = document.getElementById("accounting-entry-form-account"); |  | ||||||
|     const formAccountError = document.getElementById("accounting-entry-form-account-error") |  | ||||||
|     const formSummary = document.getElementById("accounting-entry-form-summary"); |  | ||||||
|     const formSummaryError = document.getElementById("accounting-entry-form-summary-error"); |  | ||||||
|     const formAmount = document.getElementById("accounting-entry-form-amount"); |  | ||||||
|     const formAmountError = document.getElementById("accounting-entry-form-amount-error"); |  | ||||||
|     button.onclick = function () { |  | ||||||
|         entryForm.dataset.currencyIndex = button.dataset.currencyIndex; |  | ||||||
|         entryForm.dataset.entryType = button.dataset.entryType; |  | ||||||
|         entryForm.dataset.entryIndex = button.dataset.entryIndex; |  | ||||||
|         formAccountControl.classList.remove("accounting-not-empty") |  | ||||||
|         formAccountControl.classList.remove("is-invalid"); |  | ||||||
|         formAccountControl.dataset.bsTarget = button.dataset.accountModal; |  | ||||||
|         formAccount.innerText = ""; |  | ||||||
|         formAccount.dataset.code = ""; |  | ||||||
|         formAccount.dataset.text = ""; |  | ||||||
|         formAccountError.innerText = ""; |  | ||||||
|         formSummary.value = ""; |  | ||||||
|         formSummary.classList.remove("is-invalid"); |  | ||||||
|         formSummaryError.innerText = "" |  | ||||||
|         formAmount.value = ""; |  | ||||||
|         formAmount.classList.remove("is-invalid"); |  | ||||||
|         formAmountError.innerText = ""; |  | ||||||
|     }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the reordering of a journal entry list. |  | ||||||
|  * |  | ||||||
|  * @param entryList {HTMLUListElement} the journal entry list. |  | ||||||
|  */ |  | ||||||
| function initializeJournalEntryListReorder(entryList) { |  | ||||||
|     initializeDragAndDropReordering(entryList, function () { |  | ||||||
|         const entries = Array.from(entryList.children); |  | ||||||
|         for (let i = 0; i < entries.length; i++) { |  | ||||||
|             const no = document.getElementById(entries[i].dataset.prefix + "-no"); |  | ||||||
|             no.value = String(i + 1); |  | ||||||
|         } |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the journal entry. |  | ||||||
|  * |  | ||||||
|  * @param entry {HTMLLIElement} the journal entry. |  | ||||||
|  */ |  | ||||||
| function initializeJournalEntry(entry) { |  | ||||||
|     const entryForm = document.getElementById("accounting-entry-form"); |  | ||||||
|     const accountCode = document.getElementById(entry.dataset.prefix + "-account-code"); |  | ||||||
|     const summary = document.getElementById(entry.dataset.prefix + "-summary"); |  | ||||||
|     const amount = document.getElementById(entry.dataset.prefix + "-amount"); |  | ||||||
|     const control = document.getElementById(entry.dataset.prefix + "-control"); |  | ||||||
|     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); |  | ||||||
|     const formAccount = document.getElementById("accounting-entry-form-account"); |  | ||||||
|     const formSummary = document.getElementById("accounting-entry-form-summary"); |  | ||||||
|     const formAmount = document.getElementById("accounting-entry-form-amount"); |  | ||||||
|     control.onclick = function () { |  | ||||||
|         entryForm.dataset.currencyIndex = entry.dataset.currencyIndex; |  | ||||||
|         entryForm.dataset.entryType = entry.dataset.entryType; |  | ||||||
|         entryForm.dataset.entryIndex = entry.dataset.entryIndex; |  | ||||||
|         if (accountCode.value === "") { |  | ||||||
|             formAccountControl.classList.remove("accounting-not-empty"); |  | ||||||
|         } else { |  | ||||||
|             formAccountControl.classList.add("accounting-not-empty"); |  | ||||||
|         } |  | ||||||
|         formAccountControl.dataset.bsTarget = entry.dataset.accountModal; |  | ||||||
|         formAccount.innerText = accountCode.dataset.text; |  | ||||||
|         formAccount.dataset.code = accountCode.value; |  | ||||||
|         formAccount.dataset.text = accountCode.dataset.text; |  | ||||||
|         formSummary.value = summary.value; |  | ||||||
|         formAmount.value = amount.value; |  | ||||||
|         validateJournalEntryForm(); |  | ||||||
|     }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the journal entry form modal. |  | ||||||
|  * |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function initializeJournalEntryFormModal() { |  | ||||||
|     const entryForm = document.getElementById("accounting-entry-form"); |  | ||||||
|     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); |  | ||||||
|     const formAccount = document.getElementById("accounting-entry-form-account"); |  | ||||||
|     const formSummary = document.getElementById("accounting-entry-form-summary"); |  | ||||||
|     const formAmount = document.getElementById("accounting-entry-form-amount"); |  | ||||||
|     const modal = document.getElementById("accounting-entry-form-modal"); |  | ||||||
|     formAccountControl.onclick = function () { |  | ||||||
|         const prefix = "accounting-" + entryForm.dataset.entryType + "-account"; |  | ||||||
|         const query = document.getElementById(prefix + "-selector-query") |  | ||||||
|         const more = document.getElementById(prefix + "-more"); |  | ||||||
|         const options = Array.from(document.getElementsByClassName(prefix + "-option")); |  | ||||||
|         const btnClear = document.getElementById(prefix + "-btn-clear"); |  | ||||||
|         query.value = ""; |  | ||||||
|         more.classList.remove("d-none"); |  | ||||||
|         filterAccountOptions(prefix); |  | ||||||
|         options.forEach(function (option) { |  | ||||||
|             if (option.dataset.code === formAccount.dataset.code) { |  | ||||||
|                 option.classList.add("active"); |  | ||||||
|             } else { |  | ||||||
|                 option.classList.remove("active"); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|         if (formAccount.dataset.code === "") { |  | ||||||
|             btnClear.classList.add("btn-secondary"); |  | ||||||
|             btnClear.classList.remove("btn-danger"); |  | ||||||
|             btnClear.disabled = true; |  | ||||||
|         } else { |  | ||||||
|             btnClear.classList.add("btn-danger"); |  | ||||||
|             btnClear.classList.remove("btn-secondary"); |  | ||||||
|             btnClear.disabled = false; |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
|     formSummary.onchange = validateJournalEntrySummary; |  | ||||||
|     formAmount.onchange = validateJournalEntryAmount; |  | ||||||
|     entryForm.onsubmit = function () { |  | ||||||
|         if (validateJournalEntryForm()) { |  | ||||||
|             saveJournalEntryForm(); |  | ||||||
|             bootstrap.Modal.getInstance(modal).hide(); |  | ||||||
|         } |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the journal entry form modal. |  | ||||||
|  * |  | ||||||
|  * @return {boolean} true if the form is valid, or false otherwise. |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateJournalEntryForm() { |  | ||||||
|     let isValid = true; |  | ||||||
|     isValid = validateJournalEntryAccount() && isValid; |  | ||||||
|     isValid = validateJournalEntrySummary() && isValid; |  | ||||||
|     isValid = validateJournalEntryAmount() && isValid |  | ||||||
|     return isValid; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the account in the journal entry form modal. |  | ||||||
|  * |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateJournalEntryAccount() { |  | ||||||
|     const field = document.getElementById("accounting-entry-form-account"); |  | ||||||
|     const error = document.getElementById("accounting-entry-form-account-error"); |  | ||||||
|     const control = document.getElementById("accounting-entry-form-account-control"); |  | ||||||
|     if (field.dataset.code === "") { |  | ||||||
|         control.classList.add("is-invalid"); |  | ||||||
|         error.innerText = A_("Please select the account."); |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
|     control.classList.remove("is-invalid"); |  | ||||||
|     error.innerText = ""; |  | ||||||
|     return true; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the summary in the journal entry form modal. |  | ||||||
|  * |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateJournalEntrySummary() { |  | ||||||
|     const field = document.getElementById("accounting-entry-form-summary"); |  | ||||||
|     const error = document.getElementById("accounting-entry-form-summary-error"); |  | ||||||
|     field.value = field.value.trim(); |  | ||||||
|     field.classList.remove("is-invalid"); |  | ||||||
|     error.innerText = ""; |  | ||||||
|     return true; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the amount in the journal entry form modal. |  | ||||||
|  * |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateJournalEntryAmount() { |  | ||||||
|     const field = document.getElementById("accounting-entry-form-amount"); |  | ||||||
|     const error = document.getElementById("accounting-entry-form-amount-error"); |  | ||||||
|     field.value = field.value.trim(); |  | ||||||
|     field.classList.remove("is-invalid"); |  | ||||||
|     if (field.value === "") { |  | ||||||
|         field.classList.add("is-invalid"); |  | ||||||
|         error.innerText = A_("Please fill in the amount."); |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
|     error.innerText = ""; |  | ||||||
|     return true; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Saves the journal entry form modal to the form. |  | ||||||
|  * |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function saveJournalEntryForm() { |  | ||||||
|     const form = document.getElementById("accounting-form"); |  | ||||||
|     const entryForm = document.getElementById("accounting-entry-form"); |  | ||||||
|     const formAccount = document.getElementById("accounting-entry-form-account"); |  | ||||||
|     const formSummary = document.getElementById("accounting-entry-form-summary"); |  | ||||||
|     const formAmount = document.getElementById("accounting-entry-form-amount"); |  | ||||||
|     const currencyIndex = entryForm.dataset.currencyIndex; |  | ||||||
|     const entryType = entryForm.dataset.entryType; |  | ||||||
|     let entryIndex; |  | ||||||
|     if (entryForm.dataset.entryIndex === "new") { |  | ||||||
|         const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType)); |  | ||||||
|         const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list") |  | ||||||
|         let maxIndex = 0; |  | ||||||
|         entries.forEach(function (entry) { |  | ||||||
|             const index = parseInt(entry.dataset.entryIndex); |  | ||||||
|             if (maxIndex < index) { |  | ||||||
|                 maxIndex = index; |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|         entryIndex = String(maxIndex + 1); |  | ||||||
|         const html = form.dataset.entryTemplate |  | ||||||
|             .replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex)) |  | ||||||
|             .replaceAll("ENTRY_TYPE", escapeHtml(entryType)) |  | ||||||
|             .replaceAll("ENTRY_INDEX", escapeHtml(entryIndex)); |  | ||||||
|         entryList.insertAdjacentHTML("beforeend", html); |  | ||||||
|         initializeJournalEntryListReorder(entryList); |  | ||||||
|     } else { |  | ||||||
|         entryIndex = entryForm.dataset.entryIndex; |  | ||||||
|     } |  | ||||||
|     const currency = document.getElementById("accounting-currency-" + currencyIndex); |  | ||||||
|     const entry = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-" + entryIndex); |  | ||||||
|     const accountCode = document.getElementById(entry.dataset.prefix + "-account-code"); |  | ||||||
|     const accountText = document.getElementById(entry.dataset.prefix + "-account-text"); |  | ||||||
|     const summary = document.getElementById(entry.dataset.prefix + "-summary"); |  | ||||||
|     const summaryText = document.getElementById(entry.dataset.prefix + "-summary-text"); |  | ||||||
|     const amount = document.getElementById(entry.dataset.prefix + "-amount"); |  | ||||||
|     const amountText = document.getElementById(entry.dataset.prefix + "-amount-text"); |  | ||||||
|     accountCode.value = formAccount.dataset.code; |  | ||||||
|     accountCode.dataset.text = formAccount.dataset.text; |  | ||||||
|     accountText.innerText = formAccount.dataset.text; |  | ||||||
|     summary.value = formSummary.value; |  | ||||||
|     summaryText.innerText = formSummary.value; |  | ||||||
|     amount.value = formAmount.value; |  | ||||||
|     amountText.innerText = formatDecimal(new Decimal(formAmount.value)); |  | ||||||
|     if (entryForm.dataset.entryIndex === "new") { |  | ||||||
|         const btnDelete = document.getElementById(entry.dataset.prefix + "-btn-delete"); |  | ||||||
|         initializeJournalEntry(entry); |  | ||||||
|         initializeDeleteJournalEntryButton(btnDelete); |  | ||||||
|         resetDeleteJournalEntryButtons(btnDelete.dataset.sameClass); |  | ||||||
|     } |  | ||||||
|     updateBalance(currencyIndex, entryType); |  | ||||||
|     validateJournalEntriesReal(currencyIndex, entryType); |  | ||||||
|     validateBalance(currency); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the button to delete a journal entry. |  | ||||||
|  * |  | ||||||
|  * @param button {HTMLButtonElement} the button to delete a journal entry |  | ||||||
|  */ |  | ||||||
| function initializeDeleteJournalEntryButton(button) { |  | ||||||
|     const target = document.getElementById(button.dataset.target); |  | ||||||
|     const currencyIndex = target.dataset.currencyIndex; |  | ||||||
|     const entryType = target.dataset.entryType; |  | ||||||
|     const currency = document.getElementById("accounting-currency-" + currencyIndex); |  | ||||||
|     button.onclick = function () { |  | ||||||
|         target.parentElement.removeChild(target); |  | ||||||
|         resetDeleteJournalEntryButtons(button.dataset.sameClass); |  | ||||||
|         updateBalance(currencyIndex, entryType); |  | ||||||
|         validateJournalEntriesReal(currencyIndex, entryType); |  | ||||||
|         validateBalance(currency); |  | ||||||
|     }; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Resets the status of the delete journal entry buttons. |  | ||||||
|  * |  | ||||||
|  * @param sameClass {string} the class of the buttons |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function resetDeleteJournalEntryButtons(sameClass) { |  | ||||||
|     const buttons = Array.from(document.getElementsByClassName(sameClass)); |  | ||||||
|     if (buttons.length > 1) { |  | ||||||
|         buttons.forEach(function (button) { |  | ||||||
|             button.classList.remove("d-none"); |  | ||||||
|         }); |  | ||||||
|     } else { |  | ||||||
|         buttons[0].classList.add("d-none"); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Updates the balance. |  | ||||||
|  * |  | ||||||
|  * @param currencyIndex {string} the currency index. |  | ||||||
|  * @param entryType {string} the journal entry type, either "debit" or "credit" |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function updateBalance(currencyIndex, entryType) { |  | ||||||
|     const prefix = "accounting-currency-" + currencyIndex + "-" + entryType; |  | ||||||
|     const amounts = Array.from(document.getElementsByClassName(prefix + "-amount")); |  | ||||||
|     const totalText = document.getElementById(prefix + "-total"); |  | ||||||
|     let total = new Decimal("0"); |  | ||||||
|     amounts.forEach(function (amount) { |  | ||||||
|         if (amount.value !== "") { |  | ||||||
|             total = total.plus(new Decimal(amount.value)); |  | ||||||
|         } |  | ||||||
|     }); |  | ||||||
|     totalText.innerText = formatDecimal(total); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the account selectors. |  | ||||||
|  * |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function initializeAccountSelectors() { |  | ||||||
|     const selectors = Array.from(document.getElementsByClassName("accounting-selector-modal")); |  | ||||||
|     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); |  | ||||||
|     const formAccount = document.getElementById("accounting-entry-form-account"); |  | ||||||
|     selectors.forEach(function (selector) { |  | ||||||
|         const more = document.getElementById(selector.dataset.prefix + "-more"); |  | ||||||
|         const btnClear = document.getElementById(selector.dataset.prefix + "-btn-clear"); |  | ||||||
|         const options = Array.from(document.getElementsByClassName(selector.dataset.prefix + "-option")); |  | ||||||
|         more.onclick = function () { |  | ||||||
|             more.classList.add("d-none"); |  | ||||||
|             filterAccountOptions(selector.dataset.prefix); |  | ||||||
|         }; |  | ||||||
|         initializeAccountQuery(selector); |  | ||||||
|         btnClear.onclick = function () { |  | ||||||
|             formAccountControl.classList.remove("accounting-not-empty"); |  | ||||||
|             formAccount.innerText = ""; |  | ||||||
|             formAccount.dataset.code = ""; |  | ||||||
|             formAccount.dataset.text = ""; |  | ||||||
|             validateJournalEntryAccount(); |  | ||||||
|         }; |  | ||||||
|         options.forEach(function (option) { |  | ||||||
|             option.onclick = function () { |  | ||||||
|                 formAccountControl.classList.add("accounting-not-empty"); |  | ||||||
|                 formAccount.innerText = option.dataset.content; |  | ||||||
|                 formAccount.dataset.code = option.dataset.code; |  | ||||||
|                 formAccount.dataset.text = option.dataset.content; |  | ||||||
|                 validateJournalEntryAccount(); |  | ||||||
|             }; |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the query on the account options. |  | ||||||
|  * |  | ||||||
|  * @param selector {HTMLDivElement} the selector modal |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function initializeAccountQuery(selector) { |  | ||||||
|     const query = document.getElementById(selector.dataset.prefix + "-selector-query"); |  | ||||||
|     query.addEventListener("input", function () { |  | ||||||
|         filterAccountOptions(selector.dataset.prefix); |  | ||||||
|     }); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Filters the account options. |  | ||||||
|  * |  | ||||||
|  * @param prefix {string} the HTML ID and class prefix |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function filterAccountOptions(prefix) { |  | ||||||
|     const query = document.getElementById(prefix + "-selector-query"); |  | ||||||
|     const optionList = document.getElementById(prefix + "-option-list"); |  | ||||||
|     if (optionList === null) { |  | ||||||
|         console.log(prefix + "-option-list"); |  | ||||||
|     } |  | ||||||
|     const options = Array.from(document.getElementsByClassName(prefix + "-option")); |  | ||||||
|     const more = document.getElementById(prefix + "-more"); |  | ||||||
|     const queryNoResult = document.getElementById(prefix + "-option-no-result"); |  | ||||||
|     const codesInUse = getAccountCodeUsedInForm(); |  | ||||||
|     let shouldAnyShow = false; |  | ||||||
|     options.forEach(function (option) { |  | ||||||
|         const shouldShow = shouldAccountOptionShow(option, more, codesInUse, query); |  | ||||||
|         if (shouldShow) { |  | ||||||
|             option.classList.remove("d-none"); |  | ||||||
|             shouldAnyShow = true; |  | ||||||
|         } else { |  | ||||||
|             option.classList.add("d-none"); |  | ||||||
|         } |  | ||||||
|     }); |  | ||||||
|     if (!shouldAnyShow && more.classList.contains("d-none")) { |  | ||||||
|         optionList.classList.add("d-none"); |  | ||||||
|         queryNoResult.classList.remove("d-none"); |  | ||||||
|     } else { |  | ||||||
|         optionList.classList.remove("d-none"); |  | ||||||
|         queryNoResult.classList.add("d-none"); |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Returns whether an account option should show. |  | ||||||
|  * |  | ||||||
|  * @param option {HTMLLIElement} the account option |  | ||||||
|  * @param more {HTMLLIElement} the more account element |  | ||||||
|  * @param inUse {string[]} the account codes that are used in the form |  | ||||||
|  * @param query {HTMLInputElement} the query element, if any |  | ||||||
|  * @return {boolean} true if the account option should show, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function shouldAccountOptionShow(option, more, inUse, query) { |  | ||||||
|     const isQueryMatched = function () { |  | ||||||
|         if (query.value === "") { |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|         const queryValues = JSON.parse(option.dataset.queryValues); |  | ||||||
|         for (const queryValue of queryValues) { |  | ||||||
|             if (queryValue.includes(query.value)) { |  | ||||||
|                 return true; |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|         return false; |  | ||||||
|     }; |  | ||||||
|     const isMoreMatched = function () { |  | ||||||
|         if (more.classList.contains("d-none")) { |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|         return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code); |  | ||||||
|     }; |  | ||||||
|     return isMoreMatched() && isQueryMatched(); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Returns the account codes that are used in the form. |  | ||||||
|  * |  | ||||||
|  * @return {string[]} the account codes that are used in the form |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function getAccountCodeUsedInForm() { |  | ||||||
|     const accountCodes = Array.from(document.getElementsByClassName("accounting-account-code")); |  | ||||||
|     const formAccount = document.getElementById("accounting-entry-form-account"); |  | ||||||
|     const inUse = [formAccount.dataset.code]; |  | ||||||
|     accountCodes.forEach(function (accountCode) { |  | ||||||
|         inUse.push(accountCode.value); |  | ||||||
|     }); |  | ||||||
|     return inUse |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Initializes the form validation. |  | ||||||
|  * |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function initializeFormValidation() { |  | ||||||
|     const date = document.getElementById("accounting-date"); |  | ||||||
|     const note = document.getElementById("accounting-note"); |  | ||||||
|     const form = document.getElementById("accounting-form"); |  | ||||||
|     date.onchange = validateDate; |  | ||||||
|     note.onchange = validateNote; |  | ||||||
|     form.onsubmit = validateForm; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the form. |  | ||||||
|  * |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateForm() { |  | ||||||
|     let isValid = true; |  | ||||||
|     isValid = validateDate() && isValid; |  | ||||||
|     isValid = validateCurrencies() && isValid; |  | ||||||
|     isValid = validateNote() && isValid; |  | ||||||
|     return isValid; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the date. |  | ||||||
|  * |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateDate() { |  | ||||||
|     const field = document.getElementById("accounting-date"); |  | ||||||
|     const error = document.getElementById("accounting-date-error"); |  | ||||||
|     field.value = field.value.trim(); |  | ||||||
|     field.classList.remove("is-invalid"); |  | ||||||
|     if (field.value === "") { |  | ||||||
|         field.classList.add("is-invalid"); |  | ||||||
|         error.innerText = A_("Please fill in the date."); |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
|     error.innerText = ""; |  | ||||||
|     return true; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the currency sub-forms. |  | ||||||
|  * |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateCurrencies() { |  | ||||||
|     const currencies = Array.from(document.getElementsByClassName("accounting-currency")); |  | ||||||
|     let isValid = true; |  | ||||||
|     isValid = validateCurrenciesReal() && isValid; |  | ||||||
|     currencies.forEach(function (currency) { |  | ||||||
|         isValid = validateCurrency(currency) && isValid; |  | ||||||
|     }); |  | ||||||
|     return isValid; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the currency sub-forms, the validator itself. |  | ||||||
|  * |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateCurrenciesReal() { |  | ||||||
|     const field = document.getElementById("accounting-currencies"); |  | ||||||
|     const error = document.getElementById("accounting-currencies-error"); |  | ||||||
|     const currencies = Array.from(document.getElementsByClassName("accounting-currency")); |  | ||||||
|     if (currencies.length === 0) { |  | ||||||
|         field.classList.add("is-invalid"); |  | ||||||
|         error.innerText = A_("Please add some currencies."); |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
|     field.classList.remove("is-invalid"); |  | ||||||
|     error.innerText = ""; |  | ||||||
|     return true; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates a currency sub-form. |  | ||||||
|  * |  | ||||||
|  * @param currency {HTMLDivElement} the currency sub-form |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateCurrency(currency) { |  | ||||||
|     const prefix = "accounting-currency-" + currency.dataset.index; |  | ||||||
|     const debit = document.getElementById(prefix + "-debit"); |  | ||||||
|     const credit = document.getElementById(prefix + "-credit"); |  | ||||||
|     let isValid = true; |  | ||||||
|     if (debit !== null) { |  | ||||||
|         isValid = validateJournalEntries(currency, "debit") && isValid; |  | ||||||
|     } |  | ||||||
|     if (credit !== null) { |  | ||||||
|         isValid = validateJournalEntries(currency, "credit") && isValid; |  | ||||||
|     } |  | ||||||
|     if (debit !== null && credit !== null) { |  | ||||||
|         isValid = validateBalance(currency) && isValid; |  | ||||||
|     } |  | ||||||
|     return isValid; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the journal entries in a currency sub-form. |  | ||||||
|  * |  | ||||||
|  * @param currency {HTMLDivElement} the currency |  | ||||||
|  * @param entryType {string} the journal entry type, either "debit" or "credit" |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateJournalEntries(currency, entryType) { |  | ||||||
|     const currencyIndex = currency.dataset.index; |  | ||||||
|     const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType)); |  | ||||||
|     let isValid = true; |  | ||||||
|     isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid; |  | ||||||
|     entries.forEach(function (entry) { |  | ||||||
|         isValid = validateJournalEntry(entry) && isValid; |  | ||||||
|     }) |  | ||||||
|     return isValid; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the journal entries, the validator itself. |  | ||||||
|  * |  | ||||||
|  * @param currencyIndex {string} the currency index |  | ||||||
|  * @param entryType {string} the journal entry type, either "debit" or "credit" |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateJournalEntriesReal(currencyIndex, entryType) { |  | ||||||
|     const prefix = "accounting-currency-" + currencyIndex + "-" + entryType; |  | ||||||
|     const field = document.getElementById(prefix); |  | ||||||
|     const error = document.getElementById(prefix + "-error"); |  | ||||||
|     const entries = Array.from(document.getElementsByClassName(prefix)); |  | ||||||
|     if (entries.length === 0) { |  | ||||||
|         field.classList.add("is-invalid"); |  | ||||||
|         error.innerText = A_("Please add some journal entries."); |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
|     field.classList.remove("is-invalid"); |  | ||||||
|     error.innerText = ""; |  | ||||||
|     return true; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates a journal entry sub-form in a currency sub-form. |  | ||||||
|  * |  | ||||||
|  * @param entry {HTMLLIElement} the journal entry |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateJournalEntry(entry) { |  | ||||||
|     const control = document.getElementById(entry.dataset.prefix + "-control"); |  | ||||||
|     const error = document.getElementById(entry.dataset.prefix + "-error"); |  | ||||||
|     const accountCode = document.getElementById(entry.dataset.prefix + "-account-code"); |  | ||||||
|     const amount = document.getElementById(entry.dataset.prefix + "-amount"); |  | ||||||
|     if (accountCode.value === "") { |  | ||||||
|         control.classList.add("is-invalid"); |  | ||||||
|         error.innerText = A_("Please select the account."); |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
|     if (amount.value === "") { |  | ||||||
|         control.classList.add("is-invalid"); |  | ||||||
|         error.innerText = A_("Please fill in the amount."); |  | ||||||
|         return false; |  | ||||||
|     } |  | ||||||
|     control.classList.remove("is-invalid"); |  | ||||||
|     error.innerText = ""; |  | ||||||
|     return true; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the balance of a currency sub-form. |  | ||||||
|  * |  | ||||||
|  * @param currency {HTMLDivElement} the currency sub-form |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateBalance(currency) { |  | ||||||
|     const prefix = "accounting-currency-" + currency.dataset.index; |  | ||||||
|     const control = document.getElementById(prefix + "-control"); |  | ||||||
|     const error = document.getElementById(prefix + "-error"); |  | ||||||
|     const debit = document.getElementById(prefix + "-debit"); |  | ||||||
|     const debitAmounts = Array.from(document.getElementsByClassName(prefix + "-debit-amount")); |  | ||||||
|     const credit = document.getElementById(prefix + "-credit"); |  | ||||||
|     const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount")); |  | ||||||
|     if (debit !== null && credit !== null) { |  | ||||||
|         let debitTotal = new Decimal("0"); |  | ||||||
|         debitAmounts.forEach(function (amount) { |  | ||||||
|             if (amount.value !== "") { |  | ||||||
|                 debitTotal = debitTotal.plus(new Decimal(amount.value)); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|         let creditTotal = new Decimal("0"); |  | ||||||
|         creditAmounts.forEach(function (amount) { |  | ||||||
|             if (amount.value !== "") { |  | ||||||
|                 creditTotal = creditTotal.plus(new Decimal(amount.value)); |  | ||||||
|             } |  | ||||||
|         }); |  | ||||||
|         if (!debitTotal.equals(creditTotal)) { |  | ||||||
|             control.classList.add("is-invalid"); |  | ||||||
|             error.innerText = A_("The totals of the debit and credit amounts do not match."); |  | ||||||
|             return false; |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|     control.classList.remove("is-invalid"); |  | ||||||
|     error.innerText = ""; |  | ||||||
|     return true; |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Validates the note. |  | ||||||
|  * |  | ||||||
|  * @return {boolean} true if valid, or false otherwise |  | ||||||
|  * @private |  | ||||||
|  */ |  | ||||||
| function validateNote() { |  | ||||||
|     const field = document.getElementById("accounting-note"); |  | ||||||
|     const error = document.getElementById("accounting-note-error"); |  | ||||||
|     field.value = field.value |  | ||||||
|          .replace(/^\s*\n/, "") |  | ||||||
|          .trimEnd(); |  | ||||||
|     field.classList.remove("is-invalid"); |  | ||||||
|     error.innerText = ""; |  | ||||||
|     return true; |  | ||||||
| } |  | ||||||
							
								
								
									
										81
									
								
								src/accounting/template_filters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								src/accounting/template_filters.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | |||||||
|  | # The Mia! Accounting Flask 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. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import typing as t | ||||||
|  | from datetime import date, timedelta | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | from flask_babel import get_locale | ||||||
|  |  | ||||||
|  | from accounting.locale import gettext | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def format_amount(value: Decimal | None) -> str | None: | ||||||
|  |     """Formats an amount for readability. | ||||||
|  |  | ||||||
|  |     :param value: The amount. | ||||||
|  |     :return: The formatted amount text. | ||||||
|  |     """ | ||||||
|  |     if value is None: | ||||||
|  |         return None | ||||||
|  |     if value == 0: | ||||||
|  |         return "-" | ||||||
|  |     whole: int = int(value) | ||||||
|  |     frac: Decimal = (value - whole).normalize() | ||||||
|  |     return "{:,}".format(whole) + str(abs(frac))[1:] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def format_date(value: date) -> str: | ||||||
|  |     """Formats a date to be human-friendly. | ||||||
|  |  | ||||||
|  |     :param value: The date. | ||||||
|  |     :return: The human-friendly date text. | ||||||
|  |     """ | ||||||
|  |     today: date = date.today() | ||||||
|  |     if value == today: | ||||||
|  |         return gettext("Today") | ||||||
|  |     if value == today - timedelta(days=1): | ||||||
|  |         return gettext("Yesterday") | ||||||
|  |     if value == today + timedelta(days=1): | ||||||
|  |         return gettext("Tomorrow") | ||||||
|  |     locale = str(get_locale()) | ||||||
|  |     if locale == "zh" or locale.startswith("zh_"): | ||||||
|  |         if value == today - timedelta(days=2): | ||||||
|  |             return gettext("The day before yesterday") | ||||||
|  |         if value == today + timedelta(days=2): | ||||||
|  |             return gettext("The day after tomorrow") | ||||||
|  |     if locale == "zh" or locale.startswith("zh_"): | ||||||
|  |         weekdays = ["一", "二", "三", "四", "五", "六", "日"] | ||||||
|  |         weekday = weekdays[value.weekday()] | ||||||
|  |     else: | ||||||
|  |         weekday = value.strftime("%a") | ||||||
|  |     if value.year != today.year: | ||||||
|  |         return "{}/{}/{}({})".format( | ||||||
|  |             value.year, value.month, value.day, weekday) | ||||||
|  |     return "{}/{}({})".format(value.month, value.day, weekday) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def default(value: t.Any, default_value: t.Any = "") -> t.Any: | ||||||
|  |     """Returns the default value if the given value is None. | ||||||
|  |  | ||||||
|  |     :param value: The value. | ||||||
|  |     :param default_value: The default value when the given value is None. | ||||||
|  |     :return: The value, or the default value if the given value is None. | ||||||
|  |     """ | ||||||
|  |     return default_value if value is None else value | ||||||
							
								
								
									
										37
									
								
								src/accounting/template_globals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/accounting/template_globals.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The template globals. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from accounting.models import Currency | ||||||
|  | from accounting.utils.options import options | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def currency_options() -> str: | ||||||
|  |     """Returns the currency options. | ||||||
|  |  | ||||||
|  |     :return: The currency options. | ||||||
|  |     """ | ||||||
|  |     return Currency.query.order_by(Currency.code).all() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def default_currency_code() -> str: | ||||||
|  |     """Returns the default currency code. | ||||||
|  |  | ||||||
|  |     :return: The default currency code. | ||||||
|  |     """ | ||||||
|  |     return options.default_currency_code | ||||||
| @@ -25,48 +25,55 @@ First written: 2023/1/31 | |||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
| <div class="btn-group mb-3"> | <div class="mb-3 accounting-toolbar"> | ||||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> |   <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> | ||||||
|     <i class="fa-solid fa-circle-chevron-left"></i> |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|     {{ A_("Back") }} |     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||||
|   </a> |   </a> | ||||||
|   {% if accounting_can_edit() %} |   {% if accounting_can_edit() %} | ||||||
|     <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}"> |     <a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}"> | ||||||
|       <i class="fa-solid fa-gear"></i> |       <i class="fa-solid fa-pen-to-square"></i> | ||||||
|       {{ A_("Settings") }} |       {{ A_("Edit") }} | ||||||
|     </a> |     </a> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}"> |   <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}"> | ||||||
|     <i class="fa-solid fa-bars-staggered"></i> |     <i class="fa-solid fa-bars-staggered"></i> | ||||||
|     {{ A_("Order") }} |     <span class="d-none d-md-inline">{{ A_("Order") }}</span> | ||||||
|   </a> |   </a> | ||||||
|   {% if accounting_can_edit() %} |   {% if accounting_can_edit() %} | ||||||
|     <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> |     {% if obj.can_delete %} | ||||||
|       <i class="fa-solid fa-trash"></i> |       <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> | ||||||
|       {{ A_("Delete") }} |         <i class="fa-solid fa-trash"></i> | ||||||
|     </button> |         <span class="d-none d-md-inline">{{ A_("Delete") }}</span> | ||||||
|  |       </button> | ||||||
|  |     {% else %} | ||||||
|  |       <button class="btn btn-secondary" type="button" disabled="disabled"> | ||||||
|  |         <i class="fa-solid fa-trash"></i> | ||||||
|  |         <span class="d-none d-md-inline">{{ A_("Delete") }}</span> | ||||||
|  |       </button> | ||||||
|  |     {% endif %} | ||||||
|   {% endif %} |   {% endif %} | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| {% if accounting_can_edit() %} | {% if accounting_can_edit() %} | ||||||
|   <div class="d-md-none accounting-material-fab"> |   <div class="d-md-none accounting-material-fab"> | ||||||
|     <a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}"> |     <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}"> | ||||||
|       <i class="fa-solid fa-pen-to-square"></i> |       <i class="fa-solid fa-pen-to-square"></i> | ||||||
|     </a> |     </a> | ||||||
|   </div> |   </div> | ||||||
| {% endif %} | {% endif %} | ||||||
|  |  | ||||||
| {% if accounting_can_edit() %} | {% if accounting_can_edit() and obj.can_delete %} | ||||||
|   <form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post"> |   <form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post"> | ||||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> |     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||||
|     {% if "next" in request.args %} |     {% if request.args.next %} | ||||||
|       <input type="hidden" name="next" value="{{ request.args["next"] }}"> |       <input type="hidden" name="next" value="{{ request.args.next }}"> | ||||||
|     {% endif %} |     {% endif %} | ||||||
|     <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true"> |     <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true"> | ||||||
|       <div class="modal-dialog"> |       <div class="modal-dialog"> | ||||||
|         <div class="modal-content"> |         <div class="modal-content"> | ||||||
|           <div class="modal-header"> |           <div class="modal-header"> | ||||||
|             <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Account Confirmation") }}</h1> |             <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Account") }}</h1> | ||||||
|             <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> |             <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> | ||||||
|           </div> |           </div> | ||||||
|           <div class="modal-body"> |           <div class="modal-body"> | ||||||
| @@ -83,11 +90,11 @@ First written: 2023/1/31 | |||||||
| {% endif %} | {% endif %} | ||||||
|  |  | ||||||
| <div class="accounting-card col-sm-6"> | <div class="accounting-card col-sm-6"> | ||||||
|   <div class="accounting-card-title">{{ obj.title }}</div> |   <div class="accounting-card-title">{{ obj.title|title }}</div> | ||||||
|   <div class="accounting-card-code">{{ obj.code }}</div> |   <div class="accounting-card-code">{{ obj.code }}</div> | ||||||
|   {% if obj.is_pay_off_needed %} |   {% if obj.is_need_offset %} | ||||||
|     <div> |     <div> | ||||||
|       <span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span> |       <span class="badge rounded-pill bg-info">{{ A_("Needs Offset") }}</span> | ||||||
|     </div> |     </div> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   <div class="small text-secondary fst-italic"> |   <div class="small text-secondary fst-italic"> | ||||||
|   | |||||||
| @@ -27,23 +27,23 @@ First written: 2023/2/1 | |||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
| <div class="btn-group btn-actions mb-3"> | <div class="mb-3 accounting-toolbar"> | ||||||
|   <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}"> |   <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}"> | ||||||
|     <i class="fa-solid fa-circle-chevron-left"></i> |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|     {{ A_("Back") }} |     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||||
|   </a> |   </a> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post"> | <form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post"> | ||||||
|   {{ form.csrf_token }} |   {{ form.csrf_token }} | ||||||
|   {% if "next" in request.args %} |   {% if request.args.next %} | ||||||
|     <input type="hidden" name="next" value="{{ request.args["next"] }}"> |     <input type="hidden" name="next" value="{{ request.args.next }}"> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   <div class="form-floating mb-3"> |   <div class="form-floating mb-3"> | ||||||
|     <input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}"> |     <input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}"> | ||||||
|     <div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal"> |     <div id="accounting-base-control" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal"> | ||||||
|       <label class="form-label" for="accounting-base">{{ A_("Base account") }}</label> |       <label class="form-label" for="accounting-base">{{ A_("Base account") }}</label> | ||||||
|       <div id="accounting-base-content"> |       <div id="accounting-base"> | ||||||
|         {% if form.base_code.data %} |         {% if form.base_code.data %} | ||||||
|           {% if form.base_code.errors %} |           {% if form.base_code.errors %} | ||||||
|             {{ A_("(Unknown)") }} |             {{ A_("(Unknown)") }} | ||||||
| @@ -53,19 +53,19 @@ First written: 2023/2/1 | |||||||
|         {% endif %} |         {% endif %} | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|     <div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div> |     <div id="accounting-base-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <div class="form-floating mb-3"> |   <div class="form-floating mb-3"> | ||||||
|     <input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required"> |     <input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ form.title.data|accounting_default }}" placeholder=" " required="required"> | ||||||
|     <label class="form-label" for="accounting-title">{{ A_("Title") }}</label> |     <label class="form-label" for="accounting-title">{{ A_("Title") }}</label> | ||||||
|     <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div> |     <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <div class="form-check form-switch mb-3"> |   <div id="accounting-is-need-offset-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2", "3"] %} d-none {% endif %}"> | ||||||
|     <input id="accounting-is-pay-off-needed" class="form-check-input" type="checkbox" name="is_pay_off_needed" value="1" {% if form.is_pay_off_needed.data %} checked="checked" {% endif %}> |     <input id="accounting-is-need-offset" class="form-check-input" type="checkbox" name="is_need_offset" value="1" {% if form.is_need_offset.data %} checked="checked" {% endif %}> | ||||||
|     <label class="form-check-label" for="accounting-is-pay-off-needed"> |     <label class="form-check-label" for="accounting-is-need-offset"> | ||||||
|       {{ A_("The entries in the account need pay-off.") }} |       {{ A_("The line items in the account need offset.") }} | ||||||
|     </label> |     </label> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
| @@ -99,21 +99,21 @@ First written: 2023/2/1 | |||||||
|           </label> |           </label> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <ul id="accounting-base-option-list" class="list-group accounting-selector-list"> |         <ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list"> | ||||||
|           {% for base in form.base_options %} |           {% for base in form.base_options %} | ||||||
|           <li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}"> |             <li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-text="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal"> | ||||||
|             {{ base }} |               {{ base }} | ||||||
|           </li> |             </li> | ||||||
|           {% endfor %} |           {% endfor %} | ||||||
|         </ul> |         </ul> | ||||||
|         <p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> |         <p id="accounting-base-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> | ||||||
|       </div> |       </div> | ||||||
|       <div class="modal-footer"> |       <div class="modal-footer"> | ||||||
|         <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button> |         <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button> | ||||||
|         {% if form.base_code.data %} |         {% if form.base_code.data %} | ||||||
|           <button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button> |           <button id="accounting-base-selector-clear" type="button" class="btn btn-danger" data-bs-dismiss="modal">{{ A_("Clear") }}</button> | ||||||
|         {% else %} |         {% else %} | ||||||
|           <button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button> |           <button id="accounting-base-selector-clear" type="button" class="btn btn-secondary" disabled="disabled" data-bs-dismiss="modal">{{ A_("Clear") }}</button> | ||||||
|         {% endif %} |         {% endif %} | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   | |||||||
| @@ -21,35 +21,23 @@ First written: 2023/1/30 | |||||||
| #} | #} | ||||||
| {% extends "accounting/base.html" %} | {% extends "accounting/base.html" %} | ||||||
|  |  | ||||||
| {% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %} | {% block header %}{% block title %}{% if request.args.q %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
| <div class="btn-group mb-2 d-none d-md-inline-flex"> | <div class="mb-2 accounting-toolbar"> | ||||||
|   {% if accounting_can_edit() %} |   {% if accounting_can_edit() %} | ||||||
|     <a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.account.create")|accounting_append_next }}"> |     <a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}"> | ||||||
|       <i class="fa-solid fa-plus"></i> |       <i class="fa-solid fa-plus"></i> | ||||||
|       {{ A_("New") }} |       {{ A_("New") }} | ||||||
|     </a> |     </a> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}"> |   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> | ||||||
|     <input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required"> |     <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> | ||||||
|     <label for="accounting-search-desktop" class="accounting-search-label"> |     <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> | ||||||
|       <button type="submit"> |       <button type="submit"> | ||||||
|         <i class="fa-solid fa-magnifying-glass"></i> |         <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|         {{ A_("Search") }} |         <span class="d-none d-md-inline">{{ A_("Search") }}</span> | ||||||
|       </button> |  | ||||||
|     </label> |  | ||||||
|   </form> |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| <div class="btn-group mb-2 d-md-none"> |  | ||||||
|   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}"> |  | ||||||
|     <input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required"> |  | ||||||
|     <label for="accounting-search-mobile" class="accounting-search-label"> |  | ||||||
|       <button type="submit"> |  | ||||||
|         <i class="fa-solid fa-magnifying-glass"></i> |  | ||||||
|         {{ A_("Search") }} |  | ||||||
|       </button> |       </button> | ||||||
|     </label> |     </label> | ||||||
|   </form> |   </form> | ||||||
| @@ -57,7 +45,7 @@ First written: 2023/1/30 | |||||||
|  |  | ||||||
| {% if accounting_can_edit() %} | {% if accounting_can_edit() %} | ||||||
|   <div class="d-md-none accounting-material-fab"> |   <div class="d-md-none accounting-material-fab"> | ||||||
|     <a class="btn btn-primary" href="{{ url_for("accounting.account.create")|accounting_append_next }}"> |     <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}"> | ||||||
|       <i class="fa-solid fa-plus"></i> |       <i class="fa-solid fa-plus"></i> | ||||||
|     </a> |     </a> | ||||||
|   </div> |   </div> | ||||||
| @@ -70,8 +58,8 @@ First written: 2023/1/30 | |||||||
|   {% for item in list %} |   {% for item in list %} | ||||||
|     <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}"> |     <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}"> | ||||||
|       {{ item }} |       {{ item }} | ||||||
|       {% if item.is_pay_off_needed %} |       {% if item.is_need_offset %} | ||||||
|         <span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span> |         <span class="badge rounded-pill bg-info">{{ A_("Needs Offset") }}</span> | ||||||
|       {% endif %} |       {% endif %} | ||||||
|     </a> |     </a> | ||||||
|   {% endfor %} |   {% endfor %} | ||||||
|   | |||||||
| @@ -30,18 +30,18 @@ First written: 2023/2/2 | |||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
| <div class="btn-group mb-3"> | <div class="mb-3 accounting-toolbar"> | ||||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> |   <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> | ||||||
|     <i class="fa-solid fa-circle-chevron-left"></i> |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|     {{ A_("Back") }} |     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||||
|   </a> |   </a> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| {% if base.accounts|length > 1 and accounting_can_edit() %} | {% if base.accounts|length > 1 and accounting_can_edit() %} | ||||||
|   <form action="{{ url_for("accounting.account.sort", base=base) }}" method="post"> |   <form action="{{ url_for("accounting.account.sort", base=base) }}" method="post"> | ||||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> |     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||||
|     {% if "next" in request.args %} |     {% if request.args.next %} | ||||||
|       <input type="hidden" name="next" value="{{ request.args["next"] }}"> |       <input type="hidden" name="next" value="{{ request.args.next }}"> | ||||||
|     {% endif %} |     {% endif %} | ||||||
|     <ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}"> |     <ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}"> | ||||||
|       {% for account in base.accounts|sort(attribute="no") %} |       {% for account in base.accounts|sort(attribute="no") %} | ||||||
|   | |||||||
| @@ -25,20 +25,20 @@ First written: 2023/2/1 | |||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
| <div class="btn-group mb-3"> | <div class="mb-3 accounting-toolbar"> | ||||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> |   <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> | ||||||
|     <i class="fa-solid fa-circle-chevron-left"></i> |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|     {{ A_("Back") }} |     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||||
|   </a> |   </a> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <div class="accounting-card col-sm-6"> | <div class="accounting-card col-sm-6"> | ||||||
|   <div class="accounting-card-title">{{ obj.title }}</div> |   <div class="accounting-card-title">{{ obj.title|title }}</div> | ||||||
|   <div class="accounting-card-code">{{ obj.code }}</div> |   <div class="accounting-card-code">{{ obj.code }}</div> | ||||||
|   {% if obj.accounts %} |   {% if obj.accounts %} | ||||||
|     <div> |     <div> | ||||||
|     {% for account in obj.accounts %} |     {% for account in obj.accounts %} | ||||||
|       <a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}"> |       <a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}"> | ||||||
|         {{ account }} |         {{ account }} | ||||||
|       </a> |       </a> | ||||||
|     {% endfor %} |     {% endfor %} | ||||||
|   | |||||||
| @@ -21,17 +21,17 @@ First written: 2023/1/26 | |||||||
| #} | #} | ||||||
| {% extends "accounting/base.html" %} | {% extends "accounting/base.html" %} | ||||||
|  |  | ||||||
| {% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %} | {% block header %}{% block title %}{% if request.args.q %}{{ A_("Search Result for \"%(query)s\"", query=request.args.q) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
| <div class="btn-group mb-2"> | <div class="mb-2 accounting-toolbar"> | ||||||
|   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-label="{{ A_("Search") }}"> |   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> | ||||||
|     <input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required"> |     <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> | ||||||
|     <label for="accounting-search" class="accounting-search-label"> |     <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> | ||||||
|       <button type="submit"> |       <button type="submit"> | ||||||
|         <i class="fa-solid fa-magnifying-glass"></i> |         <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|         {{ A_("Search") }} |         <span class="d-none d-md-inline">{{ A_("Search") }}</span> | ||||||
|       </button> |       </button> | ||||||
|     </label> |     </label> | ||||||
|   </form> |   </form> | ||||||
|   | |||||||
| @@ -25,44 +25,51 @@ First written: 2023/2/6 | |||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
| <div class="btn-group mb-3"> | <div class="mb-3 accounting-toolbar"> | ||||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|accounting_or_next }}"> |   <a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.list")|accounting_or_next }}"> | ||||||
|     <i class="fa-solid fa-circle-chevron-left"></i> |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|     {{ A_("Back") }} |     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||||
|   </a> |   </a> | ||||||
|   {% if accounting_can_edit() %} |   {% if accounting_can_edit() %} | ||||||
|     <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}"> |     <a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}"> | ||||||
|       <i class="fa-solid fa-gear"></i> |       <i class="fa-solid fa-pen-to-square"></i> | ||||||
|       {{ A_("Settings") }} |       {{ A_("Edit") }} | ||||||
|     </a> |     </a> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   {% if accounting_can_edit() %} |   {% if accounting_can_edit() %} | ||||||
|     <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> |     {% if obj.can_delete %} | ||||||
|       <i class="fa-solid fa-trash"></i> |       <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> | ||||||
|       {{ A_("Delete") }} |         <i class="fa-solid fa-trash"></i> | ||||||
|     </button> |         <span class="d-none d-md-inline">{{ A_("Delete") }}</span> | ||||||
|  |       </button> | ||||||
|  |     {% else %} | ||||||
|  |       <button class="btn btn-secondary" type="button" disabled="disabled"> | ||||||
|  |         <i class="fa-solid fa-trash"></i> | ||||||
|  |         <span class="d-none d-md-inline">{{ A_("Delete") }}</span> | ||||||
|  |       </button> | ||||||
|  |     {% endif %} | ||||||
|   {% endif %} |   {% endif %} | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| {% if accounting_can_edit() %} | {% if accounting_can_edit() %} | ||||||
|   <div class="d-md-none accounting-material-fab"> |   <div class="d-md-none accounting-material-fab"> | ||||||
|     <a class="btn btn-primary" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}"> |     <a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}"> | ||||||
|       <i class="fa-solid fa-pen-to-square"></i> |       <i class="fa-solid fa-pen-to-square"></i> | ||||||
|     </a> |     </a> | ||||||
|   </div> |   </div> | ||||||
| {% endif %} | {% endif %} | ||||||
|  |  | ||||||
| {% if accounting_can_edit() %} | {% if accounting_can_edit() and obj.can_delete %} | ||||||
|   <form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post"> |   <form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post"> | ||||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> |     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||||
|     {% if "next" in request.args %} |     {% if request.args.next %} | ||||||
|       <input type="hidden" name="next" value="{{ request.args["next"] }}"> |       <input type="hidden" name="next" value="{{ request.args.next }}"> | ||||||
|     {% endif %} |     {% endif %} | ||||||
|     <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true"> |     <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true"> | ||||||
|       <div class="modal-dialog"> |       <div class="modal-dialog"> | ||||||
|         <div class="modal-content"> |         <div class="modal-content"> | ||||||
|           <div class="modal-header"> |           <div class="modal-header"> | ||||||
|             <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Currency Confirmation") }}</h1> |             <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Currency") }}</h1> | ||||||
|             <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> |             <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> | ||||||
|           </div> |           </div> | ||||||
|           <div class="modal-body"> |           <div class="modal-body"> | ||||||
| @@ -79,7 +86,7 @@ First written: 2023/2/6 | |||||||
| {% endif %} | {% endif %} | ||||||
|  |  | ||||||
| <div class="accounting-card col-sm-6"> | <div class="accounting-card col-sm-6"> | ||||||
|   <div class="accounting-card-title">{{ obj.name }}</div> |   <div class="accounting-card-title">{{ obj.name|title }}</div> | ||||||
|   <div class="accounting-card-code">{{ obj.code }}</div> |   <div class="accounting-card-code">{{ obj.code }}</div> | ||||||
|   <div class="small text-secondary fst-italic"> |   <div class="small text-secondary fst-italic"> | ||||||
|     <div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div> |     <div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div> | ||||||
|   | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user