Compare commits
	
		
			270 Commits
		
	
	
		
			v0.5.0
			...
			d5a9e1af18
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d5a9e1af18 | |||
| 73f5d63f44 | |||
| bf2c7bb785 | |||
| 93ba086548 | |||
| 5c4f6017b8 | |||
| cb16b2f0ff | |||
| d2f11e8779 | |||
| 4ccaf01b3c | |||
| 7c512b1c15 | |||
| dc432da398 | |||
| c8504bcbf5 | |||
| c865141583 | |||
| 8c1ecd6eac | |||
| e8e4100677 | |||
| 6a8773c531 | |||
| 30e0c7682c | |||
| eb5a7bef7e | |||
| 8a174d8847 | |||
| 7459afd63a | |||
| a9afc385e9 | |||
| a8be739ec7 | |||
| 0130bc58a9 | |||
| 821059fa80 | |||
| 5b4f57d0b3 | |||
| 4bfac2d545 | |||
| f105f0cf7b | |||
| 5e320729d7 | |||
| 7515032082 | |||
| 361b18e411 | |||
| 7d084e570e | |||
| cb397910f8 | |||
| 5f8b0dec98 | |||
| 8398d1e8bb | |||
| 562801692a | |||
| faee1e61c6 | |||
| 57a4177037 | |||
| fa1dedf207 | |||
| 7ed13dc0af | |||
| 52807c5322 | |||
| 231a71feea | |||
| 4902eecae0 | |||
| 889e4c058e | |||
| 7262a6cb42 | |||
| c4ff4ecb3d | |||
| 2859f628ea | |||
| e0355b2af1 | |||
| b4d390c33a | |||
| a4ab8a761c | |||
| 907ce6d06e | |||
| 7e1388735e | |||
| 6f773dd837 | |||
| 87fa5aa6bc | |||
| 35e05b3708 | |||
| 7ccc96bda0 | |||
| 283758ebe9 | |||
| b673c7aeaf | |||
| 0ad2ac53dd | |||
| 7e90ec5a8f | |||
| 7755365467 | |||
| 979eea606a | |||
| 5a9e08f2c4 | |||
| 68c810d492 | |||
| 5f88260507 | |||
| 779d89f8c4 | |||
| 5d4bf4361b | |||
| 10170d613d | |||
| c885c08c37 | |||
| e2a4340f2a | |||
| 9728ff30e0 | |||
| a4644ede5f | |||
| 8f477dd6f1 | |||
| 44ac53f15c | |||
| 5edb5465c5 | |||
| 067afdb165 | |||
| 37a4c26f86 | |||
| 89e43830b4 | |||
| 671dbfb692 | |||
| 2014344d25 | |||
| f9c39709c8 | |||
| b394c58ec6 | |||
| 0af3e2785b | |||
| 7066f75e72 | |||
| 619540da49 | |||
| 567004f7d9 | |||
| 761d5a5824 | |||
| fa3cdace7f | |||
| 656762850c | |||
| e2325f08d0 | |||
| 855356084e | |||
| 7aaeb32a3d | |||
| b376cf1580 | |||
| ccbdc779ac | |||
| 61ee08fda2 | |||
| d8afadda02 | |||
| c8e1270d8f | |||
| 2a78799404 | |||
| 863d7a9368 | |||
| 6fd37b21d9 | |||
| bbf3ee3320 | |||
| b60cc7902d | |||
| 623313b58a | |||
| d0d2d77a2e | |||
| 494faeffea | |||
| 871a5fd1d8 | |||
| e615ad2690 | |||
| da92a0b42c | |||
| 678d0aa773 | |||
| 9248ba7e3b | |||
| 446087b212 | |||
| a42e7d13a2 | |||
| a82f5091f1 | |||
| 3455827c09 | |||
| 5dccf99a55 | |||
| 8818b46e01 | |||
| 2f3ad99467 | |||
| 592910187b | |||
| cb7a0d377f | |||
| 79175285f8 | |||
| fef474977c | |||
| fa1a55cd3d | |||
| 2253ec7e6d | |||
| 32aa532548 | |||
| 56138f7de3 | |||
| 21ef944259 | |||
| 760f1c2877 | |||
| e377eac407 | |||
| 77787eee9f | |||
| 03265a1232 | |||
| 079dc1ab6d | |||
| d4fe91ec4a | |||
| acc5b4d6ea | |||
| 19a93cb4c3 | |||
| 116089d1d2 | |||
| 50dd6078c7 | |||
| 9a4531b26c | |||
| b1af1d7425 | |||
| 8f909965a9 | |||
| e26af6f3fc | |||
| 02fffc3400 | |||
| d7d6929bf2 | |||
| e4cc61552e | |||
| d18dd7d4d2 | |||
| 3251660092 | |||
| c1235608d8 | |||
| 25c45b16ae | |||
| 78f570b81b | |||
| 5db13393cc | |||
| 1e286fbeba | |||
| d4b3fe67b9 | |||
| 5d0757c845 | |||
| b69a519904 | |||
| 122b7b059c | |||
| 4977847dd8 | |||
| b9b197ea27 | |||
| 884e37fe1b | |||
| cc6a73211e | |||
| 2299b86d0f | |||
| 6d293a1aac | |||
| a2311aee24 | |||
| 5571c0d01f | |||
| 98e1bad413 | |||
| 7ff52d99e6 | |||
| cc440a4110 | |||
| f5149a0c37 | |||
| ca928636fd | |||
| 4a8297d594 | |||
| 915e4408e1 | |||
| fd9eac06f6 | |||
| 403942dfc0 | |||
| 35dc513760 | |||
| 01861f0b6a | |||
| 8c10f1e96a | |||
| 5f7fc0b8e8 | |||
| 700c179774 | |||
| cabe02f7d0 | |||
| 5ceb9f2e83 | |||
| fe1c7669b6 | |||
| 4eac10981f | |||
| c869bccc04 | |||
| 61c111db69 | |||
| 34f63c1cdf | |||
| a643d9e811 | |||
| 2239ddfad1 | |||
| 12fbe36b9a | |||
| 46e34bb89a | |||
| c9453d3023 | |||
| fc766724c4 | |||
| 38c394c0af | |||
| 67e2b06d37 | |||
| be10a8d99e | |||
| fbeec600b7 | |||
| 1a54592d4c | |||
| 94a527caf2 | |||
| 0a1bbbdd47 | |||
| 82b63e4bd4 | |||
| e1d1aff0c1 | |||
| 2e5f9ee01f | |||
| f901a0020f | |||
| fc2be75c3b | |||
| 96c131940b | |||
| b9435a255b | |||
| 56045f0faf | |||
| 08d1e60238 | |||
| d88b3ac770 | |||
| 40e329d37f | |||
| 23a0721d8d | |||
| 2b2c665eb6 | |||
| 954173a2c2 | |||
| 91e6dc6668 | |||
| e9d8a8fcd8 | |||
| 4c84686395 | |||
| 61fd1849ed | |||
| a67158f8f6 | |||
| 5c6bfd8b49 | |||
| d9ecf51c6d | |||
| 5d31eb9172 | |||
| fadce244c5 | |||
| cbe7c6ca6d | |||
| b03938fb2e | |||
| 8061a23fdc | |||
| cd8a480cd0 | |||
| b8b87714eb | |||
| bf2f96621d | |||
| 2d771f04be | |||
| 3a12472d4b | |||
| d5a686a5d8 | |||
| 690f89e29a | |||
| 82a6a53dc4 | |||
| cdd31b1047 | |||
| 5bad949cfa | |||
| 3826646d06 | |||
| 74071e8997 | |||
| 3ce34803f3 | |||
| 232f73172f | |||
| ff1bb7142b | |||
| 7155bf635a | |||
| c306ff8009 | |||
| b344abce06 | |||
| b3c666c872 | |||
| 6a671cac84 | |||
| fe87c3a7de | |||
| 2013f8cbd9 | |||
| 2325842471 | |||
| c80e58b049 | |||
| be0ae5eba4 | |||
| 2b84f64554 | |||
| 0a658a76e8 | |||
| 50dc79d865 | |||
| 8e5377a416 | |||
| 4299fd6fbd | |||
| 1d6a53f7cd | |||
| bb2993b0c0 | |||
| f6946c1165 | |||
| 8e219d8006 | |||
| 53565eb9e6 | |||
| 965e78d8ad | |||
| 74b81d3e23 | |||
| a0fba6387f | |||
| d28bdf2064 | |||
| edf0c00e34 | |||
| 107d161379 | |||
| f2c184f769 | |||
| b45986ecfc | |||
| a2c2452ec5 | |||
| 5194258b48 | |||
| 3fe7eb41ac | |||
| 7fb9e2f0a1 | |||
| 1d443f7b76 | |||
| 6ad4fba9cd | |||
| 3dda6531b5 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -38,3 +38,4 @@ excludes | ||||
| *.mo | ||||
| zh_Hans | ||||
| test_temp.py | ||||
| dummy.js | ||||
|   | ||||
| @@ -15,6 +15,7 @@ | ||||
| #  See the License for the specific language governing permissions and | ||||
| #  limitations under the License. | ||||
|  | ||||
| exclude src/accounting/static/js/dummy.js | ||||
| include src/accounting/translations/* | ||||
| include src/accounting/translations/*/LC_MESSAGES/* | ||||
| include docs/* | ||||
| @@ -22,7 +23,9 @@ include docs/source/* | ||||
| include docs/source/_static/* | ||||
| include docs/source/_templates/* | ||||
| include tests/* | ||||
| exclude tests/test_temp.py | ||||
| include tests/test_site/* | ||||
| include tests/test_site/static/* | ||||
| include tests/test_site/templates/* | ||||
| include tests/test_site/translations/* | ||||
| include tests/test_site/translations/*/LC_MESSAGES/* | ||||
|   | ||||
							
								
								
									
										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: | ||||
| @@ -28,14 +28,6 @@ accounting.report.utils.csv\_export module | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.report.utils.ie\_account module | ||||
| ------------------------------------------ | ||||
|  | ||||
| .. automodule:: accounting.report.utils.ie_account | ||||
|    :members: | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.report.utils.option\_link module | ||||
| ------------------------------------------- | ||||
|  | ||||
|   | ||||
| @@ -10,13 +10,22 @@ Subpackages | ||||
|    accounting.account | ||||
|    accounting.base_account | ||||
|    accounting.currency | ||||
|    accounting.journal_entry | ||||
|    accounting.option | ||||
|    accounting.report | ||||
|    accounting.transaction | ||||
|    accounting.utils | ||||
|  | ||||
| Submodules | ||||
| ---------- | ||||
|  | ||||
| accounting.forms module | ||||
| ----------------------- | ||||
|  | ||||
| .. automodule:: accounting.forms | ||||
|    :members: | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.locale module | ||||
| ------------------------ | ||||
|  | ||||
|   | ||||
| @@ -1,61 +0,0 @@ | ||||
| accounting.transaction package | ||||
| ============================== | ||||
|  | ||||
| Submodules | ||||
| ---------- | ||||
|  | ||||
| accounting.transaction.converters module | ||||
| ---------------------------------------- | ||||
|  | ||||
| .. automodule:: accounting.transaction.converters | ||||
|    :members: | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.transaction.forms module | ||||
| ----------------------------------- | ||||
|  | ||||
| .. automodule:: accounting.transaction.forms | ||||
|    :members: | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.transaction.operators module | ||||
| --------------------------------------- | ||||
|  | ||||
| .. automodule:: accounting.transaction.operators | ||||
|    :members: | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.transaction.summary\_editor module | ||||
| --------------------------------------------- | ||||
|  | ||||
| .. automodule:: accounting.transaction.summary_editor | ||||
|    :members: | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.transaction.template\_filters module | ||||
| ----------------------------------------------- | ||||
|  | ||||
| .. automodule:: accounting.transaction.template_filters | ||||
|    :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 | ||||
| ---------- | ||||
|  | ||||
| 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 | ||||
| ------------------------------------- | ||||
|  | ||||
| @@ -12,6 +28,14 @@ accounting.utils.flash\_errors module | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.utils.journal\_entry\_types module | ||||
| --------------------------------------------- | ||||
|  | ||||
| .. automodule:: accounting.utils.journal_entry_types | ||||
|    :members: | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.utils.next\_uri module | ||||
| --------------------------------- | ||||
|  | ||||
| @@ -20,6 +44,14 @@ accounting.utils.next\_uri module | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.utils.options module | ||||
| ------------------------------- | ||||
|  | ||||
| .. automodule:: accounting.utils.options | ||||
|    :members: | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.utils.pagination module | ||||
| ---------------------------------- | ||||
|  | ||||
| @@ -60,14 +92,6 @@ accounting.utils.strip\_text module | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.utils.txn\_types module | ||||
| ---------------------------------- | ||||
|  | ||||
| .. automodule:: accounting.utils.txn_types | ||||
|    :members: | ||||
|    :undoc-members: | ||||
|    :show-inheritance: | ||||
|  | ||||
| accounting.utils.user module | ||||
| ---------------------------- | ||||
|  | ||||
|   | ||||
| @@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/')) | ||||
| project = 'Mia! Accounting Flask' | ||||
| copyright = '2023, imacat' | ||||
| author = 'imacat' | ||||
| release = '0.4.0' | ||||
| release = '0.9.1' | ||||
|  | ||||
| # -- General configuration --------------------------------------------------- | ||||
| # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration | ||||
|   | ||||
| @@ -17,7 +17,7 @@ | ||||
|  | ||||
| [metadata] | ||||
| name = mia-accounting-flask | ||||
| version = 0.5.0 | ||||
| version = 0.9.1 | ||||
| author = imacat | ||||
| author_email = imacat@mail.imacat.idv.tw | ||||
| description = The Mia! Accounting Flask project. | ||||
|   | ||||
| @@ -17,13 +17,12 @@ | ||||
| """The accounting application. | ||||
|  | ||||
| """ | ||||
| import typing as t | ||||
| from pathlib import Path | ||||
|  | ||||
| from flask import Flask, Blueprint | ||||
| from flask_sqlalchemy import SQLAlchemy | ||||
|  | ||||
| from accounting.utils.user import AbstractUserUtils | ||||
| from accounting.utils.user import UserUtilityInterface | ||||
|  | ||||
| db: SQLAlchemy = SQLAlchemy() | ||||
| """The database instance.""" | ||||
| @@ -31,19 +30,13 @@ data_dir: Path = Path(__file__).parent / "data" | ||||
| """The data directory.""" | ||||
|  | ||||
|  | ||||
| def init_app(app: Flask, user_utils: AbstractUserUtils, | ||||
|              url_prefix: str = "/accounting", | ||||
|              can_view_func: t.Callable[[], bool] | None = None, | ||||
|              can_edit_func: t.Callable[[], bool] | None = None) -> None: | ||||
| def init_app(app: Flask, user_utils: UserUtilityInterface, | ||||
|              url_prefix: str = "/accounting") -> None: | ||||
|     """Initialize the application. | ||||
|  | ||||
|     :param app: The Flask application. | ||||
|     :param user_utils: The user utilities. | ||||
|     :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. | ||||
|     """ | ||||
|     # The database instance must be set before loading everything | ||||
| @@ -73,7 +66,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils, | ||||
|     locale.init_app(app, bp) | ||||
|  | ||||
|     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) | ||||
| @@ -87,10 +80,13 @@ def init_app(app: Flask, user_utils: AbstractUserUtils, | ||||
|     from . import currency | ||||
|     currency.init_app(app, bp) | ||||
|  | ||||
|     from . import transaction | ||||
|     transaction.init_app(app, bp) | ||||
|     from . import journal_entry | ||||
|     journal_entry.init_app(app, bp) | ||||
|  | ||||
|     from . import report | ||||
|     report.init_app(app, bp) | ||||
|  | ||||
|     from . import option | ||||
|     option.init_app(bp) | ||||
|  | ||||
|     app.register_blueprint(bp) | ||||
|   | ||||
| @@ -18,7 +18,6 @@ | ||||
|  | ||||
| """ | ||||
| import os | ||||
| import re | ||||
| from secrets import randbelow | ||||
|  | ||||
| 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] | ||||
| """The format of the account data, as a list of (ID, base account code, number, | ||||
| English, Traditional Chinese, Simplified Chinese, is-offset-needed) tuples.""" | ||||
| English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples.""" | ||||
|  | ||||
|  | ||||
| def __validate_username(ctx: click.core.Context, param: click.core.Option, | ||||
| @@ -93,14 +92,37 @@ def init_accounts_command(username: str) -> None: | ||||
|     data: list[AccountData] = [] | ||||
|     for base in bases_to_add: | ||||
|         l10n: dict[str, str] = {x.locale: x.title for x in base.l10n} | ||||
|         is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \ | ||||
|             else False | ||||
|         is_need_offset: bool = __is_need_offset(base.code) | ||||
|         data.append((get_new_id(), base.code, 1, base.title_l10n, | ||||
|                      l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed)) | ||||
|                      l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset)) | ||||
|     __add_accounting_accounts(data, creator_pk) | ||||
|     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)\ | ||||
|         -> None: | ||||
|     """Adds the accounts. | ||||
| @@ -113,7 +135,7 @@ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\ | ||||
|                                        base_code=x[1], | ||||
|                                        no=x[2], | ||||
|                                        title_l10n=x[3], | ||||
|                                        is_offset_needed=x[6], | ||||
|                                        is_need_offset=x[6], | ||||
|                                        created_by_id=creator_pk, | ||||
|                                        updated_by_id=creator_pk) | ||||
|                                for x in data] | ||||
|   | ||||
| @@ -53,6 +53,20 @@ class BaseAccountAvailable: | ||||
|                 "The base account is not available.")) | ||||
|  | ||||
|  | ||||
| class NoOffsetNominalAccount: | ||||
|     """The validator to check nominal account is not to be offset.""" | ||||
|  | ||||
|     def __call__(self, form: FlaskForm, field: BooleanField) -> None: | ||||
|         assert isinstance(form, AccountForm) | ||||
|         if not field.data: | ||||
|             return | ||||
|         if form.base_code.data is None: | ||||
|             return | ||||
|         if form.base_code.data[0] not in {"1", "2", "3"}: | ||||
|             raise ValidationError(lazy_gettext( | ||||
|                 "A nominal account does not need offset.")) | ||||
|  | ||||
|  | ||||
| class AccountForm(FlaskForm): | ||||
|     """The form to create or edit an account.""" | ||||
|     base_code = StringField( | ||||
| @@ -66,8 +80,9 @@ class AccountForm(FlaskForm): | ||||
|         filters=[strip_text], | ||||
|         validators=[DataRequired(lazy_gettext("Please fill in the title"))]) | ||||
|     """The title.""" | ||||
|     is_offset_needed = BooleanField() | ||||
|     """Whether the the entries of this account need offset.""" | ||||
|     is_need_offset = BooleanField( | ||||
|         validators=[NoOffsetNominalAccount()]) | ||||
|     """Whether the the journal entry line items of this account need offset.""" | ||||
|  | ||||
|     def populate_obj(self, obj: Account) -> None: | ||||
|         """Populates the form data into an account object. | ||||
| @@ -87,7 +102,10 @@ class AccountForm(FlaskForm): | ||||
|             obj.base_code = self.base_code.data | ||||
|             obj.no = count + 1 | ||||
|         obj.title = self.title.data | ||||
|         obj.is_offset_needed = self.is_offset_needed.data | ||||
|         if self.base_code.data[0] in {"1", "2", "3"}: | ||||
|             obj.is_need_offset = self.is_need_offset.data | ||||
|         else: | ||||
|             obj.is_need_offset = False | ||||
|         if is_new: | ||||
|             current_user_pk: int = get_current_user_pk() | ||||
|             obj.created_by_id = current_user_pk | ||||
|   | ||||
| @@ -40,15 +40,15 @@ def get_account_query() -> list[Account]: | ||||
|     conditions: list[sa.BinaryExpression] = [] | ||||
|     for k in keywords: | ||||
|         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} | ||||
|         sub_conditions: list[sa.BinaryExpression] \ | ||||
|             = [Account.base_code.contains(k), | ||||
|                Account.title_l10n.contains(k), | ||||
|                Account.title_l10n.icontains(k), | ||||
|                code.contains(k), | ||||
|                Account.id.in_(l10n_matches)] | ||||
|         if k in gettext("Need offset"): | ||||
|             sub_conditions.append(Account.is_offset_needed) | ||||
|         if k in gettext("Needs Offset"): | ||||
|             sub_conditions.append(Account.is_need_offset) | ||||
|         conditions.append(sa.or_(*sub_conditions)) | ||||
|  | ||||
|     return Account.query.filter(*conditions)\ | ||||
|   | ||||
| @@ -27,6 +27,7 @@ from werkzeug.datastructures import ImmutableMultiDict | ||||
| from accounting import db | ||||
| from accounting.locale import lazy_gettext | ||||
| from accounting.models import Account, BaseAccount | ||||
| from accounting.utils.cast import s | ||||
| from accounting.utils.flash_errors import flash_form_errors | ||||
| from accounting.utils.next_uri import inherit_next, or_next | ||||
| from accounting.utils.pagination import Pagination | ||||
| @@ -86,7 +87,7 @@ def add_account() -> redirect: | ||||
|     form.populate_obj(account) | ||||
|     db.session.add(account) | ||||
|     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))) | ||||
|  | ||||
|  | ||||
| @@ -138,12 +139,12 @@ def update_account(account: Account) -> redirect: | ||||
|     with db.session.no_autoflush: | ||||
|         form.populate_obj(account) | ||||
|     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))) | ||||
|     account.updated_by_id = get_current_user_pk() | ||||
|     account.updated_at = sa.func.now() | ||||
|     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))) | ||||
|  | ||||
|  | ||||
| @@ -156,10 +157,13 @@ def delete_account(account: Account) -> redirect: | ||||
|     :return: The redirection to the account list on success, or the account | ||||
|         detail on error. | ||||
|     """ | ||||
|     if not account.can_delete: | ||||
|         flash(s(lazy_gettext("The account cannot be deleted.")), "error") | ||||
|         return redirect(inherit_next(__get_detail_uri(account))) | ||||
|     account.delete() | ||||
|     sort_accounts_in(account.base_code, account.id) | ||||
|     db.session.commit() | ||||
|     flash(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())) | ||||
|  | ||||
|  | ||||
| @@ -186,10 +190,10 @@ def sort_accounts(base: BaseAccount) -> redirect: | ||||
|     form: AccountReorderForm = AccountReorderForm(base) | ||||
|     form.save_order() | ||||
|     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())) | ||||
|     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())) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -35,10 +35,10 @@ def get_base_account_query() -> list[BaseAccount]: | ||||
|     conditions: list[sa.BinaryExpression] = [] | ||||
|     for k in keywords: | ||||
|         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} | ||||
|         conditions.append(sa.or_(BaseAccount.code.contains(k), | ||||
|                                  BaseAccount.title_l10n.contains(k), | ||||
|                                  BaseAccount.title_l10n.icontains(k), | ||||
|                                  BaseAccount.code.in_(l10n_matches))) | ||||
|     return BaseAccount.query.filter(*conditions)\ | ||||
|         .order_by(BaseAccount.code).all() | ||||
|   | ||||
| @@ -17,8 +17,6 @@ | ||||
| """The forms for the currency management. | ||||
|  | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from flask_wtf import FlaskForm | ||||
| from wtforms import StringField, ValidationError | ||||
| 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 | ||||
|  | ||||
|  | ||||
| class CodeUnique: | ||||
|     """The validator to check if the code is unique.""" | ||||
|  | ||||
|     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||
|         assert isinstance(form, CurrencyForm) | ||||
|         if field.data == "": | ||||
|             return | ||||
|         if form.obj_code is not None and form.obj_code == field.data: | ||||
|             return | ||||
|         if db.session.get(Currency, field.data) is not None: | ||||
|             raise ValidationError(lazy_gettext( | ||||
|                 "Code conflicts with another currency.")) | ||||
|  | ||||
|  | ||||
| class CurrencyForm(FlaskForm): | ||||
|     """The form to create or edit a currency.""" | ||||
|     CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"] | ||||
|     """The reserved codes that are not available.""" | ||||
|  | ||||
|     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( | ||||
|         filters=[strip_text], | ||||
|         validators=[DataRequired(lazy_gettext("Please fill in the code.")), | ||||
|   | ||||
| @@ -35,10 +35,10 @@ def get_currency_query() -> list[Currency]: | ||||
|     conditions: list[sa.BinaryExpression] = [] | ||||
|     for k in keywords: | ||||
|         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} | ||||
|         conditions.append(sa.or_(Currency.code.contains(k), | ||||
|                                  Currency.name_l10n.contains(k), | ||||
|         conditions.append(sa.or_(Currency.code.icontains(k), | ||||
|                                  Currency.name_l10n.icontains(k), | ||||
|                                  Currency.code.in_(l10n_matches))) | ||||
|     return Currency.query.filter(*conditions)\ | ||||
|         .order_by(Currency.code).all() | ||||
|   | ||||
| @@ -27,6 +27,7 @@ from werkzeug.datastructures import ImmutableMultiDict | ||||
| from accounting import db | ||||
| from accounting.locale import lazy_gettext | ||||
| from accounting.models import Currency | ||||
| from accounting.utils.cast import s | ||||
| from accounting.utils.flash_errors import flash_form_errors | ||||
| from accounting.utils.next_uri import inherit_next, or_next | ||||
| from accounting.utils.pagination import Pagination | ||||
| @@ -88,7 +89,7 @@ def add_currency() -> redirect: | ||||
|     form.populate_obj(currency) | ||||
|     db.session.add(currency) | ||||
|     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))) | ||||
|  | ||||
|  | ||||
| @@ -141,12 +142,12 @@ def update_currency(currency: Currency) -> redirect: | ||||
|     with db.session.no_autoflush: | ||||
|         form.populate_obj(currency) | ||||
|     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))) | ||||
|     currency.updated_by_id = get_current_user_pk() | ||||
|     currency.updated_at = sa.func.now() | ||||
|     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))) | ||||
|  | ||||
|  | ||||
| @@ -159,9 +160,12 @@ def delete_currency(currency: Currency) -> redirect: | ||||
|     :return: The redirection to the currency list on success, or the currency | ||||
|         detail on error. | ||||
|     """ | ||||
|     if not currency.can_delete: | ||||
|         flash(s(lazy_gettext("The currency cannot be deleted.")), "error") | ||||
|         return redirect(inherit_next(__get_detail_uri(currency))) | ||||
|     currency.delete() | ||||
|     db.session.commit() | ||||
|     flash(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"))) | ||||
|  | ||||
|  | ||||
| @@ -182,4 +186,3 @@ def __get_detail_uri(currency: Currency) -> str: | ||||
|     :return: The detail URI of the 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. | ||||
| #  See the License for the specific language governing permissions and | ||||
| #  limitations under the License. | ||||
| """The transaction management. | ||||
| """The journal entry management. | ||||
| 
 | ||||
| """ | ||||
| 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. | ||||
|     :return: None. | ||||
|     """ | ||||
|     from .converters import TransactionConverter, TransactionTypeConverter, \ | ||||
|     from .converters import JournalEntryConverter, JournalEntryTypeConverter, \ | ||||
|         DateConverter | ||||
|     app.url_map.converters["transaction"] = TransactionConverter | ||||
|     app.url_map.converters["transactionType"] = TransactionTypeConverter | ||||
|     app.url_map.converters["journalEntry"] = JournalEntryConverter | ||||
|     app.url_map.converters["journalEntryType"] = JournalEntryTypeConverter | ||||
|     app.url_map.converters["date"] = DateConverter | ||||
| 
 | ||||
|     from .views import bp as transaction_bp | ||||
|     bp.register_blueprint(transaction_bp, url_prefix="/transactions") | ||||
|     from .views import bp as journal_entry_bp | ||||
|     bp.register_blueprint(journal_entry_bp, url_prefix="/journal-entries") | ||||
							
								
								
									
										107
									
								
								src/accounting/journal_entry/converters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								src/accounting/journal_entry/converters.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | ||||
| # The Mia! Accounting 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 | ||||
| @@ -14,7 +14,7 @@ | ||||
| #  WITHOUT 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 transaction management. | ||||
| """The template filters for the journal entry management. | ||||
| 
 | ||||
| """ | ||||
| from decimal import Decimal | ||||
| @@ -26,10 +26,10 @@ from flask import request | ||||
| 
 | ||||
| 
 | ||||
| def with_type(uri: str) -> str: | ||||
|     """Adds the transaction type to the URI, if it is specified. | ||||
|     """Adds the journal entry type to the URI, if it is specified. | ||||
| 
 | ||||
|     :param uri: The URI. | ||||
|     :return: The result URL, optionally with the transaction type added. | ||||
|     :return: The result URL, optionally with the journal entry type added. | ||||
|     """ | ||||
|     if "as" not in request.args: | ||||
|         return uri | ||||
| @@ -43,10 +43,10 @@ def with_type(uri: str) -> str: | ||||
| 
 | ||||
| 
 | ||||
| def to_transfer(uri: str) -> str: | ||||
|     """Adds the transfer transaction type to the URI. | ||||
|     """Adds the transfer journal entry type to the URI. | ||||
| 
 | ||||
|     :param uri: The URI. | ||||
|     :return: The result URL, with the transfer transaction type added. | ||||
|     :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) | ||||
							
								
								
									
										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 | ||||
| @@ -14,22 +14,24 @@ | ||||
| #  WITHOUT 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 summary editor. | ||||
| """The description editor. | ||||
| 
 | ||||
| """ | ||||
| import re | ||||
| import typing as t | ||||
| 
 | ||||
| import sqlalchemy as sa | ||||
| 
 | ||||
| from accounting import db | ||||
| from accounting.models import Account, JournalEntry | ||||
| from accounting.models import Account, JournalEntryLineItem | ||||
| from accounting.utils.options import options, Recurring | ||||
| 
 | ||||
| 
 | ||||
| class SummaryAccount: | ||||
|     """An account for a summary tag.""" | ||||
| class DescriptionAccount: | ||||
|     """An account for a description tag.""" | ||||
| 
 | ||||
|     def __init__(self, account: Account, freq: int): | ||||
|         """Constructs an account for a summary tag. | ||||
|         """Constructs an account for a description tag. | ||||
| 
 | ||||
|         :param account: The account. | ||||
|         :param freq: The frequency of the tag with the account. | ||||
| @@ -59,17 +61,17 @@ class SummaryAccount: | ||||
|         self.freq = self.freq + freq | ||||
| 
 | ||||
| 
 | ||||
| class SummaryTag: | ||||
|     """A summary tag.""" | ||||
| class DescriptionTag: | ||||
|     """A description tag.""" | ||||
| 
 | ||||
|     def __init__(self, name: str): | ||||
|         """Constructs a summary tag. | ||||
|         """Constructs a description tag. | ||||
| 
 | ||||
|         :param name: The tag name. | ||||
|         """ | ||||
|         self.name: str = name | ||||
|         """The tag name.""" | ||||
|         self.__account_dict: dict[int, SummaryAccount] = {} | ||||
|         self.__account_dict: dict[int, DescriptionAccount] = {} | ||||
|         """The accounts that come with the tag, in the order of their | ||||
|         frequency.""" | ||||
|         self.freq: int = 0 | ||||
| @@ -89,11 +91,11 @@ class SummaryTag: | ||||
|         :param freq: The frequency of the tag name with the account. | ||||
|         :return: None. | ||||
|         """ | ||||
|         self.__account_dict[account.id] = SummaryAccount(account, freq) | ||||
|         self.__account_dict[account.id] = DescriptionAccount(account, freq) | ||||
|         self.freq = self.freq + freq | ||||
| 
 | ||||
|     @property | ||||
|     def accounts(self) -> list[SummaryAccount]: | ||||
|     def accounts(self) -> list[DescriptionAccount]: | ||||
|         """Returns the accounts by the order of their frequencies. | ||||
| 
 | ||||
|         :return: The accounts by the order of their frequencies. | ||||
| @@ -109,17 +111,17 @@ class SummaryTag: | ||||
|         return [x.code for x in self.accounts] | ||||
| 
 | ||||
| 
 | ||||
| class SummaryType: | ||||
|     """A summary type""" | ||||
| class DescriptionType: | ||||
|     """A description type""" | ||||
| 
 | ||||
|     def __init__(self, type_id: t.Literal["general", "travel", "bus"]): | ||||
|         """Constructs a summary type. | ||||
|         """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, SummaryTag] = {} | ||||
|         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: | ||||
| @@ -131,11 +133,11 @@ class SummaryType: | ||||
|         :return: None. | ||||
|         """ | ||||
|         if name not in self.__tag_dict: | ||||
|             self.__tag_dict[name] = SummaryTag(name) | ||||
|             self.__tag_dict[name] = DescriptionTag(name) | ||||
|         self.__tag_dict[name].add_account(account, freq) | ||||
| 
 | ||||
|     @property | ||||
|     def tags(self) -> list[SummaryTag]: | ||||
|     def tags(self) -> list[DescriptionTag]: | ||||
|         """Returns the tags by the order of their frequencies. | ||||
| 
 | ||||
|         :return: The tags by the order of their frequencies. | ||||
| @@ -143,26 +145,51 @@ class SummaryType: | ||||
|         return sorted(self.__tag_dict.values(), key=lambda x: -x.freq) | ||||
| 
 | ||||
| 
 | ||||
| class SummaryEntryType: | ||||
|     """A summary type""" | ||||
| class DescriptionRecurring: | ||||
|     """A recurring transaction.""" | ||||
| 
 | ||||
|     def __init__(self, entry_type_id: t.Literal["debit", "credit"]): | ||||
|         """Constructs a summary entry type. | ||||
|     def __init__(self, name: str, account: Account, description_template: str): | ||||
|         """Constructs a recurring transaction. | ||||
| 
 | ||||
|         :param entry_type_id: The entry type ID, either "debit" or "credit". | ||||
|         :param name: The name. | ||||
|         :param description_template: The description template. | ||||
|         :param account: The account. | ||||
|         """ | ||||
|         self.type: t.Literal["debit", "credit"] = entry_type_id | ||||
|         """The entry type.""" | ||||
|         self.general: SummaryType = SummaryType("general") | ||||
|         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: SummaryType = SummaryType("travel") | ||||
|         self.travel: DescriptionType = DescriptionType("travel") | ||||
|         """The travel tags.""" | ||||
|         self.bus: SummaryType = SummaryType("bus") | ||||
|         self.bus: DescriptionType = DescriptionType("bus") | ||||
|         """The bus tags.""" | ||||
|         self.__type_dict: dict[t.Literal["general", "travel", "bus"], | ||||
|                                SummaryType] \ | ||||
|                                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: | ||||
| @@ -177,13 +204,13 @@ class SummaryEntryType: | ||||
|         self.__type_dict[tag_type].add_tag(name, account, freq) | ||||
| 
 | ||||
|     @property | ||||
|     def accounts(self) -> list[SummaryAccount]: | ||||
|         """Returns the suggested accounts of all tags in the summary editor in | ||||
|         the entry type, in their frequency order. | ||||
|     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, SummaryAccount] = {} | ||||
|         accounts: dict[int, DescriptionAccount] = {} | ||||
|         freq: dict[int, int] = {} | ||||
|         for tag_type in self.__type_dict.values(): | ||||
|             for tag in tag_type.tags: | ||||
| @@ -193,43 +220,105 @@ class SummaryEntryType: | ||||
|                         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 SummaryEditor: | ||||
|     """The summary editor.""" | ||||
| class DescriptionEditor: | ||||
|     """The description editor.""" | ||||
| 
 | ||||
|     def __init__(self): | ||||
|         """Constructs the summary editor.""" | ||||
|         self.debit: SummaryEntryType = SummaryEntryType("debit") | ||||
|         """Constructs the description editor.""" | ||||
|         self.debit: DescriptionDebitCredit = DescriptionDebitCredit("debit") | ||||
|         """The debit tags.""" | ||||
|         self.credit: SummaryEntryType = SummaryEntryType("credit") | ||||
|         self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit") | ||||
|         """The credit tags.""" | ||||
|         entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"), | ||||
|                                        else_="credit").label("entry_type") | ||||
|         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( | ||||
|             (JournalEntry.summary.like("_%—_%—_%→_%"), "bus"), | ||||
|             (sa.or_(JournalEntry.summary.like("_%—_%→_%"), | ||||
|                     JournalEntry.summary.like("_%—_%↔_%")), "travel"), | ||||
|             (JournalEntryLineItem.description.like("_%—_%—_%→_%"), "bus"), | ||||
|             (sa.or_(JournalEntryLineItem.description.like("_%—_%→_%"), | ||||
|                     JournalEntryLineItem.description.like("_%—_%↔_%")), | ||||
|              "travel"), | ||||
|             else_="general").label("tag_type") | ||||
|         tag: sa.Label = get_prefix(JournalEntry.summary, "—").label("tag") | ||||
|         select: sa.Select = sa.Select(entry_type, tag_type, tag, | ||||
|                                       JournalEntry.account_id, | ||||
|         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(JournalEntry.summary.is_not(None), | ||||
|                     JournalEntry.summary.like("_%—_%"))\ | ||||
|             .group_by(entry_type, tag_type, tag, JournalEntry.account_id) | ||||
|             .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()} | ||||
|         entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \ | ||||
|             = {x.type: x for x in {self.debit, self.credit}} | ||||
|         debit_credit_dict: dict[t.Literal["debit", "credit"], | ||||
|                                 DescriptionDebitCredit] \ | ||||
|             = {x.debit_credit: x for x in {self.debit, self.credit}} | ||||
|         for row in result: | ||||
|             entry_type_dict[row.entry_type].add_tag( | ||||
|             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: | ||||
							
								
								
									
										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 typing as t | ||||
| from datetime import date | ||||
| from decimal import Decimal | ||||
|  | ||||
| import sqlalchemy as sa | ||||
| from flask import current_app | ||||
| from flask_babel import get_locale | ||||
| from babel import Locale | ||||
| from flask_babel import get_locale, get_babel | ||||
| from sqlalchemy import text | ||||
|  | ||||
| from accounting import db | ||||
| @@ -52,7 +53,7 @@ class BaseAccount(db.Model): | ||||
|  | ||||
|         :return: The string representation of the base account. | ||||
|         """ | ||||
|         return f"{self.code} {self.title}" | ||||
|         return f"{self.code} {self.title.title()}" | ||||
|  | ||||
|     @property | ||||
|     def title(self) -> str: | ||||
| @@ -60,11 +61,11 @@ class BaseAccount(db.Model): | ||||
|  | ||||
|         :return: The title in the current locale. | ||||
|         """ | ||||
|         current_locale = str(get_locale()) | ||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: | ||||
|         current_locale: Locale = get_locale() | ||||
|         if current_locale == get_babel().instance.default_locale: | ||||
|             return self.title_l10n | ||||
|         for l10n in self.l10n: | ||||
|             if l10n.locale == current_locale: | ||||
|             if l10n.locale == str(current_locale): | ||||
|                 return l10n.title | ||||
|         return self.title_l10n | ||||
|  | ||||
| @@ -113,8 +114,8 @@ class Account(db.Model): | ||||
|     """The account number under the base account.""" | ||||
|     title_l10n = db.Column("title", db.String, nullable=False) | ||||
|     """The title.""" | ||||
|     is_offset_needed = db.Column(db.Boolean, nullable=False, default=False) | ||||
|     """Whether the entries of this account need offset.""" | ||||
|     is_need_offset = db.Column(db.Boolean, nullable=False, default=False) | ||||
|     """Whether the journal entry line items of this account need offset.""" | ||||
|     created_at = db.Column(db.DateTime(timezone=True), nullable=False, | ||||
|                            server_default=db.func.now()) | ||||
|     """The time of creation.""" | ||||
| @@ -138,8 +139,9 @@ class Account(db.Model): | ||||
|     l10n = db.relationship("AccountL10n", back_populates="account", | ||||
|                            lazy=False) | ||||
|     """The localized titles.""" | ||||
|     entries = db.relationship("JournalEntry", back_populates="account") | ||||
|     """The journal entries.""" | ||||
|     line_items = db.relationship("JournalEntryLineItem", | ||||
|                                  back_populates="account") | ||||
|     """The journal entry line items.""" | ||||
|  | ||||
|     CASH_CODE: str = "1111-001" | ||||
|     """The code of the cash account,""" | ||||
| @@ -153,7 +155,7 @@ class Account(db.Model): | ||||
|  | ||||
|         :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 | ||||
|     def code(self) -> str: | ||||
| @@ -169,11 +171,11 @@ class Account(db.Model): | ||||
|  | ||||
|         :return: The title in the current locale. | ||||
|         """ | ||||
|         current_locale = str(get_locale()) | ||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: | ||||
|         current_locale: Locale = get_locale() | ||||
|         if current_locale == get_babel().instance.default_locale: | ||||
|             return self.title_l10n | ||||
|         for l10n in self.l10n: | ||||
|             if l10n.locale == current_locale: | ||||
|             if l10n.locale == str(current_locale): | ||||
|                 return l10n.title | ||||
|         return self.title_l10n | ||||
|  | ||||
| @@ -187,15 +189,71 @@ class Account(db.Model): | ||||
|         if self.title_l10n is None: | ||||
|             self.title_l10n = value | ||||
|             return | ||||
|         current_locale = str(get_locale()) | ||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: | ||||
|         current_locale: Locale = get_locale() | ||||
|         if current_locale == get_babel().instance.default_locale: | ||||
|             self.title_l10n = value | ||||
|             return | ||||
|         for l10n in self.l10n: | ||||
|             if l10n.locale == current_locale: | ||||
|             if l10n.locale == str(current_locale): | ||||
|                 l10n.title = value | ||||
|                 return | ||||
|         self.l10n.append(AccountL10n(locale=current_locale, title=value)) | ||||
|         self.l10n.append(AccountL10n(locale=str(current_locale), title=value)) | ||||
|  | ||||
|     @property | ||||
|     def is_real(self) -> bool: | ||||
|         """Returns whether the account is a real account. | ||||
|  | ||||
|         :return: True if the account is a real account, or False otherwise. | ||||
|         """ | ||||
|         return self.base_code[0] in {"1", "2", "3"} | ||||
|  | ||||
|     @property | ||||
|     def is_nominal(self) -> bool: | ||||
|         """Returns whether the account is a nominal account. | ||||
|  | ||||
|         :return: True if the account is a nominal account, or False otherwise. | ||||
|         """ | ||||
|         return not self.is_real | ||||
|  | ||||
|     @property | ||||
|     def query_values(self) -> list[str]: | ||||
|         """Returns the values to be queried. | ||||
|  | ||||
|         :return: The values to be queried. | ||||
|         """ | ||||
|         return [self.code, self.title_l10n] + [x.title for x in self.l10n] | ||||
|  | ||||
|     @property | ||||
|     def is_modified(self) -> bool: | ||||
|         """Returns whether a product account was modified. | ||||
|  | ||||
|         :return: True if modified, or False otherwise. | ||||
|         """ | ||||
|         if db.session.is_modified(self): | ||||
|             return True | ||||
|         for l10n in self.l10n: | ||||
|             if db.session.is_modified(l10n): | ||||
|                 return True | ||||
|         return False | ||||
|  | ||||
|     @property | ||||
|     def can_delete(self) -> bool: | ||||
|         """Returns whether the account can be deleted. | ||||
|  | ||||
|         :return: True if the account can be deleted, or False otherwise. | ||||
|         """ | ||||
|         if self.code in {"1111-001", "3351-001", "3353-001"}: | ||||
|             return False | ||||
|         return len(self.line_items) == 0 | ||||
|  | ||||
|     def delete(self) -> None: | ||||
|         """Deletes this account. | ||||
|  | ||||
|         :return: None. | ||||
|         """ | ||||
|         AccountL10n.query.filter(AccountL10n.account == self).delete() | ||||
|         cls: t.Type[t.Self] = self.__class__ | ||||
|         cls.query.filter(cls.id == self.id).delete() | ||||
|  | ||||
|     @classmethod | ||||
|     def find_by_code(cls, code: str) -> t.Self | None: | ||||
| @@ -211,13 +269,15 @@ class Account(db.Model): | ||||
|                                 cls.no == int(m.group(2))).first() | ||||
|  | ||||
|     @classmethod | ||||
|     def debit(cls) -> list[t.Self]: | ||||
|         """Returns the debit accounts. | ||||
|     def selectable_debit(cls) -> list[t.Self]: | ||||
|         """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"), | ||||
|                                        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("5"), | ||||
|                                        cls.base_code.startswith("6"), | ||||
| @@ -232,12 +292,14 @@ class Account(db.Model): | ||||
|             .order_by(cls.base_code, cls.no).all() | ||||
|  | ||||
|     @classmethod | ||||
|     def credit(cls) -> list[t.Self]: | ||||
|         """Returns the debit accounts. | ||||
|     def selectable_credit(cls) -> list[t.Self]: | ||||
|         """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("3"), | ||||
|                                        cls.base_code.startswith("4"), | ||||
| @@ -251,14 +313,6 @@ class Account(db.Model): | ||||
|                                 cls.base_code != "3353")\ | ||||
|             .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 | ||||
|     def cash(cls) -> t.Self: | ||||
|         """Returns the cash account. | ||||
| @@ -275,28 +329,6 @@ class Account(db.Model): | ||||
|         """ | ||||
|         return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE) | ||||
|  | ||||
|     @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): | ||||
|     """A localized account title.""" | ||||
| @@ -346,15 +378,16 @@ class Currency(db.Model): | ||||
|     l10n = db.relationship("CurrencyL10n", back_populates="currency", | ||||
|                            lazy=False) | ||||
|     """The localized names.""" | ||||
|     entries = db.relationship("JournalEntry", back_populates="currency") | ||||
|     """The journal entries.""" | ||||
|     line_items = db.relationship("JournalEntryLineItem", | ||||
|                                  back_populates="currency") | ||||
|     """The journal entry line items.""" | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Returns the string representation of the currency. | ||||
|  | ||||
|         :return: The string representation of the currency. | ||||
|         """ | ||||
|         return f"{self.name} ({self.code})" | ||||
|         return f"{self.name.title()} ({self.code})" | ||||
|  | ||||
|     @property | ||||
|     def name(self) -> str: | ||||
| @@ -362,11 +395,11 @@ class Currency(db.Model): | ||||
|  | ||||
|         :return: The name in the current locale. | ||||
|         """ | ||||
|         current_locale = str(get_locale()) | ||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: | ||||
|         current_locale: Locale = get_locale() | ||||
|         if current_locale == get_babel().instance.default_locale: | ||||
|             return self.name_l10n | ||||
|         for l10n in self.l10n: | ||||
|             if l10n.locale == current_locale: | ||||
|             if l10n.locale == str(current_locale): | ||||
|                 return l10n.name | ||||
|         return self.name_l10n | ||||
|  | ||||
| @@ -380,15 +413,15 @@ class Currency(db.Model): | ||||
|         if self.name_l10n is None: | ||||
|             self.name_l10n = value | ||||
|             return | ||||
|         current_locale = str(get_locale()) | ||||
|         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: | ||||
|         current_locale: Locale = get_locale() | ||||
|         if current_locale == get_babel().instance.default_locale: | ||||
|             self.name_l10n = value | ||||
|             return | ||||
|         for l10n in self.l10n: | ||||
|             if l10n.locale == current_locale: | ||||
|             if l10n.locale == str(current_locale): | ||||
|                 l10n.name = value | ||||
|                 return | ||||
|         self.l10n.append(CurrencyL10n(locale=current_locale, name=value)) | ||||
|         self.l10n.append(CurrencyL10n(locale=str(current_locale), name=value)) | ||||
|  | ||||
|     @property | ||||
|     def is_modified(self) -> bool: | ||||
| @@ -403,6 +436,17 @@ class Currency(db.Model): | ||||
|                 return True | ||||
|         return False | ||||
|  | ||||
|     @property | ||||
|     def can_delete(self) -> bool: | ||||
|         """Returns whether the currency can be deleted. | ||||
|  | ||||
|         :return: True if the currency can be deleted, or False otherwise. | ||||
|         """ | ||||
|         from accounting.template_globals import default_currency_code | ||||
|         if self.code == default_currency_code(): | ||||
|             return False | ||||
|         return len(self.line_items) == 0 | ||||
|  | ||||
|     def delete(self) -> None: | ||||
|         """Deletes the currency. | ||||
|  | ||||
| @@ -430,23 +474,23 @@ class CurrencyL10n(db.Model): | ||||
|     """The localized name.""" | ||||
|  | ||||
|  | ||||
| class TransactionCurrency: | ||||
|     """A currency in a transaction.""" | ||||
| class JournalEntryCurrency: | ||||
|     """A currency in a journal entry.""" | ||||
|  | ||||
|     def __init__(self, code: str, debit: list[JournalEntry], | ||||
|                  credit: list[JournalEntry]): | ||||
|         """Constructs the currency in the transaction. | ||||
|     def __init__(self, code: str, debit: list[JournalEntryLineItem], | ||||
|                  credit: list[JournalEntryLineItem]): | ||||
|         """Constructs the currency in the journal entry. | ||||
|  | ||||
|         :param code: The currency code. | ||||
|         :param debit: The debit entries. | ||||
|         :param credit: The credit entries. | ||||
|         :param debit: The debit line items. | ||||
|         :param credit: The credit line items. | ||||
|         """ | ||||
|         self.code: str = code | ||||
|         """The currency code.""" | ||||
|         self.debit: list[JournalEntry] = debit | ||||
|         """The debit entries.""" | ||||
|         self.credit: list[JournalEntry] = credit | ||||
|         """The credit entries.""" | ||||
|         self.debit: list[JournalEntryLineItem] = debit | ||||
|         """The debit line items.""" | ||||
|         self.credit: list[JournalEntryLineItem] = credit | ||||
|         """The credit line items.""" | ||||
|  | ||||
|     @property | ||||
|     def name(self) -> str: | ||||
| @@ -458,28 +502,28 @@ class TransactionCurrency: | ||||
|  | ||||
|     @property | ||||
|     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]) | ||||
|  | ||||
|     @property | ||||
|     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]) | ||||
|  | ||||
|  | ||||
| class Transaction(db.Model): | ||||
|     """A transaction.""" | ||||
|     __tablename__ = "accounting_transactions" | ||||
| class JournalEntry(db.Model): | ||||
|     """A journal entry.""" | ||||
|     __tablename__ = "accounting_journal_entries" | ||||
|     """The table name.""" | ||||
|     id = db.Column(db.Integer, nullable=False, primary_key=True, | ||||
|                    autoincrement=False) | ||||
|     """The transaction ID.""" | ||||
|     """The journal entry ID.""" | ||||
|     date = db.Column(db.Date, nullable=False) | ||||
|     """The date.""" | ||||
|     no = db.Column(db.Integer, nullable=False, default=text("1")) | ||||
| @@ -506,46 +550,50 @@ class Transaction(db.Model): | ||||
|     """The ID of the updator.""" | ||||
|     updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) | ||||
|     """The updator.""" | ||||
|     entries = db.relationship("JournalEntry", back_populates="transaction") | ||||
|     """The journal entries.""" | ||||
|     line_items = db.relationship("JournalEntryLineItem", | ||||
|                                  back_populates="journal_entry") | ||||
|     """The line items.""" | ||||
|  | ||||
|     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: | ||||
|             return gettext("Cash Expense Transaction#%(id)s", id=self.id) | ||||
|         if self.is_cash_income: | ||||
|             return gettext("Cash Income Transaction#%(id)s", id=self.id) | ||||
|         return gettext("Transfer Transaction#%(id)s", id=self.id) | ||||
|         if self.is_cash_disbursement: | ||||
|             return gettext("Cash Disbursement Journal Entry#%(id)s", | ||||
|                            id=self.id) | ||||
|         if self.is_cash_receipt: | ||||
|             return gettext("Cash Receipt Journal Entry#%(id)s", id=self.id) | ||||
|         return gettext("Transfer Journal Entry#%(id)s", id=self.id) | ||||
|  | ||||
|     @property | ||||
|     def currencies(self) -> list[TransactionCurrency]: | ||||
|         """Returns the journal entries categorized by their currencies. | ||||
|     def currencies(self) -> list[JournalEntryCurrency]: | ||||
|         """Returns the line items categorized by their currencies. | ||||
|  | ||||
|         :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] = [] | ||||
|         by_currency: dict[str, list[JournalEntry]] = {} | ||||
|         for entry in entries: | ||||
|             if entry.currency_code not in by_currency: | ||||
|                 codes.append(entry.currency_code) | ||||
|                 by_currency[entry.currency_code] = [] | ||||
|             by_currency[entry.currency_code].append(entry) | ||||
|         return [TransactionCurrency(code=x, | ||||
|                                     debit=[y for y in by_currency[x] | ||||
|                                            if y.is_debit], | ||||
|                                     credit=[y for y in by_currency[x] | ||||
|                                             if not y.is_debit]) | ||||
|         by_currency: dict[str, list[JournalEntryLineItem]] = {} | ||||
|         for line_item in line_items: | ||||
|             if line_item.currency_code not in by_currency: | ||||
|                 codes.append(line_item.currency_code) | ||||
|                 by_currency[line_item.currency_code] = [] | ||||
|             by_currency[line_item.currency_code].append(line_item) | ||||
|         return [JournalEntryCurrency(code=x, | ||||
|                                      debit=[y for y in by_currency[x] | ||||
|                                             if y.is_debit], | ||||
|                                      credit=[y for y in by_currency[x] | ||||
|                                              if not y.is_debit]) | ||||
|                 for x in codes] | ||||
|  | ||||
|     @property | ||||
|     def is_cash_income(self) -> bool: | ||||
|         """Returns whether this is a cash income transaction. | ||||
|     def is_cash_receipt(self) -> bool: | ||||
|         """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: | ||||
|             if len(currency.debit) > 1: | ||||
| @@ -555,10 +603,10 @@ class Transaction(db.Model): | ||||
|         return True | ||||
|  | ||||
|     @property | ||||
|     def is_cash_expense(self) -> bool: | ||||
|         """Returns whether this is a cash expense transaction. | ||||
|     def is_cash_disbursement(self) -> bool: | ||||
|         """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. | ||||
|         """ | ||||
|         for currency in self.currencies: | ||||
| @@ -568,70 +616,89 @@ class Transaction(db.Model): | ||||
|                 return False | ||||
|         return True | ||||
|  | ||||
|     @property | ||||
|     def can_delete(self) -> bool: | ||||
|         """Returns whether the journal entry can be deleted. | ||||
|  | ||||
|         :return: True if the journal entry can be deleted, or False otherwise. | ||||
|         """ | ||||
|         for line_item in self.line_items: | ||||
|             if len(line_item.offsets) > 0: | ||||
|                 return False | ||||
|         return True | ||||
|  | ||||
|     def delete(self) -> None: | ||||
|         """Deletes the transaction. | ||||
|         """Deletes the journal entry. | ||||
|  | ||||
|         :return: None. | ||||
|         """ | ||||
|         JournalEntry.query\ | ||||
|             .filter(JournalEntry.transaction_id == self.id).delete() | ||||
|         JournalEntryLineItem.query\ | ||||
|             .filter(JournalEntryLineItem.journal_entry_id == self.id).delete() | ||||
|         db.session.delete(self) | ||||
|  | ||||
|  | ||||
| class JournalEntry(db.Model): | ||||
|     """An accounting journal entry.""" | ||||
|     __tablename__ = "accounting_journal_entries" | ||||
| class JournalEntryLineItem(db.Model): | ||||
|     """A line item in the journal entry.""" | ||||
|     __tablename__ = "accounting_journal_entry_line_items" | ||||
|     """The table name.""" | ||||
|     id = db.Column(db.Integer, nullable=False, primary_key=True, | ||||
|                    autoincrement=False) | ||||
|     """The entry ID.""" | ||||
|     transaction_id = db.Column(db.Integer, | ||||
|                                db.ForeignKey(Transaction.id, | ||||
|                                              onupdate="CASCADE", | ||||
|                                              ondelete="CASCADE"), | ||||
|                                nullable=False) | ||||
|     """The transaction ID.""" | ||||
|     transaction = db.relationship(Transaction, back_populates="entries") | ||||
|     """The transaction.""" | ||||
|     """The line item ID.""" | ||||
|     journal_entry_id = db.Column(db.Integer, | ||||
|                                  db.ForeignKey(JournalEntry.id, | ||||
|                                                onupdate="CASCADE", | ||||
|                                                ondelete="CASCADE"), | ||||
|                                  nullable=False) | ||||
|     """The journal entry ID.""" | ||||
|     journal_entry = db.relationship(JournalEntry, back_populates="line_items") | ||||
|     """The journal entry.""" | ||||
|     is_debit = db.Column(db.Boolean, nullable=False) | ||||
|     """True for a debit 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) | ||||
|     """The entry number under the transaction and debit or credit.""" | ||||
|     offset_original_id = db.Column(db.Integer, | ||||
|                                    db.ForeignKey(id, onupdate="CASCADE"), | ||||
|                                    nullable=True) | ||||
|     """The ID of the original entry to offset.""" | ||||
|     offset_original = db.relationship("JournalEntry", back_populates="offsets", | ||||
|                                       remote_side=id, passive_deletes=True) | ||||
|     """The original entry to offset.""" | ||||
|     offsets = db.relationship("JournalEntry", back_populates="offset_original") | ||||
|     """The offset entries.""" | ||||
|     """The line item number under the journal entry and debit or credit.""" | ||||
|     original_line_item_id = db.Column(db.Integer, | ||||
|                                       db.ForeignKey(id, onupdate="CASCADE"), | ||||
|                                       nullable=True) | ||||
|     """The ID of the original line item.""" | ||||
|     original_line_item = db.relationship("JournalEntryLineItem", | ||||
|                                          back_populates="offsets", | ||||
|                                          remote_side=id, passive_deletes=True) | ||||
|     """The original line item.""" | ||||
|     offsets = db.relationship("JournalEntryLineItem", | ||||
|                               back_populates="original_line_item") | ||||
|     """The offset items.""" | ||||
|     currency_code = db.Column(db.String, | ||||
|                               db.ForeignKey(Currency.code, onupdate="CASCADE"), | ||||
|                               nullable=False) | ||||
|     """The currency code.""" | ||||
|     currency = db.relationship(Currency, back_populates="entries") | ||||
|     currency = db.relationship(Currency, back_populates="line_items") | ||||
|     """The currency.""" | ||||
|     account_id = db.Column(db.Integer, | ||||
|                            db.ForeignKey(Account.id, | ||||
|                                          onupdate="CASCADE"), | ||||
|                            nullable=False) | ||||
|     """The account ID.""" | ||||
|     account = db.relationship(Account, back_populates="entries", lazy=False) | ||||
|     account = db.relationship(Account, back_populates="line_items", lazy=False) | ||||
|     """The account.""" | ||||
|     summary = db.Column(db.String, nullable=True) | ||||
|     """The summary.""" | ||||
|     description = db.Column(db.String, nullable=True) | ||||
|     """The description.""" | ||||
|     amount = db.Column(db.Numeric(14, 2), nullable=False) | ||||
|     """The amount.""" | ||||
|  | ||||
|     @property | ||||
|     def eid(self) -> int | None: | ||||
|         """Returns the journal entry ID.  This is the alternative name of the | ||||
|         ID field, to work with WTForms. | ||||
|     def __str__(self) -> str: | ||||
|         """Returns the string representation of the line item. | ||||
|  | ||||
|         :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 | ||||
|     def account_code(self) -> str: | ||||
| @@ -645,14 +712,96 @@ class JournalEntry(db.Model): | ||||
|     def debit(self) -> Decimal | None: | ||||
|         """Returns the debit amount. | ||||
|  | ||||
|         :return: The debit amount, or None if this is not a debit entry. | ||||
|         :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 entry. | ||||
|         :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"))) | ||||
| @@ -23,8 +23,8 @@ 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 | ||||
| from .utils.ie_account import IncomeExpensesAccount | ||||
|  | ||||
|  | ||||
| class PeriodConverter(BaseConverter): | ||||
| @@ -55,22 +55,22 @@ class IncomeExpensesAccountConverter(BaseConverter): | ||||
|     """The supplier converter to convert the income and expenses log pseudo | ||||
|     account code from and to the corresponding pseudo account in the routes.""" | ||||
|  | ||||
|     def to_python(self, value: str) -> IncomeExpensesAccount: | ||||
|     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 == IncomeExpensesAccount.CURRENT_AL_CODE: | ||||
|             return IncomeExpensesAccount.current_assets_and_liabilities() | ||||
|         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 IncomeExpensesAccount(account) | ||||
|         return CurrentAccount(account) | ||||
|  | ||||
|     def to_url(self, value: IncomeExpensesAccount) -> str: | ||||
|     def to_url(self, value: CurrentAccount) -> str: | ||||
|         """Converts an account to account code. | ||||
|  | ||||
|         :param value: The account. | ||||
|   | ||||
| @@ -23,7 +23,7 @@ This file is largely taken from the NanoParma ERP project, first written in | ||||
| import typing as t | ||||
| from datetime import date | ||||
|  | ||||
| from accounting.models import Transaction | ||||
| from accounting.models import JournalEntry | ||||
| from .period import Period | ||||
| from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \ | ||||
|     LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod | ||||
| @@ -61,8 +61,8 @@ class PeriodChooser: | ||||
|         self.url_template: str = get_url(TemplatePeriod()) | ||||
|         """The URL template.""" | ||||
|  | ||||
|         first: Transaction | None \ | ||||
|             = Transaction.query.order_by(Transaction.date).first() | ||||
|         first: JournalEntry | None \ | ||||
|             = JournalEntry.query.order_by(JournalEntry.date).first() | ||||
|         start: date | None = None if first is None else first.date | ||||
|  | ||||
|         # Attributes | ||||
|   | ||||
| @@ -35,7 +35,7 @@ class ThisMonth(Period): | ||||
|  | ||||
|     def _set_properties(self) -> None: | ||||
|         self.spec = "this-month" | ||||
|         self.desc = gettext("This month") | ||||
|         self.desc = gettext("This Month") | ||||
|         self.is_a_month = True | ||||
|         self.is_type_month = True | ||||
|  | ||||
| @@ -55,7 +55,7 @@ class LastMonth(Period): | ||||
|  | ||||
|     def _set_properties(self) -> None: | ||||
|         self.spec = "last-month" | ||||
|         self.desc = gettext("Last month") | ||||
|         self.desc = gettext("Last Month") | ||||
|         self.is_a_month = True | ||||
|         self.is_type_month = True | ||||
|  | ||||
| @@ -75,7 +75,7 @@ class SinceLastMonth(Period): | ||||
|  | ||||
|     def _set_properties(self) -> None: | ||||
|         self.spec = "since-last-month" | ||||
|         self.desc = gettext("Since last month") | ||||
|         self.desc = gettext("Since Last Month") | ||||
|         self.is_type_month = True | ||||
|  | ||||
|  | ||||
| @@ -90,7 +90,7 @@ class ThisYear(Period): | ||||
|  | ||||
|     def _set_properties(self) -> None: | ||||
|         self.spec = "this-year" | ||||
|         self.desc = gettext("This year") | ||||
|         self.desc = gettext("This Year") | ||||
|         self.is_a_year = True | ||||
|  | ||||
|  | ||||
| @@ -105,7 +105,7 @@ class LastYear(Period): | ||||
|  | ||||
|     def _set_properties(self) -> None: | ||||
|         self.spec = "last-year" | ||||
|         self.desc = gettext("Last year") | ||||
|         self.desc = gettext("Last Year") | ||||
|         self.is_a_year = True | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -24,8 +24,8 @@ from flask import render_template, Response | ||||
|  | ||||
| from accounting import db | ||||
| from accounting.locale import gettext | ||||
| from accounting.models import Currency, BaseAccount, Account, Transaction, \ | ||||
|     JournalEntry | ||||
| from accounting.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 | ||||
| @@ -124,17 +124,17 @@ class AccountCollector: | ||||
|         sub_conditions: list[sa.BinaryExpression] \ | ||||
|             = [Account.base_code.startswith(x) for x in {"1", "2", "3"}] | ||||
|         conditions: list[sa.BinaryExpression] \ | ||||
|             = [JournalEntry.currency_code == self.__currency.code, | ||||
|             = [JournalEntryLineItem.currency_code == self.__currency.code, | ||||
|                sa.or_(*sub_conditions)] | ||||
|         if self.__period.end is not None: | ||||
|             conditions.append(Transaction.date <= self.__period.end) | ||||
|             conditions.append(JournalEntry.date <= self.__period.end) | ||||
|         balance_func: sa.Function = sa.func.sum(sa.case( | ||||
|             (JournalEntry.is_debit, JournalEntry.amount), | ||||
|             else_=-JournalEntry.amount)).label("balance") | ||||
|             (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(Transaction).join(Account)\ | ||||
|             .join(JournalEntry).join(Account)\ | ||||
|             .filter(*conditions)\ | ||||
|             .group_by(Account.id, Account.base_code, Account.no)\ | ||||
|             .order_by(Account.base_code, Account.no) | ||||
| @@ -178,8 +178,8 @@ class AccountCollector: | ||||
|         if self.__period.start is None: | ||||
|             return None | ||||
|         conditions: list[sa.BinaryExpression] \ | ||||
|             = [JournalEntry.currency_code == self.__currency.code, | ||||
|                Transaction.date < self.__period.start] | ||||
|             = [JournalEntryLineItem.currency_code == self.__currency.code, | ||||
|                JournalEntry.date < self.__period.start] | ||||
|         return self.__query_balance(conditions) | ||||
|  | ||||
|     def __add_current_period(self) -> None: | ||||
| @@ -188,20 +188,20 @@ class AccountCollector: | ||||
|         :return: None. | ||||
|         """ | ||||
|         self.__add_owner_s_equity(Account.NET_CHANGE_CODE, | ||||
|                                   self.__query_currency_period(), | ||||
|                                   self.__query_current_period(), | ||||
|                                   self.__period) | ||||
|  | ||||
|     def __query_currency_period(self) -> Decimal | None: | ||||
|     def __query_current_period(self) -> Decimal | None: | ||||
|         """Queries and returns the net income or loss for current period. | ||||
|  | ||||
|         :return: The net income or loss for current period. | ||||
|         """ | ||||
|         conditions: list[sa.BinaryExpression] \ | ||||
|             = [JournalEntry.currency_code == self.__currency.code] | ||||
|             = [JournalEntryLineItem.currency_code == self.__currency.code] | ||||
|         if self.__period.start is not None: | ||||
|             conditions.append(Transaction.date >= self.__period.start) | ||||
|             conditions.append(JournalEntry.date >= self.__period.start) | ||||
|         if self.__period.end is not None: | ||||
|             conditions.append(Transaction.date <= self.__period.end) | ||||
|             conditions.append(JournalEntry.date <= self.__period.end) | ||||
|         return self.__query_balance(conditions) | ||||
|  | ||||
|     @staticmethod | ||||
| @@ -213,12 +213,12 @@ class AccountCollector: | ||||
|         :return: The balance. | ||||
|         """ | ||||
|         conditions.extend([sa.not_(Account.base_code.startswith(x)) | ||||
|                            for x in {"1", "2"}]) | ||||
|                            for x in {"1", "2", "3"}]) | ||||
|         balance_func: sa.Function = sa.func.sum(sa.case( | ||||
|             (JournalEntry.is_debit, JournalEntry.amount), | ||||
|             else_=-JournalEntry.amount)) | ||||
|             (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), | ||||
|             else_=-JournalEntryLineItem.amount)) | ||||
|         select_balance: sa.Select = sa.select(balance_func)\ | ||||
|             .join(Transaction).join(Account).filter(*conditions) | ||||
|             .join(JournalEntry).join(Account).filter(*conditions) | ||||
|         return db.session.scalar(select_balance) | ||||
|  | ||||
|     def __add_owner_s_equity(self, code: str, amount: Decimal | None, | ||||
|   | ||||
| @@ -26,38 +26,40 @@ from sqlalchemy.orm import selectinload | ||||
|  | ||||
| from accounting import db | ||||
| from accounting.locale import gettext | ||||
| from accounting.models import Currency, Account, Transaction, JournalEntry | ||||
| 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.ie_account import IncomeExpensesAccount | ||||
| from accounting.report.utils.option_link import OptionLink | ||||
| from accounting.report.utils.report_chooser import ReportChooser | ||||
| from accounting.report.utils.report_type import ReportType | ||||
| from accounting.report.utils.urls import income_expenses_url | ||||
| from accounting.utils.cast import be | ||||
| from accounting.utils.current_account import CurrentAccount | ||||
| from accounting.utils.pagination import Pagination | ||||
|  | ||||
|  | ||||
| class ReportEntry: | ||||
|     """An entry in the report.""" | ||||
| class ReportLineItem: | ||||
|     """A line item in the report.""" | ||||
|  | ||||
|     def __init__(self, entry: JournalEntry | None = None): | ||||
|         """Constructs the entry in the report. | ||||
|     def __init__(self, line_item: JournalEntryLineItem | None = None): | ||||
|         """Constructs the line item in the report. | ||||
|  | ||||
|         :param entry: The journal entry. | ||||
|         :param line_item: The journal entry line item. | ||||
|         """ | ||||
|         self.is_brought_forward: bool = False | ||||
|         """Whether this is the brought-forward entry.""" | ||||
|         """Whether this is the brought-forward line item.""" | ||||
|         self.is_total: bool = False | ||||
|         """Whether this is the total entry.""" | ||||
|         """Whether this is the total line item.""" | ||||
|         self.date: date | None = None | ||||
|         """The date.""" | ||||
|         self.account: Account | None = None | ||||
|         """The account.""" | ||||
|         self.summary: str | None = None | ||||
|         """The summary.""" | ||||
|         self.description: str | None = None | ||||
|         """The description.""" | ||||
|         self.income: Decimal | None = None | ||||
|         """The income amount.""" | ||||
|         self.expense: Decimal | None = None | ||||
| @@ -67,24 +69,24 @@ class ReportEntry: | ||||
|         self.note: str | None = None | ||||
|         """The note.""" | ||||
|         self.url: str | None = None | ||||
|         """The URL to the journal entry.""" | ||||
|         if entry is not None: | ||||
|             self.date = entry.transaction.date | ||||
|             self.account = entry.account | ||||
|             self.summary = entry.summary | ||||
|             self.income = None if entry.is_debit else entry.amount | ||||
|             self.expense = entry.amount if entry.is_debit else None | ||||
|             self.note = entry.transaction.note | ||||
|             self.url = url_for("accounting.transaction.detail", | ||||
|                                txn=entry.transaction) | ||||
|         """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 EntryCollector: | ||||
|     """The report entry collector.""" | ||||
| class LineItemCollector: | ||||
|     """The line item collector.""" | ||||
|  | ||||
|     def __init__(self, currency: Currency, account: IncomeExpensesAccount, | ||||
|     def __init__(self, currency: Currency, account: CurrentAccount, | ||||
|                  period: Period): | ||||
|         """Constructs the report entry collector. | ||||
|         """Constructs the line item collector. | ||||
|  | ||||
|         :param currency: The currency. | ||||
|         :param account: The account. | ||||
| @@ -92,147 +94,150 @@ class EntryCollector: | ||||
|         """ | ||||
|         self.__currency: Currency = currency | ||||
|         """The currency.""" | ||||
|         self.__account: IncomeExpensesAccount = account | ||||
|         self.__account: CurrentAccount = account | ||||
|         """The account.""" | ||||
|         self.__period: Period = period | ||||
|         """The period""" | ||||
|         self.brought_forward: ReportEntry | None | ||||
|         """The brought-forward entry.""" | ||||
|         self.entries: list[ReportEntry] | ||||
|         """The log entries.""" | ||||
|         self.total: ReportEntry | None | ||||
|         """The total entry.""" | ||||
|         self.brought_forward = self.__get_brought_forward_entry() | ||||
|         self.entries = self.__query_entries() | ||||
|         self.total = self.__get_total_entry() | ||||
|         self.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_entry(self) -> ReportEntry | None: | ||||
|         """Queries, composes and returns the brought-forward entry. | ||||
|     def __get_brought_forward(self) -> ReportLineItem | None: | ||||
|         """Queries, composes and returns the brought-forward line item. | ||||
|  | ||||
|         :return: The brought-forward entry, or None if the period starts from | ||||
|             the beginning. | ||||
|         :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( | ||||
|             (JournalEntry.is_debit, JournalEntry.amount), | ||||
|             else_=-JournalEntry.amount)) | ||||
|             (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), | ||||
|             else_=-JournalEntryLineItem.amount)) | ||||
|         select: sa.Select = sa.Select(balance_func)\ | ||||
|             .join(Transaction).join(Account)\ | ||||
|             .filter(JournalEntry.currency_code == self.__currency.code, | ||||
|             .join(JournalEntry).join(Account)\ | ||||
|             .filter(be(JournalEntryLineItem.currency_code | ||||
|                        == self.__currency.code), | ||||
|                     self.__account_condition, | ||||
|                     Transaction.date < self.__period.start) | ||||
|                     JournalEntry.date < self.__period.start) | ||||
|         balance: int | None = db.session.scalar(select) | ||||
|         if balance is None: | ||||
|             return None | ||||
|         entry: ReportEntry = ReportEntry() | ||||
|         entry.is_brought_forward = True | ||||
|         entry.date = self.__period.start | ||||
|         entry.account = Account.accumulated_change() | ||||
|         entry.summary = gettext("Brought forward") | ||||
|         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: | ||||
|             entry.income = balance | ||||
|             line_item.income = balance | ||||
|         elif balance < 0: | ||||
|             entry.expense = -balance | ||||
|         entry.balance = balance | ||||
|         return entry | ||||
|             line_item.expense = -balance | ||||
|         line_item.balance = balance | ||||
|         return line_item | ||||
|  | ||||
|     def __query_entries(self) -> list[ReportEntry]: | ||||
|         """Queries and returns the log entries. | ||||
|     def __query_line_items(self) -> list[ReportLineItem]: | ||||
|         """Queries and returns the line items. | ||||
|  | ||||
|         :return: The log entries. | ||||
|         :return: The line items. | ||||
|         """ | ||||
|         conditions: list[sa.BinaryExpression] \ | ||||
|             = [JournalEntry.currency_code == self.__currency.code, | ||||
|             = [JournalEntryLineItem.currency_code == self.__currency.code, | ||||
|                self.__account_condition] | ||||
|         if self.__period.start is not None: | ||||
|             conditions.append(Transaction.date >= self.__period.start) | ||||
|             conditions.append(JournalEntry.date >= self.__period.start) | ||||
|         if self.__period.end is not None: | ||||
|             conditions.append(Transaction.date <= self.__period.end) | ||||
|         txn_with_account: sa.Select = sa.Select(Transaction.id).\ | ||||
|             join(JournalEntry).join(Account).filter(*conditions) | ||||
|             conditions.append(JournalEntry.date <= self.__period.end) | ||||
|         journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\ | ||||
|             join(JournalEntryLineItem).join(Account).filter(*conditions) | ||||
|  | ||||
|         return [ReportEntry(x) | ||||
|                 for x in JournalEntry.query.join(Transaction).join(Account) | ||||
|                 .filter(JournalEntry.transaction_id.in_(txn_with_account), | ||||
|                         JournalEntry.currency_code == self.__currency.code, | ||||
|         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(Transaction.date, | ||||
|                           JournalEntry.is_debit, | ||||
|                           JournalEntry.no) | ||||
|                 .options(selectinload(JournalEntry.account), | ||||
|                          selectinload(JournalEntry.transaction))] | ||||
|                 .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 == IncomeExpensesAccount.CURRENT_AL_CODE: | ||||
|             return sa.or_(Account.base_code.startswith("11"), | ||||
|                           Account.base_code.startswith("12"), | ||||
|                           Account.base_code.startswith("21"), | ||||
|                           Account.base_code.startswith("22")) | ||||
|         if self.__account.code == CurrentAccount.CURRENT_AL_CODE: | ||||
|             return CurrentAccount.sql_condition() | ||||
|         return Account.id == self.__account.id | ||||
|  | ||||
|     def __get_total_entry(self) -> ReportEntry | None: | ||||
|         """Composes the total entry. | ||||
|     def __get_total(self) -> ReportLineItem | None: | ||||
|         """Composes the total line item. | ||||
|  | ||||
|         :return: The total entry, or None if there is no data. | ||||
|         :return: The total line item, or None if there is no data. | ||||
|         """ | ||||
|         if self.brought_forward is None and len(self.entries) == 0: | ||||
|         if self.brought_forward is None and len(self.line_items) == 0: | ||||
|             return None | ||||
|         entry: ReportEntry = ReportEntry() | ||||
|         entry.is_total = True | ||||
|         entry.summary = gettext("Total") | ||||
|         entry.income = sum([x.income for x in self.entries | ||||
|                             if x.income is not None]) | ||||
|         entry.expense = sum([x.expense for x in self.entries | ||||
|                              if x.expense is not None]) | ||||
|         entry.balance = entry.income - entry.expense | ||||
|         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: | ||||
|             entry.balance = self.brought_forward.balance + entry.balance | ||||
|         return entry | ||||
|             line_item.balance \ | ||||
|                 = self.brought_forward.balance + line_item.balance | ||||
|         return line_item | ||||
|  | ||||
|     def __populate_balance(self) -> None: | ||||
|         """Populates the balance of the entries. | ||||
|         """Populates the balance of the line items. | ||||
|  | ||||
|         :return: None. | ||||
|         """ | ||||
|         balance: Decimal = 0 if self.brought_forward is None \ | ||||
|             else self.brought_forward.balance | ||||
|         for entry in self.entries: | ||||
|             if entry.income is not None: | ||||
|                 balance = balance + entry.income | ||||
|             if entry.expense is not None: | ||||
|                 balance = balance - entry.expense | ||||
|             entry.balance = balance | ||||
|         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, txn_date: date | str | None, | ||||
|     def __init__(self, journal_entry_date: date | str | None, | ||||
|                  account: str | None, | ||||
|                  summary: 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 txn_date: The transaction date. | ||||
|         :param journal_entry_date: The journal entry date. | ||||
|         :param account: The account. | ||||
|         :param summary: The summary. | ||||
|         :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 = txn_date | ||||
|         self.date: date | str | None = journal_entry_date | ||||
|         """The date.""" | ||||
|         self.account: str | None = account | ||||
|         """The account.""" | ||||
|         self.summary: str | None = summary | ||||
|         """The summary.""" | ||||
|         self.description: str | None = description | ||||
|         """The description.""" | ||||
|         self.income: str | Decimal | None = income | ||||
|         """The income.""" | ||||
|         self.expense: str | Decimal | None = expense | ||||
| @@ -248,7 +253,7 @@ class CSVRow(BaseCSVRow): | ||||
|  | ||||
|         :return: The values of the row. | ||||
|         """ | ||||
|         return [self.date, self.account, self.summary, | ||||
|         return [self.date, self.account, self.description, | ||||
|                 self.income, self.expense, self.balance, self.note] | ||||
|  | ||||
|  | ||||
| @@ -256,39 +261,39 @@ class PageParams(BasePageParams): | ||||
|     """The HTML page parameters.""" | ||||
|  | ||||
|     def __init__(self, currency: Currency, | ||||
|                  account: IncomeExpensesAccount, | ||||
|                  account: CurrentAccount, | ||||
|                  period: Period, | ||||
|                  has_data: bool, | ||||
|                  pagination: Pagination[ReportEntry], | ||||
|                  brought_forward: ReportEntry | None, | ||||
|                  entries: list[ReportEntry], | ||||
|                  total: ReportEntry | None): | ||||
|                  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 entry. | ||||
|         :param entries: The log entries. | ||||
|         :param total: The total entry. | ||||
|         :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: IncomeExpensesAccount = account | ||||
|         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[ReportEntry] = pagination | ||||
|         self.pagination: Pagination[ReportLineItem] = pagination | ||||
|         """The pagination.""" | ||||
|         self.brought_forward: ReportEntry | None = brought_forward | ||||
|         """The brought-forward entry.""" | ||||
|         self.entries: list[ReportEntry] = entries | ||||
|         """The report entries.""" | ||||
|         self.total: ReportEntry | None = total | ||||
|         """The total entry.""" | ||||
|         self.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.""" | ||||
| @@ -333,25 +338,23 @@ class PageParams(BasePageParams): | ||||
|  | ||||
|         :return: The account options. | ||||
|         """ | ||||
|         current_al: IncomeExpensesAccount \ | ||||
|             = IncomeExpensesAccount.current_assets_and_liabilities() | ||||
|         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(JournalEntry.account_id)\ | ||||
|         in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\ | ||||
|             .join(Account)\ | ||||
|             .filter(JournalEntry.currency_code == self.currency.code, | ||||
|                     sa.or_(Account.base_code.startswith("11"), | ||||
|                            Account.base_code.startswith("12"), | ||||
|                            Account.base_code.startswith("21"), | ||||
|                            Account.base_code.startswith("22")))\ | ||||
|             .group_by(JournalEntry.account_id) | ||||
|             .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, | ||||
|                                        IncomeExpensesAccount(x), | ||||
|                                        CurrentAccount(x), | ||||
|                                        self.period), | ||||
|                                    x.id == self.account.id) | ||||
|                         for x in Account.query.filter(Account.id.in_(in_use)) | ||||
| @@ -362,7 +365,7 @@ class PageParams(BasePageParams): | ||||
| class IncomeExpenses(BaseReport): | ||||
|     """The income and expenses log.""" | ||||
|  | ||||
|     def __init__(self, currency: Currency, account: IncomeExpensesAccount, | ||||
|     def __init__(self, currency: Currency, account: CurrentAccount, | ||||
|                  period: Period): | ||||
|         """Constructs an income and expenses log. | ||||
|  | ||||
| @@ -372,18 +375,19 @@ class IncomeExpenses(BaseReport): | ||||
|         """ | ||||
|         self.__currency: Currency = currency | ||||
|         """The currency.""" | ||||
|         self.__account: IncomeExpensesAccount = account | ||||
|         self.__account: CurrentAccount = account | ||||
|         """The account.""" | ||||
|         self.__period: Period = period | ||||
|         """The period.""" | ||||
|         collector: EntryCollector = EntryCollector( | ||||
|         collector: LineItemCollector = LineItemCollector( | ||||
|             self.__currency, self.__account, self.__period) | ||||
|         self.__brought_forward: ReportEntry | None = collector.brought_forward | ||||
|         """The brought-forward entry.""" | ||||
|         self.__entries: list[ReportEntry] = collector.entries | ||||
|         """The report entries.""" | ||||
|         self.__total: ReportEntry | None = collector.total | ||||
|         """The total entry.""" | ||||
|         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. | ||||
| @@ -401,20 +405,20 @@ class IncomeExpenses(BaseReport): | ||||
|         :return: The CSV rows. | ||||
|         """ | ||||
|         rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"), | ||||
|                                      gettext("Summary"), gettext("Income"), | ||||
|                                      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.summary, | ||||
|                                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.summary, | ||||
|         rows.extend([CSVRow(x.date, str(x.account).title(), x.description, | ||||
|                             x.income, x.expense, x.balance, x.note) | ||||
|                      for x in self.__entries]) | ||||
|                      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, | ||||
| @@ -426,31 +430,31 @@ class IncomeExpenses(BaseReport): | ||||
|  | ||||
|         :return: The report as HTML. | ||||
|         """ | ||||
|         all_entries: list[ReportEntry] = [] | ||||
|         all_line_items: list[ReportLineItem] = [] | ||||
|         if self.__brought_forward is not None: | ||||
|             all_entries.append(self.__brought_forward) | ||||
|         all_entries.extend(self.__entries) | ||||
|             all_line_items.append(self.__brought_forward) | ||||
|         all_line_items.extend(self.__line_items) | ||||
|         if self.__total is not None: | ||||
|             all_entries.append(self.__total) | ||||
|         pagination: Pagination[ReportEntry] \ | ||||
|             = Pagination[ReportEntry](all_entries) | ||||
|         page_entries: list[ReportEntry] = pagination.list | ||||
|         has_data: bool = len(page_entries) > 0 | ||||
|         brought_forward: ReportEntry | None = None | ||||
|         if len(page_entries) > 0 and page_entries[0].is_brought_forward: | ||||
|             brought_forward = page_entries[0] | ||||
|             page_entries = page_entries[1:] | ||||
|         total: ReportEntry | None = None | ||||
|         if len(page_entries) > 0 and page_entries[-1].is_total: | ||||
|             total = page_entries[-1] | ||||
|             page_entries = page_entries[:-1] | ||||
|             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, | ||||
|                                         entries=page_entries, | ||||
|                                         line_items=page_line_items, | ||||
|                                         total=total) | ||||
|         return render_template("accounting/report/income-expenses.html", | ||||
|                                report=params) | ||||
|   | ||||
| @@ -24,8 +24,8 @@ from flask import render_template, Response | ||||
|  | ||||
| from accounting import db | ||||
| from accounting.locale import gettext | ||||
| from accounting.models import Currency, BaseAccount, Account, Transaction, \ | ||||
|     JournalEntry | ||||
| from accounting.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 | ||||
| @@ -256,17 +256,17 @@ class IncomeStatement(BaseReport): | ||||
|         sub_conditions: list[sa.BinaryExpression] \ | ||||
|             = [Account.base_code.startswith(str(x)) for x in range(4, 10)] | ||||
|         conditions: list[sa.BinaryExpression] \ | ||||
|             = [JournalEntry.currency_code == self.__currency.code, | ||||
|             = [JournalEntryLineItem.currency_code == self.__currency.code, | ||||
|                sa.or_(*sub_conditions)] | ||||
|         if self.__period.start is not None: | ||||
|             conditions.append(Transaction.date >= self.__period.start) | ||||
|             conditions.append(JournalEntry.date >= self.__period.start) | ||||
|         if self.__period.end is not None: | ||||
|             conditions.append(Transaction.date <= self.__period.end) | ||||
|             conditions.append(JournalEntry.date <= self.__period.end) | ||||
|         balance_func: sa.Function = sa.func.sum(sa.case( | ||||
|             (JournalEntry.is_debit, -JournalEntry.amount), | ||||
|             else_=JournalEntry.amount)).label("balance") | ||||
|             (JournalEntryLineItem.is_debit, -JournalEntryLineItem.amount), | ||||
|             else_=JournalEntryLineItem.amount)).label("balance") | ||||
|         select_balances: sa.Select = sa.select(Account.id, balance_func)\ | ||||
|             .join(Transaction).join(Account)\ | ||||
|             .join(JournalEntry).join(Account)\ | ||||
|             .filter(*conditions)\ | ||||
|             .group_by(Account.id)\ | ||||
|             .order_by(Account.base_code, Account.no) | ||||
|   | ||||
| @@ -25,7 +25,8 @@ from flask import render_template, Response | ||||
| from sqlalchemy.orm import selectinload | ||||
|  | ||||
| from accounting.locale import gettext | ||||
| from accounting.models import Currency, Account, Transaction, JournalEntry | ||||
| from accounting.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 | ||||
| @@ -37,58 +38,58 @@ from accounting.report.utils.urls import journal_url | ||||
| from accounting.utils.pagination import Pagination | ||||
|  | ||||
|  | ||||
| class ReportEntry: | ||||
|     """An entry in the report.""" | ||||
| class ReportLineItem: | ||||
|     """A line item in the report.""" | ||||
|  | ||||
|     def __init__(self, entry: JournalEntry): | ||||
|         """Constructs the entry in the report. | ||||
|     def __init__(self, line_item: JournalEntryLineItem): | ||||
|         """Constructs the line item in the report. | ||||
|  | ||||
|         :param entry: The journal entry. | ||||
|         :param line_item: The journal entry line item. | ||||
|         """ | ||||
|         self.entry: JournalEntry = entry | ||||
|         self.line_item: JournalEntryLineItem = line_item | ||||
|         """The journal entry line item.""" | ||||
|         self.journal_entry: JournalEntry = line_item.journal_entry | ||||
|         """The journal entry.""" | ||||
|         self.transaction: Transaction = entry.transaction | ||||
|         """The transaction.""" | ||||
|         self.currency: Currency = entry.currency | ||||
|         self.currency: Currency = line_item.currency | ||||
|         """The account.""" | ||||
|         self.account: Account = entry.account | ||||
|         self.account: Account = line_item.account | ||||
|         """The account.""" | ||||
|         self.summary: str | None = entry.summary | ||||
|         """The summary.""" | ||||
|         self.debit: Decimal | None = entry.debit | ||||
|         self.description: str | None = line_item.description | ||||
|         """The description.""" | ||||
|         self.debit: Decimal | None = line_item.debit | ||||
|         """The debit amount.""" | ||||
|         self.credit: Decimal | None = entry.credit | ||||
|         self.credit: Decimal | None = line_item.credit | ||||
|         """The credit amount.""" | ||||
|         self.amount: Decimal = entry.amount | ||||
|         self.amount: Decimal = line_item.amount | ||||
|         """The amount.""" | ||||
|  | ||||
|  | ||||
| class CSVRow(BaseCSVRow): | ||||
|     """A row in the CSV.""" | ||||
|  | ||||
|     def __init__(self, txn_date: str | date, | ||||
|     def __init__(self, journal_entry_date: str | date, | ||||
|                  currency: str, | ||||
|                  account: str, | ||||
|                  summary: str | None, | ||||
|                  description: str | None, | ||||
|                  debit: str | Decimal | None, | ||||
|                  credit: str | Decimal | None, | ||||
|                  note: str | None): | ||||
|         """Constructs a row in the CSV. | ||||
|  | ||||
|         :param txn_date: The transaction date. | ||||
|         :param summary: The summary. | ||||
|         :param 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 = txn_date | ||||
|         self.date: str | date = journal_entry_date | ||||
|         """The date.""" | ||||
|         self.currency: str = currency | ||||
|         """The currency.""" | ||||
|         self.account: str = account | ||||
|         """The account.""" | ||||
|         self.summary: str | None = summary | ||||
|         """The summary.""" | ||||
|         self.description: str | None = description | ||||
|         """The description.""" | ||||
|         self.debit: str | Decimal | None = debit | ||||
|         """The debit amount.""" | ||||
|         self.credit: str | Decimal | None = credit | ||||
| @@ -102,7 +103,7 @@ class CSVRow(BaseCSVRow): | ||||
|  | ||||
|         :return: The values of the row. | ||||
|         """ | ||||
|         return [self.date, self.currency, self.account, self.summary, | ||||
|         return [self.date, self.currency, self.account, self.description, | ||||
|                 self.debit, self.credit, self.note] | ||||
|  | ||||
|  | ||||
| @@ -110,19 +111,19 @@ class PageParams(BasePageParams): | ||||
|     """The HTML page parameters.""" | ||||
|  | ||||
|     def __init__(self, period: Period, | ||||
|                  pagination: Pagination[JournalEntry], | ||||
|                  entries: list[JournalEntry]): | ||||
|                  pagination: Pagination[JournalEntryLineItem], | ||||
|                  line_items: list[JournalEntryLineItem]): | ||||
|         """Constructs the HTML page parameters. | ||||
|  | ||||
|         :param period: The period. | ||||
|         :param entries: The journal entries. | ||||
|         :param line_items: The line items. | ||||
|         """ | ||||
|         self.period: Period = period | ||||
|         """The period.""" | ||||
|         self.pagination: Pagination[JournalEntry] = pagination | ||||
|         self.pagination: Pagination[JournalEntryLineItem] = pagination | ||||
|         """The pagination.""" | ||||
|         self.entries: list[JournalEntry] = entries | ||||
|         """The entries.""" | ||||
|         self.line_items: list[JournalEntryLineItem] = line_items | ||||
|         """The line items.""" | ||||
|         self.period_chooser: PeriodChooser = PeriodChooser( | ||||
|             lambda x: journal_url(x)) | ||||
|         """The period chooser.""" | ||||
| @@ -133,7 +134,7 @@ class PageParams(BasePageParams): | ||||
|  | ||||
|         :return: True if there is any data, or False otherwise. | ||||
|         """ | ||||
|         return len(self.entries) > 0 | ||||
|         return len(self.line_items) > 0 | ||||
|  | ||||
|     @property | ||||
|     def report_chooser(self) -> ReportChooser: | ||||
| @@ -145,20 +146,20 @@ class PageParams(BasePageParams): | ||||
|                              period=self.period) | ||||
|  | ||||
|  | ||||
| def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]: | ||||
|     """Composes and returns the CSV rows from the report entries. | ||||
| def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]: | ||||
|     """Composes and returns the CSV rows from the line items. | ||||
|  | ||||
|     :param entries: The report entries. | ||||
|     :param line_items: The line items. | ||||
|     :return: The CSV rows. | ||||
|     """ | ||||
|     rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"), | ||||
|                                  gettext("Account"), gettext("Summary"), | ||||
|                                  gettext("Account"), gettext("Description"), | ||||
|                                  gettext("Debit"), gettext("Credit"), | ||||
|                                  gettext("Note"))] | ||||
|     rows.extend([CSVRow(x.transaction.date, x.currency.code, | ||||
|                         str(x.account).title(), x.summary, | ||||
|                         x.debit, x.credit, x.transaction.note) | ||||
|                  for x in entries]) | ||||
|     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 | ||||
|  | ||||
|  | ||||
| @@ -172,27 +173,29 @@ class Journal(BaseReport): | ||||
|         """ | ||||
|         self.__period: Period = period | ||||
|         """The period.""" | ||||
|         self.__entries: list[JournalEntry] = self.__query_entries() | ||||
|         """The journal entries.""" | ||||
|         self.__line_items: list[JournalEntryLineItem] \ | ||||
|             = self.__query_line_items() | ||||
|         """The line items.""" | ||||
|  | ||||
|     def __query_entries(self) -> list[JournalEntry]: | ||||
|         """Queries and returns the journal entries. | ||||
|     def __query_line_items(self) -> list[JournalEntryLineItem]: | ||||
|         """Queries and returns the line items. | ||||
|  | ||||
|         :return: The journal entries. | ||||
|         :return: The line items. | ||||
|         """ | ||||
|         conditions: list[sa.BinaryExpression] = [] | ||||
|         if self.__period.start is not None: | ||||
|             conditions.append(Transaction.date >= self.__period.start) | ||||
|             conditions.append(JournalEntry.date >= self.__period.start) | ||||
|         if self.__period.end is not None: | ||||
|             conditions.append(Transaction.date <= self.__period.end) | ||||
|         return JournalEntry.query.join(Transaction)\ | ||||
|             conditions.append(JournalEntry.date <= self.__period.end) | ||||
|         return JournalEntryLineItem.query.join(JournalEntry)\ | ||||
|             .filter(*conditions)\ | ||||
|             .order_by(Transaction.date, | ||||
|                       JournalEntry.is_debit.desc(), | ||||
|                       JournalEntry.no)\ | ||||
|             .options(selectinload(JournalEntry.account), | ||||
|                      selectinload(JournalEntry.currency), | ||||
|                      selectinload(JournalEntry.transaction)).all() | ||||
|             .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. | ||||
| @@ -200,17 +203,18 @@ class Journal(BaseReport): | ||||
|         :return: The response of the report for download. | ||||
|         """ | ||||
|         filename: str = f"journal-{period_spec(self.__period)}.csv" | ||||
|         return csv_download(filename, get_csv_rows(self.__entries)) | ||||
|         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[JournalEntry] \ | ||||
|             = Pagination[JournalEntry](self.__entries) | ||||
|         pagination: Pagination[JournalEntryLineItem] \ | ||||
|             = Pagination[JournalEntryLineItem](self.__line_items, | ||||
|                                                is_reversed=True) | ||||
|         params: PageParams = PageParams(period=self.__period, | ||||
|                                         pagination=pagination, | ||||
|                                         entries=pagination.list) | ||||
|                                         line_items=pagination.list) | ||||
|         return render_template("accounting/report/journal.html", | ||||
|                                report=params) | ||||
|   | ||||
| @@ -26,7 +26,8 @@ from sqlalchemy.orm import selectinload | ||||
|  | ||||
| from accounting import db | ||||
| from accounting.locale import gettext | ||||
| from accounting.models import Currency, Account, Transaction, JournalEntry | ||||
| 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 | ||||
| @@ -36,25 +37,26 @@ from accounting.report.utils.option_link import OptionLink | ||||
| from accounting.report.utils.report_chooser import ReportChooser | ||||
| from accounting.report.utils.report_type import ReportType | ||||
| from accounting.report.utils.urls import ledger_url | ||||
| from accounting.utils.cast import be | ||||
| from accounting.utils.pagination import Pagination | ||||
|  | ||||
|  | ||||
| class ReportEntry: | ||||
|     """An entry in the report.""" | ||||
| class ReportLineItem: | ||||
|     """A line item in the report.""" | ||||
|  | ||||
|     def __init__(self, entry: JournalEntry | None = None): | ||||
|         """Constructs the entry in the report. | ||||
|     def __init__(self, line_item: JournalEntryLineItem | None = None): | ||||
|         """Constructs the line item in the report. | ||||
|  | ||||
|         :param entry: The journal entry. | ||||
|         :param line_item: The journal entry line item. | ||||
|         """ | ||||
|         self.is_brought_forward: bool = False | ||||
|         """Whether this is the brought-forward entry.""" | ||||
|         """Whether this is the brought-forward line item.""" | ||||
|         self.is_total: bool = False | ||||
|         """Whether this is the total entry.""" | ||||
|         """Whether this is the total line item.""" | ||||
|         self.date: date | None = None | ||||
|         """The date.""" | ||||
|         self.summary: str | None = None | ||||
|         """The summary.""" | ||||
|         self.description: str | None = None | ||||
|         """The description.""" | ||||
|         self.debit: Decimal | None = None | ||||
|         """The debit amount.""" | ||||
|         self.credit: Decimal | None = None | ||||
| @@ -64,22 +66,22 @@ class ReportEntry: | ||||
|         self.note: str | None = None | ||||
|         """The note.""" | ||||
|         self.url: str | None = None | ||||
|         """The URL to the journal entry.""" | ||||
|         if entry is not None: | ||||
|             self.date = entry.transaction.date | ||||
|             self.summary = entry.summary | ||||
|             self.debit = entry.amount if entry.is_debit else None | ||||
|             self.credit = None if entry.is_debit else entry.amount | ||||
|             self.note = entry.transaction.note | ||||
|             self.url = url_for("accounting.transaction.detail", | ||||
|                                txn=entry.transaction) | ||||
|         """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 EntryCollector: | ||||
|     """The report entry collector.""" | ||||
| class LineItemCollector: | ||||
|     """The line item collector.""" | ||||
|  | ||||
|     def __init__(self, currency: Currency, account: Account, period: Period): | ||||
|         """Constructs the report entry collector. | ||||
|         """Constructs the line item collector. | ||||
|  | ||||
|         :param currency: The currency. | ||||
|         :param account: The account. | ||||
| @@ -91,123 +93,131 @@ class EntryCollector: | ||||
|         """The account.""" | ||||
|         self.__period: Period = period | ||||
|         """The period""" | ||||
|         self.brought_forward: ReportEntry | None | ||||
|         """The brought-forward entry.""" | ||||
|         self.entries: list[ReportEntry] | ||||
|         """The report entries.""" | ||||
|         self.total: ReportEntry | None | ||||
|         """The total entry.""" | ||||
|         self.brought_forward = self.__get_brought_forward_entry() | ||||
|         self.entries = self.__query_entries() | ||||
|         self.total = self.__get_total_entry() | ||||
|         self.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_entry(self) -> ReportEntry | None: | ||||
|         """Queries, composes and returns the brought-forward entry. | ||||
|     def __get_brought_forward(self) -> ReportLineItem | None: | ||||
|         """Queries, composes and returns the brought-forward line item. | ||||
|  | ||||
|         :return: The brought-forward entry, or None if the report starts from | ||||
|             the beginning. | ||||
|         :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.base_code[0] not in {"1", "2", "3"}: | ||||
|         if self.__account.is_nominal: | ||||
|             return None | ||||
|         balance_func: sa.Function = sa.func.sum(sa.case( | ||||
|             (JournalEntry.is_debit, JournalEntry.amount), | ||||
|             else_=-JournalEntry.amount)) | ||||
|         select: sa.Select = sa.Select(balance_func).join(Transaction)\ | ||||
|             .filter(JournalEntry.currency_code == self.__currency.code, | ||||
|                     JournalEntry.account_id == self.__account.id, | ||||
|                     Transaction.date < self.__period.start) | ||||
|             (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 | ||||
|         entry: ReportEntry = ReportEntry() | ||||
|         entry.is_brought_forward = True | ||||
|         entry.date = self.__period.start | ||||
|         entry.summary = gettext("Brought forward") | ||||
|         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: | ||||
|             entry.debit = balance | ||||
|             line_item.debit = balance | ||||
|         elif balance < 0: | ||||
|             entry.credit = -balance | ||||
|         entry.balance = balance | ||||
|         return entry | ||||
|             line_item.credit = -balance | ||||
|         line_item.balance = balance | ||||
|         return line_item | ||||
|  | ||||
|     def __query_entries(self) -> list[ReportEntry]: | ||||
|         """Queries and returns the report entries. | ||||
|     def __query_line_items(self) -> list[ReportLineItem]: | ||||
|         """Queries and returns the line items. | ||||
|  | ||||
|         :return: The report entries. | ||||
|         :return: The line items. | ||||
|         """ | ||||
|         conditions: list[sa.BinaryExpression] \ | ||||
|             = [JournalEntry.currency_code == self.__currency.code, | ||||
|                JournalEntry.account_id == self.__account.id] | ||||
|             = [JournalEntryLineItem.currency_code == self.__currency.code, | ||||
|                JournalEntryLineItem.account_id == self.__account.id] | ||||
|         if self.__period.start is not None: | ||||
|             conditions.append(Transaction.date >= self.__period.start) | ||||
|             conditions.append(JournalEntry.date >= self.__period.start) | ||||
|         if self.__period.end is not None: | ||||
|             conditions.append(Transaction.date <= self.__period.end) | ||||
|         return [ReportEntry(x) for x in JournalEntry.query.join(Transaction) | ||||
|             conditions.append(JournalEntry.date <= self.__period.end) | ||||
|         return [ReportLineItem(x) for x in JournalEntryLineItem.query | ||||
|                 .join(JournalEntry) | ||||
|                 .filter(*conditions) | ||||
|                 .order_by(Transaction.date, | ||||
|                           JournalEntry.is_debit.desc(), | ||||
|                           JournalEntry.no) | ||||
|                 .options(selectinload(JournalEntry.transaction)).all()] | ||||
|                 .order_by(JournalEntry.date, | ||||
|                           JournalEntry.no, | ||||
|                           JournalEntryLineItem.is_debit.desc(), | ||||
|                           JournalEntryLineItem.no) | ||||
|                 .options(selectinload(JournalEntryLineItem.journal_entry)) | ||||
|                 .all()] | ||||
|  | ||||
|     def __get_total_entry(self) -> ReportEntry | None: | ||||
|         """Composes the total entry. | ||||
|     def __get_total(self) -> ReportLineItem | None: | ||||
|         """Composes the total line item. | ||||
|  | ||||
|         :return: The total entry, or None if there is no data. | ||||
|         :return: The total line item, or None if there is no data. | ||||
|         """ | ||||
|         if self.brought_forward is None and len(self.entries) == 0: | ||||
|         if self.brought_forward is None and len(self.line_items) == 0: | ||||
|             return None | ||||
|         entry: ReportEntry = ReportEntry() | ||||
|         entry.is_total = True | ||||
|         entry.summary = gettext("Total") | ||||
|         entry.debit = sum([x.debit for x in self.entries | ||||
|                            if x.debit is not None]) | ||||
|         entry.credit = sum([x.credit for x in self.entries | ||||
|                             if x.credit is not None]) | ||||
|         entry.balance = entry.debit - entry.credit | ||||
|         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: | ||||
|             entry.balance = self.brought_forward.balance + entry.balance | ||||
|         return entry | ||||
|             line_item.balance \ | ||||
|                 = self.brought_forward.balance + line_item.balance | ||||
|         return line_item | ||||
|  | ||||
|     def __populate_balance(self) -> None: | ||||
|         """Populates the balance of the entries. | ||||
|         """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 entry in self.entries: | ||||
|             if entry.debit is not None: | ||||
|                 balance = balance + entry.debit | ||||
|             if entry.credit is not None: | ||||
|                 balance = balance - entry.credit | ||||
|             entry.balance = balance | ||||
|         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, txn_date: date | str | None, | ||||
|                  summary: str | None, | ||||
|     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 txn_date: The transaction date. | ||||
|         :param summary: The summary. | ||||
|         :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 = txn_date | ||||
|         self.date: date | str | None = journal_entry_date | ||||
|         """The date.""" | ||||
|         self.summary: str | None = summary | ||||
|         """The summary.""" | ||||
|         self.description: str | None = description | ||||
|         """The description.""" | ||||
|         self.debit: str | Decimal | None = debit | ||||
|         """The debit amount.""" | ||||
|         self.credit: str | Decimal | None = credit | ||||
| @@ -223,7 +233,7 @@ class CSVRow(BaseCSVRow): | ||||
|  | ||||
|         :return: The values of the row. | ||||
|         """ | ||||
|         return [self.date, self.summary, | ||||
|         return [self.date, self.description, | ||||
|                 self.debit, self.credit, self.balance, self.note] | ||||
|  | ||||
|  | ||||
| @@ -234,19 +244,19 @@ class PageParams(BasePageParams): | ||||
|                  account: Account, | ||||
|                  period: Period, | ||||
|                  has_data: bool, | ||||
|                  pagination: Pagination[ReportEntry], | ||||
|                  brought_forward: ReportEntry | None, | ||||
|                  entries: list[ReportEntry], | ||||
|                  total: ReportEntry | None): | ||||
|                  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 entry. | ||||
|         :param entries: The report entries. | ||||
|         :param total: The total entry. | ||||
|         :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.""" | ||||
| @@ -256,14 +266,14 @@ class PageParams(BasePageParams): | ||||
|         """The period.""" | ||||
|         self.__has_data: bool = has_data | ||||
|         """True if there is any data, or False otherwise.""" | ||||
|         self.pagination: Pagination[ReportEntry] = pagination | ||||
|         self.pagination: Pagination[ReportLineItem] = pagination | ||||
|         """The pagination.""" | ||||
|         self.brought_forward: ReportEntry | None = brought_forward | ||||
|         """The brought-forward entry.""" | ||||
|         self.entries: list[ReportEntry] = entries | ||||
|         """The entries.""" | ||||
|         self.total: ReportEntry | None = total | ||||
|         """The total entry.""" | ||||
|         self.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.""" | ||||
| @@ -302,9 +312,10 @@ class PageParams(BasePageParams): | ||||
|  | ||||
|         :return: The account options. | ||||
|         """ | ||||
|         in_use: sa.Select = sa.Select(JournalEntry.account_id)\ | ||||
|             .filter(JournalEntry.currency_code == self.currency.code)\ | ||||
|             .group_by(JournalEntry.account_id) | ||||
|         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)) | ||||
| @@ -327,14 +338,15 @@ class Ledger(BaseReport): | ||||
|         """The account.""" | ||||
|         self.__period: Period = period | ||||
|         """The period.""" | ||||
|         collector: EntryCollector = EntryCollector( | ||||
|         collector: LineItemCollector = LineItemCollector( | ||||
|             self.__currency, self.__account, self.__period) | ||||
|         self.__brought_forward: ReportEntry | None = collector.brought_forward | ||||
|         """The brought-forward entry.""" | ||||
|         self.__entries: list[ReportEntry] = collector.entries | ||||
|         """The report entries.""" | ||||
|         self.__total: ReportEntry | None = collector.total | ||||
|         """The total entry.""" | ||||
|         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. | ||||
| @@ -351,19 +363,19 @@ class Ledger(BaseReport): | ||||
|  | ||||
|         :return: The CSV rows. | ||||
|         """ | ||||
|         rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"), | ||||
|         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.summary, | ||||
|                                self.__brought_forward.description, | ||||
|                                self.__brought_forward.debit, | ||||
|                                self.__brought_forward.credit, | ||||
|                                self.__brought_forward.balance, | ||||
|                                None)) | ||||
|         rows.extend([CSVRow(x.date, x.summary, | ||||
|         rows.extend([CSVRow(x.date, x.description, | ||||
|                             x.debit, x.credit, x.balance, x.note) | ||||
|                      for x in self.__entries]) | ||||
|                      for x in self.__line_items]) | ||||
|         if self.__total is not None: | ||||
|             rows.append(CSVRow(gettext("Total"), None, | ||||
|                                self.__total.debit, self.__total.credit, | ||||
| @@ -375,31 +387,31 @@ class Ledger(BaseReport): | ||||
|  | ||||
|         :return: The report as HTML. | ||||
|         """ | ||||
|         all_entries: list[ReportEntry] = [] | ||||
|         all_line_items: list[ReportLineItem] = [] | ||||
|         if self.__brought_forward is not None: | ||||
|             all_entries.append(self.__brought_forward) | ||||
|         all_entries.extend(self.__entries) | ||||
|             all_line_items.append(self.__brought_forward) | ||||
|         all_line_items.extend(self.__line_items) | ||||
|         if self.__total is not None: | ||||
|             all_entries.append(self.__total) | ||||
|         pagination: Pagination[ReportEntry] \ | ||||
|             = Pagination[ReportEntry](all_entries) | ||||
|         page_entries: list[ReportEntry] = pagination.list | ||||
|         has_data: bool = len(page_entries) > 0 | ||||
|         brought_forward: ReportEntry | None = None | ||||
|         if len(page_entries) > 0 and page_entries[0].is_brought_forward: | ||||
|             brought_forward = page_entries[0] | ||||
|             page_entries = page_entries[1:] | ||||
|         total: ReportEntry | None = None | ||||
|         if len(page_entries) > 0 and page_entries[-1].is_total: | ||||
|             total = page_entries[-1] | ||||
|             page_entries = page_entries[:-1] | ||||
|             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, | ||||
|                                         entries=page_entries, | ||||
|                                         line_items=page_line_items, | ||||
|                                         total=total) | ||||
|         return render_template("accounting/report/ledger.html", | ||||
|                                report=params) | ||||
|   | ||||
| @@ -26,29 +26,30 @@ from sqlalchemy.orm import selectinload | ||||
|  | ||||
| from accounting.locale import gettext | ||||
| from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \ | ||||
|     Transaction, JournalEntry | ||||
|     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 EntryCollector: | ||||
|     """The report entry collector.""" | ||||
| class LineItemCollector: | ||||
|     """The line item collector.""" | ||||
|  | ||||
|     def __init__(self): | ||||
|         """Constructs the report entry collector.""" | ||||
|         self.entries: list[JournalEntry] = self.__query_entries() | ||||
|         """The report entries.""" | ||||
|         """Constructs the line item collector.""" | ||||
|         self.line_items: list[JournalEntryLineItem] = self.__query_line_items() | ||||
|         """The line items.""" | ||||
|  | ||||
|     def __query_entries(self) -> list[JournalEntry]: | ||||
|         """Queries and returns the journal entries. | ||||
|     def __query_line_items(self) -> list[JournalEntryLineItem]: | ||||
|         """Queries and returns the line items. | ||||
|  | ||||
|         :return: The journal entries. | ||||
|         :return: The line items. | ||||
|         """ | ||||
|         keywords: list[str] = parse_query_keywords(request.args.get("q")) | ||||
|         if len(keywords) == 0: | ||||
| @@ -56,22 +57,28 @@ class EntryCollector: | ||||
|         conditions: list[sa.BinaryExpression] = [] | ||||
|         for k in keywords: | ||||
|             sub_conditions: list[sa.BinaryExpression] \ | ||||
|                 = [JournalEntry.summary.contains(k), | ||||
|                    JournalEntry.account_id.in_( | ||||
|                 = [JournalEntryLineItem.description.icontains(k), | ||||
|                    JournalEntryLineItem.account_id.in_( | ||||
|                        self.__get_account_condition(k)), | ||||
|                    JournalEntry.currency_code.in_( | ||||
|                    JournalEntryLineItem.currency_code.in_( | ||||
|                        self.__get_currency_condition(k)), | ||||
|                    JournalEntry.transaction_id.in_( | ||||
|                        self.__get_transaction_condition(k))] | ||||
|                    JournalEntryLineItem.journal_entry_id.in_( | ||||
|                        self.__get_journal_entry_condition(k))] | ||||
|             try: | ||||
|                 sub_conditions.append(JournalEntry.amount == Decimal(k)) | ||||
|                 sub_conditions.append( | ||||
|                     JournalEntryLineItem.amount == Decimal(k)) | ||||
|             except ArithmeticError: | ||||
|                 pass | ||||
|             conditions.append(sa.or_(*sub_conditions)) | ||||
|         return JournalEntry.query.filter(*conditions)\ | ||||
|             .options(selectinload(JournalEntry.account), | ||||
|                      selectinload(JournalEntry.currency), | ||||
|                      selectinload(JournalEntry.transaction)).all() | ||||
|         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: | ||||
| @@ -85,14 +92,14 @@ class EntryCollector: | ||||
|                              sa.func.char_length(sa.cast(Account.no, | ||||
|                                                          sa.String)) + 1) | ||||
|         select_l10n: sa.Select = sa.select(AccountL10n.account_id)\ | ||||
|             .filter(AccountL10n.title.contains(k)) | ||||
|             .filter(AccountL10n.title.icontains(k)) | ||||
|         conditions: list[sa.BinaryExpression] \ | ||||
|             = [Account.base_code.contains(k), | ||||
|                Account.title_l10n.contains(k), | ||||
|                Account.title_l10n.icontains(k), | ||||
|                code.contains(k), | ||||
|                Account.id.in_(select_l10n)] | ||||
|         if k in gettext("Need offset"): | ||||
|             conditions.append(Account.is_offset_needed) | ||||
|         if k in gettext("Needs Offset"): | ||||
|             conditions.append(Account.is_need_offset) | ||||
|         return sa.select(Account.id).filter(sa.or_(*conditions)) | ||||
|  | ||||
|     @staticmethod | ||||
| @@ -103,57 +110,63 @@ class EntryCollector: | ||||
|         :return: The condition to filter the currency. | ||||
|         """ | ||||
|         select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\ | ||||
|             .filter(CurrencyL10n.name.contains(k)) | ||||
|             .filter(CurrencyL10n.name.icontains(k)) | ||||
|         return sa.select(Currency.code).filter( | ||||
|             sa.or_(Currency.code.contains(k), | ||||
|                    Currency.name_l10n.contains(k), | ||||
|             sa.or_(Currency.code.icontains(k), | ||||
|                    Currency.name_l10n.icontains(k), | ||||
|                    Currency.code.in_(select_l10n))) | ||||
|  | ||||
|     @staticmethod | ||||
|     def __get_transaction_condition(k: str) -> sa.Select: | ||||
|         """Composes and returns the condition to filter the transaction. | ||||
|     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 transaction. | ||||
|         :return: The condition to filter the journal entry. | ||||
|         """ | ||||
|         conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)] | ||||
|         txn_date: datetime | ||||
|         conditions: list[sa.BinaryExpression] \ | ||||
|             = [JournalEntry.note.icontains(k)] | ||||
|         journal_entry_date: datetime | ||||
|         try: | ||||
|             txn_date = datetime.strptime(k, "%Y") | ||||
|             journal_entry_date = datetime.strptime(k, "%Y") | ||||
|             conditions.append( | ||||
|                 sa.extract("year", Transaction.date) == txn_date.year) | ||||
|                 be(sa.extract("year", JournalEntry.date) | ||||
|                    == journal_entry_date.year)) | ||||
|         except ValueError: | ||||
|             pass | ||||
|         try: | ||||
|             txn_date = datetime.strptime(k, "%Y/%m") | ||||
|             journal_entry_date = datetime.strptime(k, "%Y/%m") | ||||
|             conditions.append(sa.and_( | ||||
|                 sa.extract("year", Transaction.date) == txn_date.year, | ||||
|                 sa.extract("month", Transaction.date) == txn_date.month)) | ||||
|                 sa.extract("year", JournalEntry.date) | ||||
|                 == journal_entry_date.year, | ||||
|                 sa.extract("month", JournalEntry.date) | ||||
|                 == journal_entry_date.month)) | ||||
|         except ValueError: | ||||
|             pass | ||||
|         try: | ||||
|             txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d") | ||||
|             journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d") | ||||
|             conditions.append(sa.and_( | ||||
|                 sa.extract("month", Transaction.date) == txn_date.month, | ||||
|                 sa.extract("day", Transaction.date) == txn_date.day)) | ||||
|                 sa.extract("month", JournalEntry.date) | ||||
|                 == journal_entry_date.month, | ||||
|                 sa.extract("day", JournalEntry.date) | ||||
|                 == journal_entry_date.day)) | ||||
|         except ValueError: | ||||
|             pass | ||||
|         return sa.select(Transaction.id).filter(sa.or_(*conditions)) | ||||
|         return sa.select(JournalEntry.id).filter(sa.or_(*conditions)) | ||||
|  | ||||
|  | ||||
| class PageParams(BasePageParams): | ||||
|     """The HTML page parameters.""" | ||||
|  | ||||
|     def __init__(self, pagination: Pagination[JournalEntry], | ||||
|                  entries: list[JournalEntry]): | ||||
|     def __init__(self, pagination: Pagination[JournalEntryLineItem], | ||||
|                  line_items: list[JournalEntryLineItem]): | ||||
|         """Constructs the HTML page parameters. | ||||
|  | ||||
|         :param entries: The search result entries. | ||||
|         :param line_items: The search result line items. | ||||
|         """ | ||||
|         self.pagination: Pagination[JournalEntry] = pagination | ||||
|         self.pagination: Pagination[JournalEntryLineItem] = pagination | ||||
|         """The pagination.""" | ||||
|         self.entries: list[JournalEntry] = entries | ||||
|         """The entries.""" | ||||
|         self.line_items: list[JournalEntryLineItem] = line_items | ||||
|         """The line items.""" | ||||
|  | ||||
|     @property | ||||
|     def has_data(self) -> bool: | ||||
| @@ -161,7 +174,7 @@ class PageParams(BasePageParams): | ||||
|  | ||||
|         :return: True if there is any data, or False otherwise. | ||||
|         """ | ||||
|         return len(self.entries) > 0 | ||||
|         return len(self.line_items) > 0 | ||||
|  | ||||
|     @property | ||||
|     def report_chooser(self) -> ReportChooser: | ||||
| @@ -177,8 +190,9 @@ class Search(BaseReport): | ||||
|  | ||||
|     def __init__(self): | ||||
|         """Constructs a search.""" | ||||
|         self.__entries: list[JournalEntry] = EntryCollector().entries | ||||
|         """The journal entries.""" | ||||
|         self.__line_items: list[JournalEntryLineItem] \ | ||||
|             = LineItemCollector().line_items | ||||
|         """The line items.""" | ||||
|  | ||||
|     def csv(self) -> Response: | ||||
|         """Returns the report as CSV for download. | ||||
| @@ -186,16 +200,17 @@ class Search(BaseReport): | ||||
|         :return: The response of the report for download. | ||||
|         """ | ||||
|         filename: str = "search-{q}.csv".format(q=request.args["q"]) | ||||
|         return csv_download(filename, get_csv_rows(self.__entries)) | ||||
|         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[JournalEntry] \ | ||||
|             = Pagination[JournalEntry](self.__entries) | ||||
|         pagination: Pagination[JournalEntryLineItem] \ | ||||
|             = Pagination[JournalEntryLineItem](self.__line_items, | ||||
|                                                is_reversed=True) | ||||
|         params: PageParams = PageParams(pagination=pagination, | ||||
|                                         entries=pagination.list) | ||||
|                                         line_items=pagination.list) | ||||
|         return render_template("accounting/report/search.html", | ||||
|                                report=params) | ||||
|   | ||||
| @@ -24,7 +24,8 @@ from flask import Response, render_template | ||||
|  | ||||
| from accounting import db | ||||
| from accounting.locale import gettext | ||||
| from accounting.models import Currency, Account, Transaction, JournalEntry | ||||
| from accounting.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 | ||||
| @@ -178,16 +179,16 @@ class TrialBalance(BaseReport): | ||||
|         :return: None. | ||||
|         """ | ||||
|         conditions: list[sa.BinaryExpression] \ | ||||
|             = [JournalEntry.currency_code == self.__currency.code] | ||||
|             = [JournalEntryLineItem.currency_code == self.__currency.code] | ||||
|         if self.__period.start is not None: | ||||
|             conditions.append(Transaction.date >= self.__period.start) | ||||
|             conditions.append(JournalEntry.date >= self.__period.start) | ||||
|         if self.__period.end is not None: | ||||
|             conditions.append(Transaction.date <= self.__period.end) | ||||
|             conditions.append(JournalEntry.date <= self.__period.end) | ||||
|         balance_func: sa.Function = sa.func.sum(sa.case( | ||||
|             (JournalEntry.is_debit, JournalEntry.amount), | ||||
|             else_=-JournalEntry.amount)).label("balance") | ||||
|             (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), | ||||
|             else_=-JournalEntryLineItem.amount)).label("balance") | ||||
|         select_balances: sa.Select = sa.select(Account.id, balance_func)\ | ||||
|             .join(Transaction).join(Account)\ | ||||
|             .join(JournalEntry).join(Account)\ | ||||
|             .filter(*conditions)\ | ||||
|             .group_by(Account.id)\ | ||||
|             .order_by(Account.base_code, Account.no) | ||||
|   | ||||
| @@ -26,8 +26,8 @@ import sqlalchemy as sa | ||||
| from flask import request | ||||
|  | ||||
| from accounting import db | ||||
| from accounting.models import Currency, JournalEntry | ||||
| from accounting.utils.txn_types import TransactionType | ||||
| from accounting.models import Currency, JournalEntryLineItem | ||||
| from accounting.utils.journal_entry_types import JournalEntryType | ||||
| from .option_link import OptionLink | ||||
| from .report_chooser import ReportChooser | ||||
|  | ||||
| @@ -52,12 +52,12 @@ class BasePageParams(ABC): | ||||
|         """ | ||||
|  | ||||
|     @property | ||||
|     def txn_types(self) -> t.Type[TransactionType]: | ||||
|         """Returns the transaction types. | ||||
|     def journal_entry_types(self) -> t.Type[JournalEntryType]: | ||||
|         """Returns the journal entry types. | ||||
|  | ||||
|         :return: The transaction types. | ||||
|         :return: The journal entry types. | ||||
|         """ | ||||
|         return TransactionType | ||||
|         return JournalEntryType | ||||
|  | ||||
|     @property | ||||
|     def csv_uri(self) -> str: | ||||
| @@ -81,8 +81,8 @@ class BasePageParams(ABC): | ||||
|         :return: The currency options. | ||||
|         """ | ||||
|         in_use: set[str] = set(db.session.scalars( | ||||
|             sa.select(JournalEntry.currency_code) | ||||
|             .group_by(JournalEntry.currency_code)).all()) | ||||
|             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()] | ||||
|   | ||||
| @@ -27,10 +27,15 @@ class OptionLink: | ||||
|         """Constructs an option link. | ||||
|  | ||||
|         :param title: The title. | ||||
|         :param url: The URI. | ||||
|         :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.""" | ||||
|   | ||||
| @@ -30,7 +30,7 @@ from accounting.locale import gettext | ||||
| from accounting.models import Currency, Account | ||||
| from accounting.report.period import Period, get_period | ||||
| from accounting.template_globals import default_currency_code | ||||
| from .ie_account import IncomeExpensesAccount | ||||
| from 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, \ | ||||
| @@ -113,7 +113,7 @@ class ReportChooser: | ||||
|             account: Account = Account.cash() | ||||
|         return OptionLink(gettext("Income and Expenses Log"), | ||||
|                           income_expenses_url(self.__currency, | ||||
|                                               IncomeExpensesAccount(account), | ||||
|                                               CurrentAccount(account), | ||||
|                                               self.__period), | ||||
|                           self.__active_report == ReportType.INCOME_EXPENSES, | ||||
|                           fa_icon="fa-solid fa-money-bill-wave") | ||||
|   | ||||
| @@ -22,7 +22,8 @@ from flask import url_for | ||||
| from accounting.models import Currency, Account | ||||
| from accounting.report.period import Period | ||||
| from accounting.template_globals import default_currency_code | ||||
| from .ie_account import IncomeExpensesAccount, default_ie_account_code | ||||
| from accounting.utils.current_account import CurrentAccount | ||||
| from accounting.utils.options import options | ||||
|  | ||||
|  | ||||
| def journal_url(period: Period) \ | ||||
| @@ -54,7 +55,7 @@ def ledger_url(currency: Currency, account: Account, period: Period) \ | ||||
|                    period=period) | ||||
|  | ||||
|  | ||||
| def income_expenses_url(currency: Currency, account: IncomeExpensesAccount, | ||||
| def income_expenses_url(currency: Currency, account: CurrentAccount, | ||||
|                         period: Period) -> str: | ||||
|     """Returns the URL of an income and expenses log. | ||||
|  | ||||
| @@ -64,7 +65,7 @@ def income_expenses_url(currency: Currency, account: IncomeExpensesAccount, | ||||
|     :return: The URL of the income and expenses log. | ||||
|     """ | ||||
|     if currency.code == default_currency_code() \ | ||||
|             and account.code == default_ie_account_code() \ | ||||
|             and account.code == options.default_ie_account_code \ | ||||
|             and period.is_default: | ||||
|         return url_for("accounting.report.default") | ||||
|     if period.is_default: | ||||
|   | ||||
| @@ -23,11 +23,12 @@ 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 | ||||
| from .utils.ie_account import IncomeExpensesAccount, default_ie_account | ||||
|  | ||||
| bp: Blueprint = Blueprint("report", __name__) | ||||
| """The view blueprint for the reports.""" | ||||
| @@ -43,7 +44,7 @@ def get_default_report() -> str | Response: | ||||
|     """ | ||||
|     return __get_income_expenses( | ||||
|         db.session.get(Currency, default_currency_code()), | ||||
|         default_ie_account(), | ||||
|         options.default_ie_account, | ||||
|         get_period()) | ||||
|  | ||||
|  | ||||
| @@ -126,8 +127,7 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \ | ||||
| @bp.get("income-expenses/<currency:currency>/<ieAccount:account>", | ||||
|         endpoint="income-expenses-default") | ||||
| @has_permission(can_view) | ||||
| def get_default_income_expenses(currency: Currency, | ||||
|                                 account: IncomeExpensesAccount) \ | ||||
| def get_default_income_expenses(currency: Currency, account: CurrentAccount) \ | ||||
|         -> str | Response: | ||||
|     """Returns the income and expenses log in the default period. | ||||
|  | ||||
| @@ -142,7 +142,7 @@ def get_default_income_expenses(currency: Currency, | ||||
|     "income-expenses/<currency:currency>/<ieAccount:account>/<period:period>", | ||||
|     endpoint="income-expenses") | ||||
| @has_permission(can_view) | ||||
| def get_income_expenses(currency: Currency, account: IncomeExpensesAccount, | ||||
| def get_income_expenses(currency: Currency, account: CurrentAccount, | ||||
|                         period: Period) -> str | Response: | ||||
|     """Returns the income and expenses log. | ||||
|  | ||||
| @@ -154,7 +154,7 @@ def get_income_expenses(currency: Currency, account: IncomeExpensesAccount, | ||||
|     return __get_income_expenses(currency, account, period) | ||||
|  | ||||
|  | ||||
| def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount, | ||||
| def __get_income_expenses(currency: Currency, account: CurrentAccount, | ||||
|                           period: Period) -> str | Response: | ||||
|     """Returns the income and expenses log. | ||||
|  | ||||
|   | ||||
| @@ -31,6 +31,9 @@ | ||||
|     color: #141619; | ||||
|     background-color: #D3D3D4; | ||||
| } | ||||
| .form-control.accounting-disabled { | ||||
|     background-color: #e9ecef; | ||||
| } | ||||
|  | ||||
| /** The toolbar */ | ||||
| .accounting-toolbar { | ||||
| @@ -73,7 +76,7 @@ | ||||
|         height: 3.2rem; | ||||
|         width: 3.2rem; | ||||
|         border-radius: 50%; | ||||
|         margin-left: 1rem; | ||||
|         margin-right: 0.5rem; | ||||
|     } | ||||
|     .accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn { | ||||
|         padding-top: 0.7rem; | ||||
| @@ -113,46 +116,70 @@ | ||||
|     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 */ | ||||
| .accounting-selector-list { | ||||
|     height: 20rem; | ||||
|     overflow-y: scroll; | ||||
| } | ||||
|  | ||||
| /** The transaction management */ | ||||
| /** The journal entry management */ | ||||
| .accounting-currency-control { | ||||
|     background-color: transparent; | ||||
| } | ||||
| .accounting-currency-content { | ||||
|     width: calc(100% - 3rem); | ||||
| } | ||||
| .accounting-entry-content { | ||||
| .accounting-line-item-content { | ||||
|     width: calc(100% - 3rem); | ||||
|     background-color: transparent; | ||||
| } | ||||
| .accounting-entry-control { | ||||
|     border-color: transparent; | ||||
| } | ||||
| .accounting-list-group-stripped .list-group-item:nth-child(2n+1) { | ||||
|     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 { | ||||
|     background-color: #ececec; | ||||
| } | ||||
| .accounting-transaction-entry { | ||||
| .accounting-journal-entry-line-item { | ||||
|     border: none; | ||||
| } | ||||
| .accounting-transaction-entry-header { | ||||
| .accounting-journal-entry-line-item-header { | ||||
|     font-weight: bolder; | ||||
|     border-bottom: thick double slategray; | ||||
| } | ||||
| .list-group-item.accounting-transaction-entry-total { | ||||
| .list-group-item.accounting-journal-entry-line-item-total { | ||||
|     font-weight: bolder; | ||||
|     border-top: thick double slategray; | ||||
| } | ||||
| .accounting-line-item-editor-original-line-item-content { | ||||
|     width: calc(100% - 3rem); | ||||
| } | ||||
|  | ||||
| /* The report table */ | ||||
| .accounting-report-table-header, .accounting-report-table-footer { | ||||
| @@ -191,12 +218,18 @@ a.accounting-report-table-row { | ||||
| .accounting-journal-table .accounting-report-table-row { | ||||
|     grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr; | ||||
| } | ||||
| .accounting-ledger-table .accounting-report-table-row { | ||||
| .accounting-ledger-real-table .accounting-report-table-row { | ||||
|     grid-template-columns: 1fr 4fr 1fr 1fr 1fr; | ||||
| } | ||||
| .accounting-ledger-table .accounting-report-table-footer .accounting-report-table-row { | ||||
| .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; | ||||
| } | ||||
| @@ -282,6 +315,30 @@ a.accounting-report-table-row { | ||||
|     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) */ | ||||
| .accounting-material-text-field { | ||||
|     position: relative; | ||||
| @@ -306,7 +363,7 @@ a.accounting-report-table-row { | ||||
| .accounting-material-fab { | ||||
|     position: fixed; | ||||
|     right: 2rem; | ||||
|     bottom: 1rem; | ||||
|     bottom: 2rem; | ||||
|     z-index: 10; | ||||
|     flex-direction: column-reverse; | ||||
| } | ||||
|   | ||||
| @@ -24,161 +24,411 @@ | ||||
|  | ||||
| // Initializes the page JavaScript. | ||||
| document.addEventListener("DOMContentLoaded", () => { | ||||
|     initializeBaseAccountSelector(); | ||||
|     document.getElementById("accounting-base-code") | ||||
|         .onchange = validateBase; | ||||
|     document.getElementById("accounting-title") | ||||
|         .onchange = validateTitle; | ||||
|     document.getElementById("accounting-form") | ||||
|         .onsubmit = validateForm; | ||||
|     AccountForm.initialize(); | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * Initializes the base account selector. | ||||
|  * The account form. | ||||
|  * | ||||
|  * @private | ||||
|  */ | ||||
| function initializeBaseAccountSelector() { | ||||
|     const selector = document.getElementById("accounting-base-selector-modal"); | ||||
|     const base = document.getElementById("accounting-base"); | ||||
|     const baseCode = document.getElementById("accounting-base-code"); | ||||
|     const baseContent = document.getElementById("accounting-base-content"); | ||||
|     const options = Array.from(document.getElementsByClassName("accounting-base-option")); | ||||
|     const btnClear = document.getElementById("accounting-btn-clear-base"); | ||||
|     selector.addEventListener("show.bs.modal", () => { | ||||
|         base.classList.add("accounting-not-empty"); | ||||
|         for (const option of options) { | ||||
|             option.classList.remove("active"); | ||||
|         } | ||||
|         const selected = document.getElementById("accounting-base-option-" + baseCode.value); | ||||
|         if (selected !== null) { | ||||
|             selected.classList.add("active"); | ||||
|         } | ||||
|     }); | ||||
|     selector.addEventListener("hidden.bs.modal", () => { | ||||
|         if (baseCode.value === "") { | ||||
|             base.classList.remove("accounting-not-empty"); | ||||
|         } | ||||
|     }); | ||||
|     for (const option of options) { | ||||
|         option.onclick = () => { | ||||
|             baseCode.value = option.dataset.code; | ||||
|             baseContent.innerText = option.dataset.content; | ||||
|             btnClear.classList.add("btn-danger"); | ||||
|             btnClear.classList.remove("btn-secondary") | ||||
|             btnClear.disabled = false; | ||||
|             validateBase(); | ||||
|             bootstrap.Modal.getInstance(selector).hide(); | ||||
| class AccountForm { | ||||
|  | ||||
|     /** | ||||
|      * The base account selector | ||||
|      * @type {BaseAccountSelector} | ||||
|      */ | ||||
|     #baseAccountSelector; | ||||
|  | ||||
|     /** | ||||
|      * The form element | ||||
|      * @type {HTMLFormElement} | ||||
|      */ | ||||
|     #formElement; | ||||
|  | ||||
|     /** | ||||
|      * The control of the base account | ||||
|      * @type {HTMLDivElement} | ||||
|      */ | ||||
|     #baseControl; | ||||
|  | ||||
|     /** | ||||
|      * The input of the base account | ||||
|      * @type {HTMLInputElement} | ||||
|      */ | ||||
|     #baseCode; | ||||
|  | ||||
|     /** | ||||
|      * The base account | ||||
|      * @type {HTMLDivElement} | ||||
|      */ | ||||
|     #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 = () => { | ||||
|         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. | ||||
|  * | ||||
|  * @private | ||||
|  */ | ||||
| function initializeBaseAccountQuery() { | ||||
|     const query = document.getElementById("accounting-base-selector-query"); | ||||
|     const optionList = document.getElementById("accounting-base-option-list"); | ||||
|     const options = Array.from(document.getElementsByClassName("accounting-base-option")); | ||||
|     const queryNoResult = document.getElementById("accounting-base-option-no-result"); | ||||
|     query.addEventListener("input", () => { | ||||
|         if (query.value === "") { | ||||
|             for (const option of options) { | ||||
|                 option.classList.remove("d-none"); | ||||
|             } | ||||
|             optionList.classList.remove("d-none"); | ||||
|             queryNoResult.classList.add("d-none"); | ||||
|             return | ||||
|     /** | ||||
|      * Returns the base code. | ||||
|      * | ||||
|      * @return {string|null} | ||||
|      */ | ||||
|     get baseCode() { | ||||
|         return this.#baseCode.value === ""? null: this.#baseCode.value; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * The callback when the base account selector is closed. | ||||
|      * | ||||
|      */ | ||||
|     onBaseAccountSelectorClosed() { | ||||
|         if (this.#baseCode.value === "") { | ||||
|             this.#baseControl.classList.remove("accounting-not-empty"); | ||||
|         } | ||||
|         let hasAnyMatched = false; | ||||
|         for (const option of options) { | ||||
|             const queryValues = JSON.parse(option.dataset.queryValues); | ||||
|             let isMatched = false; | ||||
|             for (const queryValue of queryValues) { | ||||
|                 if (queryValue.includes(query.value)) { | ||||
|                     isMatched = true; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|             if (isMatched) { | ||||
|                 option.classList.remove("d-none"); | ||||
|                 hasAnyMatched = true; | ||||
|             } else { | ||||
|                 option.classList.add("d-none"); | ||||
|             } | ||||
|         } | ||||
|         if (!hasAnyMatched) { | ||||
|             optionList.classList.add("d-none"); | ||||
|             queryNoResult.classList.remove("d-none"); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Saves the selected base account. | ||||
|      * | ||||
|      * @param account {BaseAccountOption} the selected base account | ||||
|      */ | ||||
|     saveBaseAccount(account) { | ||||
|         this.#baseCode.value = account.code; | ||||
|         this.#base.innerText = account.text; | ||||
|         if (["1", "2", "3"].includes(account.code.substring(0, 1))) { | ||||
|             this.#isNeedOffsetControl.classList.remove("d-none"); | ||||
|             this.#isNeedOffset.disabled = false; | ||||
|         } else { | ||||
|             optionList.classList.remove("d-none"); | ||||
|             queryNoResult.classList.add("d-none"); | ||||
|             this.#isNeedOffsetControl.classList.add("d-none"); | ||||
|             this.#isNeedOffset.disabled = true; | ||||
|             this.#isNeedOffset.checked = false; | ||||
|         } | ||||
|     }); | ||||
|         this.#validateBase(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Clears the base account. | ||||
|      * | ||||
|      */ | ||||
|     clearBaseAccount() { | ||||
|         this.#baseCode.value = ""; | ||||
|         this.#base.innerText = ""; | ||||
|         this.#validateBase(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Validates the form. | ||||
|      * | ||||
|      * @returns {boolean} true if valid, or false otherwise | ||||
|      */ | ||||
|     #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 | ||||
|  */ | ||||
| function validateForm() { | ||||
|     let isValid = true; | ||||
|     isValid = validateBase() && isValid; | ||||
|     isValid = validateTitle() && isValid; | ||||
|     return isValid; | ||||
| class BaseAccountSelector { | ||||
|  | ||||
|     /** | ||||
|      * The account form | ||||
|      * @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() { | ||||
|     const field = document.getElementById("accounting-base-code"); | ||||
|     const error = document.getElementById("accounting-base-code-error"); | ||||
|     const displayField = document.getElementById("accounting-base"); | ||||
|     field.value = field.value.trim(); | ||||
|     if (field.value === "") { | ||||
|         displayField.classList.add("is-invalid"); | ||||
|         error.innerText = A_("Please select the base account."); | ||||
| class BaseAccountOption { | ||||
|  | ||||
|     /** | ||||
|      * The base account selector | ||||
|      * @type {BaseAccountSelector} | ||||
|      */ | ||||
|     #selector; | ||||
|  | ||||
|     /** | ||||
|      * 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; | ||||
|     } | ||||
|     displayField.classList.remove("is-invalid"); | ||||
|     error.innerText = ""; | ||||
|     return true; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Validates the title. | ||||
|  * | ||||
|  * @returns {boolean} true if valid, or false otherwise | ||||
|  * @private | ||||
|  */ | ||||
| function validateTitle() { | ||||
|     const field = document.getElementById("accounting-title"); | ||||
|     const error = document.getElementById("accounting-title-error"); | ||||
|     field.value = field.value.trim(); | ||||
|     if (field.value === "") { | ||||
|         field.classList.add("is-invalid"); | ||||
|         error.innerText = A_("Please fill in the title."); | ||||
|         return false; | ||||
|     /** | ||||
|      * 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"); | ||||
|         } | ||||
|     } | ||||
|     field.classList.remove("is-invalid"); | ||||
|     error.innerText = ""; | ||||
|     return true; | ||||
| } | ||||
|   | ||||
| @@ -29,10 +29,10 @@ document.addEventListener("DOMContentLoaded", () => { | ||||
|         const onReorder = () => { | ||||
|             const accounts = Array.from(list.children); | ||||
|             for (let i = 0; i < accounts.length; i++) { | ||||
|                 const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no"); | ||||
|                 const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code"); | ||||
|                 const no = document.getElementById(`accounting-order-${accounts[i].dataset.id}-no`); | ||||
|                 const code = document.getElementById(`accounting-order-${accounts[i].dataset.id}-code`); | ||||
|                 no.value = String(i + 1); | ||||
|                 code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3); | ||||
|                 code.innerText = `${list.dataset.baseCode}-${`000${i + 1}`.slice(-3)}`; | ||||
|             } | ||||
|         }; | ||||
|         initializeDragAndDropReordering(list, onReorder); | ||||
|   | ||||
| @@ -1,250 +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/28 | ||||
|  */ | ||||
| "use strict"; | ||||
|  | ||||
| // Initializes the page JavaScript. | ||||
| document.addEventListener("DOMContentLoaded", () => { | ||||
|     AccountSelector.initialize(); | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * The account selector. | ||||
|  * | ||||
|  */ | ||||
| class AccountSelector { | ||||
|  | ||||
|     /** | ||||
|      * The entry type | ||||
|      * @type {string} | ||||
|      */ | ||||
|     #entryType; | ||||
|  | ||||
|     /** | ||||
|      * The prefix of the HTML ID and class | ||||
|      * @type {string} | ||||
|      */ | ||||
|     #prefix; | ||||
|  | ||||
|     /** | ||||
|      * Constructs an account selector. | ||||
|      * | ||||
|      * @param modal {HTMLFormElement} the account selector modal | ||||
|      */ | ||||
|     constructor(modal) { | ||||
|         this.#entryType = modal.dataset.entryType; | ||||
|         this.#prefix = "accounting-account-selector-" + modal.dataset.entryType; | ||||
|         this.#init(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Initializes the account selector. | ||||
|      * | ||||
|      */ | ||||
|     #init() { | ||||
|         const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||
|         const formAccount = document.getElementById("accounting-entry-form-account"); | ||||
|         const more = document.getElementById(this.#prefix + "-more"); | ||||
|         const btnClear = document.getElementById(this.#prefix + "-btn-clear"); | ||||
|         const options = Array.from(document.getElementsByClassName(this.#prefix + "-option")); | ||||
|         more.onclick = () => { | ||||
|             more.classList.add("d-none"); | ||||
|             this.#filterAccountOptions(); | ||||
|         }; | ||||
|         this.#initializeAccountQuery(); | ||||
|         btnClear.onclick = () => { | ||||
|             formAccountControl.classList.remove("accounting-not-empty"); | ||||
|             formAccount.innerText = ""; | ||||
|             formAccount.dataset.code = ""; | ||||
|             formAccount.dataset.text = ""; | ||||
|             validateJournalEntryAccount(); | ||||
|         }; | ||||
|         for (const option of options) { | ||||
|             option.onclick = () => { | ||||
|                 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. | ||||
|      * | ||||
|      */ | ||||
|     #initializeAccountQuery() { | ||||
|         const query = document.getElementById(this.#prefix + "-query"); | ||||
|         query.addEventListener("input", () => { | ||||
|             this.#filterAccountOptions(); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Filters the account options. | ||||
|      * | ||||
|      */ | ||||
|     #filterAccountOptions() { | ||||
|         const query = document.getElementById(this.#prefix + "-query"); | ||||
|         const optionList = document.getElementById(this.#prefix + "-option-list"); | ||||
|         if (optionList === null) { | ||||
|             console.log(this.#prefix + "-option-list"); | ||||
|         } | ||||
|         const options = Array.from(document.getElementsByClassName(this.#prefix + "-option")); | ||||
|         const more = document.getElementById(this.#prefix + "-more"); | ||||
|         const queryNoResult = document.getElementById(this.#prefix + "-option-no-result"); | ||||
|         const codesInUse = this.#getAccountCodeUsedInForm(); | ||||
|         let shouldAnyShow = false; | ||||
|         for (const option of options) { | ||||
|             const shouldShow = this.#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 the account codes that are used in the form. | ||||
|      * | ||||
|      * @return {string[]} the account codes that are used in the form | ||||
|      */ | ||||
|     #getAccountCodeUsedInForm() { | ||||
|         const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code")); | ||||
|         const formAccount = document.getElementById("accounting-entry-form-account"); | ||||
|         const inUse = [formAccount.dataset.code]; | ||||
|         for (const accountCode of accountCodes) { | ||||
|             inUse.push(accountCode.value); | ||||
|         } | ||||
|         return inUse | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 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 | ||||
|      */ | ||||
|     #shouldAccountOptionShow(option, more, inUse, query) { | ||||
|         const isQueryMatched = () => { | ||||
|             if (query.value === "") { | ||||
|                 return true; | ||||
|             } | ||||
|             const queryValues = JSON.parse(option.dataset.queryValues); | ||||
|             for (const queryValue of queryValues) { | ||||
|                 if (queryValue.includes(query.value)) { | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|             return false; | ||||
|         }; | ||||
|         const isMoreMatched = () => { | ||||
|             if (more.classList.contains("d-none")) { | ||||
|                 return true; | ||||
|             } | ||||
|             return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code); | ||||
|         }; | ||||
|         return isMoreMatched() && isQueryMatched(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Initializes the account selector when it is shown. | ||||
|      * | ||||
|      */ | ||||
|     initShow() { | ||||
|         const formAccount = document.getElementById("accounting-entry-form-account"); | ||||
|         const query = document.getElementById(this.#prefix + "-query") | ||||
|         const more = document.getElementById(this.#prefix + "-more"); | ||||
|         const options = Array.from(document.getElementsByClassName(this.#prefix + "-option")); | ||||
|         const btnClear = document.getElementById(this.#prefix + "-btn-clear"); | ||||
|         query.value = ""; | ||||
|         more.classList.remove("d-none"); | ||||
|         this.#filterAccountOptions(); | ||||
|         for (const option of options) { | ||||
|             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; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * The account selectors. | ||||
|      * @type {{debit: AccountSelector, credit: AccountSelector}} | ||||
|      */ | ||||
|     static #selectors = {} | ||||
|  | ||||
|     /** | ||||
|      * Initializes the account selectors. | ||||
|      * | ||||
|      */ | ||||
|     static initialize() { | ||||
|         const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal")); | ||||
|         for (const modal of modals) { | ||||
|             const selector = new AccountSelector(modal); | ||||
|             this.#selectors[selector.#entryType] = selector; | ||||
|         } | ||||
|         this.#initializeTransactionForm(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Initializes the transaction form. | ||||
|      * | ||||
|      */ | ||||
|     static #initializeTransactionForm() { | ||||
|         const entryForm = document.getElementById("accounting-entry-form"); | ||||
|         const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||
|         formAccountControl.onclick = () => this.#selectors[entryForm.dataset.entryType].initShow(); | ||||
|     } | ||||
|     /** | ||||
|      * Initializes the account selector for the journal entry form. | ||||
|      *x | ||||
|      */ | ||||
|     static initializeJournalEntryForm() { | ||||
|         const entryForm = document.getElementById("accounting-entry-form"); | ||||
|         const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||
|         formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal"; | ||||
|     } | ||||
| } | ||||
| @@ -24,152 +24,151 @@ | ||||
|  | ||||
| // Initializes the page JavaScript. | ||||
| document.addEventListener("DOMContentLoaded", () => { | ||||
|     document.getElementById("accounting-code") | ||||
|         .onchange = validateCode; | ||||
|     document.getElementById("accounting-name") | ||||
|         .onchange = validateName; | ||||
|     document.getElementById("accounting-form") | ||||
|         .onsubmit = validateForm; | ||||
|     CurrencyForm.initialize(); | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * The asynchronous validation result | ||||
|  * @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. | ||||
|  * The currency form. | ||||
|  * | ||||
|  * @private | ||||
|  */ | ||||
| function submitFormIfAllAsyncValid() { | ||||
|     let isValid = true; | ||||
|     for (const key of Object.keys(isAsyncValid)) { | ||||
|         isValid = isAsyncValid[key] && isValid; | ||||
|     } | ||||
|     if (isValid) { | ||||
|         document.getElementById("accounting-form").submit() | ||||
|     } | ||||
| } | ||||
| class CurrencyForm { | ||||
|  | ||||
| /** | ||||
|  * Validates the code. | ||||
|  * | ||||
|  * @param changeEvent {Event} the change event, if invoked from onchange | ||||
|  * @returns {boolean} true if valid, or false otherwise | ||||
|  * @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; | ||||
| } | ||||
|     /** | ||||
|      * The form. | ||||
|      * @type {HTMLFormElement} | ||||
|      */ | ||||
|     #formElement; | ||||
|  | ||||
| /** | ||||
|  * Validates asynchronously whether the code is duplicated. | ||||
|  * The boolean validation result is stored in isAsyncValid[key]. | ||||
|  * | ||||
|  * @param isSubmission {boolean} whether this is invoked from a form submission | ||||
|  * @param key {string} the key to store the result in isAsyncValid | ||||
|  * @private | ||||
|  */ | ||||
| function validateAsyncCodeIsDuplicated(isSubmission, key) { | ||||
|     const field = document.getElementById("accounting-code"); | ||||
|     const error = document.getElementById("accounting-code-error"); | ||||
|     const url = field.dataset.existsUrl; | ||||
|     const onLoad = function () { | ||||
|         if (this.status === 200) { | ||||
|             const result = JSON.parse(this.responseText); | ||||
|             if (result["exists"]) { | ||||
|                 field.classList.add("is-invalid"); | ||||
|                 error.innerText = A_("Code conflicts with another currency."); | ||||
|                 if (isSubmission) { | ||||
|                     isAsyncValid[key] = false; | ||||
|     /** | ||||
|      * The code | ||||
|      * @type {HTMLInputElement} | ||||
|      */ | ||||
|     #code; | ||||
|  | ||||
|     /** | ||||
|      * The error message of the code | ||||
|      * @type {HTMLDivElement} | ||||
|      */ | ||||
|     #codeError; | ||||
|  | ||||
|     /** | ||||
|      * The name | ||||
|      * @type {HTMLInputElement} | ||||
|      */ | ||||
|     #name; | ||||
|  | ||||
|     /** | ||||
|      * 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; | ||||
|             } | ||||
|             field.classList.remove("is-invalid"); | ||||
|             error.innerText = ""; | ||||
|             if (isSubmission) { | ||||
|                 isAsyncValid[key] = true; | ||||
|                 submitFormIfAllAsyncValid(); | ||||
|             }); | ||||
|             return false; | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 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; | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|     const request = new XMLHttpRequest(); | ||||
|     request.onload = onLoad; | ||||
|     request.open("GET", url + "?q=" + encodeURIComponent(field.value)); | ||||
|     request.send(); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Validates the name. | ||||
|  * | ||||
|  * @returns {boolean} true if valid, or false otherwise | ||||
|  * @private | ||||
|  */ | ||||
| function validateName() { | ||||
|     const field = document.getElementById("accounting-name"); | ||||
|     const error = document.getElementById("accounting-name-error"); | ||||
|     field.value = field.value.trim(); | ||||
|     if (field.value === "") { | ||||
|         field.classList.add("is-invalid"); | ||||
|         error.innerText = A_("Please fill in the name."); | ||||
|         return false; | ||||
|         this.#code.classList.remove("is-invalid"); | ||||
|         this.#codeError.innerText = ""; | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Validates the name. | ||||
|      * | ||||
|      * @returns {boolean} true if valid, or false otherwise | ||||
|      */ | ||||
|     #validateName() { | ||||
|         this.#name.value = this.#name.value.trim(); | ||||
|         if (this.#name.value === "") { | ||||
|             this.#name.classList.add("is-invalid"); | ||||
|             this.#nameError.innerText = A_("Please fill in the name."); | ||||
|             return false; | ||||
|         } | ||||
|         this.#name.classList.remove("is-invalid"); | ||||
|         this.#nameError.innerText = ""; | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 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; | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| /* The Mia! Accounting Flask Project | ||||
|  * summary-editor.js: The JavaScript for the summary editor | ||||
|  * description-editor.js: The JavaScript for the description editor | ||||
|  */ | ||||
| 
 | ||||
| /*  Copyright (c) 2023 imacat. | ||||
| @@ -22,61 +22,68 @@ | ||||
|  */ | ||||
| "use strict"; | ||||
| 
 | ||||
| // Initializes the page JavaScript.
 | ||||
| document.addEventListener("DOMContentLoaded", () => { | ||||
|     SummaryEditor.initialize(); | ||||
| }); | ||||
| 
 | ||||
| /** | ||||
|  * A summary editor. | ||||
|  * A description editor. | ||||
|  * | ||||
|  */ | ||||
| class SummaryEditor { | ||||
| class DescriptionEditor { | ||||
| 
 | ||||
|     /** | ||||
|      * The summary editor form | ||||
|      * The line item editor | ||||
|      * @type {JournalEntryLineItemEditor} | ||||
|      */ | ||||
|     lineItemEditor; | ||||
| 
 | ||||
|     /** | ||||
|      * The description editor form | ||||
|      * @type {HTMLFormElement} | ||||
|      */ | ||||
|     #form; | ||||
| 
 | ||||
|     /** | ||||
|      * The prefix of the HTML ID and class | ||||
|      * The prefix of the HTML ID and class names | ||||
|      * @type {string} | ||||
|      */ | ||||
|     prefix; | ||||
| 
 | ||||
|     /** | ||||
|      * The modal of the summary editor | ||||
|      * The modal of the description editor | ||||
|      * @type {HTMLDivElement} | ||||
|      */ | ||||
|     #modal; | ||||
| 
 | ||||
|     /** | ||||
|      * The entry type, either "debit" or "credit" | ||||
|      * Either "debit" or "credit" | ||||
|      * @type {string} | ||||
|      */ | ||||
|     #entryType; | ||||
|     debitCredit; | ||||
| 
 | ||||
|     /** | ||||
|      * The current tab. | ||||
|      * The current tab | ||||
|      * @type {TabPlane} | ||||
|      */ | ||||
|     currentTab; | ||||
| 
 | ||||
|     /** | ||||
|      * The summary input. | ||||
|      * The description input | ||||
|      * @type {HTMLInputElement} | ||||
|      */ | ||||
|     summary; | ||||
|     description; | ||||
| 
 | ||||
|     /** | ||||
|      * The number input. | ||||
|      * The button to the original line item selector | ||||
|      * @type {HTMLButtonElement} | ||||
|      */ | ||||
|     #offsetButton; | ||||
| 
 | ||||
|     /** | ||||
|      * The number input | ||||
|      * @type {HTMLInputElement} | ||||
|      */ | ||||
|     number; | ||||
| 
 | ||||
|     /** | ||||
|      * The note. | ||||
|      * The note | ||||
|      * @type {HTMLInputElement} | ||||
|      */ | ||||
|     note; | ||||
| @@ -93,72 +100,39 @@ class SummaryEditor { | ||||
|      */ | ||||
|     #selectedAccount = null; | ||||
| 
 | ||||
|     /** | ||||
|      * The modal of the journal entry form | ||||
|      * @type {HTMLDivElement} | ||||
|      */ | ||||
|     #entryFormModal; | ||||
| 
 | ||||
|     /** | ||||
|      * The control of the account on the journal entry form | ||||
|      * @type {HTMLDivElement} | ||||
|      */ | ||||
|     #formAccountControl; | ||||
| 
 | ||||
|     /** | ||||
|      * The account on the journal entry form | ||||
|      * @type {HTMLDivElement} | ||||
|      */ | ||||
|     #formAccount; | ||||
| 
 | ||||
|     /** | ||||
|      * The control of the summary on the journal entry form | ||||
|      * @type {HTMLDivElement} | ||||
|      */ | ||||
|     #formSummaryControl; | ||||
| 
 | ||||
|     /** | ||||
|      * The summary on the journal entry form | ||||
|      * @type {HTMLDivElement} | ||||
|      */ | ||||
|     #formSummary; | ||||
| 
 | ||||
|     /** | ||||
|      * The tab planes | ||||
|      * @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}} | ||||
|      * @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, recurring: RecurringTransactionTab, annotation: AnnotationTab}} | ||||
|      */ | ||||
|     tabPlanes = {}; | ||||
| 
 | ||||
|     /** | ||||
|      * Constructs a summary editor. | ||||
|      * Constructs a description editor. | ||||
|      * | ||||
|      * @param form {HTMLFormElement} the summary editor form | ||||
|      * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor | ||||
|      * @param debitCredit {string} either "debit" or "credit" | ||||
|      */ | ||||
|     constructor(form) { | ||||
|         this.#form = form; | ||||
|         this.#entryType = form.dataset.entryType; | ||||
|         this.prefix = "accounting-summary-editor-" + form.dataset.entryType; | ||||
|         this.#modal = document.getElementById(this.prefix + "-modal"); | ||||
|         this.summary = document.getElementById(this.prefix + "-summary"); | ||||
|         this.number = document.getElementById(this.prefix + "-annotation-number"); | ||||
|         this.note = document.getElementById(this.prefix + "-annotation-note"); | ||||
|     constructor(lineItemEditor, debitCredit) { | ||||
|         this.lineItemEditor = lineItemEditor; | ||||
|         this.debitCredit = debitCredit; | ||||
|         this.prefix = `accounting-description-editor-${debitCredit}`; | ||||
|         this.#form = document.getElementById(this.prefix); | ||||
|         this.#modal = document.getElementById(`${this.prefix}-modal`); | ||||
|         this.description = document.getElementById(`${this.prefix}-description`); | ||||
|         this.#offsetButton = document.getElementById(`${this.prefix}-offset`); | ||||
|         this.number = document.getElementById(`${this.prefix}-annotation-number`); | ||||
|         this.note = document.getElementById(`${this.prefix}-annotation-note`); | ||||
|         // noinspection JSValidateTypes
 | ||||
|         this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account")); | ||||
|         this.#accountButtons = Array.from(document.getElementsByClassName(`${this.prefix}-account`)); | ||||
| 
 | ||||
|         // Things from the entry form
 | ||||
|         this.#entryFormModal = document.getElementById("accounting-entry-form-modal"); | ||||
|         this.#formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||
|         this.#formAccount = document.getElementById("accounting-entry-form-account"); | ||||
|         this.#formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); | ||||
|         this.#formSummary = document.getElementById("accounting-entry-form-summary"); | ||||
| 
 | ||||
|         for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) { | ||||
|         for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RecurringTransactionTab, AnnotationTab]) { | ||||
|             const tab = new cls(this); | ||||
|             this.tabPlanes[tab.tabId()] = tab; | ||||
|         } | ||||
|         this.currentTab = this.tabPlanes.general; | ||||
|         this.#initializeSuggestedAccounts(); | ||||
|         this.summary.onchange = () => this.#onSummaryChange(); | ||||
|         this.description.onchange = () => this.#onDescriptionChange(); | ||||
|         this.#offsetButton.onclick = () => this.lineItemEditor.originalLineItemSelector.onOpen(); | ||||
|         this.#form.onsubmit = () => { | ||||
|             if (this.currentTab.validate()) { | ||||
|                 this.#submit(); | ||||
| @@ -168,12 +142,12 @@ class SummaryEditor { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * The callback when the summary input is changed. | ||||
|      * The callback when the description input is changed. | ||||
|      * | ||||
|      */ | ||||
|     #onSummaryChange() { | ||||
|         this.summary.value = this.summary.value.trim(); | ||||
|         for (const tabPlane of [this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) { | ||||
|     #onDescriptionChange() { | ||||
|         this.description.value = this.description.value.trim(); | ||||
|         for (const tabPlane of [this.tabPlanes.recurring, this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) { | ||||
|             if (tabPlane.populate()) { | ||||
|                 break; | ||||
|             } | ||||
| @@ -184,27 +158,31 @@ class SummaryEditor { | ||||
|     /** | ||||
|      * Filters the suggested accounts. | ||||
|      * | ||||
|      * @param tagButton {HTMLButtonElement|null} the tag button | ||||
|      * @param tagButton {HTMLButtonElement} the tag button | ||||
|      */ | ||||
|     filterSuggestedAccounts(tagButton) { | ||||
|         for (const accountButton of this.#accountButtons) { | ||||
|             accountButton.classList.add("d-none"); | ||||
|         } | ||||
|         if (tagButton === null) { | ||||
|             this.#selectAccount(null); | ||||
|             return; | ||||
|         } | ||||
|         this.clearSuggestedAccounts(); | ||||
|         const suggested = JSON.parse(tagButton.dataset.accounts); | ||||
|         let selectedAccountButton = null; | ||||
|         for (const accountButton of this.#accountButtons) { | ||||
|             if (suggested.includes(accountButton.dataset.code)) { | ||||
|                 accountButton.classList.remove("d-none"); | ||||
|                 if (accountButton.dataset.code === suggested[0]) { | ||||
|                     selectedAccountButton = accountButton; | ||||
|                     this.#selectAccount(accountButton); | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         this.#selectAccount(selectedAccountButton); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Clears the suggested accounts. | ||||
|      * | ||||
|      */ | ||||
|     clearSuggestedAccounts() { | ||||
|         for (const accountButton of this.#accountButtons) { | ||||
|             accountButton.classList.add("d-none"); | ||||
|         } | ||||
|         this.#selectAccount(null); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @@ -235,43 +213,34 @@ class SummaryEditor { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Submits the summary. | ||||
|      * Submits the description. | ||||
|      * | ||||
|      */ | ||||
|     #submit() { | ||||
|         if (this.summary.value === "") { | ||||
|             this.#formSummaryControl.classList.remove("accounting-not-empty"); | ||||
|         } else { | ||||
|             this.#formSummaryControl.classList.add("accounting-not-empty"); | ||||
|         } | ||||
|         if (this.#selectedAccount !== null) { | ||||
|             this.#formAccountControl.classList.add("accounting-not-empty"); | ||||
|             this.#formAccount.dataset.code = this.#selectedAccount.dataset.code; | ||||
|             this.#formAccount.dataset.text = this.#selectedAccount.dataset.text; | ||||
|             this.#formAccount.innerText = this.#selectedAccount.dataset.text; | ||||
|         } | ||||
|         this.#formSummary.dataset.value = this.summary.value; | ||||
|         this.#formSummary.innerText = this.summary.value; | ||||
|         bootstrap.Modal.getOrCreateInstance(this.#modal).hide(); | ||||
|         bootstrap.Modal.getOrCreateInstance(this.#entryFormModal).show(); | ||||
|         if (this.#selectedAccount !== null) { | ||||
|             this.lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset")); | ||||
|         } else { | ||||
|             this.lineItemEditor.saveDescription(this.description.value); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * The callback when the summary editor is shown. | ||||
|      * The callback when the description editor is shown. | ||||
|      * | ||||
|      */ | ||||
|     #onOpen() { | ||||
|     onOpen() { | ||||
|         this.#reset(); | ||||
|         this.summary.value = this.#formSummary.dataset.value; | ||||
|         this.#onSummaryChange(); | ||||
|         this.description.value = this.lineItemEditor.description === null? "": this.lineItemEditor.description; | ||||
|         this.#onDescriptionChange(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Resets the summary editor. | ||||
|      * Resets the description editor. | ||||
|      * | ||||
|      */ | ||||
|     #reset() { | ||||
|         this.summary.value = ""; | ||||
|         this.description.value = ""; | ||||
|         for (const tabPlane of Object.values(this.tabPlanes)) { | ||||
|             tabPlane.reset(); | ||||
|         } | ||||
| @@ -279,33 +248,18 @@ class SummaryEditor { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * The summary editors. | ||||
|      * @type {{debit: SummaryEditor, credit: SummaryEditor}} | ||||
|      */ | ||||
|     static #editors = {} | ||||
| 
 | ||||
|     /** | ||||
|      * Initializes the summary editors. | ||||
|      * Returns the description editor instances. | ||||
|      * | ||||
|      * @param lineItemEditor {JournalEntryLineItemEditor} the line item editor | ||||
|      * @return {{debit: DescriptionEditor, credit: DescriptionEditor}} | ||||
|      */ | ||||
|     static initialize() { | ||||
|         const forms = Array.from(document.getElementsByClassName("accounting-summary-editor")); | ||||
|         const entryForm = document.getElementById("accounting-entry-form"); | ||||
|         const formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); | ||||
|     static getInstances(lineItemEditor) { | ||||
|         const editors = {} | ||||
|         const forms = Array.from(document.getElementsByClassName("accounting-description-editor")); | ||||
|         for (const form of forms) { | ||||
|             const editor = new SummaryEditor(form); | ||||
|             this.#editors[editor.#entryType] = editor; | ||||
|             editors[form.dataset.debitCredit] = new DescriptionEditor(lineItemEditor, form.dataset.debitCredit); | ||||
|         } | ||||
|         formSummaryControl.onclick = () => this.#editors[entryForm.dataset.entryType].#onOpen() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initializes the summary editor for a new journal entry. | ||||
|      * | ||||
|      * @param entryType {string} the entry type, either "debit" or "credit" | ||||
|      */ | ||||
|     static initializeNewJournalEntry(entryType) { | ||||
|         this.#editors[entryType].#onOpen(); | ||||
|         return editors; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @@ -318,13 +272,13 @@ class SummaryEditor { | ||||
| class TabPlane { | ||||
| 
 | ||||
|     /** | ||||
|      * The parent summary editor | ||||
|      * @type {SummaryEditor} | ||||
|      * The parent description editor | ||||
|      * @type {DescriptionEditor} | ||||
|      */ | ||||
|     editor; | ||||
| 
 | ||||
|     /** | ||||
|      * The prefix of the HTML ID and classes | ||||
|      * The prefix of the HTML ID and class names | ||||
|      * @type {string} | ||||
|      */ | ||||
|     prefix; | ||||
| @@ -344,13 +298,13 @@ class TabPlane { | ||||
|     /** | ||||
|      * Constructs a tab plane. | ||||
|      * | ||||
|      * @param editor {SummaryEditor} the parent summary editor | ||||
|      * @param editor {DescriptionEditor} the parent description editor | ||||
|      */ | ||||
|     constructor(editor) { | ||||
|         this.editor = editor; | ||||
|         this.prefix = this.editor.prefix + "-" + this.tabId(); | ||||
|         this.#tab = document.getElementById(this.prefix + "-tab"); | ||||
|         this.#page = document.getElementById(this.prefix + "-page"); | ||||
|         this.prefix = `${this.editor.prefix}-${this.tabId()}`; | ||||
|         this.#tab = document.getElementById(`${this.prefix}-tab`); | ||||
|         this.#page = document.getElementById(`${this.prefix}-page`); | ||||
|         this.#tab.onclick = () => this.switchToMe(); | ||||
|     } | ||||
| 
 | ||||
| @@ -370,9 +324,9 @@ class TabPlane { | ||||
|     reset() { throw new Error("Method not implemented."); } | ||||
| 
 | ||||
|     /** | ||||
|      * Populates the tab plane with the summary input. | ||||
|      * Populates the tab plane with the description input. | ||||
|      * | ||||
|      * @return {boolean} true if the summary input matches this tab, or false otherwise | ||||
|      * @return {boolean} true if the description input matches this tab, or false otherwise | ||||
|      * @abstract | ||||
|      */ | ||||
|     populate() { throw new Error("Method not implemented."); } | ||||
| @@ -433,19 +387,19 @@ class TagTabPlane extends TabPlane { | ||||
|     /** | ||||
|      * Constructs a tab plane. | ||||
|      * | ||||
|      * @param editor {SummaryEditor} the parent summary editor | ||||
|      * @param editor {DescriptionEditor} the parent description editor | ||||
|      * @override | ||||
|      */ | ||||
|     constructor(editor) { | ||||
|         super(editor); | ||||
|         this.tag = document.getElementById(this.prefix + "-tag"); | ||||
|         this.tagError = document.getElementById(this.prefix + "-tag-error"); | ||||
|         this.tag = document.getElementById(`${this.prefix}-tag`); | ||||
|         this.tagError = document.getElementById(`${this.prefix}-tag-error`); | ||||
|         // noinspection JSValidateTypes
 | ||||
|         this.tagButtons = Array.from(document.getElementsByClassName(this.prefix + "-btn-tag")); | ||||
|         this.tagButtons = Array.from(document.getElementsByClassName(`${this.prefix}-btn-tag`)); | ||||
|         this.initializeTagButtons(); | ||||
|         this.tag.onchange = () => { | ||||
|             this.onTagChange(); | ||||
|             this.updateSummary(); | ||||
|             this.updateDescription(); | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
| @@ -468,17 +422,17 @@ class TagTabPlane extends TabPlane { | ||||
|             } | ||||
|         } | ||||
|         if (!isMatched) { | ||||
|             this.editor.filterSuggestedAccounts(null); | ||||
|             this.editor.clearSuggestedAccounts(); | ||||
|         } | ||||
|         this.validateTag(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Updates the summary according to the input in the tab plane. | ||||
|      * Updates the description according to the input in the tab plane. | ||||
|      * | ||||
|      * @abstract | ||||
|      */ | ||||
|     updateSummary() { throw new Error("Method not implemented."); } | ||||
|     updateDescription() { throw new Error("Method not implemented."); } | ||||
| 
 | ||||
|     /** | ||||
|      * Switches to the tab plane. | ||||
| @@ -486,14 +440,13 @@ class TagTabPlane extends TabPlane { | ||||
|      */ | ||||
|     switchToMe() { | ||||
|         super.switchToMe(); | ||||
|         let selectedTagButton = null; | ||||
|         for (const tagButton of this.tagButtons) { | ||||
|             if (tagButton.classList.contains("btn-primary")) { | ||||
|                 selectedTagButton = tagButton; | ||||
|                 break; | ||||
|                 this.editor.filterSuggestedAccounts(tagButton); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|         this.editor.filterSuggestedAccounts(selectedTagButton); | ||||
|         this.editor.clearSuggestedAccounts(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @@ -511,7 +464,7 @@ class TagTabPlane extends TabPlane { | ||||
|                 tagButton.classList.add("btn-primary"); | ||||
|                 this.tag.value = tagButton.dataset.value; | ||||
|                 this.editor.filterSuggestedAccounts(tagButton); | ||||
|                 this.updateSummary(); | ||||
|                 this.updateDescription(); | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
| @@ -547,7 +500,7 @@ class TagTabPlane extends TabPlane { | ||||
|         errorContainer.innerText = ""; | ||||
|         return true; | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     /** | ||||
|      * Resets the tab plane input. | ||||
|      * | ||||
| @@ -582,28 +535,28 @@ class GeneralTagTab extends TagTabPlane { | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
|      * Updates the summary according to the input in the tab plane. | ||||
|      * Updates the description according to the input in the tab plane. | ||||
|      * | ||||
|      * @override | ||||
|      */ | ||||
|     updateSummary() { | ||||
|         const pos = this.editor.summary.value.indexOf("—"); | ||||
|         const prefix = this.tag.value === ""? "": this.tag.value + "—"; | ||||
|     updateDescription() { | ||||
|         const pos = this.editor.description.value.indexOf("—"); | ||||
|         const prefix = this.tag.value === ""? "": `${this.tag.value}—`; | ||||
|         if (pos === -1) { | ||||
|             this.editor.summary.value = prefix + this.editor.summary.value; | ||||
|             this.editor.description.value = `${prefix}${this.editor.description.value}`; | ||||
|         } else { | ||||
|             this.editor.summary.value = prefix + this.editor.summary.value.substring(pos + 1); | ||||
|             this.editor.description.value = `${prefix}${this.editor.description.value.substring(pos + 1)}`; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Populates the tab plane with the summary input. | ||||
|      * Populates the tab plane with the description input. | ||||
|      * | ||||
|      * @return {boolean} true if the summary input matches this tab, or false otherwise | ||||
|      * @return {boolean} true if the description input matches this tab, or false otherwise | ||||
|      * @override | ||||
|      */ | ||||
|     populate() { | ||||
|         const found = this.editor.summary.value.match(/^([^—]+)—/); | ||||
|         const found = this.editor.description.value.match(/^([^—]+)—/); | ||||
|         if (found === null) { | ||||
|             return false; | ||||
|         } | ||||
| @@ -611,13 +564,6 @@ class GeneralTagTab extends TagTabPlane { | ||||
|             this.tag.value = found[1]; | ||||
|             this.onTagChange(); | ||||
|         } | ||||
|         for (const tagButton of this.tagButtons) { | ||||
|             if (tagButton.dataset.value === this.tag.value) { | ||||
|                 tagButton.classList.remove("btn-outline-primary"); | ||||
|                 tagButton.classList.add("btn-primary"); | ||||
|                 this.editor.filterSuggestedAccounts(tagButton); | ||||
|             } | ||||
|         } | ||||
|         this.switchToMe(); | ||||
|         return true; | ||||
|     } | ||||
| @@ -672,20 +618,20 @@ class GeneralTripTab extends TagTabPlane { | ||||
|     /** | ||||
|      * Constructs a tab plane. | ||||
|      * | ||||
|      * @param editor {SummaryEditor} the parent summary editor | ||||
|      * @param editor {DescriptionEditor} the parent description editor | ||||
|      * @override | ||||
|      */ | ||||
|     constructor(editor) { | ||||
|         super(editor); | ||||
|         this.#from = document.getElementById(this.prefix + "-from"); | ||||
|         this.#fromError = document.getElementById(this.prefix + "-from-error"); | ||||
|         this.#to = document.getElementById(this.prefix + "-to"); | ||||
|         this.#toError = document.getElementById(this.prefix + "-to-error") | ||||
|         this.#from = document.getElementById(`${this.prefix}-from`); | ||||
|         this.#fromError = document.getElementById(`${this.prefix}-from-error`); | ||||
|         this.#to = document.getElementById(`${this.prefix}-to`); | ||||
|         this.#toError = document.getElementById(`${this.prefix}-to-error`) | ||||
|         // noinspection JSValidateTypes
 | ||||
|         this.#directionButtons = Array.from(document.getElementsByClassName(this.prefix + "-direction")); | ||||
|         this.#directionButtons = Array.from(document.getElementsByClassName(`${this.prefix}-direction`)); | ||||
|         this.#from.onchange = () => { | ||||
|             this.#from.value = this.#from.value.trim(); | ||||
|             this.updateSummary(); | ||||
|             this.updateDescription(); | ||||
|             this.validateFrom(); | ||||
|         }; | ||||
|         for (const directionButton of this.#directionButtons) { | ||||
| @@ -696,12 +642,12 @@ class GeneralTripTab extends TagTabPlane { | ||||
|                 } | ||||
|                 directionButton.classList.remove("btn-outline-primary"); | ||||
|                 directionButton.classList.add("btn-primary"); | ||||
|                 this.updateSummary(); | ||||
|                 this.updateDescription(); | ||||
|             }; | ||||
|         } | ||||
|         this.#to.onchange = () => { | ||||
|             this.#to.value = this.#to.value.trim(); | ||||
|             this.updateSummary(); | ||||
|             this.updateDescription(); | ||||
|             this.validateTo(); | ||||
|         }; | ||||
|     } | ||||
| @@ -717,11 +663,11 @@ class GeneralTripTab extends TagTabPlane { | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
|      * Updates the summary according to the input in the tab plane. | ||||
|      * Updates the description according to the input in the tab plane. | ||||
|      * | ||||
|      * @override | ||||
|      */ | ||||
|     updateSummary() { | ||||
|     updateDescription() { | ||||
|         let direction; | ||||
|         for (const directionButton of this.#directionButtons) { | ||||
|             if (directionButton.classList.contains("btn-primary")) { | ||||
| @@ -729,7 +675,7 @@ class GeneralTripTab extends TagTabPlane { | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|         this.editor.summary.value = this.tag.value + "—" + this.#from.value + direction + this.#to.value; | ||||
|         this.editor.description.value = `${this.tag.value}—${this.#from.value}${direction}${this.#to.value}`; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @@ -757,13 +703,13 @@ class GeneralTripTab extends TagTabPlane { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Populates the tab plane with the summary input. | ||||
|      * Populates the tab plane with the description input. | ||||
|      * | ||||
|      * @return {boolean} true if the summary input matches this tab, or false otherwise | ||||
|      * @return {boolean} true if the description input matches this tab, or false otherwise | ||||
|      * @override | ||||
|      */ | ||||
|     populate() { | ||||
|         const found = this.editor.summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/); | ||||
|         const found = this.editor.description.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/); | ||||
|         if (found === null) { | ||||
|             return false; | ||||
|         } | ||||
| @@ -782,13 +728,6 @@ class GeneralTripTab extends TagTabPlane { | ||||
|             } | ||||
|         } | ||||
|         this.#to.value = found[4]; | ||||
|         for (const tagButton of this.tagButtons) { | ||||
|             if (tagButton.dataset.value === this.tag.value) { | ||||
|                 tagButton.classList.remove("btn-outline-primary"); | ||||
|                 tagButton.classList.add("btn-primary"); | ||||
|                 this.editor.filterSuggestedAccounts(tagButton); | ||||
|             } | ||||
|         } | ||||
|         this.switchToMe(); | ||||
|         return true; | ||||
|     } | ||||
| @@ -884,30 +823,30 @@ class BusTripTab extends TagTabPlane { | ||||
|     /** | ||||
|      * Constructs a tab plane. | ||||
|      * | ||||
|      * @param editor {SummaryEditor} the parent summary editor | ||||
|      * @param editor {DescriptionEditor} the parent description editor | ||||
|      * @override | ||||
|      */ | ||||
|     constructor(editor) { | ||||
|         super(editor); | ||||
|         this.#route = document.getElementById(this.prefix + "-route"); | ||||
|         this.#routeError = document.getElementById(this.prefix + "-route-error"); | ||||
|         this.#from = document.getElementById(this.prefix + "-from"); | ||||
|         this.#fromError = document.getElementById(this.prefix + "-from-error"); | ||||
|         this.#to = document.getElementById(this.prefix + "-to"); | ||||
|         this.#toError = document.getElementById(this.prefix + "-to-error") | ||||
|         this.#route = document.getElementById(`${this.prefix}-route`); | ||||
|         this.#routeError = document.getElementById(`${this.prefix}-route-error`); | ||||
|         this.#from = document.getElementById(`${this.prefix}-from`); | ||||
|         this.#fromError = document.getElementById(`${this.prefix}-from-error`); | ||||
|         this.#to = document.getElementById(`${this.prefix}-to`); | ||||
|         this.#toError = document.getElementById(`${this.prefix}-to-error`) | ||||
|         this.#route.onchange = () => { | ||||
|             this.#route.value = this.#route.value.trim(); | ||||
|             this.updateSummary(); | ||||
|             this.updateDescription(); | ||||
|             this.validateRoute(); | ||||
|         }; | ||||
|         this.#from.onchange = () => { | ||||
|             this.#from.value = this.#from.value.trim(); | ||||
|             this.updateSummary(); | ||||
|             this.updateDescription(); | ||||
|             this.validateFrom(); | ||||
|         }; | ||||
|         this.#to.onchange = () => { | ||||
|             this.#to.value = this.#to.value.trim(); | ||||
|             this.updateSummary(); | ||||
|             this.updateDescription(); | ||||
|             this.validateTo(); | ||||
|         }; | ||||
|     } | ||||
| @@ -923,12 +862,12 @@ class BusTripTab extends TagTabPlane { | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
|      * Updates the summary according to the input in the tab plane. | ||||
|      * Updates the description according to the input in the tab plane. | ||||
|      * | ||||
|      * @override | ||||
|      */ | ||||
|     updateSummary() { | ||||
|         this.editor.summary.value = this.tag.value + "—" + this.#route.value + "—" + this.#from.value + "→" + this.#to.value; | ||||
|     updateDescription() { | ||||
|         this.editor.description.value = `${this.tag.value}—${this.#route.value}—${this.#from.value}→${this.#to.value}`; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @@ -950,13 +889,13 @@ class BusTripTab extends TagTabPlane { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Populates the tab plane with the summary input. | ||||
|      * Populates the tab plane with the description input. | ||||
|      * | ||||
|      * @return {boolean} true if the summary input matches this tab, or false otherwise | ||||
|      * @return {boolean} true if the description input matches this tab, or false otherwise | ||||
|      * @override | ||||
|      */ | ||||
|     populate() { | ||||
|         const found = this.editor.summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/); | ||||
|         const found = this.editor.description.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/); | ||||
|         if (found === null) { | ||||
|             return false; | ||||
|         } | ||||
| @@ -967,14 +906,6 @@ class BusTripTab extends TagTabPlane { | ||||
|         this.#route.value = found[2]; | ||||
|         this.#from.value = found[3]; | ||||
|         this.#to.value = found[4]; | ||||
|         for (const tagButton of this.tagButtons) { | ||||
|             if (tagButton.dataset.value === this.tag.value) { | ||||
|                 tagButton.classList.remove("btn-outline-primary"); | ||||
|                 tagButton.classList.add("btn-primary"); | ||||
|                 this.editor.filterSuggestedAccounts(tagButton); | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|         this.switchToMe(); | ||||
|         return true; | ||||
|     } | ||||
| @@ -1035,29 +966,70 @@ class BusTripTab extends TagTabPlane { | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * The regular payment tab plane. | ||||
|  * The recurring transaction tab plane. | ||||
|  * | ||||
|  * @private | ||||
|  */ | ||||
| class RegularPaymentTab extends TabPlane { | ||||
| class RecurringTransactionTab extends TabPlane { | ||||
| 
 | ||||
|     /** | ||||
|      * The payment buttons | ||||
|      * The month names | ||||
|      * @type {string[]} | ||||
|      */ | ||||
|     #monthNames; | ||||
| 
 | ||||
|     /** | ||||
|      * The buttons of the recurring items | ||||
|      * @type {HTMLButtonElement[]} | ||||
|      */ | ||||
|     #payments; | ||||
|     #itemButtons; | ||||
| 
 | ||||
|     // noinspection JSValidateTypes
 | ||||
|     /** | ||||
|      * Constructs a tab plane. | ||||
|      * | ||||
|      * @param editor {SummaryEditor} the parent summary editor | ||||
|      * @param editor {DescriptionEditor} the parent description editor | ||||
|      * @override | ||||
|      */ | ||||
|     constructor(editor) { | ||||
|         super(editor); | ||||
|         this.#monthNames = [ | ||||
|             "", | ||||
|             A_("January"), A_("February"), A_("March"), A_("April"), | ||||
|             A_("May"), A_("June"), A_("July"), A_("August"), | ||||
|             A_("September"), A_("October"), A_("November"), A_("December"), | ||||
|         ]; | ||||
|         // noinspection JSValidateTypes
 | ||||
|         this.#payments = Array.from(document.getElementsByClassName(this.prefix + "-payment")); | ||||
|         this.#itemButtons = Array.from(document.getElementsByClassName(`${this.prefix}-item`)); | ||||
|         for (const itemButton of this.#itemButtons) { | ||||
|             itemButton.onclick = () => { | ||||
|                 this.reset(); | ||||
|                 itemButton.classList.add("btn-primary"); | ||||
|                 itemButton.classList.remove("btn-outline-primary"); | ||||
|                 this.editor.description.value = this.#getDescription(itemButton); | ||||
|                 this.editor.filterSuggestedAccounts(itemButton); | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Returns the description for a recurring item. | ||||
|      * | ||||
|      * @param itemButton {HTMLButtonElement} the recurring item | ||||
|      * @return {string} the description of the recurring item | ||||
|      */ | ||||
|     #getDescription(itemButton) { | ||||
|         const today = new Date(this.editor.lineItemEditor.form.date); | ||||
|         const thisMonth = today.getMonth() + 1; | ||||
|         const lastMonth = (thisMonth + 10) % 12 + 1; | ||||
|         const lastBimonthlyFrom = ((thisMonth + thisMonth % 2 + 8) % 12 + 1); | ||||
|         const lastBimonthlyTo = ((thisMonth + thisMonth % 2 + 9) % 12 + 1); | ||||
|         return itemButton.dataset.template | ||||
|             .replaceAll("{this_month_number}", String(thisMonth)) | ||||
|             .replaceAll("{this_month_name}", this.#monthNames[thisMonth]) | ||||
|             .replaceAll("{last_month_number}", String(lastMonth)) | ||||
|             .replaceAll("{last_month_name}", this.#monthNames[lastMonth]) | ||||
|             .replaceAll("{last_bimonthly_number}", `${String(lastBimonthlyFrom)}–${String(lastBimonthlyTo)}`) | ||||
|             .replaceAll("{last_bimonthly_name}", `${this.#monthNames[lastBimonthlyFrom]}–${this.#monthNames[lastBimonthlyTo]}`); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @@ -1067,7 +1039,7 @@ class RegularPaymentTab extends TabPlane { | ||||
|      * @abstract | ||||
|      */ | ||||
|     tabId() { | ||||
|         return "regular"; | ||||
|         return "recurring"; | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
| @@ -1076,22 +1048,45 @@ class RegularPaymentTab extends TabPlane { | ||||
|      * @override | ||||
|      */ | ||||
|     reset() { | ||||
|         for (const payment of this.#payments) { | ||||
|             payment.classList.remove("btn-primary"); | ||||
|             payment.classList.add("btn-outline-primary"); | ||||
|         for (const itemButton of this.#itemButtons) { | ||||
|             itemButton.classList.remove("btn-primary"); | ||||
|             itemButton.classList.add("btn-outline-primary"); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Populates the tab plane with the summary input. | ||||
|      * Populates the tab plane with the description input. | ||||
|      * | ||||
|      * @return {boolean} true if the summary input matches this tab, or false otherwise | ||||
|      * @return {boolean} true if the description input matches this tab, or false otherwise | ||||
|      * @override | ||||
|      */ | ||||
|     populate() { | ||||
|         for (const itemButton of this.#itemButtons) { | ||||
|             if (this.#getDescription(itemButton) === this.editor.description.value) { | ||||
|                 itemButton.classList.add("btn-primary"); | ||||
|                 itemButton.classList.remove("btn-outline-primary"); | ||||
|                 this.switchToMe(); | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Switches to the tab plane. | ||||
|      * | ||||
|      */ | ||||
|     switchToMe() { | ||||
|         super.switchToMe(); | ||||
|         for (const itemButton of this.#itemButtons) { | ||||
|             if (itemButton.classList.contains("btn-primary")) { | ||||
|                 this.editor.filterSuggestedAccounts(itemButton); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|         this.editor.clearSuggestedAccounts(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Validates the input in the tab plane. | ||||
|      * | ||||
| @@ -1113,15 +1108,15 @@ class AnnotationTab extends TabPlane { | ||||
|     /** | ||||
|      * Constructs a tab plane. | ||||
|      * | ||||
|      * @param editor {SummaryEditor} the parent summary editor | ||||
|      * @param editor {DescriptionEditor} the parent description editor | ||||
|      * @override | ||||
|      */ | ||||
|     constructor(editor) { | ||||
|         super(editor); | ||||
|         this.editor.number.onchange = () => this.updateSummary(); | ||||
|         this.editor.number.onchange = () => this.updateDescription(); | ||||
|         this.editor.note.onchange = () => { | ||||
|             this.editor.note.value = this.editor.note.value.trim(); | ||||
|             this.updateSummary(); | ||||
|             this.updateDescription(); | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
| @@ -1136,20 +1131,20 @@ class AnnotationTab extends TabPlane { | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
|      * Updates the summary according to the input in the tab plane. | ||||
|      * Updates the description according to the input in the tab plane. | ||||
|      * | ||||
|      * @override | ||||
|      */ | ||||
|     updateSummary() { | ||||
|         const found = this.editor.summary.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/); | ||||
|     updateDescription() { | ||||
|         const found = this.editor.description.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/); | ||||
|         if (found !== null) { | ||||
|             this.editor.summary.value = found[1]; | ||||
|             this.editor.description.value = found[1]; | ||||
|         } | ||||
|         if (parseInt(this.editor.number.value) > 1) { | ||||
|             this.editor.summary.value = this.editor.summary.value + "×" + this.editor.number.value; | ||||
|             this.editor.description.value = `${this.editor.description.value}×${this.editor.number.value}`; | ||||
|         } | ||||
|         if (this.editor.note.value !== "") { | ||||
|             this.editor.summary.value = this.editor.summary.value + "(" + this.editor.note.value + ")"; | ||||
|             this.editor.description.value = `${this.editor.description.value}(${this.editor.note.value})`; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @@ -1164,25 +1159,25 @@ class AnnotationTab extends TabPlane { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Populates the tab plane with the summary input. | ||||
|      * Populates the tab plane with the description input. | ||||
|      * | ||||
|      * @return {boolean} true if the summary input matches this tab, or false otherwise | ||||
|      * @return {boolean} true if the description input matches this tab, or false otherwise | ||||
|      * @override | ||||
|      */ | ||||
|     populate() { | ||||
|         const found = this.editor.summary.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/); | ||||
|         this.editor.summary.value = found[1]; | ||||
|         const found = this.editor.description.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/); | ||||
|         this.editor.description.value = found[1]; | ||||
|         if (found[2] === undefined || parseInt(found[2]) === 1) { | ||||
|             this.editor.number.value = ""; | ||||
|         } else { | ||||
|             this.editor.number.value = found[2]; | ||||
|             this.editor.summary.value = this.editor.summary.value + "×" + this.editor.number.value; | ||||
|             this.editor.description.value = `${this.editor.description.value}×${this.editor.number.value}`; | ||||
|         } | ||||
|         if (found[3] === undefined) { | ||||
|             this.editor.note.value = ""; | ||||
|         } else { | ||||
|             this.editor.note.value = found[3]; | ||||
|             this.editor.summary.value = this.editor.summary.value + "(" + this.editor.note.value + ")"; | ||||
|             this.editor.description.value = `${this.editor.description.value}(${this.editor.note.value})`; | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
							
								
								
									
										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 | ||||
|  * transaction-order.js: The JavaScript for the transaction order | ||||
|  * journal-entry-order.js: The JavaScript for the journal entry order | ||||
|  */ | ||||
| 
 | ||||
| /*  Copyright (c) 2023 imacat. | ||||
| @@ -29,7 +29,7 @@ document.addEventListener("DOMContentLoaded", () => { | ||||
|         const onReorder = () => { | ||||
|             const accounts = Array.from(list.children); | ||||
|             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`); | ||||
|                 no.value = String(i + 1); | ||||
|             } | ||||
|         }; | ||||
							
								
								
									
										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"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -33,12 +33,6 @@ document.addEventListener("DOMContentLoaded", () => { | ||||
|  */ | ||||
| class PeriodChooser { | ||||
|  | ||||
|     /** | ||||
|      * The prefix of the HTML ID and class | ||||
|      * @type {string} | ||||
|      */ | ||||
|     prefix; | ||||
|  | ||||
|     /** | ||||
|      * The modal of the period chooser | ||||
|      * @type {HTMLDivElement} | ||||
| @@ -56,8 +50,8 @@ class PeriodChooser { | ||||
|      * | ||||
|      */ | ||||
|     constructor() { | ||||
|         this.prefix = "accounting-period-chooser"; | ||||
|         this.modal = document.getElementById(this.prefix + "-modal"); | ||||
|         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; | ||||
| @@ -94,7 +88,7 @@ class TabPlane { | ||||
|     chooser; | ||||
|  | ||||
|     /** | ||||
|      * The prefix of the HTML ID and class | ||||
|      * The prefix of the HTML ID and class names | ||||
|      * @type {string} | ||||
|      */ | ||||
|     prefix; | ||||
| @@ -118,9 +112,9 @@ class TabPlane { | ||||
|      */ | ||||
|     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.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(); | ||||
|     } | ||||
|  | ||||
| @@ -170,12 +164,11 @@ class MonthTab extends TabPlane { | ||||
|      */ | ||||
|     constructor(chooser) { | ||||
|         super(chooser); | ||||
|         const monthChooser = document.getElementById(this.prefix + "-chooser"); | ||||
|         const monthChooser = document.getElementById(`${this.prefix}-chooser`); | ||||
|         if (monthChooser !== null) { | ||||
|             let start = monthChooser.dataset.start; | ||||
|             this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, { | ||||
|                 restrictions: { | ||||
|                     minDate: start, | ||||
|                     minDate: new Date(monthChooser.dataset.start), | ||||
|                 }, | ||||
|                 display: { | ||||
|                     inline: true, | ||||
| @@ -184,13 +177,11 @@ class MonthTab extends TabPlane { | ||||
|                         clock: false, | ||||
|                     }, | ||||
|                 }, | ||||
|                 defaultDate: monthChooser.dataset.default, | ||||
|                 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; | ||||
|                 const period = `${date.year}-${`0${date.month + 1}`.slice(-2)}`; | ||||
|                 window.location = chooser.modal.dataset.urlTemplate | ||||
|                     .replaceAll("PERIOD", period); | ||||
|             }); | ||||
| @@ -250,8 +241,8 @@ class DayTab extends TabPlane { | ||||
|      */ | ||||
|     constructor(chooser) { | ||||
|         super(chooser); | ||||
|         this.#date = document.getElementById(this.prefix + "-date"); | ||||
|         this.#dateError = document.getElementById(this.prefix + "-date-error"); | ||||
|         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()) { | ||||
| @@ -337,11 +328,11 @@ class CustomTab extends TabPlane { | ||||
|      */ | ||||
|     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"); | ||||
|         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()) { | ||||
| @@ -359,7 +350,7 @@ class CustomTab extends TabPlane { | ||||
|                 isValid = this.#validateEnd() && isValid; | ||||
|                 if (isValid) { | ||||
|                     window.location = chooser.modal.dataset.urlTemplate | ||||
|                         .replaceAll("PERIOD", this.#start.value + "-" + this.#end.value); | ||||
|                         .replaceAll("PERIOD", `${this.#start.value}-${this.#end.value}`); | ||||
|                 } | ||||
|             }; | ||||
|         } | ||||
|   | ||||
| @@ -1,680 +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 | ||||
|  */ | ||||
| "use strict"; | ||||
|  | ||||
| // Initializes the page JavaScript. | ||||
| document.addEventListener("DOMContentLoaded", () => { | ||||
|     initializeCurrencyForms(); | ||||
|     initializeJournalEntries(); | ||||
|     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 = () => { | ||||
|         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 = () => { | ||||
|         const currencies = Array.from(document.getElementsByClassName("accounting-currency")); | ||||
|         let maxIndex = 0; | ||||
|         for (const currency of currencies) { | ||||
|             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 = () => { | ||||
|         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) { | ||||
|         for (const button of buttons) { | ||||
|             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 formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); | ||||
|     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 = () => { | ||||
|         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"); | ||||
|         formAccount.innerText = ""; | ||||
|         formAccount.dataset.code = ""; | ||||
|         formAccount.dataset.text = ""; | ||||
|         formAccountError.innerText = ""; | ||||
|         formSummaryControl.dataset.bsTarget = "#accounting-summary-editor-" + button.dataset.entryType + "-modal"; | ||||
|         formSummaryControl.classList.remove("accounting-not-empty"); | ||||
|         formSummaryControl.classList.remove("is-invalid"); | ||||
|         formSummary.dataset.value = ""; | ||||
|         formSummary.innerText = "" | ||||
|         formSummaryError.innerText = "" | ||||
|         formAmount.value = ""; | ||||
|         formAmount.classList.remove("is-invalid"); | ||||
|         formAmountError.innerText = ""; | ||||
|         AccountSelector.initializeJournalEntryForm(); | ||||
|         SummaryEditor.initializeNewJournalEntry(button.dataset.entryType); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Initializes the reordering of a journal entry list. | ||||
|  * | ||||
|  * @param entryList {HTMLUListElement} the journal entry list. | ||||
|  */ | ||||
| function initializeJournalEntryListReorder(entryList) { | ||||
|     initializeDragAndDropReordering(entryList, () => { | ||||
|         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 formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); | ||||
|     const formSummary = document.getElementById("accounting-entry-form-summary"); | ||||
|     const formAmount = document.getElementById("accounting-entry-form-amount"); | ||||
|     control.onclick = () => { | ||||
|         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"); | ||||
|         } | ||||
|         formAccount.innerText = accountCode.dataset.text; | ||||
|         formAccount.dataset.code = accountCode.value; | ||||
|         formAccount.dataset.text = accountCode.dataset.text; | ||||
|         formSummaryControl.dataset.bsTarget = "#accounting-summary-editor-" + entry.dataset.entryType + "-modal"; | ||||
|         if (summary.value === "") { | ||||
|             formSummaryControl.classList.remove("accounting-not-empty"); | ||||
|         } else { | ||||
|             formSummaryControl.classList.add("accounting-not-empty"); | ||||
|         } | ||||
|         formSummary.dataset.value = summary.value; | ||||
|         formSummary.innerText = summary.value; | ||||
|         formAmount.value = amount.value; | ||||
|         AccountSelector.initializeJournalEntryForm(); | ||||
|         validateJournalEntryForm(); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Initializes the journal entry form modal. | ||||
|  * | ||||
|  * @private | ||||
|  */ | ||||
| function initializeJournalEntryFormModal() { | ||||
|     const entryForm = document.getElementById("accounting-entry-form"); | ||||
|     const formAmount = document.getElementById("accounting-entry-form-amount"); | ||||
|     const modal = document.getElementById("accounting-entry-form-modal"); | ||||
|     formAmount.onchange = validateJournalEntryAmount; | ||||
|     entryForm.onsubmit = () => { | ||||
|         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 | ||||
|  */ | ||||
| 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 control = document.getElementById("accounting-entry-form-summary-control"); | ||||
|     const error = document.getElementById("accounting-entry-form-summary-error"); | ||||
|     control.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; | ||||
|         for (const entry of entries) { | ||||
|             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.dataset.value; | ||||
|     summaryText.innerText = formSummary.dataset.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 = () => { | ||||
|         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) { | ||||
|         for (const button of buttons) { | ||||
|             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"); | ||||
|     for (const amount of amounts) { | ||||
|         if (amount.value !== "") { | ||||
|             total = total.plus(new Decimal(amount.value)); | ||||
|         } | ||||
|     } | ||||
|     totalText.innerText = formatDecimal(total); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * 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; | ||||
|     for (const currency of currencies) { | ||||
|         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; | ||||
|     for (const entry of entries) { | ||||
|         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"); | ||||
|         for (const amount of debitAmounts) { | ||||
|             if (amount.value !== "") { | ||||
|                 debitTotal = debitTotal.plus(new Decimal(amount.value)); | ||||
|             } | ||||
|         } | ||||
|         let creditTotal = new Decimal("0"); | ||||
|         for (const amount of creditAmounts) { | ||||
|             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; | ||||
| } | ||||
| @@ -14,12 +14,11 @@ | ||||
| #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
| #  See the License for the specific language governing permissions and | ||||
| #  limitations under the License. | ||||
| """The template globals for the transaction management. | ||||
| """The template globals. | ||||
|  | ||||
| """ | ||||
| from flask import current_app | ||||
|  | ||||
| from accounting.models import Currency | ||||
| from accounting.utils.options import options | ||||
|  | ||||
|  | ||||
| def currency_options() -> str: | ||||
| @@ -35,5 +34,4 @@ def default_currency_code() -> str: | ||||
|  | ||||
|     :return: The default currency code. | ||||
|     """ | ||||
|     with current_app.app_context(): | ||||
|         return current_app.config.get("DEFAULT_CURRENCY", "USD") | ||||
|     return options.default_currency_code | ||||
|   | ||||
| @@ -25,38 +25,45 @@ First written: 2023/1/31 | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div class="btn-group mb-3"> | ||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> | ||||
| <div class="mb-3 accounting-toolbar"> | ||||
|   <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> | ||||
|     {{ A_("Back") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||
|   </a> | ||||
|   {% 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 }}"> | ||||
|       <i class="fa-solid fa-gear"></i> | ||||
|       {{ A_("Settings") }} | ||||
|     <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-pen-to-square"></i> | ||||
|       {{ A_("Edit") }} | ||||
|     </a> | ||||
|   {% 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> | ||||
|     {{ A_("Order") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Order") }}</span> | ||||
|   </a> | ||||
|   {% if accounting_can_edit() %} | ||||
|     <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> | ||||
|       <i class="fa-solid fa-trash"></i> | ||||
|       {{ A_("Delete") }} | ||||
|     </button> | ||||
|     {% if obj.can_delete %} | ||||
|       <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> | ||||
|         <i class="fa-solid fa-trash"></i> | ||||
|         <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 %} | ||||
| </div> | ||||
|  | ||||
| {% if accounting_can_edit() %} | ||||
|   <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> | ||||
|     </a> | ||||
|   </div> | ||||
| {% endif %} | ||||
|  | ||||
| {% if accounting_can_edit() %} | ||||
| {% if accounting_can_edit() and obj.can_delete %} | ||||
|   <form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post"> | ||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|     {% if request.args.next %} | ||||
| @@ -66,7 +73,7 @@ First written: 2023/1/31 | ||||
|       <div class="modal-dialog"> | ||||
|         <div class="modal-content"> | ||||
|           <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> | ||||
|           </div> | ||||
|           <div class="modal-body"> | ||||
| @@ -83,11 +90,11 @@ First written: 2023/1/31 | ||||
| {% endif %} | ||||
|  | ||||
| <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> | ||||
|   {% if obj.is_offset_needed %} | ||||
|   {% if obj.is_need_offset %} | ||||
|     <div> | ||||
|       <span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span> | ||||
|       <span class="badge rounded-pill bg-info">{{ A_("Needs Offset") }}</span> | ||||
|     </div> | ||||
|   {% endif %} | ||||
|   <div class="small text-secondary fst-italic"> | ||||
|   | ||||
| @@ -27,10 +27,10 @@ First written: 2023/2/1 | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div class="btn-group mb-3"> | ||||
| <div class="mb-3 accounting-toolbar"> | ||||
|   <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}"> | ||||
|     <i class="fa-solid fa-circle-chevron-left"></i> | ||||
|     {{ A_("Back") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||
|   </a> | ||||
| </div> | ||||
|  | ||||
| @@ -41,9 +41,9 @@ First written: 2023/2/1 | ||||
|   {% endif %} | ||||
|   <div class="form-floating mb-3"> | ||||
|     <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> | ||||
|       <div id="accounting-base-content"> | ||||
|       <div id="accounting-base"> | ||||
|         {% if form.base_code.data %} | ||||
|           {% if form.base_code.errors %} | ||||
|             {{ A_("(Unknown)") }} | ||||
| @@ -53,7 +53,7 @@ First written: 2023/2/1 | ||||
|         {% endif %} | ||||
|       </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 class="form-floating mb-3"> | ||||
| @@ -62,10 +62,10 @@ First written: 2023/2/1 | ||||
|     <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div> | ||||
|   </div> | ||||
|  | ||||
|   <div class="form-check form-switch mb-3"> | ||||
|     <input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}> | ||||
|     <label class="form-check-label" for="accounting-is-offset-needed"> | ||||
|       {{ A_("The entries in the account need offset.") }} | ||||
|   <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-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-need-offset"> | ||||
|       {{ A_("The line items in the account need offset.") }} | ||||
|     </label> | ||||
|   </div> | ||||
|  | ||||
| @@ -99,21 +99,21 @@ First written: 2023/2/1 | ||||
|           </label> | ||||
|         </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 %} | ||||
|           <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 }}"> | ||||
|             {{ base }} | ||||
|           </li> | ||||
|             <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 }} | ||||
|             </li> | ||||
|           {% endfor %} | ||||
|         </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 class="modal-footer"> | ||||
|         <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button> | ||||
|         {% 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 %} | ||||
|           <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 %} | ||||
|       </div> | ||||
|     </div> | ||||
|   | ||||
| @@ -27,7 +27,7 @@ First written: 2023/1/30 | ||||
|  | ||||
| <div class="mb-2 accounting-toolbar"> | ||||
|   {% if accounting_can_edit() %} | ||||
|     <a class="btn btn-primary text-nowrap d-none d-md-block" 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> | ||||
|       {{ A_("New") }} | ||||
|     </a> | ||||
| @@ -45,7 +45,7 @@ First written: 2023/1/30 | ||||
|  | ||||
| {% if accounting_can_edit() %} | ||||
|   <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> | ||||
|     </a> | ||||
|   </div> | ||||
| @@ -58,8 +58,8 @@ First written: 2023/1/30 | ||||
|   {% for item in list %} | ||||
|     <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}"> | ||||
|       {{ item }} | ||||
|       {% if item.is_offset_needed %} | ||||
|         <span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span> | ||||
|       {% if item.is_need_offset %} | ||||
|         <span class="badge rounded-pill bg-info">{{ A_("Needs Offset") }}</span> | ||||
|       {% endif %} | ||||
|     </a> | ||||
|   {% endfor %} | ||||
|   | ||||
| @@ -30,10 +30,10 @@ First written: 2023/2/2 | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div class="btn-group mb-3"> | ||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> | ||||
| <div class="mb-3 accounting-toolbar"> | ||||
|   <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> | ||||
|     {{ A_("Back") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||
|   </a> | ||||
| </div> | ||||
|  | ||||
|   | ||||
| @@ -25,20 +25,20 @@ First written: 2023/2/1 | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div class="btn-group mb-3"> | ||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> | ||||
| <div class="mb-3 accounting-toolbar"> | ||||
|   <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> | ||||
|     {{ A_("Back") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||
|   </a> | ||||
| </div> | ||||
|  | ||||
| <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> | ||||
|   {% if obj.accounts %} | ||||
|     <div> | ||||
|     {% 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 }} | ||||
|       </a> | ||||
|     {% endfor %} | ||||
|   | ||||
| @@ -25,34 +25,41 @@ First written: 2023/2/6 | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div class="btn-group mb-3"> | ||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|accounting_or_next }}"> | ||||
| <div class="mb-3 accounting-toolbar"> | ||||
|   <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> | ||||
|     {{ A_("Back") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||
|   </a> | ||||
|   {% 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 }}"> | ||||
|       <i class="fa-solid fa-gear"></i> | ||||
|       {{ A_("Settings") }} | ||||
|     <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-pen-to-square"></i> | ||||
|       {{ A_("Edit") }} | ||||
|     </a> | ||||
|   {% endif %} | ||||
|   {% if accounting_can_edit() %} | ||||
|     <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> | ||||
|       <i class="fa-solid fa-trash"></i> | ||||
|       {{ A_("Delete") }} | ||||
|     </button> | ||||
|     {% if obj.can_delete %} | ||||
|       <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> | ||||
|         <i class="fa-solid fa-trash"></i> | ||||
|         <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 %} | ||||
| </div> | ||||
|  | ||||
| {% if accounting_can_edit() %} | ||||
|   <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> | ||||
|     </a> | ||||
|   </div> | ||||
| {% endif %} | ||||
|  | ||||
| {% if accounting_can_edit() %} | ||||
| {% if accounting_can_edit() and obj.can_delete %} | ||||
|   <form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post"> | ||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|     {% if request.args.next %} | ||||
| @@ -62,7 +69,7 @@ First written: 2023/2/6 | ||||
|       <div class="modal-dialog"> | ||||
|         <div class="modal-content"> | ||||
|           <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> | ||||
|           </div> | ||||
|           <div class="modal-body"> | ||||
| @@ -79,7 +86,7 @@ First written: 2023/2/6 | ||||
| {% endif %} | ||||
|  | ||||
| <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="small text-secondary fst-italic"> | ||||
|     <div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div> | ||||
|   | ||||
| @@ -27,10 +27,10 @@ First written: 2023/2/6 | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
| <div class="btn-group mb-3"> | ||||
| <div class="mb-3 accounting-toolbar"> | ||||
|   <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}"> | ||||
|     <i class="fa-solid fa-circle-chevron-left"></i> | ||||
|     {{ A_("Back") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||
|   </a> | ||||
| </div> | ||||
|  | ||||
|   | ||||
| @@ -27,7 +27,7 @@ First written: 2023/2/6 | ||||
|  | ||||
| <div class="mb-2 accounting-toolbar"> | ||||
|   {% if accounting_can_edit() %} | ||||
|     <a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|accounting_append_next }}"> | ||||
|     <a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}"> | ||||
|       <i class="fa-solid fa-plus"></i> | ||||
|       {{ A_("New") }} | ||||
|     </a> | ||||
| @@ -45,7 +45,7 @@ First written: 2023/2/6 | ||||
|  | ||||
| {% if accounting_can_edit() %} | ||||
|   <div class="d-md-none accounting-material-fab"> | ||||
|     <a class="btn btn-primary" href="{{ url_for("accounting.currency.create")|accounting_append_next }}"> | ||||
|     <a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}"> | ||||
|       <i class="fa-solid fa-plus"></i> | ||||
|     </a> | ||||
|   </div> | ||||
|   | ||||
| @@ -28,29 +28,37 @@ First written: 2023/1/26 | ||||
|     </span> | ||||
|     <ul class="dropdown-menu"> | ||||
|       <li> | ||||
|         <a class="dropdown-item {% if request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}"> | ||||
|         <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}"> | ||||
|           <i class="fa-solid fa-book"></i> | ||||
|           {{ A_("Reports") }} | ||||
|         </a> | ||||
|       </li> | ||||
|       <li> | ||||
|         <a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}"> | ||||
|         <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}"> | ||||
|           <i class="fa-solid fa-clipboard"></i> | ||||
|           {{ A_("Accounts") }} | ||||
|         </a> | ||||
|       </li> | ||||
|       <li> | ||||
|         <a class="dropdown-item {% if request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}"> | ||||
|         <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}"> | ||||
|           <i class="fa-solid fa-list"></i> | ||||
|           {{ A_("Base Accounts") }} | ||||
|         </a> | ||||
|       </li> | ||||
|       <li> | ||||
|         <a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}"> | ||||
|         <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}"> | ||||
|           <i class="fa-solid fa-money-bill-wave"></i> | ||||
|           {{ A_("Currencies") }} | ||||
|         </a> | ||||
|       </li> | ||||
|       {% if accounting_can_admin() %} | ||||
|         <li> | ||||
|           <a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.option.") %} active {% endif %}" href="{{ url_for("accounting.option.detail") }}"> | ||||
|             <i class="fa-solid fa-gear"></i> | ||||
|             {{ A_("Settings") }} | ||||
|           </a> | ||||
|         </li> | ||||
|       {% endif %} | ||||
|     </ul> | ||||
|   </li> | ||||
| {% endif %} | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| create.html: The cash expense transaction creation form | ||||
| create.html: The cash disbursement journal entry creation form | ||||
| 
 | ||||
|  Copyright (c) 2023 imacat. | ||||
| 
 | ||||
| @@ -19,10 +19,10 @@ create.html: The cash expense transaction creation form | ||||
| Author: imacat@mail.imacat.idv.tw (imacat) | ||||
| First written: 2023/2/25 | ||||
| #} | ||||
| {% extends "accounting/transaction/expense/include/form.html" %} | ||||
| {% extends "accounting/journal-entry/disbursement/include/form.html" %} | ||||
| 
 | ||||
| {% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %} | ||||
| {% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %} | ||||
| 
 | ||||
| {% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %} | ||||
| 
 | ||||
| {% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %} | ||||
| {% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %} | ||||
| @@ -1,6 +1,6 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| detail.html: The account detail | ||||
| detail.html: The cash disbursement journal entry detail | ||||
| 
 | ||||
|  Copyright (c) 2023 imacat. | ||||
| 
 | ||||
| @@ -19,36 +19,26 @@ detail.html: The account detail | ||||
| Author: imacat@mail.imacat.idv.tw (imacat) | ||||
| First written: 2023/2/26 | ||||
| #} | ||||
| {% extends "accounting/transaction/include/detail.html" %} | ||||
| {% extends "accounting/journal-entry/include/detail.html" %} | ||||
| 
 | ||||
| {% block to_transfer %} | ||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}"> | ||||
|     <i class="fa-solid fa-bars-staggered"></i> | ||||
|     {{ A_("To Transfer") }} | ||||
| {% block as_trasfer %} | ||||
|   <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}"> | ||||
|     <i class="fa-solid fa-table-columns"></i> | ||||
|     <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span> | ||||
|   </a> | ||||
| {% endblock %} | ||||
| 
 | ||||
| {% block transaction_currencies %} | ||||
| {% block journal_entry_currencies %} | ||||
|   {% for currency in obj.currencies %} | ||||
|     <div class="mb-3"> | ||||
|       <div class="mb-2 fw-bolder">{{ currency.name }}</div> | ||||
| 
 | ||||
|       <ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> | ||||
|         <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li> | ||||
|         {% for entry in currency.credit %} | ||||
|           <li class="list-group-item accounting-transaction-entry"> | ||||
|             <div class="d-flex justify-content-between"> | ||||
|               <div> | ||||
|                 <div class="small">{{ entry.account }}</div> | ||||
|                 {% if entry.summary is not none %} | ||||
|                   <div>{{ entry.summary }}</div> | ||||
|                 {% endif %} | ||||
|               </div> | ||||
|               <div>{{ entry.amount|accounting_format_amount }}</div> | ||||
|             </div> | ||||
|           </li> | ||||
|         {% endfor %} | ||||
|         <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> | ||||
|         <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Content") }}</li> | ||||
|         {% with line_items = currency.debit %} | ||||
|           {% include "accounting/journal-entry/include/detail-line-items.html" %} | ||||
|         {% endwith %} | ||||
|         <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total"> | ||||
|           <div class="d-flex justify-content-between"> | ||||
|             <div>{{ A_("Total") }}</div> | ||||
|             <div>{{ currency.debit_total|accounting_format_amount }}</div> | ||||
| @@ -0,0 +1,28 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| edit.html: The cash disbursement journal entry edit 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 | ||||
| #} | ||||
| {% extends "accounting/journal-entry/disbursement/include/form.html" %} | ||||
|  | ||||
| {% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %} | ||||
|  | ||||
| {% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %} | ||||
|  | ||||
| {% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %} | ||||
| @@ -1,6 +1,6 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| edit.html: The cash income transaction edit form | ||||
| form-currency.html: The currency sub-form in the cash disbursement journal entry form | ||||
| 
 | ||||
|  Copyright (c) 2023 imacat. | ||||
| 
 | ||||
| @@ -19,10 +19,15 @@ edit.html: The cash income transaction edit form | ||||
| Author: imacat@mail.imacat.idv.tw (imacat) | ||||
| First written: 2023/2/25 | ||||
| #} | ||||
| {% extends "accounting/transaction/income/include/form.html" %} | ||||
| {% extends "accounting/journal-entry/include/form-currency.html" %} | ||||
| 
 | ||||
| {% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %} | ||||
| 
 | ||||
| {% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %} | ||||
| 
 | ||||
| {% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %} | ||||
| {% block line_items %} | ||||
|   {% with currency_index = currency_index, | ||||
|           debit_credit = "debit", | ||||
|           line_item_forms = debit_forms, | ||||
|           header = A_("Content"), | ||||
|           debit_credit_total = debit_total, | ||||
|           debit_credit_errors = debit_errors %} | ||||
|     {% include "accounting/journal-entry/include/form-debit-credit.html" %} | ||||
|   {% endwith %} | ||||
| {% endblock %} | ||||
| @@ -1,6 +1,6 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| form.html: The cash expense transaction form | ||||
| form.html: The cash disbursement journal entry form | ||||
| 
 | ||||
|  Copyright (c) 2023 imacat. | ||||
| 
 | ||||
| @@ -19,7 +19,7 @@ form.html: The cash expense transaction form | ||||
| Author: imacat@mail.imacat.idv.tw (imacat) | ||||
| First written: 2023/2/25 | ||||
| #} | ||||
| {% extends "accounting/transaction/include/form.html" %} | ||||
| {% extends "accounting/journal-entry/include/form.html" %} | ||||
| 
 | ||||
| {% block currency_sub_forms %} | ||||
|   {% if form.currencies %} | ||||
| @@ -29,10 +29,11 @@ First written: 2023/2/25 | ||||
|               currency_errors = currency_form.whole_form.errors, | ||||
|               currency_code_data = currency_form.code.data, | ||||
|               currency_code_errors = currency_form.code.errors, | ||||
|               currency_code_is_locked = currency_form.is_code_locked, | ||||
|               debit_forms = currency_form.debit, | ||||
|               debit_errors = currency_form.debit_errors, | ||||
|               debit_total = currency_form.form.debit_total|accounting_format_amount %} | ||||
|         {% include "accounting/transaction/expense/include/form-currency-item.html" %} | ||||
|               debit_total = currency_form.debit_total|accounting_format_amount %} | ||||
|         {% include "accounting/journal-entry/disbursement/include/form-currency.html" %} | ||||
|       {% endwith %} | ||||
|     {% endfor %} | ||||
|   {% else %} | ||||
| @@ -40,17 +41,17 @@ First written: 2023/2/25 | ||||
|             only_one_currency_form = True, | ||||
|             currency_code_data = accounting_default_currency_code(), | ||||
|             debit_total = "-" %} | ||||
|       {% include "accounting/transaction/expense/include/form-currency-item.html" %} | ||||
|       {% include "accounting/journal-entry/disbursement/include/form-currency.html" %} | ||||
|     {% endwith %} | ||||
|   {% endif %} | ||||
| {% endblock %} | ||||
| 
 | ||||
| {% block form_modals %} | ||||
|   {% with summary_editor = form.summary_editor.debit %} | ||||
|     {% include "accounting/transaction/include/summary-editor-modal.html" %} | ||||
|   {% with description_editor = form.description_editor.debit %} | ||||
|     {% include "accounting/journal-entry/include/description-editor-modal.html" %} | ||||
|   {% endwith %} | ||||
|   {% with entry_type = "debit", | ||||
|   {% with debit_credit = "debit", | ||||
|           account_options = form.debit_account_options %} | ||||
|     {% include "accounting/transaction/include/account-selector-modal.html" %} | ||||
|     {% include "accounting/journal-entry/include/account-selector-modal.html" %} | ||||
|   {% endwith %} | ||||
| {% endblock %} | ||||
| @@ -0,0 +1,54 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| account-selector-modal.html: The modal for the account 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/2/25 | ||||
| #} | ||||
| <div id="accounting-account-selector-{{ debit_credit }}-modal" class="modal fade accounting-account-selector" data-debit-credit="{{ debit_credit }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ debit_credit }}-modal-label" aria-hidden="true"> | ||||
|   <div class="modal-dialog"> | ||||
|     <div class="modal-content"> | ||||
|       <div class="modal-header"> | ||||
|         <h1 class="modal-title fs-5" id="accounting-account-selector-{{ debit_credit }}-modal-label">{{ A_("Select Account") }}</h1> | ||||
|         <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button> | ||||
|       </div> | ||||
|       <div class="modal-body"> | ||||
|         <div class="input-group mb-2"> | ||||
|           <input id="accounting-account-selector-{{ debit_credit }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required"> | ||||
|           <label class="input-group-text" for="accounting-account-selector-{{ debit_credit }}-query"> | ||||
|             <i class="fa-solid fa-magnifying-glass"></i> | ||||
|             {{ A_("Search") }} | ||||
|           </label> | ||||
|         </div> | ||||
|  | ||||
|         <ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list"> | ||||
|           {% for account in account_options %} | ||||
|             <li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-is-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal"> | ||||
|               {{ account }} | ||||
|             </li> | ||||
|           {% endfor %} | ||||
|           <li id="accounting-account-selector-{{ debit_credit }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li> | ||||
|         </ul> | ||||
|         <p id="accounting-account-selector-{{ debit_credit }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> | ||||
|       </div> | ||||
|       <div class="modal-footer"> | ||||
|         <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button> | ||||
|         <button id="accounting-account-selector-{{ debit_credit }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Clear") }}</button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| @@ -0,0 +1,199 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| description-editor-modal.html: The modal of the description 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/28 | ||||
| #} | ||||
| <form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}"> | ||||
|   <div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true"> | ||||
|     <div class="modal-dialog"> | ||||
|       <div class="modal-content"> | ||||
|         <div class="modal-header"> | ||||
|           <h1 class="modal-title fs-5" id="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label"> | ||||
|             <label for="accounting-description-editor-{{ description_editor.debit_credit }}-description">{{ A_("Description") }}</label> | ||||
|           </h1> | ||||
|           <button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button> | ||||
|         </div> | ||||
|         <div class="modal-body"> | ||||
|           <div class="d-flex justify-content-between mb-3"> | ||||
|             <input id="accounting-description-editor-{{ description_editor.debit_credit }}-description" class="form-control" type="text" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label"> | ||||
|             <button id="accounting-description-editor-{{ description_editor.debit_credit }}-offset" class="btn btn-primary text-nowrap ms-2" type="button" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal"> | ||||
|               {{ A_("Offset...") }} | ||||
|             </button> | ||||
|           </div> | ||||
|  | ||||
|           {# Tab navigation #} | ||||
|           <ul class="nav nav-tabs mb-2"> | ||||
|             <li class="nav-item"> | ||||
|               <span id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab" class="nav-link active accounting-clickable" aria-current="page"> | ||||
|                 {{ A_("General") }} | ||||
|               </span> | ||||
|             </li> | ||||
|             <li class="nav-item"> | ||||
|               <span id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab" class="nav-link accounting-clickable" aria-current="false"> | ||||
|                 {{ A_("Travel") }} | ||||
|               </span> | ||||
|             </li> | ||||
|             <li class="nav-item"> | ||||
|               <span id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab" class="nav-link accounting-clickable" aria-current="false"> | ||||
|                 {{ A_("Bus") }} | ||||
|               </span> | ||||
|             </li> | ||||
|             <li class="nav-item"> | ||||
|               <span id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab" class="nav-link accounting-clickable" aria-current="false"> | ||||
|                 {{ A_("Recurring") }} | ||||
|               </span> | ||||
|             </li> | ||||
|             <li class="nav-item"> | ||||
|               <span id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab" class="nav-link accounting-clickable" aria-current="false"> | ||||
|                 {{ A_("Annotation") }} | ||||
|               </span> | ||||
|             </li> | ||||
|           </ul> | ||||
|  | ||||
|           {# A general description with a tag #} | ||||
|           <div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-page" aria-current="page" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab"> | ||||
|             <div class="form-floating mb-2"> | ||||
|               <input id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag" class="form-control" type="text" value="" placeholder=" "> | ||||
|               <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag">{{ A_("Tag") }}</label> | ||||
|               <div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag-error" class="invalid-feedback"></div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="accounting-description-editor-buttons"> | ||||
|               {% for tag in description_editor.general.tags %} | ||||
|                 <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}"> | ||||
|                   {{ tag }} | ||||
|                 </button> | ||||
|               {% endfor %} | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           {# A general trip with the origin and distination #} | ||||
|           <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab"> | ||||
|             <div class="form-floating mb-2"> | ||||
|               <input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag" class="form-control" type="text" value="" placeholder=" "> | ||||
|               <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag">{{ A_("Tag") }}</label> | ||||
|               <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag-error" class="invalid-feedback"></div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="accounting-description-editor-buttons"> | ||||
|               {% for tag in description_editor.travel.tags %} | ||||
|                 <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}"> | ||||
|                   {{ tag }} | ||||
|                 </button> | ||||
|               {% endfor %} | ||||
|             </div> | ||||
|  | ||||
|             <div class="d-flex justify-content-between"> | ||||
|               <div class="form-floating"> | ||||
|                 <input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from" class="form-control" type="text" value="" placeholder=" "> | ||||
|                 <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from">{{ A_("From") }}</label> | ||||
|                 <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from-error" class="invalid-feedback"></div> | ||||
|               </div> | ||||
|               <div class="btn-group-vertical ms-1 me-1"> | ||||
|                 <button class="btn btn-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="→">→</button> | ||||
|                 <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction" type="button" tabindex="-1" data-arrow="↔">↔</button> | ||||
|               </div> | ||||
|               <div class="form-floating"> | ||||
|                 <input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to" class="form-control" type="text" value="" placeholder=" "> | ||||
|                 <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to">{{ A_("To") }}</label> | ||||
|                 <div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to-error" class="invalid-feedback"></div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           {# A bus trip with the route name or route number, the origin and distination #} | ||||
|           <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab"> | ||||
|             <div class="d-flex justify-content-between mb-2"> | ||||
|               <div class="form-floating me-2"> | ||||
|                 <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag" class="form-control" type="text" value="" placeholder=" "> | ||||
|                 <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag">{{ A_("Tag") }}</label> | ||||
|                 <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag-error" class="invalid-feedback"></div> | ||||
|               </div> | ||||
|               <div class="form-floating"> | ||||
|                 <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route" class="form-control" type="text" value="" placeholder=" "> | ||||
|                 <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route">{{ A_("Route") }}</label> | ||||
|                 <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route-error" class="invalid-feedback"></div> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="accounting-description-editor-buttons"> | ||||
|               {% for tag in description_editor.bus.tags %} | ||||
|                 <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}"> | ||||
|                   {{ tag }} | ||||
|                 </button> | ||||
|               {% endfor %} | ||||
|             </div> | ||||
|  | ||||
|             <div class="d-flex justify-content-between"> | ||||
|               <div class="form-floating me-2"> | ||||
|                 <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from" class="form-control" type="text" value="" placeholder=" "> | ||||
|                 <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from">{{ A_("From") }}</label> | ||||
|                 <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from-error" class="invalid-feedback"></div> | ||||
|               </div> | ||||
|               <div class="form-floating"> | ||||
|                 <input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to" class="form-control" type="text" value="" placeholder=" "> | ||||
|                 <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to">{{ A_("To") }}</label> | ||||
|                 <div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to-error" class="invalid-feedback"></div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           {# A recurring transaction #} | ||||
|           <div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab"> | ||||
|             <div class="accounting-description-editor-buttons"> | ||||
|               {% for recurring in description_editor.recurring %} | ||||
|                 <button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.description_template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}"> | ||||
|                   {{ recurring.name }} | ||||
|                 </button> | ||||
|               {% endfor %} | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           {# The annotation #} | ||||
|           <div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab"> | ||||
|             <div class="form-floating"> | ||||
|               <input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" "> | ||||
|               <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number">{{ A_("The Number of Items") }}</label> | ||||
|               <div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number-error" class="invalid-feedback"></div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="form-floating mt-2"> | ||||
|               <input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note" class="form-control" type="text" value="" placeholder=" "> | ||||
|               <label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note">{{ A_("Note") }}</label> | ||||
|               <div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note-error" class="invalid-feedback"></div> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           {# The suggested accounts #} | ||||
|           <div class="mt-3"> | ||||
|             {% for account in description_editor.accounts %} | ||||
|               <button class="btn btn-outline-primary d-none accounting-description-editor-{{ description_editor.debit_credit }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}"> | ||||
|                 {{ account }} | ||||
|               </button> | ||||
|             {% endfor %} | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="modal-footer"> | ||||
|           <button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button> | ||||
|           <button id="accounting-description-editor-{{ description_editor.debit_credit }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </form> | ||||
| @@ -0,0 +1,75 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| detail-line-items-item: The line items in the journal entry detail | ||||
|  | ||||
|  Copyright (c) 2023 imacat. | ||||
|  | ||||
|  Licensed under the Apache License, Version 2.0 (the "License"); | ||||
|  you may not use this file except in compliance with the License. | ||||
|  You may obtain a copy of the License at | ||||
|  | ||||
|      http://www.apache.org/licenses/LICENSE-2.0 | ||||
|  | ||||
|  Unless required by applicable law or agreed to in writing, software | ||||
|  distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  WITHOUT 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/14 | ||||
| #} | ||||
| {# <ul> For SonarQube not to complain about incorrect HTML #} | ||||
| {% for line_item in line_items %} | ||||
|   <li class="list-group-item accounting-journal-entry-line-item"> | ||||
|     <div class="d-flex justify-content-between"> | ||||
|       <div> | ||||
|         <div class="small">{{ line_item.account }}</div> | ||||
|         {% if line_item.description is not none %} | ||||
|           <div>{{ line_item.description }}</div> | ||||
|         {% endif %} | ||||
|         {% if line_item.original_line_item %} | ||||
|           <div class="fst-italic small accounting-original-line-item"> | ||||
|             <a href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.original_line_item.journal_entry)|accounting_append_next }}"> | ||||
|               {{ A_("Offset %(item)s", item=line_item.original_line_item) }} | ||||
|             </a> | ||||
|           </div> | ||||
|         {% endif %} | ||||
|         {% if line_item.is_need_offset %} | ||||
|           <div class="fst-italic small accounting-offset-line-items"> | ||||
|             {% if line_item.offsets %} | ||||
|               <div class="d-flex justify-content-between"> | ||||
|                 <div>{{ A_("Offsets") }}</div> | ||||
|                 <ul class="ms-2 ps-0"> | ||||
|                   {% for offset in line_item.offsets %} | ||||
|                     <li> | ||||
|                       <a href="{{ url_for("accounting.journal-entry.detail", journal_entry=offset.journal_entry)|accounting_append_next }}"> | ||||
|                         {{ offset.journal_entry.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }} | ||||
|                       </a> | ||||
|                     </li> | ||||
|                   {% endfor %} | ||||
|                 </ul> | ||||
|               </div> | ||||
|               {% if line_item.balance %} | ||||
|                 <div class="d-flex justify-content-between"> | ||||
|                   <div>{{ A_("Net balance") }}</div> | ||||
|                   <div>{{ line_item.balance|accounting_format_amount }}</div> | ||||
|                 </div> | ||||
|               {% else %} | ||||
|                 <div class="d-flex justify-content-between"> | ||||
|                   <div>{{ A_("Fully offset") }}</div> | ||||
|                 </div> | ||||
|               {% endif %} | ||||
|             {% else %} | ||||
|               <div class="d-flex justify-content-between"> | ||||
|                 {{ A_("Unmatched") }} | ||||
|               </div> | ||||
|             {% endif %} | ||||
|           </div> | ||||
|         {% endif %} | ||||
|       </div> | ||||
|       <div>{{ line_item.amount|accounting_format_amount }}</div> | ||||
|     </div> | ||||
|   </li> | ||||
| {% endfor %} | ||||
| {# </ul> For SonarQube not to complain about incorrect HTML #} | ||||
| @@ -25,40 +25,47 @@ First written: 2023/2/26 | ||||
| 
 | ||||
| {% block content %} | ||||
| 
 | ||||
| <div class="btn-group mb-3"> | ||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}"> | ||||
| <div class="mb-3 accounting-toolbar"> | ||||
|   <a class="btn btn-primary" role="button" href="{{ url_for("accounting.report.default")|accounting_or_next }}"> | ||||
|     <i class="fa-solid fa-circle-chevron-left"></i> | ||||
|     {{ A_("Back") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||
|   </a> | ||||
|   {% if accounting_can_edit() %} | ||||
|     <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}"> | ||||
|       <i class="fa-solid fa-gear"></i> | ||||
|       {{ A_("Settings") }} | ||||
|     <a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}"> | ||||
|       <i class="fa-solid fa-pen-to-square"></i> | ||||
|       {{ A_("Edit") }} | ||||
|     </a> | ||||
|   {% endif %} | ||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.transaction.order", txn_date=obj.date)|accounting_append_next }}"> | ||||
|   <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}"> | ||||
|     <i class="fa-solid fa-bars-staggered"></i> | ||||
|     {{ A_("Order") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Order") }}</span> | ||||
|   </a> | ||||
|   {% if accounting_can_edit() %} | ||||
|     {% block to_transfer %}{% endblock %} | ||||
|     <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> | ||||
|       <i class="fa-solid fa-trash"></i> | ||||
|       {{ A_("Delete") }} | ||||
|     </button> | ||||
|     {% block as_trasfer %}{% endblock %} | ||||
|     {% if obj.can_delete %} | ||||
|       <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> | ||||
|         <i class="fa-solid fa-trash"></i> | ||||
|         <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 %} | ||||
| </div> | ||||
| 
 | ||||
| {% if accounting_can_edit() %} | ||||
|   <div class="d-md-none accounting-material-fab"> | ||||
|     <a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}"> | ||||
|     <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}"> | ||||
|       <i class="fa-solid fa-pen-to-square"></i> | ||||
|     </a> | ||||
|   </div> | ||||
| {% endif %} | ||||
| 
 | ||||
| {% if accounting_can_edit() %} | ||||
|   <form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post"> | ||||
| {% if accounting_can_edit() and obj.can_delete %} | ||||
|   <form action="{{ url_for("accounting.journal-entry.delete", journal_entry=obj) }}" method="post"> | ||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|     {% if request.args.next %} | ||||
|       <input type="hidden" name="next" value="{{ request.args.next }}"> | ||||
| @@ -67,11 +74,11 @@ First written: 2023/2/26 | ||||
|       <div class="modal-dialog"> | ||||
|         <div class="modal-content"> | ||||
|           <div class="modal-header"> | ||||
|             <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Transaction Confirmation") }}</h1> | ||||
|             <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Journal Entry") }}</h1> | ||||
|             <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> | ||||
|           </div> | ||||
|           <div class="modal-body"> | ||||
|             {{ A_("Do you really want to delete this transaction?") }} | ||||
|             {{ A_("Do you really want to delete this journal entry?") }} | ||||
|           </div> | ||||
|           <div class="modal-footer"> | ||||
|             <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button> | ||||
| @@ -92,13 +99,13 @@ First written: 2023/2/26 | ||||
|     {{ obj.date|accounting_format_date }} | ||||
|   </div> | ||||
| 
 | ||||
|   {% block transaction_currencies %}{% endblock %} | ||||
|   {% block journal_entry_currencies %}{% endblock %} | ||||
| 
 | ||||
|   {% if obj.note %} | ||||
|     <div class="card mb-3"> | ||||
|       <div class="card-body"> | ||||
|         <i class="far fa-comment-dots"></i> | ||||
|         {{ obj.note|accounting_txn_text2html|safe }} | ||||
|         {{ obj.note|accounting_journal_entry_text2html|safe }} | ||||
|       </div> | ||||
|     </div> | ||||
|   {% endif %} | ||||
| @@ -0,0 +1,47 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| form-currency.html: The currency sub-form in 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/3/21 | ||||
| #} | ||||
| <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}"> | ||||
|   <div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}"> | ||||
|     <div class="d-flex justify-content-between mt-2 mb-3"> | ||||
|       <div class="form-floating accounting-currency-content"> | ||||
|         <select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" {% if currency_code_is_locked %} disabled="disabled" {% endif %}> | ||||
|           {% for currency in accounting_currency_options() %} | ||||
|             <option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option> | ||||
|           {% endfor %} | ||||
|         </select> | ||||
|         <label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label> | ||||
|         <div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div> | ||||
|       </div> | ||||
|  | ||||
|       <div> | ||||
|         <button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}"> | ||||
|           <i class="fas fa-minus"></i> | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     {% block line_items %}{% endblock %} | ||||
|   </div> | ||||
|   <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div> | ||||
| </div> | ||||
| @@ -0,0 +1,51 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| form-debit-credit.html: The debit or credit line items in 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/3/21 | ||||
| #} | ||||
| <div class="mb-2"> | ||||
|   <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}" class="form-control accounting-material-text-field {% if line_item_forms %} accounting-not-empty {% else %} accounting-clickable {% endif %} {% if debit_errors %} is-invalid {% endif %}"> | ||||
|     <label class="form-label" for="accounting-currency-{{ currency_index }}-{{ debit_credit }}">{{ header }}</label> | ||||
|     <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-content" class="mt-2 {% if not line_item_forms %} d-none {% endif %}"> | ||||
|       <ul id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-list" class="list-group accounting-line-item-list"> | ||||
|         {% for line_item_form in line_item_forms %} | ||||
|           {% with currency_index = currency_index, | ||||
|                   line_item_index = loop.index, | ||||
|                   only_one_line_item_form = line_item_forms|length == 1, | ||||
|                   form = line_item_form.form %} | ||||
|             {% include "accounting/journal-entry/include/form-line-item.html" %} | ||||
|           {% endwith %} | ||||
|         {% endfor %} | ||||
|       </ul> | ||||
|  | ||||
|       <div class="d-flex justify-content-between mt-2 mb-2"> | ||||
|         <div>{{ A_("Total") }}</div> | ||||
|         <div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-total" class="badge rounded-pill bg-primary">{{ debit_credit_total }}</span></div> | ||||
|       </div> | ||||
|  | ||||
|       <div> | ||||
|         <button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal"> | ||||
|           <i class="fas fa-plus"></i> | ||||
|           {{ A_("New") }} | ||||
|         </button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-error" class="invalid-feedback">{% if debit_credit_errors %}{{ debit_credit_errors[0] }}{% endif %}</div> | ||||
| </div> | ||||
| @@ -0,0 +1,74 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| form-line-item.html: The line item sub-form in 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/25 | ||||
| #} | ||||
| {# <ul> For SonarQube not to complain about incorrect HTML #} | ||||
| <li id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ debit_credit }} {% if form.offsets %} accounting-matched-line-item {% endif %}" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-line-item-index="{{ line_item_index }}" {% if form.is_need_offset %} data-is-need-offset="true" {% endif %}> | ||||
|   {% if form.id.data %} | ||||
|     <input type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-id" value="{{ form.id.data }}"> | ||||
|   {% endif %} | ||||
|   <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" value="{{ line_item_index }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-id" class="accounting-original-line-item-id" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original_line_item_id" value="{{ form.original_line_item_id.data|accounting_default }}" data-date="{{ form.original_line_item_date|accounting_default }}" data-text="{{ form.original_line_item_text|accounting_default }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-text="{{ form.account_text }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" value="{{ form.description.data|accounting_default }}"> | ||||
|   <input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" value="{{ form.amount.data|accounting_journal_entry_format_amount_input }}" data-min="{{ form.offset_total|accounting_default("0") }}"> | ||||
|   <div class="accounting-line-item-content"> | ||||
|     <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-control" class="form-control clickable d-flex justify-content-between {% if form.all_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal"> | ||||
|       <div> | ||||
|         <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-text" class="small">{{ form.account_text }}</div> | ||||
|         <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description-text">{{ form.description.data|accounting_default }}</div> | ||||
|         <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-text" class="fst-italic small accounting-original-line-item {% if not form.original_line_item_id.data %} d-none {% endif %}"> | ||||
|           {% if form.original_line_item_id.data %}{{ A_("Offset %(item)s", item=form.original_line_item_text|accounting_default) }}{% endif %} | ||||
|         </div> | ||||
|         <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-offsets" class="fst-italic small accounting-offset-line-items {% if not form.is_need_offset %} d-none {% endif %}"> | ||||
|           {% if form.offsets %} | ||||
|             <div class="d-flex justify-content-between {% if not form.offsets %} d-none {% endif %}"> | ||||
|               <div>{{ A_("Offsets") }}</div> | ||||
|               <ul class="ms-2 ps-0"> | ||||
|                 {% for offset in form.offsets %} | ||||
|                   <li>{{ offset.journal_entry.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li> | ||||
|                 {% endfor %} | ||||
|               </ul> | ||||
|             </div> | ||||
|             {% if form.net_balance == 0 %} | ||||
|               <div>{{ A_("Fully offset") }}</div> | ||||
|             {% else %} | ||||
|               <div class="d-flex justify-content-between"> | ||||
|                 <div>{{ A_("Net balance") }}</div> | ||||
|                 <div>{{ form.net_balance|accounting_format_amount }}</div> | ||||
|               </div> | ||||
|             {% endif %} | ||||
|           {% else %} | ||||
|             {{ A_("Unmatched") }} | ||||
|           {% endif %} | ||||
|         </div> | ||||
|       </div> | ||||
|       <div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount-text" class="badge rounded-pill bg-primary">{{ form.amount.data|accounting_format_amount }}</span></div> | ||||
|     </div> | ||||
|     <div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-error" class="invalid-feedback">{% if form.all_errors %}{{ form.all_errors[0] }}{% endif %}</div> | ||||
|   </div> | ||||
|  | ||||
|   <div> | ||||
|     <button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_form or form.offsets %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}"> | ||||
|       <i class="fas fa-minus"></i> | ||||
|     </button> | ||||
|   </div> | ||||
| </li> | ||||
| {# </ul> For SonarQube not to complain about incorrect HTML #} | ||||
| @@ -1,6 +1,6 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| form.html: The transfer transaction form | ||||
| form.html: The base journal entry form | ||||
| 
 | ||||
|  Copyright (c) 2023 imacat. | ||||
| 
 | ||||
| @@ -23,28 +23,30 @@ First written: 2023/2/26 | ||||
| 
 | ||||
| {% block accounting_scripts %} | ||||
|   <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script> | ||||
|   <script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script> | ||||
|   <script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script> | ||||
|   <script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script> | ||||
|   <script src="{{ url_for("accounting.static", filename="js/journal-entry-form.js") }}"></script> | ||||
|   <script src="{{ url_for("accounting.static", filename="js/journal-entry-line-item-editor.js") }}"></script> | ||||
|   <script src="{{ url_for("accounting.static", filename="js/journal-entry-account-selector.js") }}"></script> | ||||
|   <script src="{{ url_for("accounting.static", filename="js/original-line-item-selector.js") }}"></script> | ||||
|   <script src="{{ url_for("accounting.static", filename="js/description-editor.js") }}"></script> | ||||
| {% endblock %} | ||||
| 
 | ||||
| {% block content %} | ||||
| 
 | ||||
| <div class="btn-group mb-3"> | ||||
| <div class="mb-3 accounting-toolbar"> | ||||
|   <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}"> | ||||
|     <i class="fa-solid fa-circle-chevron-left"></i> | ||||
|     {{ A_("Back") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||
|   </a> | ||||
| </div> | ||||
| 
 | ||||
| <form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-entry-template="{{ entry_template }}"> | ||||
| <form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-line-item-template="{{ line_item_template }}"> | ||||
|   {{ form.csrf_token }} | ||||
|   {% if request.args.next %} | ||||
|     <input type="hidden" name="next" value="{{ request.args.next }}"> | ||||
|   {% endif %} | ||||
| 
 | ||||
|   <div class="form-floating mb-3"> | ||||
|     <input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ form.date.data|accounting_default }}" placeholder=" " required="required"> | ||||
|     <input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ form.date.data|accounting_default }}" max="{{ form.max_date|accounting_default }}" min="{{ form.min_date|accounting_default }}" placeholder=" " required="required"> | ||||
|     <label class="form-label" for="accounting-date">{{ A_("Date") }}</label> | ||||
|     <div id="accounting-date-error" class="invalid-feedback">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div> | ||||
|   </div> | ||||
| @@ -57,7 +59,7 @@ First written: 2023/2/26 | ||||
|       </div> | ||||
| 
 | ||||
|       <div> | ||||
|         <button id="accounting-btn-new-currency" class="btn btn-primary" type="button"> | ||||
|         <button id="accounting-add-currency" class="btn btn-primary" type="button"> | ||||
|           <i class="fas fa-plus"></i> | ||||
|           {{ A_("New") }} | ||||
|         </button> | ||||
| @@ -86,7 +88,8 @@ First written: 2023/2/26 | ||||
|   </div> | ||||
| </form> | ||||
| 
 | ||||
| {% include "accounting/transaction/include/entry-form-modal.html" %} | ||||
| {% include "accounting/journal-entry/include/journal-entry-line-item-editor-modal.html" %} | ||||
| {% block form_modals %}{% endblock %} | ||||
| {% include "accounting/journal-entry/include/original-line-item-selector-modal.html" %} | ||||
| 
 | ||||
| {% endblock %} | ||||
| @@ -0,0 +1,76 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| journal-entry-line-item-editor-modal: The modal of 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 | ||||
| #} | ||||
| <form id="accounting-line-item-editor"> | ||||
|   <div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true"> | ||||
|     <div class="modal-dialog"> | ||||
|       <div class="modal-content"> | ||||
|         <div class="modal-header"> | ||||
|           <h1 class="modal-title fs-5" id="accounting-line-item-editor-modal-label">{{ A_("Line Item Content") }}</h1> | ||||
|           <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> | ||||
|         </div> | ||||
|         <div class="modal-body"> | ||||
|           <div id="accounting-line-item-editor-original-line-item-container" class="d-flex justify-content-between mb-3"> | ||||
|             <div class="accounting-line-item-editor-original-line-item-content"> | ||||
|               <div id="accounting-line-item-editor-original-line-item-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal"> | ||||
|                 <label class="form-label" for="accounting-line-item-editor-original-line-item">{{ A_("Original Line Item") }}</label> | ||||
|                 <div id="accounting-line-item-editor-original-line-item"></div> | ||||
|               </div> | ||||
|               <div id="accounting-line-item-editor-original-line-item-error" class="invalid-feedback"></div> | ||||
|             </div> | ||||
|  | ||||
|             <div> | ||||
|               <button id="accounting-line-item-editor-original-line-item-delete" class="btn btn-danger rounded-circle" type="button"> | ||||
|                 <i class="fas fa-minus"></i> | ||||
|               </button> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           <div class="mb-3"> | ||||
|             <div id="accounting-line-item-editor-description-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target=""> | ||||
|               <label class="form-label" for="accounting-line-item-editor-description">{{ A_("Description") }}</label> | ||||
|               <div id="accounting-line-item-editor-description"></div> | ||||
|             </div> | ||||
|             <div id="accounting-line-item-editor-description-error" class="invalid-feedback"></div> | ||||
|           </div> | ||||
|  | ||||
|           <div class="mb-3"> | ||||
|             <div id="accounting-line-item-editor-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target=""> | ||||
|               <label class="form-label" for="accounting-line-item-editor-account">{{ A_("Account") }}</label> | ||||
|               <div id="accounting-line-item-editor-account"></div> | ||||
|             </div> | ||||
|             <div id="accounting-line-item-editor-account-error" class="invalid-feedback"></div> | ||||
|           </div> | ||||
|  | ||||
|           <div class="form-floating mb-3"> | ||||
|             <input id="accounting-line-item-editor-amount" class="form-control" type="number" value="" min="0" max="" step="0.01" placeholder=" " required="required"> | ||||
|             <label for="accounting-line-item-editor-amount">{{ A_("Amount") }}</label> | ||||
|             <div id="accounting-line-item-editor-amount-error" class="invalid-feedback"></div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="modal-footer"> | ||||
|           <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button> | ||||
|           <button type="submit" class="btn btn-primary">{{ A_("Save") }}</button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </form> | ||||
| @@ -0,0 +1,56 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| order-journal-entry.html: The journal entry in the journal entry order page | ||||
|  | ||||
|  Copyright (c) 2023 imacat. | ||||
|  | ||||
|  Licensed under the Apache License, Version 2.0 (the "License"); | ||||
|  you may not use this file except in compliance with the License. | ||||
|  You may obtain a copy of the License at | ||||
|  | ||||
|      http://www.apache.org/licenses/LICENSE-2.0 | ||||
|  | ||||
|  Unless required by applicable law or agreed to in writing, software | ||||
|  distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  WITHOUT 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/21 | ||||
| #} | ||||
| <a class="small w-100 accounting-journal-entry-order-item" href="{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_append_next }}"> | ||||
|   <div> | ||||
|     {% if journal_entry.is_cash_disbursement %} | ||||
|       {{ A_("Cash Disbursement") }} | ||||
|     {% elif journal_entry.is_cash_receipt %} | ||||
|       {{ A_("Cash Receipt") }} | ||||
|     {% else %} | ||||
|       {{ A_("Transfer") }} | ||||
|     {% endif %} | ||||
|   </div> | ||||
|   {% for currency in journal_entry.currencies %} | ||||
|     <div class="d-flex justify-content-between accounting-journal-entry-order-item-currency"> | ||||
|       <div> | ||||
|         {% if not journal_entry.is_cash_receipt %} | ||||
|           {% for line_item in currency.debit %} | ||||
|             <div>{{ line_item.description|accounting_default }}</div> | ||||
|           {% endfor %} | ||||
|         {% endif %} | ||||
|         {% if not journal_entry.is_cash_disbursement %} | ||||
|           {% for line_item in currency.credit %} | ||||
|             <div class="accounting-mobile-journal-credit">{{ line_item.description|accounting_default }}</div> | ||||
|           {% endfor %} | ||||
|         {% endif %} | ||||
|       </div> | ||||
|       <div> | ||||
|         <span class="badge bg-info rounded-pill"> | ||||
|           {% if currency.code != accounting_default_currency_code() %} | ||||
|             {{ currency.code }} | ||||
|           {% endif %} | ||||
|           {{ currency.debit_total|accounting_format_amount }} | ||||
|         </span> | ||||
|       </div> | ||||
|     </div> | ||||
|   {% endfor %} | ||||
| </a> | ||||
| @@ -0,0 +1,56 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| original-line-item-selector-modal.html: The modal of 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/2/25 | ||||
| #} | ||||
| <div id="accounting-original-line-item-selector-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-original-line-item-selector-modal-label" aria-hidden="true"> | ||||
|   <div class="modal-dialog"> | ||||
|     <div class="modal-content"> | ||||
|       <div class="modal-header"> | ||||
|         <h1 class="modal-title fs-5" id="accounting-original-line-item-selector-modal-label">{{ A_("Select Original Line Item") }}</h1> | ||||
|         <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button> | ||||
|       </div> | ||||
|       <div class="modal-body"> | ||||
|         <div class="input-group mb-2"> | ||||
|           <input id="accounting-original-line-item-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required"> | ||||
|           <label class="input-group-text" for="accounting-original-line-item-selector-query"> | ||||
|             <i class="fa-solid fa-magnifying-glass"></i> | ||||
|             {{ A_("Search") }} | ||||
|           </label> | ||||
|         </div> | ||||
|  | ||||
|         <ul id="accounting-original-line-item-selector-option-list" class="list-group accounting-selector-list"> | ||||
|           {% for line_item in form.original_line_item_options %} | ||||
|             <li id="accounting-original-line-item-selector-option-{{ line_item.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-line-item-selector-option" data-id="{{ line_item.id }}" data-date="{{ line_item.journal_entry.date }}" data-debit-credit="{{ "debit" if line_item.is_debit else "credit" }}" data-currency-code="{{ line_item.currency.code }}" data-account-code="{{ line_item.account_code }}" data-account-text="{{ line_item.account }}" data-description="{{ line_item.description|accounting_default }}" data-net-balance="{{ line_item.net_balance|accounting_journal_entry_format_amount_input }}" data-text="{{ line_item }}" data-query-values="{{ line_item.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal"> | ||||
|               <div>{{ line_item.journal_entry.date|accounting_format_date }} {{ line_item.description|accounting_default }}</div> | ||||
|               <div> | ||||
|                 <span class="badge bg-primary rounded-pill"> | ||||
|                   <span id="accounting-original-line-item-selector-option-{{ line_item.id }}-net-balance">{{ line_item.net_balance|accounting_format_amount }}</span> | ||||
|                   / {{ line_item.amount|accounting_format_amount }} | ||||
|                 </span> | ||||
|               </div> | ||||
|             </li> | ||||
|           {% endfor %} | ||||
|         </ul> | ||||
|         <p id="accounting-original-line-item-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
|  | ||||
| @@ -1,6 +1,6 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| order.html: The order of the transactions in a same day | ||||
| order.html: The order of the journal entries in a same day | ||||
| 
 | ||||
|  Copyright (c) 2023 imacat. | ||||
| 
 | ||||
| @@ -23,22 +23,22 @@ First written: 2023/2/26 | ||||
| 
 | ||||
| {% block accounting_scripts %} | ||||
|   <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script> | ||||
|   <script src="{{ url_for("accounting.static", filename="js/transaction-order.js") }}"></script> | ||||
|   <script src="{{ url_for("accounting.static", filename="js/journal-entry-order.js") }}"></script> | ||||
| {% endblock %} | ||||
| 
 | ||||
| {% block header %}{% block title %}{{ A_("Transactions on %(date)s", date=date) }}{% endblock %}{% endblock %} | ||||
| {% block header %}{% block title %}{{ A_("Journal Entries on %(date)s", date=date|accounting_format_date) }}{% endblock %}{% endblock %} | ||||
| 
 | ||||
| {% block content %} | ||||
| 
 | ||||
| <div class="btn-group mb-3"> | ||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}"> | ||||
| <div class="mb-3 accounting-toolbar"> | ||||
|   <a class="btn btn-primary" role="button" href="{{ url_for("accounting.report.default")|accounting_or_next }}"> | ||||
|     <i class="fa-solid fa-circle-chevron-left"></i> | ||||
|     {{ A_("Back") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("Back") }}</span> | ||||
|   </a> | ||||
| </div> | ||||
| 
 | ||||
| {% if list|length > 1 and accounting_can_edit() %} | ||||
|   <form action="{{ url_for("accounting.transaction.sort", txn_date=date) }}" method="post"> | ||||
|   <form action="{{ url_for("accounting.journal-entry.sort", journal_entry_date=date) }}" method="post"> | ||||
|     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||
|     {% if request.args.next %} | ||||
|       <input type="hidden" name="next" value="{{ request.args.next }}"> | ||||
| @@ -47,9 +47,9 @@ First written: 2023/2/26 | ||||
|       {% for item in list %} | ||||
|         <li class="list-group-item d-flex justify-content-between" data-id="{{ item.id }}"> | ||||
|           <input id="accounting-order-{{ item.id }}-no" type="hidden" name="{{ item.id }}-no" value="{{ loop.index }}"> | ||||
|           <div> | ||||
|             {{ item }} | ||||
|           </div> | ||||
|           {% with journal_entry = item %} | ||||
|             {% include "accounting/journal-entry/include/order-journal-entry.html" %} | ||||
|           {% endwith %} | ||||
|           <i class="fa-solid fa-bars"></i> | ||||
|         </li> | ||||
|       {% endfor %} | ||||
| @@ -72,7 +72,9 @@ First written: 2023/2/26 | ||||
|   <ul class="list-group mb-3"> | ||||
|     {% for item in list %} | ||||
|       <li class="list-group-item"> | ||||
|         {{ item }} | ||||
|         {% with journal_entry = item %} | ||||
|           {% include "accounting/journal-entry/include/order-journal-entry.html" %} | ||||
|         {% endwith %} | ||||
|       </li> | ||||
|     {% endfor %} | ||||
|   </ul> | ||||
| @@ -1,6 +1,6 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| create.html: The transfer transaction creation form | ||||
| create.html: The cash receipt journal entry creation form | ||||
| 
 | ||||
|  Copyright (c) 2023 imacat. | ||||
| 
 | ||||
| @@ -19,10 +19,10 @@ create.html: The transfer transaction creation form | ||||
| Author: imacat@mail.imacat.idv.tw (imacat) | ||||
| First written: 2023/2/25 | ||||
| #} | ||||
| {% extends "accounting/transaction/transfer/include/form.html" %} | ||||
| {% extends "accounting/journal-entry/receipt/include/form.html" %} | ||||
| 
 | ||||
| {% block header %}{% block title %}{{ A_("Add a New Transfer Transaction") }}{% endblock %}{% endblock %} | ||||
| {% block header %}{% block title %}{{ A_("Add a New Cash Receipt Journal Entry") }}{% endblock %}{% endblock %} | ||||
| 
 | ||||
| {% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %} | ||||
| 
 | ||||
| {% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %} | ||||
| {% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %} | ||||
| @@ -1,6 +1,6 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| detail.html: The account detail | ||||
| detail.html: The cash receipt journal entry detail | ||||
| 
 | ||||
|  Copyright (c) 2023 imacat. | ||||
| 
 | ||||
| @@ -19,36 +19,26 @@ detail.html: The account detail | ||||
| Author: imacat@mail.imacat.idv.tw (imacat) | ||||
| First written: 2023/2/26 | ||||
| #} | ||||
| {% extends "accounting/transaction/include/detail.html" %} | ||||
| {% extends "accounting/journal-entry/include/detail.html" %} | ||||
| 
 | ||||
| {% block to_transfer %} | ||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}"> | ||||
| {% block as_trasfer %} | ||||
|   <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}"> | ||||
|     <i class="fa-solid fa-bars-staggered"></i> | ||||
|     {{ A_("To Transfer") }} | ||||
|     <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span> | ||||
|   </a> | ||||
| {% endblock %} | ||||
| 
 | ||||
| {% block transaction_currencies %} | ||||
| {% block journal_entry_currencies %} | ||||
|   {% for currency in obj.currencies %} | ||||
|     <div class="mb-3"> | ||||
|       <div class="mb-2 fw-bolder">{{ currency.name }}</div> | ||||
| 
 | ||||
|       <ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> | ||||
|         <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li> | ||||
|         {% for entry in currency.debit %} | ||||
|           <li class="list-group-item accounting-transaction-entry"> | ||||
|             <div class="d-flex justify-content-between"> | ||||
|               <div> | ||||
|                 <div class="small">{{ entry.account }}</div> | ||||
|                 {% if entry.summary is not none %} | ||||
|                   <div>{{ entry.summary }}</div> | ||||
|                 {% endif %} | ||||
|               </div> | ||||
|               <div>{{ entry.amount|accounting_format_amount }}</div> | ||||
|             </div> | ||||
|           </li> | ||||
|         {% endfor %} | ||||
|         <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> | ||||
|         <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Content") }}</li> | ||||
|         {% with line_items = currency.credit %} | ||||
|           {% include "accounting/journal-entry/include/detail-line-items.html" %} | ||||
|         {% endwith %} | ||||
|         <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total"> | ||||
|           <div class="d-flex justify-content-between"> | ||||
|             <div>{{ A_("Total") }}</div> | ||||
|             <div>{{ currency.debit_total|accounting_format_amount }}</div> | ||||
| @@ -0,0 +1,28 @@ | ||||
| {# | ||||
| The Mia! Accounting Flask Project | ||||
| edit.html: The cash receipt journal entry edit 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 | ||||
| #} | ||||
| {% extends "accounting/journal-entry/receipt/include/form.html" %} | ||||
|  | ||||
| {% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %} | ||||
|  | ||||
| {% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %} | ||||
|  | ||||
| {% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %} | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user