Compare commits
	
		
			154 Commits
		
	
	
		
			v0.1.0
			...
			329027969a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 329027969a | |||
| 9f7a8c9540 | |||
| 384bb2c46d | |||
| cabfe268ce | |||
| 26df71014b | |||
| 3126ee8153 | |||
| cb622f4bad | |||
| 515d39e61c | |||
| 952061c4bb | |||
| 788225826d | |||
| c52081e528 | |||
| 1f235acdf9 | |||
| 0f6c23e1f3 | |||
| 488e72679e | |||
| 6d43b14862 | |||
| 685213cdbb | |||
| 05fde3a742 | |||
| 9383f5484f | |||
| 88314e1e45 | |||
| 83b5761bca | |||
| f25c993b75 | |||
| 6d02f8033d | |||
| 2c367703e4 | |||
| 284b5be128 | |||
| a672a13789 | |||
| 9af9afd14d | |||
| d98e9f8f05 | |||
| 652bddc07a | |||
| 5a6e4f5b5e | |||
| f878ba5535 | |||
| e7c36ba13a | |||
| 4cfe7c7c59 | |||
| b0b30a8ae6 | |||
| 2e3633b205 | |||
| d68aa91c33 | |||
| 3f63fb0bda | |||
| d5af5de3c1 | |||
| d9c08568cf | |||
| a4c89f1494 | |||
| a73e3204b9 | |||
| 330a71ebf2 | |||
| 36b0bb3a0e | |||
| 2ab60b2224 | |||
| 36f55900c7 | |||
| d99f592cff | |||
| e24ed61b99 | |||
| 354f1ff3d8 | |||
| d8e0e30c41 | |||
| d58859bcf3 | |||
| 40e64c4d2e | |||
| 2aacb67988 | |||
| a839c5a41a | |||
| 356d10eb6e | |||
| 8dc340dbf1 | |||
| 4b5b348270 | |||
| d9585f0e53 | |||
| 5737d6cef4 | |||
| 1d61fa93d3 | |||
| b1c7bc61c4 | |||
| 708a434b5d | |||
| 8e524674a3 | |||
| 699db20308 | |||
| c3cedf714b | |||
| c67ed4471c | |||
| 2d3b9f68b8 | |||
| f82278b48a | |||
| 85480804e7 | |||
| 9e85c14431 | |||
| 31dc8fab04 | |||
| dc24af1db0 | |||
| 59795635ee | |||
| 399afe56c8 | |||
| 16e2a146db | |||
| f7ce94902f | |||
| 5cf3cb1e11 | |||
| a78057a8c3 | |||
| 0491614ae4 | |||
| fb9ff1d7ff | |||
| be10984cbb | |||
| 7b2089bdfb | |||
| be8dc21c5a | |||
| 2f8c6f6981 | |||
| cdd010427b | |||
| d78b941674 | |||
| 570c84c196 | |||
| 7873e16cc3 | |||
| 52351c52bc | |||
| 591fb4a7ab | |||
| 2a6c5de6d6 | |||
| 6b94cfb908 | |||
| eb90e83c98 | |||
| 6bf18be455 | |||
| 895bca2508 | |||
| 6af29e7df7 | |||
| 50f8f06687 | |||
| cd5b1b97fd | |||
| b7dd53d2f9 | |||
| b07b0e3be4 | |||
| e7fb2288ce | |||
| 17ba7659b6 | |||
| 2c8d5e7c8a | |||
| e2f707f696 | |||
| b5c0d0b7b3 | |||
| 7fe2bb6135 | |||
| 4d870f1dcc | |||
| 16b2eb1c93 | |||
| fd63149066 | |||
| a7a432914d | |||
| 1a44f08b90 | |||
| 3e68cfe690 | |||
| 809f2b6df3 | |||
| c286aa8b8b | |||
| 1326d9538c | |||
| b9cecf343a | |||
| 3d9e6c10da | |||
| 5090e59bb1 | |||
| 62697fb782 | |||
| 8c462e7b2c | |||
| 90a8229db9 | |||
| 8be44ccf5f | |||
| 511328a0bd | |||
| 0d8cf85ec0 | |||
| 6e212f0e33 | |||
| 2fbe137243 | |||
| f4e2c21ece | |||
| fff07a2552 | |||
| 975b00bce9 | |||
| d648538fbb | |||
| dde9c38bb8 | |||
| fecf33baa8 | |||
| cea2a44226 | |||
| b5d87d2387 | |||
| 784e7bde49 | |||
| 60280f415d | |||
| f32d268494 | |||
| 1c1be87f3e | |||
| 589da0c1c6 | |||
| 8363ce6602 | |||
| 6a83f95c9f | |||
| 7dc754174c | |||
| 5238168b2d | |||
| eeb05b8616 | |||
| 9920377266 | |||
| 9f9c40c30e | |||
| d368c5e062 | |||
| 4aed2f6ba7 | |||
| 6876fdf75e | |||
| d9624c7be6 | |||
| 8364025668 | |||
| dd3690dd6a | |||
| 3312c835fd | |||
| fce9d04896 | |||
| c68786f78a | |||
| 581e803707 | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -37,4 +37,4 @@ excludes | |||||||
| *.pot | *.pot | ||||||
| *.mo | *.mo | ||||||
| zh_Hans | zh_Hans | ||||||
| node_modules | test_temp.py | ||||||
|   | |||||||
| @@ -22,7 +22,7 @@ include docs/source/* | |||||||
| include docs/source/_static/* | include docs/source/_static/* | ||||||
| include docs/source/_templates/* | include docs/source/_templates/* | ||||||
| include tests/* | include tests/* | ||||||
| include tests/testsite/* | include tests/test_site/* | ||||||
| include tests/testsite/templates/* | include tests/test_site/templates/* | ||||||
| include tests/testsite/translations/* | include tests/test_site/translations/* | ||||||
| include tests/testsite/translations/*/LC_MESSAGES/* | include tests/test_site/translations/*/LC_MESSAGES/* | ||||||
|   | |||||||
							
								
								
									
										53
									
								
								docs/source/accounting.account.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								docs/source/accounting.account.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | |||||||
|  | accounting.account package | ||||||
|  | ========================== | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.account.commands module | ||||||
|  | ---------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.account.commands | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.account.converters module | ||||||
|  | ------------------------------------ | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.account.converters | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.account.forms module | ||||||
|  | ------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.account.forms | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.account.query module | ||||||
|  | ------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.account.query | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.account.views module | ||||||
|  | ------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.account.views | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.account | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
| @@ -12,18 +12,10 @@ accounting.base\_account.commands module | |||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|  |  | ||||||
| accounting.base\_account.database module | accounting.base\_account.converters module | ||||||
| ---------------------------------------- | ------------------------------------------ | ||||||
|  |  | ||||||
| .. automodule:: accounting.base_account.database | .. automodule:: accounting.base_account.converters | ||||||
|    :members: |  | ||||||
|    :undoc-members: |  | ||||||
|    :show-inheritance: |  | ||||||
|  |  | ||||||
| accounting.base\_account.models module |  | ||||||
| -------------------------------------- |  | ||||||
|  |  | ||||||
| .. automodule:: accounting.base_account.models |  | ||||||
|    :members: |    :members: | ||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|   | |||||||
							
								
								
									
										53
									
								
								docs/source/accounting.currency.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								docs/source/accounting.currency.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | |||||||
|  | accounting.currency package | ||||||
|  | =========================== | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.currency.commands module | ||||||
|  | ----------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.currency.commands | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.currency.converters module | ||||||
|  | ------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.currency.converters | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.currency.forms module | ||||||
|  | -------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.currency.forms | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.currency.query module | ||||||
|  | -------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.currency.query | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.currency.views module | ||||||
|  | -------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.currency.views | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.currency | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
| @@ -7,7 +7,10 @@ Subpackages | |||||||
| .. toctree:: | .. toctree:: | ||||||
|    :maxdepth: 4 |    :maxdepth: 4 | ||||||
|  |  | ||||||
|  |    accounting.account | ||||||
|    accounting.base_account |    accounting.base_account | ||||||
|  |    accounting.currency | ||||||
|  |    accounting.transaction | ||||||
|    accounting.utils |    accounting.utils | ||||||
|  |  | ||||||
| Submodules | Submodules | ||||||
| @@ -21,6 +24,14 @@ accounting.locale module | |||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.models module | ||||||
|  | ------------------------ | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.models | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
| Module contents | Module contents | ||||||
| --------------- | --------------- | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										61
									
								
								docs/source/accounting.transaction.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								docs/source/accounting.transaction.rst
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | |||||||
|  | accounting.transaction package | ||||||
|  | ============================== | ||||||
|  |  | ||||||
|  | Submodules | ||||||
|  | ---------- | ||||||
|  |  | ||||||
|  | accounting.transaction.converters module | ||||||
|  | ---------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.converters | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.dispatcher module | ||||||
|  | ---------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.dispatcher | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.forms module | ||||||
|  | ----------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.forms | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.query module | ||||||
|  | ----------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.query | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.template module | ||||||
|  | -------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.template | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.transaction.views module | ||||||
|  | ----------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction.views | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | Module contents | ||||||
|  | --------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.transaction | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
| @@ -4,6 +4,22 @@ accounting.utils package | |||||||
| Submodules | Submodules | ||||||
| ---------- | ---------- | ||||||
|  |  | ||||||
|  | accounting.utils.flash\_errors module | ||||||
|  | ------------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.utils.flash_errors | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.utils.next\_uri module | ||||||
|  | --------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.utils.next_uri | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
| accounting.utils.pagination module | accounting.utils.pagination module | ||||||
| ---------------------------------- | ---------------------------------- | ||||||
|  |  | ||||||
| @@ -28,6 +44,30 @@ accounting.utils.query module | |||||||
|    :undoc-members: |    :undoc-members: | ||||||
|    :show-inheritance: |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.utils.random\_id module | ||||||
|  | ---------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.utils.random_id | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.utils.strip\_text module | ||||||
|  | ----------------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.utils.strip_text | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
|  | accounting.utils.user module | ||||||
|  | ---------------------------- | ||||||
|  |  | ||||||
|  | .. automodule:: accounting.utils.user | ||||||
|  |    :members: | ||||||
|  |    :undoc-members: | ||||||
|  |    :show-inheritance: | ||||||
|  |  | ||||||
| Module contents | Module contents | ||||||
| --------------- | --------------- | ||||||
|  |  | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ | |||||||
|  |  | ||||||
| [metadata] | [metadata] | ||||||
| name = mia-accounting-flask | name = mia-accounting-flask | ||||||
| version = 0.1.0 | version = 0.3.0 | ||||||
| author = imacat | author = imacat | ||||||
| author_email = imacat@mail.imacat.idv.tw | author_email = imacat@mail.imacat.idv.tw | ||||||
| description = The Mia! Accounting Flask project. | description = The Mia! Accounting Flask project. | ||||||
| @@ -36,7 +36,7 @@ classifiers = | |||||||
| [options] | [options] | ||||||
| package_dir = | package_dir = | ||||||
|     = src |     = src | ||||||
| python_requires = >=3.10 | python_requires = >=3.11 | ||||||
| install_requires = | install_requires = | ||||||
|   flask |   flask | ||||||
|   Flask-SQLAlchemy |   Flask-SQLAlchemy | ||||||
| @@ -53,3 +53,4 @@ accounting = | |||||||
|   static/** |   static/** | ||||||
|   templates/** |   templates/** | ||||||
|   translations/*/LC_MESSAGES/*.mo |   translations/*/LC_MESSAGES/*.mo | ||||||
|  |   data/** | ||||||
|   | |||||||
| @@ -18,55 +18,17 @@ | |||||||
|  |  | ||||||
| """ | """ | ||||||
| import typing as t | import typing as t | ||||||
| from abc import ABC, abstractmethod | from pathlib import Path | ||||||
|  |  | ||||||
| import sqlalchemy as sa |  | ||||||
| from flask import Flask, Blueprint | from flask import Flask, Blueprint | ||||||
| from flask_sqlalchemy.model import Model | from flask_sqlalchemy import SQLAlchemy | ||||||
|  |  | ||||||
| T = t.TypeVar("T", bound=Model) | from accounting.utils.user import AbstractUserUtils | ||||||
|  |  | ||||||
|  | db: SQLAlchemy = SQLAlchemy() | ||||||
| class AbstractUserUtils(t.Generic[T], ABC): | """The database instance.""" | ||||||
|     """The abstract user utilities.""" | data_dir: Path = Path(__file__).parent / "data" | ||||||
|  | """The data directory.""" | ||||||
|     @property |  | ||||||
|     @abstractmethod |  | ||||||
|     def cls(self) -> t.Type[T]: |  | ||||||
|         """Returns the user class. |  | ||||||
|  |  | ||||||
|         :return: The user class. |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     @abstractmethod |  | ||||||
|     def pk_column(self) -> sa.Column: |  | ||||||
|         """Returns the primary key column. |  | ||||||
|  |  | ||||||
|         :return: The primary key column. |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     @abstractmethod |  | ||||||
|     def current_user(self) -> T: |  | ||||||
|         """Returns the current user. |  | ||||||
|  |  | ||||||
|         :return: The current user. |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|     @abstractmethod |  | ||||||
|     def get_by_username(self, username: str) -> T | None: |  | ||||||
|         """Returns the user by her username. |  | ||||||
|  |  | ||||||
|         :return: The user by her username, or None if the user was not found. |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|     @abstractmethod |  | ||||||
|     def get_pk(self, user: T) -> int: |  | ||||||
|         """Returns the primary key of the user. |  | ||||||
|  |  | ||||||
|         :return: The primary key of the user. |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def init_app(app: Flask, user_utils: AbstractUserUtils, | def init_app(app: Flask, user_utils: AbstractUserUtils, | ||||||
| @@ -86,8 +48,10 @@ def init_app(app: Flask, user_utils: AbstractUserUtils, | |||||||
|     """ |     """ | ||||||
|     # The database instance must be set before loading everything |     # The database instance must be set before loading everything | ||||||
|     # in the application. |     # in the application. | ||||||
|     from .database import set_db |     global db | ||||||
|     set_db(app.extensions["sqlalchemy"], user_utils) |     db = app.extensions["sqlalchemy"] | ||||||
|  |     from .utils.user import init_user_utils | ||||||
|  |     init_user_utils(user_utils) | ||||||
|  |  | ||||||
|     bp: Blueprint = Blueprint("accounting", __name__, |     bp: Blueprint = Blueprint("accounting", __name__, | ||||||
|                               url_prefix=url_prefix, |                               url_prefix=url_prefix, | ||||||
| @@ -98,7 +62,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils, | |||||||
|     locale.init_app(app, bp) |     locale.init_app(app, bp) | ||||||
|  |  | ||||||
|     from .utils import permission |     from .utils import permission | ||||||
|     permission.init_app(app, can_view_func, can_edit_func) |     permission.init_app(bp, can_view_func, can_edit_func) | ||||||
|  |  | ||||||
|     from . import base_account |     from . import base_account | ||||||
|     base_account.init_app(app, bp) |     base_account.init_app(app, bp) | ||||||
| @@ -106,9 +70,13 @@ def init_app(app: Flask, user_utils: AbstractUserUtils, | |||||||
|     from . import account |     from . import account | ||||||
|     account.init_app(app, bp) |     account.init_app(app, bp) | ||||||
|  |  | ||||||
|     from .utils.next_url import append_next, inherit_next, or_next |     from . import currency | ||||||
|     bp.add_app_template_filter(append_next, "append_next") |     currency.init_app(app, bp) | ||||||
|     bp.add_app_template_filter(inherit_next, "inherit_next") |  | ||||||
|     bp.add_app_template_filter(or_next, "or_next") |     from . import transaction | ||||||
|  |     transaction.init_app(app, bp) | ||||||
|  |  | ||||||
|  |     from .utils import next_uri | ||||||
|  |     next_uri.init_app(bp) | ||||||
|  |  | ||||||
|     app.register_blueprint(bp) |     app.register_blueprint(bp) | ||||||
|   | |||||||
| @@ -23,8 +23,8 @@ from flask import Flask, Blueprint | |||||||
| def init_app(app: Flask, bp: Blueprint) -> None: | def init_app(app: Flask, bp: Blueprint) -> None: | ||||||
|     """Initialize the application. |     """Initialize the application. | ||||||
|  |  | ||||||
|     :param bp: The blueprint of the accounting application. |  | ||||||
|     :param app: The Flask application. |     :param app: The Flask application. | ||||||
|  |     :param bp: The blueprint of the accounting application. | ||||||
|     :return: None. |     :return: None. | ||||||
|     """ |     """ | ||||||
|     from .converters import AccountConverter |     from .converters import AccountConverter | ||||||
|   | |||||||
| @@ -24,12 +24,13 @@ from secrets import randbelow | |||||||
| import click | import click | ||||||
| from flask.cli import with_appcontext | from flask.cli import with_appcontext | ||||||
|  |  | ||||||
| from accounting.database import db, user_utils | from accounting import db | ||||||
| from accounting.models import BaseAccount, Account, AccountL10n | from accounting.models import BaseAccount, Account, AccountL10n | ||||||
|  | from accounting.utils.user import has_user, get_user_pk | ||||||
|  |  | ||||||
| AccountData = tuple[int, str, int, str, str, str, bool] | AccountData = tuple[int, str, int, str, str, str, bool] | ||||||
| """The format of the account data, as a list of (ID, base account code, number, | """The format of the account data, as a list of (ID, base account code, number, | ||||||
| English, Traditional Chinese, Simplified Chinese, is-offset-needed) tuples.""" | English, Traditional Chinese, Simplified Chinese, is-pay-off-needed) tuples.""" | ||||||
|  |  | ||||||
|  |  | ||||||
| def __validate_username(ctx: click.core.Context, param: click.core.Option, | def __validate_username(ctx: click.core.Context, param: click.core.Option, | ||||||
| @@ -45,8 +46,7 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option, | |||||||
|     value = value.strip() |     value = value.strip() | ||||||
|     if value == "": |     if value == "": | ||||||
|         raise click.BadParameter("Username empty.") |         raise click.BadParameter("Username empty.") | ||||||
|     user: user_utils.cls | None = user_utils.get_by_username(value) |     if not has_user(value): | ||||||
|     if user is None: |  | ||||||
|         raise click.BadParameter(f"User {value} does not exist.") |         raise click.BadParameter(f"User {value} does not exist.") | ||||||
|     return value |     return value | ||||||
|  |  | ||||||
| @@ -58,7 +58,7 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option, | |||||||
| @with_appcontext | @with_appcontext | ||||||
| def init_accounts_command(username: str) -> None: | def init_accounts_command(username: str) -> None: | ||||||
|     """Initializes the accounts.""" |     """Initializes the accounts.""" | ||||||
|     creator_pk: int = user_utils.get_pk(user_utils.get_by_username(username)) |     creator_pk: int = get_user_pk(username) | ||||||
|  |  | ||||||
|     bases: list[BaseAccount] = BaseAccount.query\ |     bases: list[BaseAccount] = BaseAccount.query\ | ||||||
|         .filter(db.func.length(BaseAccount.code) == 4)\ |         .filter(db.func.length(BaseAccount.code) == 4)\ | ||||||
| @@ -93,10 +93,10 @@ def init_accounts_command(username: str) -> None: | |||||||
|     data: list[AccountData] = [] |     data: list[AccountData] = [] | ||||||
|     for base in bases_to_add: |     for base in bases_to_add: | ||||||
|         l10n: dict[str, str] = {x.locale: x.title for x in base.l10n} |         l10n: dict[str, str] = {x.locale: x.title for x in base.l10n} | ||||||
|         is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \ |         is_pay_off_needed: bool = True if re.match("^[12]1[34]", base.code) \ | ||||||
|             else False |             else False | ||||||
|         data.append((get_new_id(), base.code, 1, base.title_l10n, |         data.append((get_new_id(), base.code, 1, base.title_l10n, | ||||||
|                      l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed)) |                      l10n["zh_Hant"], l10n["zh_Hans"], is_pay_off_needed)) | ||||||
|     __add_accounting_accounts(data, creator_pk) |     __add_accounting_accounts(data, creator_pk) | ||||||
|     click.echo(F"{len(data)} added.  Accounting accounts initialized.") |     click.echo(F"{len(data)} added.  Accounting accounts initialized.") | ||||||
|  |  | ||||||
| @@ -113,7 +113,7 @@ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\ | |||||||
|                                        base_code=x[1], |                                        base_code=x[1], | ||||||
|                                        no=x[2], |                                        no=x[2], | ||||||
|                                        title_l10n=x[3], |                                        title_l10n=x[3], | ||||||
|                                        is_offset_needed=x[6], |                                        is_pay_off_needed=x[6], | ||||||
|                                        created_by_id=creator_pk, |                                        created_by_id=creator_pk, | ||||||
|                                        updated_by_id=creator_pk) |                                        updated_by_id=creator_pk) | ||||||
|                                for x in data] |                                for x in data] | ||||||
|   | |||||||
| @@ -18,19 +18,21 @@ | |||||||
|  |  | ||||||
| """ | """ | ||||||
| import sqlalchemy as sa | import sqlalchemy as sa | ||||||
|  | from flask import request | ||||||
| from flask_wtf import FlaskForm | from flask_wtf import FlaskForm | ||||||
| from wtforms import StringField, BooleanField | from wtforms import StringField, BooleanField | ||||||
| from wtforms.validators import DataRequired, ValidationError | from wtforms.validators import DataRequired, ValidationError | ||||||
|  |  | ||||||
| from accounting.database import db, user_utils | from accounting import db | ||||||
| from accounting.locale import lazy_gettext | from accounting.locale import lazy_gettext | ||||||
| from accounting.models import BaseAccount, Account | from accounting.models import BaseAccount, Account | ||||||
| from accounting.utils.random_id import new_id | from accounting.utils.random_id import new_id | ||||||
| from accounting.utils.strip_text import strip_text | from accounting.utils.strip_text import strip_text | ||||||
|  | from accounting.utils.user import get_current_user_pk | ||||||
|  |  | ||||||
|  |  | ||||||
| class BaseAccountExists: | class BaseAccountExists: | ||||||
|     """The validator to check if the base account code exists.""" |     """The validator to check if the base account exists.""" | ||||||
|  |  | ||||||
|     def __call__(self, form: FlaskForm, field: StringField) -> None: |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|         if field.data == "": |         if field.data == "": | ||||||
| @@ -40,20 +42,32 @@ class BaseAccountExists: | |||||||
|                 "The base account does not exist.")) |                 "The base account does not exist.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BaseAccountAvailable: | ||||||
|  |     """The validator to check if the base account is available.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: FlaskForm, field: StringField) -> None: | ||||||
|  |         if field.data == "": | ||||||
|  |             return | ||||||
|  |         if len(field.data) != 4: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "The base account is not available.")) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AccountForm(FlaskForm): | class AccountForm(FlaskForm): | ||||||
|     """The form to create or edit an account.""" |     """The form to create or edit an account.""" | ||||||
|     base_code = StringField( |     base_code = StringField( | ||||||
|         filters=[strip_text], |         filters=[strip_text], | ||||||
|         validators=[ |         validators=[ | ||||||
|             DataRequired(lazy_gettext("Please select the base account.")), |             DataRequired(lazy_gettext("Please select the base account.")), | ||||||
|             BaseAccountExists()]) |             BaseAccountExists(), | ||||||
|  |             BaseAccountAvailable()]) | ||||||
|     """The code of the base account.""" |     """The code of the base account.""" | ||||||
|     title = StringField( |     title = StringField( | ||||||
|         filters=[strip_text], |         filters=[strip_text], | ||||||
|         validators=[DataRequired(lazy_gettext("Please fill in the title"))]) |         validators=[DataRequired(lazy_gettext("Please fill in the title"))]) | ||||||
|     """The title.""" |     """The title.""" | ||||||
|     is_offset_needed = BooleanField() |     is_pay_off_needed = BooleanField() | ||||||
|     """Whether the the entries of this account need offsets.""" |     """Whether the the entries of this account need pay-off.""" | ||||||
|  |  | ||||||
|     def populate_obj(self, obj: Account) -> None: |     def populate_obj(self, obj: Account) -> None: | ||||||
|         """Populates the form data into an account object. |         """Populates the form data into an account object. | ||||||
| @@ -62,32 +76,30 @@ class AccountForm(FlaskForm): | |||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         is_new: bool = obj.id is None |         is_new: bool = obj.id is None | ||||||
|         prev_base_code: str | None = obj.base_code |  | ||||||
|         if is_new: |         if is_new: | ||||||
|             obj.id = new_id(Account) |             obj.id = new_id(Account) | ||||||
|         obj.base_code = self.base_code.data |         if obj.base_code != self.base_code.data: | ||||||
|         if prev_base_code != self.base_code.data: |             if obj.base_code is not None: | ||||||
|             last_same_base: Account = Account.query\ |                 sort_accounts_in(obj.base_code, obj.id) | ||||||
|                 .filter(Account.base_code == self.base_code.data)\ |             sort_accounts_in(self.base_code.data, obj.id) | ||||||
|                 .order_by(Account.base_code.desc()).first() |             count: int = Account.query\ | ||||||
|             obj.no = 1 if last_same_base is None else last_same_base.no + 1 |                 .filter(Account.base_code == self.base_code.data).count() | ||||||
|  |             obj.base_code = self.base_code.data | ||||||
|  |             obj.no = count + 1 | ||||||
|         obj.title = self.title.data |         obj.title = self.title.data | ||||||
|         obj.is_offset_needed = self.is_offset_needed.data |         obj.is_pay_off_needed = self.is_pay_off_needed.data | ||||||
|         if is_new: |         if is_new: | ||||||
|             current_user_pk: int = user_utils.get_pk(user_utils.current_user) |             current_user_pk: int = get_current_user_pk() | ||||||
|             obj.created_by_id = current_user_pk |             obj.created_by_id = current_user_pk | ||||||
|             obj.updated_by_id = current_user_pk |             obj.updated_by_id = current_user_pk | ||||||
|         if prev_base_code is not None \ |  | ||||||
|                 and prev_base_code != self.base_code.data: |  | ||||||
|             setattr(self, "__post_update", |  | ||||||
|                     lambda: sort_accounts_in(prev_base_code, obj.id)) |  | ||||||
|  |  | ||||||
|     def post_update(self, obj) -> None: |     def post_update(self, obj: Account) -> None: | ||||||
|         """The post-processing after the update. |         """The post-processing after the update. | ||||||
|  |  | ||||||
|  |         :param obj: The account object. | ||||||
|         :return: None |         :return: None | ||||||
|         """ |         """ | ||||||
|         current_user_pk: int = user_utils.get_pk(user_utils.current_user) |         current_user_pk: int = get_current_user_pk() | ||||||
|         obj.updated_by_id = current_user_pk |         obj.updated_by_id = current_user_pk | ||||||
|         obj.updated_at = sa.func.now() |         obj.updated_at = sa.func.now() | ||||||
|         if hasattr(self, "__post_update"): |         if hasattr(self, "__post_update"): | ||||||
| @@ -127,3 +139,48 @@ def sort_accounts_in(base_code: str, exclude: int) -> None: | |||||||
|     for i in range(len(accounts)): |     for i in range(len(accounts)): | ||||||
|         if accounts[i].no != i + 1: |         if accounts[i].no != i + 1: | ||||||
|             accounts[i].no = i + 1 |             accounts[i].no = i + 1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AccountReorderForm: | ||||||
|  |     """The form to reorder the accounts.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, base: BaseAccount): | ||||||
|  |         """Constructs the form to reorder the accounts under a base account. | ||||||
|  |  | ||||||
|  |         :param base: The base account. | ||||||
|  |         """ | ||||||
|  |         self.base: BaseAccount = base | ||||||
|  |         self.is_modified: bool = False | ||||||
|  |  | ||||||
|  |     def save_order(self) -> None: | ||||||
|  |         """Saves the order of the account. | ||||||
|  |  | ||||||
|  |         :return: | ||||||
|  |         """ | ||||||
|  |         accounts: list[Account] = self.base.accounts | ||||||
|  |  | ||||||
|  |         # Collects the specified order. | ||||||
|  |         orders: dict[Account, int] = {} | ||||||
|  |         for account in accounts: | ||||||
|  |             if f"{account.id}-no" in request.form: | ||||||
|  |                 try: | ||||||
|  |                     orders[account] = int(request.form[f"{account.id}-no"]) | ||||||
|  |                 except ValueError: | ||||||
|  |                     pass | ||||||
|  |  | ||||||
|  |         # Missing and invalid orders are appended to the end. | ||||||
|  |         missing: list[Account] = [x for x in accounts if x not in orders] | ||||||
|  |         if len(missing) > 0: | ||||||
|  |             next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1 | ||||||
|  |             for account in missing: | ||||||
|  |                 orders[account] = next_no | ||||||
|  |  | ||||||
|  |         # Sort by the specified order first, and their original order. | ||||||
|  |         accounts = sorted(accounts, key=lambda x: (orders[x], x.no, x.code)) | ||||||
|  |  | ||||||
|  |         # Update the orders. | ||||||
|  |         with db.session.no_autoflush: | ||||||
|  |             for i in range(len(accounts)): | ||||||
|  |                 if accounts[i].no != i + 1: | ||||||
|  |                     accounts[i].no = i + 1 | ||||||
|  |                     self.is_modified = True | ||||||
|   | |||||||
| @@ -47,8 +47,8 @@ def get_account_query() -> list[Account]: | |||||||
|                Account.title_l10n.contains(k), |                Account.title_l10n.contains(k), | ||||||
|                code.contains(k), |                code.contains(k), | ||||||
|                Account.id.in_(l10n_matches)] |                Account.id.in_(l10n_matches)] | ||||||
|         if k in gettext("Offset needed"): |         if k in gettext("Pay-off needed"): | ||||||
|             sub_conditions.append(Account.is_offset_needed) |             sub_conditions.append(Account.is_pay_off_needed) | ||||||
|         conditions.append(sa.or_(*sub_conditions)) |         conditions.append(sa.or_(*sub_conditions)) | ||||||
|  |  | ||||||
|     return Account.query.filter(*conditions)\ |     return Account.query.filter(*conditions)\ | ||||||
|   | |||||||
| @@ -19,17 +19,21 @@ | |||||||
| """ | """ | ||||||
| from urllib.parse import parse_qsl, urlencode | from urllib.parse import parse_qsl, urlencode | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
| from flask import Blueprint, render_template, session, redirect, flash, \ | from flask import Blueprint, render_template, session, redirect, flash, \ | ||||||
|     url_for, request |     url_for, request | ||||||
| from werkzeug.datastructures import ImmutableMultiDict | from werkzeug.datastructures import ImmutableMultiDict | ||||||
|  |  | ||||||
| from accounting.database import db | from accounting import db | ||||||
| from accounting.locale import lazy_gettext | from accounting.locale import lazy_gettext | ||||||
| from accounting.models import Account, BaseAccount | from accounting.models import Account, BaseAccount | ||||||
| from accounting.utils.next_url import inherit_next, or_next | from accounting.utils.flash_errors import flash_form_errors | ||||||
|  | from accounting.utils.next_uri import inherit_next, or_next | ||||||
| from accounting.utils.pagination import Pagination | from accounting.utils.pagination import Pagination | ||||||
| from accounting.utils.permission import can_view, has_permission, can_edit | from accounting.utils.permission import can_view, has_permission, can_edit | ||||||
| from .forms import AccountForm, sort_accounts_in | from accounting.utils.user import get_current_user_pk | ||||||
|  | from .forms import AccountForm, sort_accounts_in, AccountReorderForm | ||||||
|  | from .query import get_account_query | ||||||
|  |  | ||||||
| bp: Blueprint = Blueprint("account", __name__) | bp: Blueprint = Blueprint("account", __name__) | ||||||
| """The view blueprint for the account management.""" | """The view blueprint for the account management.""" | ||||||
| @@ -38,11 +42,10 @@ bp: Blueprint = Blueprint("account", __name__) | |||||||
| @bp.get("", endpoint="list") | @bp.get("", endpoint="list") | ||||||
| @has_permission(can_view) | @has_permission(can_view) | ||||||
| def list_accounts() -> str: | def list_accounts() -> str: | ||||||
|     """Lists the base accounts. |     """Lists the accounts. | ||||||
|  |  | ||||||
|     :return: The account list. |     :return: The account list. | ||||||
|     """ |     """ | ||||||
|     from .query import get_account_query |  | ||||||
|     accounts: list[BaseAccount] = get_account_query() |     accounts: list[BaseAccount] = get_account_query() | ||||||
|     pagination: Pagination = Pagination[BaseAccount](accounts) |     pagination: Pagination = Pagination[BaseAccount](accounts) | ||||||
|     return render_template("accounting/account/list.html", |     return render_template("accounting/account/list.html", | ||||||
| @@ -76,9 +79,7 @@ def add_account() -> redirect: | |||||||
|     """ |     """ | ||||||
|     form = AccountForm(request.form) |     form = AccountForm(request.form) | ||||||
|     if not form.validate(): |     if not form.validate(): | ||||||
|         for key in form.errors: |         flash_form_errors(form) | ||||||
|             for error in form.errors[key]: |  | ||||||
|                 flash(error, "error") |  | ||||||
|         session["form"] = urlencode(list(request.form.items())) |         session["form"] = urlencode(list(request.form.items())) | ||||||
|         return redirect(inherit_next(url_for("accounting.account.create"))) |         return redirect(inherit_next(url_for("accounting.account.create"))) | ||||||
|     account: Account = Account() |     account: Account = Account() | ||||||
| @@ -86,8 +87,7 @@ def add_account() -> redirect: | |||||||
|     db.session.add(account) |     db.session.add(account) | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The account is added successfully"), "success") |     flash(lazy_gettext("The account is added successfully"), "success") | ||||||
|     return redirect(inherit_next(url_for("accounting.account.detail", |     return redirect(inherit_next(__get_detail_uri(account))) | ||||||
|                                          account=account))) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @bp.get("/<account:account>", endpoint="detail") | @bp.get("/<account:account>", endpoint="detail") | ||||||
| @@ -95,7 +95,8 @@ def add_account() -> redirect: | |||||||
| def show_account_detail(account: Account) -> str: | def show_account_detail(account: Account) -> str: | ||||||
|     """Shows the account detail. |     """Shows the account detail. | ||||||
|  |  | ||||||
|     :return: The account detail. |     :param account: The account. | ||||||
|  |     :return: The detail. | ||||||
|     """ |     """ | ||||||
|     return render_template("accounting/account/detail.html", obj=account) |     return render_template("accounting/account/detail.html", obj=account) | ||||||
|  |  | ||||||
| @@ -105,7 +106,8 @@ def show_account_detail(account: Account) -> str: | |||||||
| def show_account_edit_form(account: Account) -> str: | def show_account_edit_form(account: Account) -> str: | ||||||
|     """Shows the form to edit an account. |     """Shows the form to edit an account. | ||||||
|  |  | ||||||
|     :return: The form to edit an account. |     :param account: The account. | ||||||
|  |     :return: The form to edit the account. | ||||||
|     """ |     """ | ||||||
|     form: AccountForm |     form: AccountForm | ||||||
|     if "form" in session: |     if "form" in session: | ||||||
| @@ -123,28 +125,26 @@ def show_account_edit_form(account: Account) -> str: | |||||||
| def update_account(account: Account) -> redirect: | def update_account(account: Account) -> redirect: | ||||||
|     """Updates an account. |     """Updates an account. | ||||||
|  |  | ||||||
|  |     :param account: The account. | ||||||
|     :return: The redirection to the account detail on success, or the account |     :return: The redirection to the account detail on success, or the account | ||||||
|         edit form on error. |         edit form on error. | ||||||
|     """ |     """ | ||||||
|     form = AccountForm(request.form) |     form = AccountForm(request.form) | ||||||
|     if not form.validate(): |     if not form.validate(): | ||||||
|         for key in form.errors: |         flash_form_errors(form) | ||||||
|             for error in form.errors[key]: |  | ||||||
|                 flash(error, "error") |  | ||||||
|         session["form"] = urlencode(list(request.form.items())) |         session["form"] = urlencode(list(request.form.items())) | ||||||
|         return redirect(inherit_next(url_for("accounting.account.edit", |         return redirect(inherit_next(url_for("accounting.account.edit", | ||||||
|                                              account=account))) |                                              account=account))) | ||||||
|     with db.session.no_autoflush: |     with db.session.no_autoflush: | ||||||
|         form.populate_obj(account) |         form.populate_obj(account) | ||||||
|     if not db.session.is_modified(account): |     if not account.is_modified: | ||||||
|         flash(lazy_gettext("The account was not modified."), "success") |         flash(lazy_gettext("The account was not modified."), "success") | ||||||
|         return redirect(inherit_next(url_for("accounting.account.detail", |         return redirect(inherit_next(__get_detail_uri(account))) | ||||||
|                                              account=account))) |     account.updated_by_id = get_current_user_pk() | ||||||
|     form.post_update(account) |     account.updated_at = sa.func.now() | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The account is updated successfully."), "success") |     flash(lazy_gettext("The account is updated successfully."), "success") | ||||||
|     return redirect(inherit_next(url_for("accounting.account.detail", |     return redirect(inherit_next(__get_detail_uri(account))) | ||||||
|                                          account=account))) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @bp.post("/<account:account>/delete", endpoint="delete") | @bp.post("/<account:account>/delete", endpoint="delete") | ||||||
| @@ -152,13 +152,59 @@ def update_account(account: Account) -> redirect: | |||||||
| def delete_account(account: Account) -> redirect: | def delete_account(account: Account) -> redirect: | ||||||
|     """Deletes an account. |     """Deletes an account. | ||||||
|  |  | ||||||
|  |     :param account: The account. | ||||||
|     :return: The redirection to the account list on success, or the account |     :return: The redirection to the account list on success, or the account | ||||||
|         detail on error. |         detail on error. | ||||||
|     """ |     """ | ||||||
|     for l10n in account.l10n: |     account.delete() | ||||||
|         db.session.delete(l10n) |  | ||||||
|     db.session.delete(account) |  | ||||||
|     sort_accounts_in(account.base_code, account.id) |     sort_accounts_in(account.base_code, account.id) | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     flash(lazy_gettext("The account is deleted successfully."), "success") |     flash(lazy_gettext("The account is deleted successfully."), "success") | ||||||
|     return redirect(or_next(url_for("accounting.account.list"))) |     return redirect(or_next(__get_list_uri())) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("/bases/<baseAccount:base>", endpoint="order") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def show_account_order(base: BaseAccount) -> str: | ||||||
|  |     """Shows the order of the accounts under a same base account. | ||||||
|  |  | ||||||
|  |     :param base: The base account. | ||||||
|  |     :return: The order of the accounts under the base account. | ||||||
|  |     """ | ||||||
|  |     return render_template("accounting/account/order.html", base=base) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("/bases/<baseAccount:base>", endpoint="sort") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def sort_accounts(base: BaseAccount) -> redirect: | ||||||
|  |     """Reorders the accounts under a base account. | ||||||
|  |  | ||||||
|  |     :param base: The base account. | ||||||
|  |     :return: The redirection to the incoming account or the account list.  The | ||||||
|  |         reordering operation does not fail. | ||||||
|  |     """ | ||||||
|  |     form: AccountReorderForm = AccountReorderForm(base) | ||||||
|  |     form.save_order() | ||||||
|  |     if not form.is_modified: | ||||||
|  |         flash(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") | ||||||
|  |     return redirect(or_next(__get_list_uri())) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_detail_uri(account: Account) -> str: | ||||||
|  |     """Returns the detail URI of an account. | ||||||
|  |  | ||||||
|  |     :param account: The account. | ||||||
|  |     :return: The detail URI of the account. | ||||||
|  |     """ | ||||||
|  |     return url_for("accounting.account.detail", account=account) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_list_uri() -> str: | ||||||
|  |     """Returns the account list URI. | ||||||
|  |  | ||||||
|  |     :return: The account list URI. | ||||||
|  |     """ | ||||||
|  |     return url_for("accounting.account.list") | ||||||
|   | |||||||
| @@ -23,8 +23,8 @@ from flask import Flask, Blueprint | |||||||
| def init_app(app: Flask, bp: Blueprint) -> None: | def init_app(app: Flask, bp: Blueprint) -> None: | ||||||
|     """Initialize the application. |     """Initialize the application. | ||||||
|  |  | ||||||
|     :param bp: The blueprint of the accounting application. |  | ||||||
|     :param app: The Flask application. |     :param app: The Flask application. | ||||||
|  |     :param bp: The blueprint of the accounting application. | ||||||
|     :return: None. |     :return: None. | ||||||
|     """ |     """ | ||||||
|     from .converters import BaseAccountConverter |     from .converters import BaseAccountConverter | ||||||
|   | |||||||
| @@ -17,16 +17,15 @@ | |||||||
| """The console commands for the base account management. | """The console commands for the base account management. | ||||||
|  |  | ||||||
| """ | """ | ||||||
|  | import csv | ||||||
|  |  | ||||||
| import click | import click | ||||||
| from flask.cli import with_appcontext | from flask.cli import with_appcontext | ||||||
|  |  | ||||||
| from accounting.database import db | from accounting import data_dir | ||||||
|  | from accounting import db | ||||||
| from accounting.models import BaseAccount, BaseAccountL10n | from accounting.models import BaseAccount, BaseAccountL10n | ||||||
|  |  | ||||||
| BaseAccountData = tuple[int, str, str, str] |  | ||||||
| """The format of the base account data, as a list of (code, English, |  | ||||||
| Traditional Chinese, Simplified Chinese) tuples.""" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @click.command("accounting-init-base") | @click.command("accounting-init-base") | ||||||
| @with_appcontext | @with_appcontext | ||||||
| @@ -36,674 +35,17 @@ def init_base_accounts_command() -> None: | |||||||
|         click.echo("Base accounts already exist.") |         click.echo("Base accounts already exist.") | ||||||
|         raise click.Abort |         raise click.Abort | ||||||
|  |  | ||||||
|     db.session.bulk_save_objects( |     with open(data_dir / "base_accounts.csv") as fp: | ||||||
|         [BaseAccount(code=str(x[0]), title_l10n=x[1]) for x in DATA]) |         data: list[dict[str, str]] = [x for x in csv.DictReader(fp)] | ||||||
|     db.session.bulk_save_objects( |     account_data: list[dict[str, str]] = [{"code": x["code"], | ||||||
|         [BaseAccountL10n(account_code=x[0], locale=y[0], title=y[1]) |                                            "title_l10n": x["title"]} | ||||||
|          for x in DATA for y in (("zh_Hant", x[2]), ("zh_Hans", x[3]))]) |                                           for x in data] | ||||||
|  |     locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")] | ||||||
|  |     l10n_data: list[dict[str, str]] = [{"account_code": x["code"], | ||||||
|  |                                         "locale": y, | ||||||
|  |                                         "title": x[f"l10n-{y}"]} | ||||||
|  |                                        for x in data for y in locales] | ||||||
|  |     db.session.bulk_insert_mappings(BaseAccount, account_data) | ||||||
|  |     db.session.bulk_insert_mappings(BaseAccountL10n, l10n_data) | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
|     click.echo("Base accounts initialized.") |     click.echo("Base accounts initialized.") | ||||||
|  |  | ||||||
|  |  | ||||||
| DATA: list[BaseAccountData] = [ |  | ||||||
|     (1, "assets", "資產", "资产"), |  | ||||||
|     (2, "liabilities", "負債", "负债"), |  | ||||||
|     (3, "owners’ equity", "業主權益", "业主权益"), |  | ||||||
|     (4, "operating revenue", "營業收入", "营业收入"), |  | ||||||
|     (5, "operating costs", "營業成本", "营业成本"), |  | ||||||
|     (6, "operating expenses", "營業費用", "营业费用"), |  | ||||||
|     (7, "non-operating revenue and expenses, other income (expense)", |  | ||||||
|      "營業外收入及費用", "营业外收入及费用"), |  | ||||||
|     (8, "income tax expense (or benefit)", "所得稅費用(或利益)", |  | ||||||
|      "所得税费用(或利益)"), |  | ||||||
|     (9, "nonrecurring gain or loss", "非經常營業損益", "非经常营业损益"), |  | ||||||
|     (11, "current assets", "流動資產", "流动资产"), |  | ||||||
|     (12, "current assets", "流動資產", "流动资产"), |  | ||||||
|     (13, "funds and long-term investments", "基金及長期投資", "基金及长期投资"), |  | ||||||
|     (14, "property , plant, and equipment", "固定資產", "固定资产"), |  | ||||||
|     (15, "property , plant, and equipment", "固定資產", "固定资产"), |  | ||||||
|     (16, "depletable assets", "遞耗資產", "递耗资产"), |  | ||||||
|     (17, "intangible assets", "無形資產", "无形资产"), |  | ||||||
|     (18, "other assets", "其他資產", "其他资产"), |  | ||||||
|     (21, "current liabilities", "流動負債", "流动负债"), |  | ||||||
|     (22, "current liabilities", "流動負債", "流动负债"), |  | ||||||
|     (23, "long-term liabilities", "長期負債", "长期负债"), |  | ||||||
|     (28, "other liabilities", "其他負債", "其他负债"), |  | ||||||
|     (31, "capital", "資本", "资本"), |  | ||||||
|     (32, "additional paid-in capital", "資本公積", "资本公积"), |  | ||||||
|     (33, "retained earnings (accumulated deficit)", "保留盈餘(或累積虧損)", |  | ||||||
|      "保留盈余(或累积亏损)"), |  | ||||||
|     (34, "equity adjustments", "權益調整", "权益调整"), |  | ||||||
|     (35, "treasury stock", "庫藏股", "库藏股"), |  | ||||||
|     (36, "minority interest", "少數股權", "少数股权"), |  | ||||||
|     (41, "sales revenue", "銷貨收入", "销货收入"), |  | ||||||
|     (46, "service revenue", "勞務收入", "劳务收入"), |  | ||||||
|     (47, "agency revenue", "業務收入", "业务收入"), |  | ||||||
|     (48, "other operating revenue", "其他營業收入", "其他营业收入"), |  | ||||||
|     (51, "cost of goods sold", "銷貨成本", "销货成本"), |  | ||||||
|     (56, "service costs", "勞務成本", "劳务成本"), |  | ||||||
|     (57, "agency costs", "業務成本", "业务成本"), |  | ||||||
|     (58, "other operating costs", "其他營業成本", "其他营业成本"), |  | ||||||
|     (61, "selling expenses", "推銷費用", "推销费用"), |  | ||||||
|     (62, "general & administrative expenses", "管理及總務費用", "管理及总务费用"), |  | ||||||
|     (63, "research and development expenses", "研究發展費用", "研究发展费用"), |  | ||||||
|     (71, "non-operating revenue", "營業外收入", "营业外收入"), |  | ||||||
|     (72, "non-operating revenue", "營業外收入", "营业外收入"), |  | ||||||
|     (73, "non-operating revenue", "營業外收入", "营业外收入"), |  | ||||||
|     (74, "non-operating revenue", "營業外收入", "营业外收入"), |  | ||||||
|     (75, "non-operating expenses", "營業外費用", "营业外费用"), |  | ||||||
|     (76, "non-operating expenses", "營業外費用", "营业外费用"), |  | ||||||
|     (77, "non-operating expenses", "營業外費用", "营业外费用"), |  | ||||||
|     (78, "non-operating expenses", "營業外費用", "营业外费用"), |  | ||||||
|     (81, "income tax expense (or benefit)", "所得稅費用(或利益)", |  | ||||||
|      "所得税费用(或利益)"), |  | ||||||
|     (91, "gain (loss) from discontinued operations", "停業部門損益", |  | ||||||
|      "停业部门损益"), |  | ||||||
|     (92, "extraordinary gain or loss", "非常損益", "非常损益"), |  | ||||||
|     (93, "cumulative effect of changes in accounting principles", |  | ||||||
|      "會計原則變動累積影響數", "会计原则变动累积影响数"), |  | ||||||
|     (94, "minority interest income", "少數股權淨利", "少数股权净利"), |  | ||||||
|     (111, "cash and cash equivalents", "現金及約當現金", "现金及约当现金"), |  | ||||||
|     (112, "short-term investments", "短期投資", "短期投资"), |  | ||||||
|     (113, "notes receivable", "應收票據", "应收票据"), |  | ||||||
|     (114, "accounts receivable", "應收帳款", "应收帐款"), |  | ||||||
|     (118, "other receivables", "其他應收款", "其他应收款"), |  | ||||||
|     (121, "inventories", "存貨", "存货"), |  | ||||||
|     (122, "inventories", "存貨", "存货"), |  | ||||||
|     (125, "prepaid expenses", "預付費用", "预付费用"), |  | ||||||
|     (126, "prepayments", "預付款項", "预付款项"), |  | ||||||
|     (128, "other current assets", "其他流動資產", "其他流动资产"), |  | ||||||
|     (129, "other current assets", "其他流動資產", "其他流动资产"), |  | ||||||
|     (131, "funds", "基金", "基金"), |  | ||||||
|     (132, "long-term investments", "長期投資", "长期投资"), |  | ||||||
|     (141, "land", "土地", "土地"), |  | ||||||
|     (142, "land improvements", "土地改良物", "土地改良物"), |  | ||||||
|     (143, "buildings", "房屋及建物", "房屋及建物"), |  | ||||||
|     (144, "machinery and equipment", "機(器)具及設備", "机(器)具及设备"), |  | ||||||
|     (145, "machinery and equipment", "機(器)具及設備", "机(器)具及设备"), |  | ||||||
|     (146, "machinery and equipment", "機(器)具及設備", "机(器)具及设备"), |  | ||||||
|     (151, "leased assets", "租賃資產", "租赁资产"), |  | ||||||
|     (152, "leasehold improvements", "租賃權益改良", "租赁权益改良"), |  | ||||||
|     (156, "construction in progress and prepayments for equipment", |  | ||||||
|      "未完工程及預付購置設備款", "未完工程及预付购置设备款"), |  | ||||||
|     (158, "miscellaneous property, plant, and equipment", "雜項固定資產", |  | ||||||
|      "杂项固定资产"), |  | ||||||
|     (161, "depletable assets", "遞耗資產", "递耗资产"), |  | ||||||
|     (171, "trademarks", "商標權", "商标权"), |  | ||||||
|     (172, "patents", "專利權", "专利权"), |  | ||||||
|     (173, "franchise", "特許權", "特许权"), |  | ||||||
|     (174, "copyright", "著作權", "著作权"), |  | ||||||
|     (175, "computer software", "電腦軟體", "电脑软体"), |  | ||||||
|     (176, "goodwill", "商譽", "商誉"), |  | ||||||
|     (177, "organization costs", "開辦費", "开办费"), |  | ||||||
|     (178, "other intangibles", "其他無形資產", "其他无形资产"), |  | ||||||
|     (181, "deferred assets", "遞延資產", "递延资产"), |  | ||||||
|     (182, "idle assets", "閒置資產", "闲置资产"), |  | ||||||
|     (184, "long-term notes , accounts and overdue receivables", |  | ||||||
|      "長期應收票據及款項與催收帳款", "长期应收票据及款项与催收帐款"), |  | ||||||
|     (185, "assets leased to others", "出租資產", "出租资产"), |  | ||||||
|     (186, "refundable deposit", "存出保證金", "存出保证金"), |  | ||||||
|     (188, "miscellaneous assets", "雜項資產", "杂项资产"), |  | ||||||
|     (211, "short-term borrowings (debt)", "短期借款", "短期借款"), |  | ||||||
|     (212, "short-term notes and bills payable", "應付短期票券", "应付短期票券"), |  | ||||||
|     (213, "notes payable", "應付票據", "应付票据"), |  | ||||||
|     (214, "accounts pay able", "應付帳款", "应付帐款"), |  | ||||||
|     (216, "income taxes payable", "應付所得稅", "应付所得税"), |  | ||||||
|     (217, "accrued expenses", "應付費用", "应付费用"), |  | ||||||
|     (218, "other payables", "其他應付款", "其他应付款"), |  | ||||||
|     (219, "other payables", "其他應付款", "其他应付款"), |  | ||||||
|     (226, "advance receipts", "預收款項", "预收款项"), |  | ||||||
|     (227, "long-term liabilities -current portion", |  | ||||||
|      "一年或一營業週期內到期長期負債", "一年或一营业周期内到期长期负债"), |  | ||||||
|     (228, "other current liabilities", "其他流動負債", |  | ||||||
|      "其他流动负债"), |  | ||||||
|     (229, "other current liabilities", "其他流動負債", |  | ||||||
|      "其他流动负债"), |  | ||||||
|     (231, "corporate bonds payable", "應付公司債", "应付公司债"), |  | ||||||
|     (232, "long-term loans payable", "長期借款", "长期借款"), |  | ||||||
|     (233, "long-term notes and accounts payable", "長期應付票據及款項", |  | ||||||
|      "长期应付票据及款项"), |  | ||||||
|     (234, "accrued liabilities for land value increment tax", |  | ||||||
|      "估計應付土地增值稅", "估计应付土地增值税"), |  | ||||||
|     (235, "accrued pension liabilities", "應計退休金負債", "应计退休金负债"), |  | ||||||
|     (238, "other long-term liabilities", "其他長期負債", "其他长期负债"), |  | ||||||
|     (281, "deferred liabilities", "遞延負債", "递延负债"), |  | ||||||
|     (286, "deposits received", "存入保證金", "存入保证金"), |  | ||||||
|     (288, "miscellaneous liabilities", "雜項負債", "杂项负债"), |  | ||||||
|     (311, "capital", "資本(或股本)", "资本(或股本)"), |  | ||||||
|     (321, "paid-in capital in excess of par", "股票溢價", "股票溢价"), |  | ||||||
|     (323, "capital surplus from assets revaluation", "資產重估增值準備", |  | ||||||
|      "资产重估增值准备"), |  | ||||||
|     (324, "capital surplus from gain on disposal of assets", "處分資產溢價公積", |  | ||||||
|      "处分资产溢价公积"), |  | ||||||
|     (325, "capital surplus from business combination", "合併公積", "合并公积"), |  | ||||||
|     (326, "donated surplus", "受贈公積", "受赠公积"), |  | ||||||
|     (328, "other additional paid-in capital", "其他資本公積", "其他资本公积"), |  | ||||||
|     (331, "legal reserve", "法定盈餘公積", "法定盈余公积"), |  | ||||||
|     (332, "special reserve", "特別盈餘公積", "特别盈余公积"), |  | ||||||
|     (335, "retained earnings-unappropriated (or accumulated deficit)", |  | ||||||
|      "未分配盈餘(或累積虧損)", "未分配盈余(或累积亏损)"), |  | ||||||
|     (341, |  | ||||||
|      "unrealized loss on market value decline of long-term equity investments", |  | ||||||
|      "長期股權投資未實現跌價損失", "长期股权投资未实现跌价损失"), |  | ||||||
|     (342, "cumulative translation adjustment", "累積換算調整數", "累积换算调整数"), |  | ||||||
|     (343, "net loss not recognized as pension cost", "未認列為退休金成本之淨損失", |  | ||||||
|      "未认列为退休金成本之净损失"), |  | ||||||
|     (351, "treasury stock", "庫藏股", "库藏股"), |  | ||||||
|     (361, "minority interest", "少數股權", "少数股权"), |  | ||||||
|     (411, "sales revenue", "銷貨收入", "销货收入"), |  | ||||||
|     (417, "sales return", "銷貨退回", "销货退回"), |  | ||||||
|     (419, "sales allowances", "銷貨折讓", "销货折让"), |  | ||||||
|     (461, "service revenue", "勞務收入", "劳务收入"), |  | ||||||
|     (471, "agency revenue", "業務收入", "业务收入"), |  | ||||||
|     (488, "other operating revenue", "其他營業收入—其他", "其他营业收入—其他"), |  | ||||||
|     (511, "cost of goods sold", "銷貨成本", "销货成本"), |  | ||||||
|     (512, "purchases", "進貨", "进货"), |  | ||||||
|     (513, "materials purchased", "進料", "进料"), |  | ||||||
|     (514, "direct labor", "直接人工", "直接人工"), |  | ||||||
|     (515, "manufacturing overhead", "製造費用", "制造费用"), |  | ||||||
|     (516, "manufacturing overhead", "製造費用", "制造费用"), |  | ||||||
|     (517, "manufacturing overhead", "製造費用", "制造费用"), |  | ||||||
|     (518, "manufacturing overhead", "製造費用", "制造费用"), |  | ||||||
|     (561, "service costs", "勞務成本", "劳务成本"), |  | ||||||
|     (571, "agency costs", "業務成本", "业务成本"), |  | ||||||
|     (588, "other operating costs-other", "其他營業成本—其他", "其他营业成本—其他"), |  | ||||||
|     (615, "selling expenses", "推銷費用", "推销费用"), |  | ||||||
|     (616, "selling expenses", "推銷費用", "推销费用"), |  | ||||||
|     (617, "selling expenses", "推銷費用", "推销费用"), |  | ||||||
|     (618, "selling expenses", "推銷費用", "推销费用"), |  | ||||||
|     (625, "general & administrative expenses", "管理及總務費用", "管理及总务费用"), |  | ||||||
|     (626, "general & administrative expenses", "管理及總務費用", "管理及总务费用"), |  | ||||||
|     (627, "general & administrative expenses", "管理及總務費用", "管理及总务费用"), |  | ||||||
|     (628, "general & administrative expenses", "管理及總務費用", "管理及总务费用"), |  | ||||||
|     (635, "research and development expenses", "研究發展費用", "研究发展费用"), |  | ||||||
|     (636, "research and development expenses", "研究發展費用", "研究发展费用"), |  | ||||||
|     (637, "research and development expenses", "研究發展費用", "研究发展费用"), |  | ||||||
|     (638, "research and development expenses", "研究發展費用", "研究发展费用"), |  | ||||||
|     (711, "interest revenue", "利息收入", "利息收入"), |  | ||||||
|     (712, "investment income", "投資收益", "投资收益"), |  | ||||||
|     (713, "foreign exchange gain", "兌換利益", "兑换利益"), |  | ||||||
|     (714, "gain on disposal of investments", "處分投資收益", "处分投资收益"), |  | ||||||
|     (715, "gain on disposal of assets", "處分資產溢價收入", "处分资产溢价收入"), |  | ||||||
|     (748, "other non-operating revenue", "其他營業外收入", "其他营业外收入"), |  | ||||||
|     (751, "interest expense", "利息費用", "利息费用"), |  | ||||||
|     (752, "investment loss", "投資損失", "投资损失"), |  | ||||||
|     (753, "foreign exchange loss", "兌換損失", "兑换损失"), |  | ||||||
|     (754, "loss on disposal of investments", "處分投資損失", "处分投资损失"), |  | ||||||
|     (755, "loss on disposal of assets", "處分資產損失", "处分资产损失"), |  | ||||||
|     (788, "other non-operating expenses", "其他營業外費用", "其他营业外费用"), |  | ||||||
|     (811, "income tax expense (or benefit)", "所得稅費用(或利益)", |  | ||||||
|      "所得税费用(或利益)"), |  | ||||||
|     (911, "income (loss) from operations of discontinued segments", |  | ||||||
|      "停業部門損益—停業前營業損益", "停业部门损益—停业前营业损益"), |  | ||||||
|     (912, "gain (loss) from disposal of discontinued segments", |  | ||||||
|      "停業部門損益—處分損益", "停业部门损益—处分损益"), |  | ||||||
|     (921, "extraordinary gain or loss", "非常損益", "非常损益"), |  | ||||||
|     (931, "cumulative effect of changes in accounting principles", |  | ||||||
|      "會計原則變動累積影響數", "会计原则变动累积影响数"), |  | ||||||
|     (941, "minority interest income", "少數股權淨利", "少数股权净利"), |  | ||||||
|     (1111, "cash on hand", "庫存現金", "库存现金"), |  | ||||||
|     (1112, "petty cash/revolving funds", "零用金/週轉金", "零用金/周转金"), |  | ||||||
|     (1113, "cash in banks", "銀行存款", "银行存款"), |  | ||||||
|     (1116, "cash in transit", "在途現金", "在途现金"), |  | ||||||
|     (1117, "cash equivalents", "約當現金", "约当现金"), |  | ||||||
|     (1118, "other cash and cash equivalents", "其他現金及約當現金", |  | ||||||
|      "其他现金及约当现金"), |  | ||||||
|     (1121, "short-term investments – stock", "短期投資—股票", "短期投资—股票"), |  | ||||||
|     (1122, "short-term investments – short-term notes and bills", |  | ||||||
|      "短期投資—短期票券", "短期投资—短期票券"), |  | ||||||
|     (1123, "short-term investments – government bonds", "短期投資—政府債券", |  | ||||||
|      "短期投资—政府债券"), |  | ||||||
|     (1124, "short-term investments – beneficiary certificates", |  | ||||||
|      "短期投資—受益憑證", "短期投资—受益凭证"), |  | ||||||
|     (1125, "short-term investments – corporate bonds", "短期投資—公司債", |  | ||||||
|      "短期投资—公司债"), |  | ||||||
|     (1128, "short-term investments – other", "短期投資—其他", "短期投资—其他"), |  | ||||||
|     (1129, "allowance for reduction of short-term investment to market", |  | ||||||
|      "備抵短期投資跌價損失", "备抵短期投资跌价损失"), |  | ||||||
|     (1131, "notes receivable", "應收票據", "应收票据"), |  | ||||||
|     (1132, "discounted notes receivable", "應收票據貼現", "应收票据贴现"), |  | ||||||
|     (1137, "notes receivable – related parties", "應收票據—關係人", |  | ||||||
|      "应收票据—关系人"), |  | ||||||
|     (1138, "other notes receivable", "其他應收票據", "其他应收票据"), |  | ||||||
|     (1139, "allowance for uncollectible accounts – notes receivable", |  | ||||||
|      "備抵呆帳-應收票據", "备抵呆帐-应收票据"), |  | ||||||
|     (1141, "accounts receivable", "應收帳款", "应收帐款"), |  | ||||||
|     (1142, "installment accounts receivable", "應收分期帳款", |  | ||||||
|      "应收分期帐款"), |  | ||||||
|     (1147, "accounts receivable – related parties", "應收帳款—關係人", |  | ||||||
|      "应收帐款—关系人"), |  | ||||||
|     (1149, "allowance for uncollectible accounts – accounts receivable", |  | ||||||
|      "備抵呆帳-應收帳款", "备抵呆帐-应收帐款"), |  | ||||||
|     (1181, "forward exchange contract receivable", "應收出售遠匯款", |  | ||||||
|      "应收出售远汇款"), |  | ||||||
|     (1182, "forward exchange contract receivable – foreign currencies", |  | ||||||
|      "應收遠匯款—外幣", "应收远汇款—外币"), |  | ||||||
|     (1183, "discount on forward ex-change contract", "買賣遠匯折價", |  | ||||||
|      "买卖远汇折价"), |  | ||||||
|     (1184, "earned revenue receivable", "應收收益", "应收收益"), |  | ||||||
|     (1185, "income tax refund receivable", "應收退稅款", "应收退税款"), |  | ||||||
|     (1187, "other receivables – related parties", "其他應收款—關係人", |  | ||||||
|      "其他应收款—关系人"), |  | ||||||
|     (1188, "other receivables – other", "其他應收款—其他", "其他应收款—其他"), |  | ||||||
|     (1189, "allowance for uncollectible accounts – other receivables", |  | ||||||
|      "備抵呆帳—其他應收款", "备抵呆帐—其他应收款"), |  | ||||||
|     (1211, "merchandise inventory", "商品存貨", "商品存货"), |  | ||||||
|     (1212, "consigned goods", "寄銷商品", "寄销商品"), |  | ||||||
|     (1213, "goods in transit", "在途商品", "在途商品"), |  | ||||||
|     (1219, "allowance for reduction of inventory to market", "備抵存貨跌價損失", |  | ||||||
|      "备抵存货跌价损失"), |  | ||||||
|     (1221, "finished goods", "製成品", "制成品"), |  | ||||||
|     (1222, "consigned finished goods", "寄銷製成品", "寄销制成品"), |  | ||||||
|     (1223, "by-products", "副產品", "副产品"), |  | ||||||
|     (1224, "work in process", "在製品", "在制品"), |  | ||||||
|     (1225, "work in process – outsourced", "委外加工", "委外加工"), |  | ||||||
|     (1226, "raw materials", "原料", "原料"), |  | ||||||
|     (1227, "supplies", "物料", "物料"), |  | ||||||
|     (1228, "materials and supplies in transit", "在途原物料", "在途原物料"), |  | ||||||
|     (1229, "allowance for reduction of inventory to market", "備抵存貨跌價損失", |  | ||||||
|      "备抵存货跌价损失"), |  | ||||||
|     (1251, "prepaid payroll", "預付薪資", "预付薪资"), |  | ||||||
|     (1252, "prepaid rents", "預付租金", "预付租金"), |  | ||||||
|     (1253, "prepaid insurance", "預付保險費", "预付保险费"), |  | ||||||
|     (1254, "office supplies", "用品盤存", "用品盘存"), |  | ||||||
|     (1255, "prepaid income tax", "預付所得稅", "预付所得税"), |  | ||||||
|     (1258, "other prepaid expenses", "其他預付費用", "其他预付费用"), |  | ||||||
|     (1261, "prepayment for purchases", "預付貨款", "预付货款"), |  | ||||||
|     (1268, "other prepayments", "其他預付款項", "其他预付款项"), |  | ||||||
|     (1281, "VAT paid ( or input tax)", "進項稅額", "进项税额"), |  | ||||||
|     (1282, "excess VAT paid (or overpaid VAT)", "留抵稅額", "留抵税额"), |  | ||||||
|     (1283, "temporary payments", "暫付款", "暂付款"), |  | ||||||
|     (1284, "payment on behalf of others", "代付款", "代付款"), |  | ||||||
|     (1285, "advances to employees", "員工借支", "员工借支"), |  | ||||||
|     (1286, "refundable deposits", "存出保證金", "存出保证金"), |  | ||||||
|     (1287, "certificate of deposit-restricted", "受限制存款", "受限制存款"), |  | ||||||
|     (1291, "deferred income tax assets", "遞延所得稅資產", "递延所得税资产"), |  | ||||||
|     (1292, "deferred foreign exchange losses", "遞延兌換損失", "递延兑换损失"), |  | ||||||
|     (1293, "owners’ (stockholders’) current account", "業主(股東)往來", |  | ||||||
|      "业主(股东)往来"), |  | ||||||
|     (1294, "current account with others", "同業往來", "同业往来"), |  | ||||||
|     (1298, "other current assets – other", "其他流動資產—其他", |  | ||||||
|      "其他流动资产—其他"), |  | ||||||
|     (1311, "redemption fund (or sinking fund)", "償債基金", "偿债基金"), |  | ||||||
|     (1312, "fund for improvement and expansion", "改良及擴充基金", |  | ||||||
|      "改良及扩充基金"), |  | ||||||
|     (1313, "contingency fund", "意外損失準備基金", "意外损失准备基金"), |  | ||||||
|     (1314, "pension fund", "退休基金", "退休基金"), |  | ||||||
|     (1318, "other funds", "其他基金", "其他基金"), |  | ||||||
|     (1321, "long-term equity investments", "長期股權投資", "长期股权投资"), |  | ||||||
|     (1322, "long-term bond investments", "長期債券投資", "长期债券投资"), |  | ||||||
|     (1323, "long-term real estate in-vestments", "長期不動產投資", |  | ||||||
|      "长期不动产投资"), |  | ||||||
|     (1324, "cash surrender value of life insurance", "人壽保險現金解約價值", |  | ||||||
|      "人寿保险现金解约价值"), |  | ||||||
|     (1328, "other long-term investments", "其他長期投資", "其他长期投资"), |  | ||||||
|     (1329, |  | ||||||
|      "allowance for excess of cost over market value of long-term investments", |  | ||||||
|      "備抵長期投資跌價損失", "备抵长期投资跌价损失"), |  | ||||||
|     (1411, "land", "土地", "土地"), |  | ||||||
|     (1418, "land – revaluation increments", "土地—重估增值", "土地—重估增值"), |  | ||||||
|     (1421, "land improvements", "土地改良物", "土地改良物"), |  | ||||||
|     (1428, "land improvements – revaluation increments", "土地改良物—重估增值", |  | ||||||
|      "土地改良物—重估增值"), |  | ||||||
|     (1429, "accumulated depreciation – land improvements", "累積折舊—土地改良物", |  | ||||||
|      "累积折旧—土地改良物"), |  | ||||||
|     (1431, "buildings", "房屋及建物", "房屋及建物"), |  | ||||||
|     (1438, "buildings –revaluation increments", "房屋及建物—重估增值", |  | ||||||
|      "房屋及建物—重估增值"), |  | ||||||
|     (1439, "accumulated depreciation – buildings", "累積折舊—房屋及建物", |  | ||||||
|      "累积折旧—房屋及建物"), |  | ||||||
|     (1441, "machinery", "機(器)具", "机(器)具"), |  | ||||||
|     (1448, "machinery – revaluation increments", "機(器)具—重估增值", |  | ||||||
|      "机(器)具—重估增值"), |  | ||||||
|     (1449, "accumulated depreciation – machinery", "累積折舊—機(器)具", |  | ||||||
|      "累积折旧—机(器)具"), |  | ||||||
|     (1511, "leased assets", "租賃資產", "租赁资产"), |  | ||||||
|     (1519, "accumulated depreciation – leased assets", "累積折舊—租賃資產", |  | ||||||
|      "累积折旧—租赁资产"), |  | ||||||
|     (1521, "leasehold improvements", "租賃權益改良", "租赁权益改良"), |  | ||||||
|     (1529, "accumulated depreciation – leasehold improvements", |  | ||||||
|      "累積折舊—租賃權益改良", "累积折旧—租赁权益改良"), |  | ||||||
|     (1561, "construction in progress", "未完工程", "未完工程"), |  | ||||||
|     (1562, "prepayment for equipment", "預付購置設備款", "预付购置设备款"), |  | ||||||
|     (1581, "miscellaneous property, plant, and equipment", "雜項固定資產", |  | ||||||
|      "杂项固定资产"), |  | ||||||
|     (1588, |  | ||||||
|      "miscellaneous property, plant, and equipment – revaluation increments", |  | ||||||
|      "雜項固定資產—重估增值", "杂项固定资产—重估增值"), |  | ||||||
|     (1589, |  | ||||||
|      "accumulated depreciation – miscellaneous property, plant, and equipment", |  | ||||||
|      "累積折舊—雜項固定資產", "累积折旧—杂项固定资产"), |  | ||||||
|     (1611, "natural resources", "天然資源", "天然资源"), |  | ||||||
|     (1618, "natural resources –revaluation increments", "天然資源—重估增值", |  | ||||||
|      "天然资源—重估增值"), |  | ||||||
|     (1619, "accumulated depletion – natural resources", "累積折耗—天然資源", |  | ||||||
|      "累积折耗—天然资源"), |  | ||||||
|     (1711, "trademarks", "商標權", "商标权"), |  | ||||||
|     (1721, "patents", "專利權", "专利权"), |  | ||||||
|     (1731, "franchise", "特許權", "特许权"), |  | ||||||
|     (1741, "copyright", "著作權", "著作权"), |  | ||||||
|     (1751, "computer software cost", "電腦軟體", "电脑软体"), |  | ||||||
|     (1761, "goodwill", "商譽", "商誉"), |  | ||||||
|     (1771, "organization costs", "開辦費", "开办费"), |  | ||||||
|     (1781, "deferred pension costs", "遞延退休金成本", "递延退休金成本"), |  | ||||||
|     (1782, "leasehold improvements", "租賃權益改良", "租赁权益改良"), |  | ||||||
|     (1788, "other intangible assets – other", "其他無形資產—其他", |  | ||||||
|      "其他无形资产—其他"), |  | ||||||
|     (1811, "deferred bond issuance costs", "債券發行成本", "债券发行成本"), |  | ||||||
|     (1812, "long-term prepaid rent", "長期預付租金", "长期预付租金"), |  | ||||||
|     (1813, "long-term prepaid insurance", "長期預付保險費", "长期预付保险费"), |  | ||||||
|     (1814, "deferred income tax assets", "遞延所得稅資產", "递延所得税资产"), |  | ||||||
|     (1815, "prepaid pension cost", "預付退休金", "预付退休金"), |  | ||||||
|     (1818, "other deferred assets", "其他遞延資產", "其他递延资产"), |  | ||||||
|     (1821, "idle assets", "閒置資產", "闲置资产"), |  | ||||||
|     (1841, "long-term notes receivable", "長期應收票據", "长期应收票据"), |  | ||||||
|     (1842, "long-term accounts receivable", "長期應收帳款", "长期应收帐款"), |  | ||||||
|     (1843, "overdue receivables", "催收帳款", "催收帐款"), |  | ||||||
|     (1847, |  | ||||||
|      "long-term notes, accounts and overdue receivables – related parties", |  | ||||||
|      "長期應收票據及款項與催收帳款—關係人", "长期应收票据及款项与催收帐款—关系人"), |  | ||||||
|     (1848, "other long-term receivables", "其他長期應收款項", "其他长期应收款项"), |  | ||||||
|     (1849, |  | ||||||
|      "allowance for uncollectible accounts – long-term notes, accounts and" |  | ||||||
|      " overdue receivables", |  | ||||||
|      "備抵呆帳—長期應收票據及款項與催收帳款", "备抵呆帐—长期应收票据及款项与催收帐款"), |  | ||||||
|     (1851, "assets leased to others", "出租資產", "出租资产"), |  | ||||||
|     (1858, "assets leased to others – incremental value from revaluation", |  | ||||||
|      "出租資產—重估增值", "出租资产—重估增值"), |  | ||||||
|     (1859, "accumulated depreciation – assets leased to others", |  | ||||||
|      "累積折舊—出租資產", "累积折旧—出租资产"), |  | ||||||
|     (1861, "refundable deposits", "存出保證金", "存出保证金"), |  | ||||||
|     (1881, "certificate of deposit – restricted", "受限制存款", "受限制存款"), |  | ||||||
|     (1888, "miscellaneous assets – other", "雜項資產—其他", "杂项资产—其他"), |  | ||||||
|     (2111, "bank overdraft", "銀行透支", "银行透支"), |  | ||||||
|     (2112, "bank loan", "銀行借款", "银行借款"), |  | ||||||
|     (2114, "short-term borrowings – owners", "短期借款—業主", "短期借款—业主"), |  | ||||||
|     (2115, "short-term borrowings – employees", "短期借款—員工", "短期借款—员工"), |  | ||||||
|     (2117, "short-term borrowings – related parties", "短期借款—關係人", |  | ||||||
|      "短期借款—关系人"), |  | ||||||
|     (2118, "short-term borrowings – other", "短期借款—其他", "短期借款—其他"), |  | ||||||
|     (2121, "commercial paper payable", "應付商業本票", "应付商业本票"), |  | ||||||
|     (2122, "bank acceptance", "銀行承兌匯票", "银行承兑汇票"), |  | ||||||
|     (2128, "other short-term notes and bills payable", "其他應付短期票券", |  | ||||||
|      "其他应付短期票券"), |  | ||||||
|     (2129, "discount on short-term notes and bills payable", "應付短期票券折價", |  | ||||||
|      "应付短期票券折价"), |  | ||||||
|     (2131, "notes payable", "應付票據", "应付票据"), |  | ||||||
|     (2137, "notes payable – related parties", "應付票據—關係人", |  | ||||||
|      "应付票据—关系人"), |  | ||||||
|     (2138, "other notes payable", "其他應付票據", "其他应付票据"), |  | ||||||
|     (2141, "accounts payable", "應付帳款", "应付帐款"), |  | ||||||
|     (2147, "accounts payable – related parties", "應付帳款—關係人", |  | ||||||
|      "应付帐款—关系人"), |  | ||||||
|     (2161, "income tax payable", "應付所得稅", "应付所得税"), |  | ||||||
|     (2171, "accrued payroll", "應付薪工", "应付薪工"), |  | ||||||
|     (2172, "accrued rent payable", "應付租金", "应付租金"), |  | ||||||
|     (2173, "accrued interest payable", "應付利息", "应付利息"), |  | ||||||
|     (2174, "accrued VAT payable", "應付營業稅", "应付营业税"), |  | ||||||
|     (2175, "accrued taxes payable – other", "應付稅捐—其他", "应付税捐—其他"), |  | ||||||
|     (2178, "other accrued expenses payable", "其他應付費用", "其他应付费用"), |  | ||||||
|     (2181, "forward exchange contract payable", "應付購入遠匯款", "应付购入远汇款"), |  | ||||||
|     (2182, "forward exchange contract payable – foreign currencies", |  | ||||||
|      "應付遠匯款—外幣", "应付远汇款—外币"), |  | ||||||
|     (2183, "premium on forward exchange contract", "買賣遠匯溢價", "买卖远汇溢价"), |  | ||||||
|     (2184, "payables on land and building purchased", "應付土地房屋款", |  | ||||||
|      "应付土地房屋款"), |  | ||||||
|     (2185, "Payables on equipment", "應付設備款", "应付设备款"), |  | ||||||
|     (2187, "other payables – related parties", "其他應付款—關係人", |  | ||||||
|      "其他应付款—关系人"), |  | ||||||
|     (2191, "dividend payable", "應付股利", "应付股利"), |  | ||||||
|     (2192, "bonus payable", "應付紅利", "应付红利"), |  | ||||||
|     (2193, "compensation payable to directors and supervisors", "應付董監事酬勞", |  | ||||||
|      "应付董监事酬劳"), |  | ||||||
|     (2198, "other payables – other", "其他應付款—其他", "其他应付款—其他"), |  | ||||||
|     (2261, "sales revenue received in advance", "預收貨款", "预收货款"), |  | ||||||
|     (2262, "revenue received in advance", "預收收入", "预收收入"), |  | ||||||
|     (2268, "other advance receipts", "其他預收款", "其他预收款"), |  | ||||||
|     (2271, "corporate bonds payable – current portion", |  | ||||||
|      "一年或一營業週期內到期公司債", "一年或一营业周期内到期公司债"), |  | ||||||
|     (2272, "long-term loans payable – current portion", |  | ||||||
|      "一年或一營業週期內到期長期借款", "一年或一营业周期内到期长期借款"), |  | ||||||
|     (2273, |  | ||||||
|      "long-term notes and accounts payable due within one year or one" |  | ||||||
|      " operating cycle", |  | ||||||
|      "一年或一營業週期內到期長期應付票據及款項", |  | ||||||
|      "一年或一营业周期内到期长期应付票据及款项"), |  | ||||||
|     (2277, |  | ||||||
|      "long-term notes and accounts payables to related parties – current" |  | ||||||
|      " portion", |  | ||||||
|      "一年或一營業週期內到期長期應付票據及款項—關係人", |  | ||||||
|      "一年或一营业周期内到期长期应付票据及款项—关系人"), |  | ||||||
|     (2278, "other long-term liabilities – current portion", |  | ||||||
|      "其他一年或一營業週期內到期長期負債", "其他一年或一营业周期内到期长期负债"), |  | ||||||
|     (2281, "VAT received (or output tax)", "銷項稅額", "销项税额"), |  | ||||||
|     (2283, "temporary receipts", "暫收款", "暂收款"), |  | ||||||
|     (2284, "receipts under custody", "代收款", "代收款"), |  | ||||||
|     (2285, "estimated warranty liabilities", "估計售後服務/保固負債", |  | ||||||
|      "估计售后服务/保固负债"), |  | ||||||
|     (2291, "deferred income tax liabilities", "遞延所得稅負債", "递延所得税负债"), |  | ||||||
|     (2292, "deferred foreign exchange gain", "遞延兌換利益", "递延兑换利益"), |  | ||||||
|     (2293, "owners’ current account", "業主(股東)往來", "业主(股东)往来"), |  | ||||||
|     (2294, "current account with others", "同業往來", "同业往来"), |  | ||||||
|     (2298, "other current liabilities – others", "其他流動負債—其他", |  | ||||||
|      "其他流动负债—其他"), |  | ||||||
|     (2311, "corporate bonds payable", "應付公司債", "应付公司债"), |  | ||||||
|     (2319, "premium (discount) on corporate bonds payable", |  | ||||||
|      "應付公司債溢(折)價", "应付公司债溢(折)价"), |  | ||||||
|     (2321, "long-term loans payable – bank", "長期銀行借款", "长期银行借款"), |  | ||||||
|     (2324, "long-term loans payable – owners", "長期借款—業主", "长期借款—业主"), |  | ||||||
|     (2325, "long-term loans payable – employees", "長期借款—員工", |  | ||||||
|      "长期借款—员工"), |  | ||||||
|     (2327, "long-term loans payable – related parties", "長期借款—關係人", |  | ||||||
|      "长期借款—关系人"), |  | ||||||
|     (2328, "long-term loans payable – other", "長期借款—其他", "长期借款—其他"), |  | ||||||
|     (2331, "long-term notes payable", "長期應付票據", "长期应付票据"), |  | ||||||
|     (2332, "long-term accounts pay-able", "長期應付帳款", "长期应付帐款"), |  | ||||||
|     (2333, "long-term capital lease liabilities", "長期應付租賃負債", |  | ||||||
|      "长期应付租赁负债"), |  | ||||||
|     (2337, "Long-term notes and accounts payable – related parties", |  | ||||||
|      "長期應付票據及款項—關係人", "长期应付票据及款项—关系人"), |  | ||||||
|     (2338, "other long-term payables", "其他長期應付款項", "其他长期应付款项"), |  | ||||||
|     (2341, "estimated accrued land value incremental tax pay-able", |  | ||||||
|      "估計應付土地增值稅", "估计应付土地增值税"), |  | ||||||
|     (2351, "accrued pension liabilities", "應計退休金負債", "应计退休金负债"), |  | ||||||
|     (2388, "other long-term liabilities – other", "其他長期負債—其他", |  | ||||||
|      "其他长期负债—其他"), |  | ||||||
|     (2811, "deferred revenue", "遞延收入", "递延收入"), |  | ||||||
|     (2814, "deferred income tax liabilities", "遞延所得稅負債", "递延所得税负债"), |  | ||||||
|     (2818, "other deferred liabilities", "其他遞延負債", "其他递延负债"), |  | ||||||
|     (2861, "guarantee deposit received", "存入保證金", "存入保证金"), |  | ||||||
|     (2888, "miscellaneous liabilities – other", "雜項負債—其他", "杂项负债—其他"), |  | ||||||
|     (3111, "capital – common stock", "普通股股本", "普通股股本"), |  | ||||||
|     (3112, "capital – preferred stock", "特別股股本", "特别股股本"), |  | ||||||
|     (3113, "capital collected in advance", "預收股本", "预收股本"), |  | ||||||
|     (3114, "stock dividends to be distributed", "待分配股票股利", |  | ||||||
|      "待分配股票股利"), |  | ||||||
|     (3115, "capital", "資本", "资本"), |  | ||||||
|     (3211, "paid-in capital in excess of par- common stock", "普通股股票溢價", |  | ||||||
|      "普通股股票溢价"), |  | ||||||
|     (3212, "paid-in capital in excess of par- preferred stock", "特別股股票溢價", |  | ||||||
|      "特别股股票溢价"), |  | ||||||
|     (3231, "capital surplus from assets revaluation", "資產重估增值準備", |  | ||||||
|      "资产重估增值准备"), |  | ||||||
|     (3241, "capital surplus from gain on disposal of assets", "處分資產溢價公積", |  | ||||||
|      "处分资产溢价公积"), |  | ||||||
|     (3251, "capital surplus from business combination", "合併公積", "合并公积"), |  | ||||||
|     (3261, "donated surplus", "受贈公積", "受赠公积"), |  | ||||||
|     (3281, "additional paid-in capital from investee under equity method", |  | ||||||
|      "權益法長期股權投資資本公積", "权益法长期股权投资资本公积"), |  | ||||||
|     (3282, "additional paid-in capital – treasury stock trans-actions", |  | ||||||
|      "資本公積—庫藏股票交易", "资本公积—库藏股票交易"), |  | ||||||
|     (3311, "legal reserve", "法定盈餘公積", "法定盈余公积"), |  | ||||||
|     (3321, "contingency reserve", "意外損失準備", "意外损失准备"), |  | ||||||
|     (3322, "improvement and expansion reserve", "改良擴充準備", "改良扩充准备"), |  | ||||||
|     (3323, "special reserve for redemption of liabilities", "償債準備", |  | ||||||
|      "偿债准备"), |  | ||||||
|     (3328, "other special reserve", "其他特別盈餘公積", "其他特别盈余公积"), |  | ||||||
|     (3351, "accumulated profit or loss", "累積盈虧", "累积盈亏"), |  | ||||||
|     (3352, "prior period adjustments", "前期損益調整", "前期损益调整"), |  | ||||||
|     (3353, "net income or loss for current period", "本期損益", "本期损益"), |  | ||||||
|     (3411, |  | ||||||
|      "unrealized loss on market value decline of long-term equity investments", |  | ||||||
|      "長期股權投資未實現跌價損失", "长期股权投资未实现跌价损失"), |  | ||||||
|     (3421, "cumulative translation adjustments", "累積換算調整數", |  | ||||||
|      "累积换算调整数"), |  | ||||||
|     (3431, "net loss not recognized as pension costs", |  | ||||||
|      "未認列為退休金成本之淨損失", "未认列为退休金成本之净损失"), |  | ||||||
|     (3511, "treasury stock", "庫藏股", "库藏股"), |  | ||||||
|     (3611, "minority interest", "少數股權", "少数股权"), |  | ||||||
|     (4111, "sales revenue", "銷貨收入", "销货收入"), |  | ||||||
|     (4112, "installment sales revenue", "分期付款銷貨收入", "分期付款销货收入"), |  | ||||||
|     (4171, "sales return", "銷貨退回", "销货退回"), |  | ||||||
|     (4191, "sales discounts and allowances", "銷貨折讓", "销货折让"), |  | ||||||
|     (4611, "service revenue", "勞務收入", "劳务收入"), |  | ||||||
|     (4711, "agency revenue", "業務收入", "业务收入"), |  | ||||||
|     (4888, "other operating revenue – other", "其他營業收入—其他", |  | ||||||
|      "其他营业收入—其他"), |  | ||||||
|     (5111, "cost of goods sold", "銷貨成本", "销货成本"), |  | ||||||
|     (5112, "installment cost of goods sold", "分期付款銷貨成本", |  | ||||||
|      "分期付款销货成本"), |  | ||||||
|     (5121, "purchases", "進貨", "进货"), |  | ||||||
|     (5122, "purchase expenses", "進貨費用", "进货费用"), |  | ||||||
|     (5123, "purchase returns", "進貨退出", "进货退出"), |  | ||||||
|     (5124, "charges on purchased merchandise", "進貨折讓", "进货折让"), |  | ||||||
|     (5131, "material purchased", "進料", "进料"), |  | ||||||
|     (5132, "charges on purchased material", "進料費用", "进料费用"), |  | ||||||
|     (5133, "material purchase returns", "進料退出", "进料退出"), |  | ||||||
|     (5134, "material purchase allowances", "進料折讓", "进料折让"), |  | ||||||
|     (5141, "direct labor", "直接人工", "直接人工"), |  | ||||||
|     (5151, "indirect labor", "間接人工", "间接人工"), |  | ||||||
|     (5152, "rent expense, rent", "租金支出", "租金支出"), |  | ||||||
|     (5153, "office supplies (expense)", "文具用品", "文具用品"), |  | ||||||
|     (5154, "travelling expense, travel", "旅費", "旅费"), |  | ||||||
|     (5155, "shipping expenses, freight", "運費", "运费"), |  | ||||||
|     (5156, "postage (expenses)", "郵電費", "邮电费"), |  | ||||||
|     (5157, "repair (s) and maintenance (expense )", "修繕費", "修缮费"), |  | ||||||
|     (5158, "packing expenses", "包裝費", "包装费"), |  | ||||||
|     (5161, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"), |  | ||||||
|     (5162, "insurance (expense)", "保險費", "保险费"), |  | ||||||
|     (5163, "manufacturing overhead – outsourced", "加工費", "加工费"), |  | ||||||
|     (5166, "taxes", "稅捐", "税捐"), |  | ||||||
|     (5168, "depreciation expense", "折舊", "折旧"), |  | ||||||
|     (5169, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"), |  | ||||||
|     (5172, "meal (expenses)", "伙食費", "伙食费"), |  | ||||||
|     (5173, "employee benefits/welfare", "職工福利", "职工福利"), |  | ||||||
|     (5176, "training (expense)", "訓練費", "训练费"), |  | ||||||
|     (5177, "indirect materials", "間接材料", "间接材料"), |  | ||||||
|     (5188, "other manufacturing expenses", "其他製造費用", "其他制造费用"), |  | ||||||
|     (5611, "service costs", "勞務成本", "劳务成本"), |  | ||||||
|     (5711, "agency costs", "業務成本", "业务成本"), |  | ||||||
|     (5888, "other operating costs – other", "其他營業成本—其他", |  | ||||||
|      "其他营业成本—其他"), |  | ||||||
|     (6151, "payroll expense", "薪資支出", "薪资支出"), |  | ||||||
|     (6152, "rent expense, rent", "租金支出", "租金支出"), |  | ||||||
|     (6153, "office supplies (expense)", "文具用品", "文具用品"), |  | ||||||
|     (6154, "travelling expense, travel", "旅費", "旅费"), |  | ||||||
|     (6155, "shipping expenses, freight", "運費", "运费"), |  | ||||||
|     (6156, "postage (expenses)", "郵電費", "邮电费"), |  | ||||||
|     (6157, "repair (s) and maintenance (expense)", "修繕費", "修缮费"), |  | ||||||
|     (6159, "advertisement expense, advertisement", "廣告費", "广告费"), |  | ||||||
|     (6161, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"), |  | ||||||
|     (6162, "insurance (expense)", "保險費", "保险费"), |  | ||||||
|     (6164, "entertainment (expense)", "交際費", "交际费"), |  | ||||||
|     (6165, "donation (expense)", "捐贈", "捐赠"), |  | ||||||
|     (6166, "taxes", "稅捐", "税捐"), |  | ||||||
|     (6167, "loss on uncollectible accounts", "呆帳損失", "呆帐损失"), |  | ||||||
|     (6168, "depreciation expense", "折舊", "折旧"), |  | ||||||
|     (6169, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"), |  | ||||||
|     (6172, "meal (expenses)", "伙食費", "伙食费"), |  | ||||||
|     (6173, "employee benefits/welfare", "職工福利", "职工福利"), |  | ||||||
|     (6175, "commission (expense)", "佣金支出", "佣金支出"), |  | ||||||
|     (6176, "training (expense)", "訓練費", "训练费"), |  | ||||||
|     (6188, "other selling expenses", "其他推銷費用", "其他推销费用"), |  | ||||||
|     (6251, "payroll expense", "薪資支出", "薪资支出"), |  | ||||||
|     (6252, "rent expense, rent", "租金支出", "租金支出"), |  | ||||||
|     (6253, "office supplies", "文具用品", "文具用品"), |  | ||||||
|     (6254, "travelling expense, travel", "旅費", "旅费"), |  | ||||||
|     (6255, "shipping expenses,freight", "運費", "运费"), |  | ||||||
|     (6256, "postage (expenses)", "郵電費", "邮电费"), |  | ||||||
|     (6257, "repair (s) and maintenance (expense)", "修繕費", "修缮费"), |  | ||||||
|     (6259, "advertisement expense, advertisement", "廣告費", "广告费"), |  | ||||||
|     (6261, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"), |  | ||||||
|     (6262, "insurance (expense)", "保險費", "保险费"), |  | ||||||
|     (6264, "entertainment (expense)", "交際費", "交际费"), |  | ||||||
|     (6265, "donation (expense)", "捐贈", "捐赠"), |  | ||||||
|     (6266, "taxes", "稅捐", "税捐"), |  | ||||||
|     (6267, "loss on uncollectible accounts", "呆帳損失", "呆帐损失"), |  | ||||||
|     (6268, "depreciation expense", "折舊", "折旧"), |  | ||||||
|     (6269, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"), |  | ||||||
|     (6271, "loss on export sales", "外銷損失", "外销损失"), |  | ||||||
|     (6272, "meal (expenses)", "伙食費", "伙食费"), |  | ||||||
|     (6273, "employee benefits/welfare", "職工福利", "职工福利"), |  | ||||||
|     (6274, "research and development expense", "研究發展費用", "研究发展费用"), |  | ||||||
|     (6275, "commission (expense)", "佣金支出", "佣金支出"), |  | ||||||
|     (6276, "training (expense)", "訓練費", "训练费"), |  | ||||||
|     (6278, "professional service fees", "勞務費", "劳务费"), |  | ||||||
|     (6288, "other general and administrative expenses", "其他管理及總務費用", |  | ||||||
|      "其他管理及总务费用"), |  | ||||||
|     (6351, "payroll expense", "薪資支出", "薪资支出"), |  | ||||||
|     (6352, "rent expense, rent", "租金支出", "租金支出"), |  | ||||||
|     (6353, "office supplies", "文具用品", "文具用品"), |  | ||||||
|     (6354, "travelling expense, travel", "旅費", "旅费"), |  | ||||||
|     (6355, "shipping expenses, freight", "運費", "运费"), |  | ||||||
|     (6356, "postage (expenses)", "郵電費", "邮电费"), |  | ||||||
|     (6357, "repair (s) and maintenance (expense)", "修繕費", "修缮费"), |  | ||||||
|     (6361, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"), |  | ||||||
|     (6362, "insurance (expense)", "保險費", "保险费"), |  | ||||||
|     (6364, "entertainment (expense)", "交際費", "交际费"), |  | ||||||
|     (6366, "taxes", "稅捐", "税捐"), |  | ||||||
|     (6368, "depreciation expense", "折舊", "折旧"), |  | ||||||
|     (6369, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"), |  | ||||||
|     (6372, "meal (expenses)", "伙食費", "伙食费"), |  | ||||||
|     (6373, "employee benefits/welfare", "職工福利", "职工福利"), |  | ||||||
|     (6376, "training (expense)", "訓練費", "训练费"), |  | ||||||
|     (6378, "other research and development expenses", "其他研究發展費用", |  | ||||||
|      "其他研究发展费用"), |  | ||||||
|     (7111, "interest revenue/income", "利息收入", "利息收入"), |  | ||||||
|     (7121, "investment income recognized under equity method", |  | ||||||
|      "權益法認列之投資收益", "权益法认列之投资收益"), |  | ||||||
|     (7122, "dividends income", "股利收入", "股利收入"), |  | ||||||
|     (7123, "gain on market price recovery of short-term investment", |  | ||||||
|      "短期投資市價回升利益", "短期投资市价回升利益"), |  | ||||||
|     (7131, "foreign exchange gain", "兌換利益", "兑换利益"), |  | ||||||
|     (7141, "gain on disposal of investments", "處分投資收益", "处分投资收益"), |  | ||||||
|     (7151, "gain on disposal of assets", "處分資產溢價收入", "处分资产溢价收入"), |  | ||||||
|     (7481, "donation income", "捐贈收入", "捐赠收入"), |  | ||||||
|     (7482, "rent revenue/income", "租金收入", "租金收入"), |  | ||||||
|     (7483, "commission revenue/income", "佣金收入", "佣金收入"), |  | ||||||
|     (7484, "revenue from sale of scraps", "出售下腳及廢料收入", |  | ||||||
|      "出售下脚及废料收入"), |  | ||||||
|     (7485, "gain on physical inventory", "存貨盤盈", "存货盘盈"), |  | ||||||
|     (7486, "gain from price recovery of inventory", "存貨跌價回升利益", |  | ||||||
|      "存货跌价回升利益"), |  | ||||||
|     (7487, "gain on reversal of bad debts", "壞帳轉回利益", "坏帐转回利益"), |  | ||||||
|     (7488, "other non-operating revenue – other items", "其他營業外收入—其他", |  | ||||||
|      "其他营业外收入—其他"), |  | ||||||
|     (7511, "interest expense", "利息費用", "利息费用"), |  | ||||||
|     (7521, "investment loss recognized under equity method", |  | ||||||
|      "權益法認列之投資損失", "权益法认列之投资损失"), |  | ||||||
|     (7523, "unrealized loss on reduction of short-term investments to market", |  | ||||||
|      "短期投資未實現跌價損失", "短期投资未实现跌价损失"), |  | ||||||
|     (7531, "foreign exchange loss", "兌換損失", "兑换损失"), |  | ||||||
|     (7541, "loss on disposal of investments", "處分投資損失", "处分投资损失"), |  | ||||||
|     (7551, "loss on disposal of assets", "處分資產損失", "处分资产损失"), |  | ||||||
|     (7881, "loss on work stoppages", "停工損失", "停工损失"), |  | ||||||
|     (7882, "casualty loss", "災害損失", "灾害损失"), |  | ||||||
|     (7885, "loss on physical inventory", "存貨盤損", "存货盘损"), |  | ||||||
|     (7886, |  | ||||||
|      "loss for market price decline and obsolete and slow-moving inventories", |  | ||||||
|      "存貨跌價及呆滯損失", "存货跌价及呆滞损失"), |  | ||||||
|     (7888, "other non-operating expenses – other", "其他營業外費用—其他", |  | ||||||
|      "其他营业外费用—其他"), |  | ||||||
|     (8111, "income tax expense ( or benefit)", "所得稅費用(或利益)", |  | ||||||
|      "所得税费用(或利益)"), |  | ||||||
|     (9111, "income (loss) from operations of discontinued segment", |  | ||||||
|      "停業部門損益—停業前營業損益", "停业部门损益—停业前营业损益"), |  | ||||||
|     (9121, "gain (loss) from disposal of discontinued segment", |  | ||||||
|      "停業部門損益—處分損益", "停业部门损益—处分损益"), |  | ||||||
|     (9211, "extraordinary gain or loss", "非常損益", "非常损益"), |  | ||||||
|     (9311, "cumulative effect of changes in accounting principles", |  | ||||||
|      "會計原則變動累積影響數", "会计原则变动累积影响数"), |  | ||||||
|     (9411, "minority interest income", "少數股權淨利", "少数股权净利"), |  | ||||||
| ] |  | ||||||
| """The base account data.""" |  | ||||||
|   | |||||||
| @@ -20,7 +20,7 @@ | |||||||
| from flask import abort | from flask import abort | ||||||
| from werkzeug.routing import BaseConverter | from werkzeug.routing import BaseConverter | ||||||
|  |  | ||||||
| from accounting.database import db | from accounting import db | ||||||
| from accounting.models import BaseAccount | from accounting.models import BaseAccount | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -46,7 +46,8 @@ def list_accounts() -> str: | |||||||
| def show_account_detail(account: BaseAccount) -> str: | def show_account_detail(account: BaseAccount) -> str: | ||||||
|     """Shows the account detail. |     """Shows the account detail. | ||||||
|  |  | ||||||
|     :return: The account detail. |     :param account: The account. | ||||||
|  |     :return: The detail. | ||||||
|     """ |     """ | ||||||
|     return render_template("accounting/base-account/detail.html", obj=account) |     return render_template("accounting/base-account/detail.html", obj=account) | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										38
									
								
								src/accounting/currency/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/accounting/currency/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The currency management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from flask import Flask, Blueprint | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def init_app(app: Flask, bp: Blueprint) -> None: | ||||||
|  |     """Initialize the application. | ||||||
|  |  | ||||||
|  |     :param app: The Flask application. | ||||||
|  |     :param bp: The blueprint of the accounting application. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     from .converters import CurrencyConverter | ||||||
|  |     app.url_map.converters["currency"] = CurrencyConverter | ||||||
|  |  | ||||||
|  |     from .views import bp as currency_bp, api_bp as currency_api_bp | ||||||
|  |     bp.register_blueprint(currency_bp, url_prefix="/currencies") | ||||||
|  |     bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies") | ||||||
|  |  | ||||||
|  |     from .commands import init_currencies_command | ||||||
|  |     app.cli.add_command(init_currencies_command) | ||||||
							
								
								
									
										84
									
								
								src/accounting/currency/commands.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/accounting/currency/commands.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The console commands for the currency management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import csv | ||||||
|  | import os | ||||||
|  | import typing as t | ||||||
|  |  | ||||||
|  | import click | ||||||
|  | from flask.cli import with_appcontext | ||||||
|  |  | ||||||
|  | from accounting import db, data_dir | ||||||
|  | from accounting.models import Currency, CurrencyL10n | ||||||
|  | from accounting.utils.user import has_user, get_user_pk | ||||||
|  |  | ||||||
|  | CurrencyData = tuple[str, str, str, str] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __validate_username(ctx: click.core.Context, param: click.core.Option, | ||||||
|  |                         value: str) -> str: | ||||||
|  |     """Validates the username for the click console command. | ||||||
|  |  | ||||||
|  |     :param ctx: The console command context. | ||||||
|  |     :param param: The console command option. | ||||||
|  |     :param value: The username. | ||||||
|  |     :raise click.BadParameter: When validation fails. | ||||||
|  |     :return: The username. | ||||||
|  |     """ | ||||||
|  |     value = value.strip() | ||||||
|  |     if value == "": | ||||||
|  |         raise click.BadParameter("Username empty.") | ||||||
|  |     if not has_user(value): | ||||||
|  |         raise click.BadParameter(f"User {value} does not exist.") | ||||||
|  |     return value | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @click.command("accounting-init-currencies") | ||||||
|  | @click.option("-u", "--username", metavar="USERNAME", prompt=True, | ||||||
|  |               help="The username.", callback=__validate_username, | ||||||
|  |               default=lambda: os.getlogin()) | ||||||
|  | @with_appcontext | ||||||
|  | def init_currencies_command(username: str) -> None: | ||||||
|  |     """Initializes the currencies.""" | ||||||
|  |     existing_codes: set[str] = {x.code for x in Currency.query.all()} | ||||||
|  |  | ||||||
|  |     with open(data_dir / "currencies.csv") as fp: | ||||||
|  |         data: list[dict[str, str]] = [x for x in csv.DictReader(fp)] | ||||||
|  |     to_add: list[dict[str, str]] = [x for x in data | ||||||
|  |                                     if x["code"] not in existing_codes] | ||||||
|  |     if len(to_add) == 0: | ||||||
|  |         click.echo("No more currency to add.") | ||||||
|  |         return | ||||||
|  |  | ||||||
|  |     creator_pk: int = get_user_pk(username) | ||||||
|  |     currency_data: list[dict[str, t.Any]] = [{"code": x["code"], | ||||||
|  |                                               "name_l10n": x["name"], | ||||||
|  |                                               "created_by_id": creator_pk, | ||||||
|  |                                               "updated_by_id": creator_pk} | ||||||
|  |                                              for x in to_add] | ||||||
|  |     locales: list[str] = [x[5:] for x in to_add[0] if x.startswith("l10n-")] | ||||||
|  |     l10n_data: list[dict[str, str]] = [{"currency_code": x["code"], | ||||||
|  |                                         "locale": y, | ||||||
|  |                                         "name": x[f"l10n-{y}"]} | ||||||
|  |                                        for x in to_add for y in locales] | ||||||
|  |     db.session.bulk_insert_mappings(Currency, currency_data) | ||||||
|  |     db.session.bulk_insert_mappings(CurrencyL10n, l10n_data) | ||||||
|  |     db.session.commit() | ||||||
|  |  | ||||||
|  |     click.echo(F"{len(to_add)} added.  Currencies initialized.") | ||||||
							
								
								
									
										48
									
								
								src/accounting/currency/converters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/accounting/currency/converters.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The path converters for the currency management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from flask import abort | ||||||
|  | from werkzeug.routing import BaseConverter | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.models import Currency | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CurrencyConverter(BaseConverter): | ||||||
|  |     """The currency converter to convert the currency code and to the | ||||||
|  |     corresponding currency in the routes.""" | ||||||
|  |  | ||||||
|  |     def to_python(self, value: str) -> Currency: | ||||||
|  |         """Converts a currency code to a currency. | ||||||
|  |  | ||||||
|  |         :param value: The currency code. | ||||||
|  |         :return: The corresponding currency. | ||||||
|  |         """ | ||||||
|  |         currency: Currency | None = db.session.get(Currency, value) | ||||||
|  |         if currency is None: | ||||||
|  |             abort(404) | ||||||
|  |         return currency | ||||||
|  |  | ||||||
|  |     def to_url(self, value: Currency) -> str: | ||||||
|  |         """Converts a currency to its code. | ||||||
|  |  | ||||||
|  |         :param value: The currency. | ||||||
|  |         :return: The code. | ||||||
|  |         """ | ||||||
|  |         return value.code | ||||||
							
								
								
									
										83
									
								
								src/accounting/currency/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/accounting/currency/forms.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The forms for the currency management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from flask_wtf import FlaskForm | ||||||
|  | from wtforms import StringField, ValidationError | ||||||
|  | from wtforms.validators import DataRequired, Regexp, NoneOf | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import lazy_gettext | ||||||
|  | from accounting.models import Currency | ||||||
|  | from accounting.utils.strip_text import strip_text | ||||||
|  | from accounting.utils.user import get_current_user_pk | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class 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.")), | ||||||
|  |                     Regexp(r"^[A-Z]{3}$", | ||||||
|  |                            message=lazy_gettext( | ||||||
|  |                                "Code can only be composed of 3 upper-cased" | ||||||
|  |                                " letters.")), | ||||||
|  |                     NoneOf(CODE_BLOCKLIST, message=lazy_gettext( | ||||||
|  |                         "This code is not available.")), | ||||||
|  |                     CodeUnique()]) | ||||||
|  |     """The code.  It may not conflict with another currency.""" | ||||||
|  |     name = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[DataRequired(lazy_gettext("Please fill in the name."))]) | ||||||
|  |     """The name.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |         self.obj_code: str | None = None | ||||||
|  |         """The current code of the currency, or None when adding a new | ||||||
|  |         currency.""" | ||||||
|  |  | ||||||
|  |     def populate_obj(self, obj: Currency) -> None: | ||||||
|  |         """Populates the form data into a currency object. | ||||||
|  |  | ||||||
|  |         :param obj: The currency object. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         is_new: bool = obj.code is None | ||||||
|  |         obj.code = self.code.data | ||||||
|  |         obj.name = self.name.data | ||||||
|  |         if is_new: | ||||||
|  |             current_user_pk: int = get_current_user_pk() | ||||||
|  |             obj.created_by_id = current_user_pk | ||||||
|  |             obj.updated_by_id = current_user_pk | ||||||
							
								
								
									
										44
									
								
								src/accounting/currency/query.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/accounting/currency/query.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The currency query. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import request | ||||||
|  |  | ||||||
|  | from accounting.models import Currency, CurrencyL10n | ||||||
|  | from accounting.utils.query import parse_query_keywords | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_currency_query() -> list[Currency]: | ||||||
|  |     """Returns the base accounts, optionally filtered by the query. | ||||||
|  |  | ||||||
|  |     :return: The base accounts. | ||||||
|  |     """ | ||||||
|  |     keywords: list[str] = parse_query_keywords(request.args.get("q")) | ||||||
|  |     if len(keywords) == 0: | ||||||
|  |         return Currency.query.order_by(Currency.code).all() | ||||||
|  |     conditions: list[sa.BinaryExpression] = [] | ||||||
|  |     for k in keywords: | ||||||
|  |         l10n: list[CurrencyL10n] = CurrencyL10n.query\ | ||||||
|  |             .filter(CurrencyL10n.name.contains(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), | ||||||
|  |                                  Currency.code.in_(l10n_matches))) | ||||||
|  |     return Currency.query.filter(*conditions)\ | ||||||
|  |         .order_by(Currency.code).all() | ||||||
							
								
								
									
										185
									
								
								src/accounting/currency/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								src/accounting/currency/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,185 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The views for the currency management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from urllib.parse import urlencode, parse_qsl | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import Blueprint, render_template, redirect, session, request, \ | ||||||
|  |     flash, url_for | ||||||
|  | from werkzeug.datastructures import ImmutableMultiDict | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import lazy_gettext | ||||||
|  | from accounting.models import Currency | ||||||
|  | from accounting.utils.flash_errors import flash_form_errors | ||||||
|  | from accounting.utils.next_uri import inherit_next, or_next | ||||||
|  | from accounting.utils.pagination import Pagination | ||||||
|  | from accounting.utils.permission import has_permission, can_view, can_edit | ||||||
|  | from accounting.utils.user import get_current_user_pk | ||||||
|  | from .forms import CurrencyForm | ||||||
|  |  | ||||||
|  | bp: Blueprint = Blueprint("currency", __name__) | ||||||
|  | """The view blueprint for the currency management.""" | ||||||
|  | api_bp: Blueprint = Blueprint("currency-api", __name__) | ||||||
|  | """The view blueprint for the currency management API.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("", endpoint="list") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def list_currencies() -> str: | ||||||
|  |     """Lists the currencies. | ||||||
|  |  | ||||||
|  |     :return: The currency list. | ||||||
|  |     """ | ||||||
|  |     from .query import get_currency_query | ||||||
|  |     currencies: list[Currency] = get_currency_query() | ||||||
|  |     pagination: Pagination = Pagination[Currency](currencies) | ||||||
|  |     return render_template("accounting/currency/list.html", | ||||||
|  |                            list=pagination.list, pagination=pagination) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("/create", endpoint="create") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def show_add_currency_form() -> str: | ||||||
|  |     """Shows the form to add a currency. | ||||||
|  |  | ||||||
|  |     :return: The form to add a currency. | ||||||
|  |     """ | ||||||
|  |     if "form" in session: | ||||||
|  |         form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"]))) | ||||||
|  |         del session["form"] | ||||||
|  |         form.validate() | ||||||
|  |     else: | ||||||
|  |         form = CurrencyForm() | ||||||
|  |     return render_template("accounting/currency/create.html", | ||||||
|  |                            form=form) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("/store", endpoint="store") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def add_currency() -> redirect: | ||||||
|  |     """Adds a currency. | ||||||
|  |  | ||||||
|  |     :return: The redirection to the currency detail on success, or the currency | ||||||
|  |         creation form on error. | ||||||
|  |     """ | ||||||
|  |     form = CurrencyForm(request.form) | ||||||
|  |     if not form.validate(): | ||||||
|  |         flash_form_errors(form) | ||||||
|  |         session["form"] = urlencode(list(request.form.items())) | ||||||
|  |         return redirect(inherit_next(url_for("accounting.currency.create"))) | ||||||
|  |     currency: Currency = Currency() | ||||||
|  |     form.populate_obj(currency) | ||||||
|  |     db.session.add(currency) | ||||||
|  |     db.session.commit() | ||||||
|  |     flash(lazy_gettext("The currency is added successfully"), "success") | ||||||
|  |     return redirect(inherit_next(__get_detail_uri(currency))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("/<currency:currency>", endpoint="detail") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def show_currency_detail(currency: Currency) -> str: | ||||||
|  |     """Shows the currency detail. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :return: The detail. | ||||||
|  |     """ | ||||||
|  |     return render_template("accounting/currency/detail.html", obj=currency) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("/<currency:currency>/edit", endpoint="edit") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def show_currency_edit_form(currency: Currency) -> str: | ||||||
|  |     """Shows the form to edit a currency. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :return: The form to edit the currency. | ||||||
|  |     """ | ||||||
|  |     form: CurrencyForm | ||||||
|  |     if "form" in session: | ||||||
|  |         form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"]))) | ||||||
|  |         del session["form"] | ||||||
|  |         form.validate() | ||||||
|  |     else: | ||||||
|  |         form = CurrencyForm(obj=currency) | ||||||
|  |     return render_template("accounting/currency/edit.html", | ||||||
|  |                            currency=currency, form=form) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("/<currency:currency>/update", endpoint="update") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def update_currency(currency: Currency) -> redirect: | ||||||
|  |     """Updates a currency. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :return: The redirection to the currency detail on success, or the currency | ||||||
|  |         edit form on error. | ||||||
|  |     """ | ||||||
|  |     form = CurrencyForm(request.form) | ||||||
|  |     form.obj_code = currency.code | ||||||
|  |     if not form.validate(): | ||||||
|  |         flash_form_errors(form) | ||||||
|  |         session["form"] = urlencode(list(request.form.items())) | ||||||
|  |         return redirect(inherit_next(url_for("accounting.currency.edit", | ||||||
|  |                                              currency=currency))) | ||||||
|  |     with db.session.no_autoflush: | ||||||
|  |         form.populate_obj(currency) | ||||||
|  |     if not currency.is_modified: | ||||||
|  |         flash(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") | ||||||
|  |     return redirect(inherit_next(__get_detail_uri(currency))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("/<currency:currency>/delete", endpoint="delete") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def delete_currency(currency: Currency) -> redirect: | ||||||
|  |     """Deletes a currency. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :return: The redirection to the currency list on success, or the currency | ||||||
|  |         detail on error. | ||||||
|  |     """ | ||||||
|  |     currency.delete() | ||||||
|  |     db.session.commit() | ||||||
|  |     flash(lazy_gettext("The currency is deleted successfully."), "success") | ||||||
|  |     return redirect(or_next(url_for("accounting.currency.list"))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @api_bp.get("/exists-code", endpoint="exists") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def exists_code() -> dict[str, bool]: | ||||||
|  |     """Validates whether a currency code exists. | ||||||
|  |  | ||||||
|  |     :return: Whether the currency code exists. | ||||||
|  |     """ | ||||||
|  |     return {"exists": db.session.get(Currency, request.args["q"]) is not None} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_detail_uri(currency: Currency) -> str: | ||||||
|  |     """Returns the detail URI of a currency. | ||||||
|  |  | ||||||
|  |     :param currency: The currency. | ||||||
|  |     :return: The detail URI of the currency. | ||||||
|  |     """ | ||||||
|  |     return url_for("accounting.currency.detail", currency=currency) | ||||||
|  |  | ||||||
							
								
								
									
										528
									
								
								src/accounting/data/base_accounts.csv
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										528
									
								
								src/accounting/data/base_accounts.csv
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,528 @@ | |||||||
|  | code,title,l10n-zh_Hant,l10n-zh_Hans | ||||||
|  | 1,assets,資產,资产 | ||||||
|  | 2,liabilities,負債,负债 | ||||||
|  | 3,owners’ equity,業主權益,业主权益 | ||||||
|  | 4,operating revenue,營業收入,营业收入 | ||||||
|  | 5,operating costs,營業成本,营业成本 | ||||||
|  | 6,operating expenses,營業費用,营业费用 | ||||||
|  | 7,"non-operating revenue and expenses, other income (expense)",營業外收入及費用,营业外收入及费用 | ||||||
|  | 8,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益) | ||||||
|  | 9,nonrecurring gain or loss,非經常營業損益,非经常营业损益 | ||||||
|  | 11,current assets,流動資產,流动资产 | ||||||
|  | 12,current assets,流動資產,流动资产 | ||||||
|  | 13,funds and long-term investments,基金及長期投資,基金及长期投资 | ||||||
|  | 14,"property , plant, and equipment",固定資產,固定资产 | ||||||
|  | 15,"property , plant, and equipment",固定資產,固定资产 | ||||||
|  | 16,depletable assets,遞耗資產,递耗资产 | ||||||
|  | 17,intangible assets,無形資產,无形资产 | ||||||
|  | 18,other assets,其他資產,其他资产 | ||||||
|  | 21,current liabilities,流動負債,流动负债 | ||||||
|  | 22,current liabilities,流動負債,流动负债 | ||||||
|  | 23,long-term liabilities,長期負債,长期负债 | ||||||
|  | 28,other liabilities,其他負債,其他负债 | ||||||
|  | 31,capital,資本,资本 | ||||||
|  | 32,additional paid-in capital,資本公積,资本公积 | ||||||
|  | 33,retained earnings (accumulated deficit),保留盈餘(或累積虧損),保留盈余(或累积亏损) | ||||||
|  | 34,equity adjustments,權益調整,权益调整 | ||||||
|  | 35,treasury stock,庫藏股,库藏股 | ||||||
|  | 36,minority interest,少數股權,少数股权 | ||||||
|  | 41,sales revenue,銷貨收入,销货收入 | ||||||
|  | 46,service revenue,勞務收入,劳务收入 | ||||||
|  | 47,agency revenue,業務收入,业务收入 | ||||||
|  | 48,other operating revenue,其他營業收入,其他营业收入 | ||||||
|  | 51,cost of goods sold,銷貨成本,销货成本 | ||||||
|  | 56,service costs,勞務成本,劳务成本 | ||||||
|  | 57,agency costs,業務成本,业务成本 | ||||||
|  | 58,other operating costs,其他營業成本,其他营业成本 | ||||||
|  | 61,selling expenses,推銷費用,推销费用 | ||||||
|  | 62,general & administrative expenses,管理及總務費用,管理及总务费用 | ||||||
|  | 63,research and development expenses,研究發展費用,研究发展费用 | ||||||
|  | 71,non-operating revenue,營業外收入,营业外收入 | ||||||
|  | 72,non-operating revenue,營業外收入,营业外收入 | ||||||
|  | 73,non-operating revenue,營業外收入,营业外收入 | ||||||
|  | 74,non-operating revenue,營業外收入,营业外收入 | ||||||
|  | 75,non-operating expenses,營業外費用,营业外费用 | ||||||
|  | 76,non-operating expenses,營業外費用,营业外费用 | ||||||
|  | 77,non-operating expenses,營業外費用,营业外费用 | ||||||
|  | 78,non-operating expenses,營業外費用,营业外费用 | ||||||
|  | 81,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益) | ||||||
|  | 91,gain (loss) from discontinued operations,停業部門損益,停业部门损益 | ||||||
|  | 92,extraordinary gain or loss,非常損益,非常损益 | ||||||
|  | 93,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数 | ||||||
|  | 94,minority interest income,少數股權淨利,少数股权净利 | ||||||
|  | 111,cash and cash equivalents,現金及約當現金,现金及约当现金 | ||||||
|  | 112,short-term investments,短期投資,短期投资 | ||||||
|  | 113,notes receivable,應收票據,应收票据 | ||||||
|  | 114,accounts receivable,應收帳款,应收帐款 | ||||||
|  | 118,other receivables,其他應收款,其他应收款 | ||||||
|  | 121,inventories,存貨,存货 | ||||||
|  | 122,inventories,存貨,存货 | ||||||
|  | 125,prepaid expenses,預付費用,预付费用 | ||||||
|  | 126,prepayments,預付款項,预付款项 | ||||||
|  | 128,other current assets,其他流動資產,其他流动资产 | ||||||
|  | 129,other current assets,其他流動資產,其他流动资产 | ||||||
|  | 131,funds,基金,基金 | ||||||
|  | 132,long-term investments,長期投資,长期投资 | ||||||
|  | 141,land,土地,土地 | ||||||
|  | 142,land improvements,土地改良物,土地改良物 | ||||||
|  | 143,buildings,房屋及建物,房屋及建物 | ||||||
|  | 144,machinery and equipment,機(器)具及設備,机(器)具及设备 | ||||||
|  | 145,machinery and equipment,機(器)具及設備,机(器)具及设备 | ||||||
|  | 146,machinery and equipment,機(器)具及設備,机(器)具及设备 | ||||||
|  | 151,leased assets,租賃資產,租赁资产 | ||||||
|  | 152,leasehold improvements,租賃權益改良,租赁权益改良 | ||||||
|  | 156,construction in progress and prepayments for equipment,未完工程及預付購置設備款,未完工程及预付购置设备款 | ||||||
|  | 158,"miscellaneous property, plant, and equipment",雜項固定資產,杂项固定资产 | ||||||
|  | 161,depletable assets,遞耗資產,递耗资产 | ||||||
|  | 171,trademarks,商標權,商标权 | ||||||
|  | 172,patents,專利權,专利权 | ||||||
|  | 173,franchise,特許權,特许权 | ||||||
|  | 174,copyright,著作權,著作权 | ||||||
|  | 175,computer software,電腦軟體,电脑软体 | ||||||
|  | 176,goodwill,商譽,商誉 | ||||||
|  | 177,organization costs,開辦費,开办费 | ||||||
|  | 178,other intangibles,其他無形資產,其他无形资产 | ||||||
|  | 181,deferred assets,遞延資產,递延资产 | ||||||
|  | 182,idle assets,閒置資產,闲置资产 | ||||||
|  | 184,"long-term notes , accounts and overdue receivables",長期應收票據及款項與催收帳款,长期应收票据及款项与催收帐款 | ||||||
|  | 185,assets leased to others,出租資產,出租资产 | ||||||
|  | 186,refundable deposit,存出保證金,存出保证金 | ||||||
|  | 188,miscellaneous assets,雜項資產,杂项资产 | ||||||
|  | 211,short-term borrowings (debt),短期借款,短期借款 | ||||||
|  | 212,short-term notes and bills payable,應付短期票券,应付短期票券 | ||||||
|  | 213,notes payable,應付票據,应付票据 | ||||||
|  | 214,accounts pay able,應付帳款,应付帐款 | ||||||
|  | 216,income taxes payable,應付所得稅,应付所得税 | ||||||
|  | 217,accrued expenses,應付費用,应付费用 | ||||||
|  | 218,other payables,其他應付款,其他应付款 | ||||||
|  | 219,other payables,其他應付款,其他应付款 | ||||||
|  | 226,advance receipts,預收款項,预收款项 | ||||||
|  | 227,long-term liabilities -current portion,一年或一營業週期內到期長期負債,一年或一营业周期内到期长期负债 | ||||||
|  | 228,other current liabilities,其他流動負債,其他流动负债 | ||||||
|  | 229,other current liabilities,其他流動負債,其他流动负债 | ||||||
|  | 231,corporate bonds payable,應付公司債,应付公司债 | ||||||
|  | 232,long-term loans payable,長期借款,长期借款 | ||||||
|  | 233,long-term notes and accounts payable,長期應付票據及款項,长期应付票据及款项 | ||||||
|  | 234,accrued liabilities for land value increment tax,估計應付土地增值稅,估计应付土地增值税 | ||||||
|  | 235,accrued pension liabilities,應計退休金負債,应计退休金负债 | ||||||
|  | 238,other long-term liabilities,其他長期負債,其他长期负债 | ||||||
|  | 281,deferred liabilities,遞延負債,递延负债 | ||||||
|  | 286,deposits received,存入保證金,存入保证金 | ||||||
|  | 288,miscellaneous liabilities,雜項負債,杂项负债 | ||||||
|  | 311,capital,資本(或股本),资本(或股本) | ||||||
|  | 321,paid-in capital in excess of par,股票溢價,股票溢价 | ||||||
|  | 323,capital surplus from assets revaluation,資產重估增值準備,资产重估增值准备 | ||||||
|  | 324,capital surplus from gain on disposal of assets,處分資產溢價公積,处分资产溢价公积 | ||||||
|  | 325,capital surplus from business combination,合併公積,合并公积 | ||||||
|  | 326,donated surplus,受贈公積,受赠公积 | ||||||
|  | 328,other additional paid-in capital,其他資本公積,其他资本公积 | ||||||
|  | 331,legal reserve,法定盈餘公積,法定盈余公积 | ||||||
|  | 332,special reserve,特別盈餘公積,特别盈余公积 | ||||||
|  | 335,retained earnings-unappropriated (or accumulated deficit),未分配盈餘(或累積虧損),未分配盈余(或累积亏损) | ||||||
|  | 341,unrealized loss on market value decline of long-term equity investments,長期股權投資未實現跌價損失,长期股权投资未实现跌价损失 | ||||||
|  | 342,cumulative translation adjustment,累積換算調整數,累积换算调整数 | ||||||
|  | 343,net loss not recognized as pension cost,未認列為退休金成本之淨損失,未认列为退休金成本之净损失 | ||||||
|  | 351,treasury stock,庫藏股,库藏股 | ||||||
|  | 361,minority interest,少數股權,少数股权 | ||||||
|  | 411,sales revenue,銷貨收入,销货收入 | ||||||
|  | 417,sales return,銷貨退回,销货退回 | ||||||
|  | 419,sales allowances,銷貨折讓,销货折让 | ||||||
|  | 461,service revenue,勞務收入,劳务收入 | ||||||
|  | 471,agency revenue,業務收入,业务收入 | ||||||
|  | 488,other operating revenue,其他營業收入—其他,其他营业收入—其他 | ||||||
|  | 511,cost of goods sold,銷貨成本,销货成本 | ||||||
|  | 512,purchases,進貨,进货 | ||||||
|  | 513,materials purchased,進料,进料 | ||||||
|  | 514,direct labor,直接人工,直接人工 | ||||||
|  | 515,manufacturing overhead,製造費用,制造费用 | ||||||
|  | 516,manufacturing overhead,製造費用,制造费用 | ||||||
|  | 517,manufacturing overhead,製造費用,制造费用 | ||||||
|  | 518,manufacturing overhead,製造費用,制造费用 | ||||||
|  | 561,service costs,勞務成本,劳务成本 | ||||||
|  | 571,agency costs,業務成本,业务成本 | ||||||
|  | 588,other operating costs-other,其他營業成本—其他,其他营业成本—其他 | ||||||
|  | 615,selling expenses,推銷費用,推销费用 | ||||||
|  | 616,selling expenses,推銷費用,推销费用 | ||||||
|  | 617,selling expenses,推銷費用,推销费用 | ||||||
|  | 618,selling expenses,推銷費用,推销费用 | ||||||
|  | 625,general & administrative expenses,管理及總務費用,管理及总务费用 | ||||||
|  | 626,general & administrative expenses,管理及總務費用,管理及总务费用 | ||||||
|  | 627,general & administrative expenses,管理及總務費用,管理及总务费用 | ||||||
|  | 628,general & administrative expenses,管理及總務費用,管理及总务费用 | ||||||
|  | 635,research and development expenses,研究發展費用,研究发展费用 | ||||||
|  | 636,research and development expenses,研究發展費用,研究发展费用 | ||||||
|  | 637,research and development expenses,研究發展費用,研究发展费用 | ||||||
|  | 638,research and development expenses,研究發展費用,研究发展费用 | ||||||
|  | 711,interest revenue,利息收入,利息收入 | ||||||
|  | 712,investment income,投資收益,投资收益 | ||||||
|  | 713,foreign exchange gain,兌換利益,兑换利益 | ||||||
|  | 714,gain on disposal of investments,處分投資收益,处分投资收益 | ||||||
|  | 715,gain on disposal of assets,處分資產溢價收入,处分资产溢价收入 | ||||||
|  | 748,other non-operating revenue,其他營業外收入,其他营业外收入 | ||||||
|  | 751,interest expense,利息費用,利息费用 | ||||||
|  | 752,investment loss,投資損失,投资损失 | ||||||
|  | 753,foreign exchange loss,兌換損失,兑换损失 | ||||||
|  | 754,loss on disposal of investments,處分投資損失,处分投资损失 | ||||||
|  | 755,loss on disposal of assets,處分資產損失,处分资产损失 | ||||||
|  | 788,other non-operating expenses,其他營業外費用,其他营业外费用 | ||||||
|  | 811,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益) | ||||||
|  | 911,income (loss) from operations of discontinued segments,停業部門損益—停業前營業損益,停业部门损益—停业前营业损益 | ||||||
|  | 912,gain (loss) from disposal of discontinued segments,停業部門損益—處分損益,停业部门损益—处分损益 | ||||||
|  | 921,extraordinary gain or loss,非常損益,非常损益 | ||||||
|  | 931,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数 | ||||||
|  | 941,minority interest income,少數股權淨利,少数股权净利 | ||||||
|  | 1111,cash on hand,庫存現金,库存现金 | ||||||
|  | 1112,petty cash/revolving funds,零用金/週轉金,零用金/周转金 | ||||||
|  | 1113,cash in banks,銀行存款,银行存款 | ||||||
|  | 1116,cash in transit,在途現金,在途现金 | ||||||
|  | 1117,cash equivalents,約當現金,约当现金 | ||||||
|  | 1118,other cash and cash equivalents,其他現金及約當現金,其他现金及约当现金 | ||||||
|  | 1121,short-term investments – stock,短期投資—股票,短期投资—股票 | ||||||
|  | 1122,short-term investments – short-term notes and bills,短期投資—短期票券,短期投资—短期票券 | ||||||
|  | 1123,short-term investments – government bonds,短期投資—政府債券,短期投资—政府债券 | ||||||
|  | 1124,short-term investments – beneficiary certificates,短期投資—受益憑證,短期投资—受益凭证 | ||||||
|  | 1125,short-term investments – corporate bonds,短期投資—公司債,短期投资—公司债 | ||||||
|  | 1128,short-term investments – other,短期投資—其他,短期投资—其他 | ||||||
|  | 1129,allowance for reduction of short-term investment to market,備抵短期投資跌價損失,备抵短期投资跌价损失 | ||||||
|  | 1131,notes receivable,應收票據,应收票据 | ||||||
|  | 1132,discounted notes receivable,應收票據貼現,应收票据贴现 | ||||||
|  | 1137,notes receivable – related parties,應收票據—關係人,应收票据—关系人 | ||||||
|  | 1138,other notes receivable,其他應收票據,其他应收票据 | ||||||
|  | 1139,allowance for uncollectible accounts – notes receivable,備抵呆帳-應收票據,备抵呆帐-应收票据 | ||||||
|  | 1141,accounts receivable,應收帳款,应收帐款 | ||||||
|  | 1142,installment accounts receivable,應收分期帳款,应收分期帐款 | ||||||
|  | 1147,accounts receivable – related parties,應收帳款—關係人,应收帐款—关系人 | ||||||
|  | 1149,allowance for uncollectible accounts – accounts receivable,備抵呆帳-應收帳款,备抵呆帐-应收帐款 | ||||||
|  | 1181,forward exchange contract receivable,應收出售遠匯款,应收出售远汇款 | ||||||
|  | 1182,forward exchange contract receivable – foreign currencies,應收遠匯款—外幣,应收远汇款—外币 | ||||||
|  | 1183,discount on forward ex-change contract,買賣遠匯折價,买卖远汇折价 | ||||||
|  | 1184,earned revenue receivable,應收收益,应收收益 | ||||||
|  | 1185,income tax refund receivable,應收退稅款,应收退税款 | ||||||
|  | 1187,other receivables – related parties,其他應收款—關係人,其他应收款—关系人 | ||||||
|  | 1188,other receivables – other,其他應收款—其他,其他应收款—其他 | ||||||
|  | 1189,allowance for uncollectible accounts – other receivables,備抵呆帳—其他應收款,备抵呆帐—其他应收款 | ||||||
|  | 1211,merchandise inventory,商品存貨,商品存货 | ||||||
|  | 1212,consigned goods,寄銷商品,寄销商品 | ||||||
|  | 1213,goods in transit,在途商品,在途商品 | ||||||
|  | 1219,allowance for reduction of inventory to market,備抵存貨跌價損失,备抵存货跌价损失 | ||||||
|  | 1221,finished goods,製成品,制成品 | ||||||
|  | 1222,consigned finished goods,寄銷製成品,寄销制成品 | ||||||
|  | 1223,by-products,副產品,副产品 | ||||||
|  | 1224,work in process,在製品,在制品 | ||||||
|  | 1225,work in process – outsourced,委外加工,委外加工 | ||||||
|  | 1226,raw materials,原料,原料 | ||||||
|  | 1227,supplies,物料,物料 | ||||||
|  | 1228,materials and supplies in transit,在途原物料,在途原物料 | ||||||
|  | 1229,allowance for reduction of inventory to market,備抵存貨跌價損失,备抵存货跌价损失 | ||||||
|  | 1251,prepaid payroll,預付薪資,预付薪资 | ||||||
|  | 1252,prepaid rents,預付租金,预付租金 | ||||||
|  | 1253,prepaid insurance,預付保險費,预付保险费 | ||||||
|  | 1254,office supplies,用品盤存,用品盘存 | ||||||
|  | 1255,prepaid income tax,預付所得稅,预付所得税 | ||||||
|  | 1258,other prepaid expenses,其他預付費用,其他预付费用 | ||||||
|  | 1261,prepayment for purchases,預付貨款,预付货款 | ||||||
|  | 1268,other prepayments,其他預付款項,其他预付款项 | ||||||
|  | 1281,VAT paid ( or input tax),進項稅額,进项税额 | ||||||
|  | 1282,excess VAT paid (or overpaid VAT),留抵稅額,留抵税额 | ||||||
|  | 1283,temporary payments,暫付款,暂付款 | ||||||
|  | 1284,payment on behalf of others,代付款,代付款 | ||||||
|  | 1285,advances to employees,員工借支,员工借支 | ||||||
|  | 1286,refundable deposits,存出保證金,存出保证金 | ||||||
|  | 1287,certificate of deposit-restricted,受限制存款,受限制存款 | ||||||
|  | 1291,deferred income tax assets,遞延所得稅資產,递延所得税资产 | ||||||
|  | 1292,deferred foreign exchange losses,遞延兌換損失,递延兑换损失 | ||||||
|  | 1293,owners’ (stockholders’) current account,業主(股東)往來,业主(股东)往来 | ||||||
|  | 1294,current account with others,同業往來,同业往来 | ||||||
|  | 1298,other current assets – other,其他流動資產—其他,其他流动资产—其他 | ||||||
|  | 1311,redemption fund (or sinking fund),償債基金,偿债基金 | ||||||
|  | 1312,fund for improvement and expansion,改良及擴充基金,改良及扩充基金 | ||||||
|  | 1313,contingency fund,意外損失準備基金,意外损失准备基金 | ||||||
|  | 1314,pension fund,退休基金,退休基金 | ||||||
|  | 1318,other funds,其他基金,其他基金 | ||||||
|  | 1321,long-term equity investments,長期股權投資,长期股权投资 | ||||||
|  | 1322,long-term bond investments,長期債券投資,长期债券投资 | ||||||
|  | 1323,long-term real estate in-vestments,長期不動產投資,长期不动产投资 | ||||||
|  | 1324,cash surrender value of life insurance,人壽保險現金解約價值,人寿保险现金解约价值 | ||||||
|  | 1328,other long-term investments,其他長期投資,其他长期投资 | ||||||
|  | 1329,allowance for excess of cost over market value of long-term investments,備抵長期投資跌價損失,备抵长期投资跌价损失 | ||||||
|  | 1411,land,土地,土地 | ||||||
|  | 1418,land – revaluation increments,土地—重估增值,土地—重估增值 | ||||||
|  | 1421,land improvements,土地改良物,土地改良物 | ||||||
|  | 1428,land improvements – revaluation increments,土地改良物—重估增值,土地改良物—重估增值 | ||||||
|  | 1429,accumulated depreciation – land improvements,累積折舊—土地改良物,累积折旧—土地改良物 | ||||||
|  | 1431,buildings,房屋及建物,房屋及建物 | ||||||
|  | 1438,buildings –revaluation increments,房屋及建物—重估增值,房屋及建物—重估增值 | ||||||
|  | 1439,accumulated depreciation – buildings,累積折舊—房屋及建物,累积折旧—房屋及建物 | ||||||
|  | 1441,machinery,機(器)具,机(器)具 | ||||||
|  | 1448,machinery – revaluation increments,機(器)具—重估增值,机(器)具—重估增值 | ||||||
|  | 1449,accumulated depreciation – machinery,累積折舊—機(器)具,累积折旧—机(器)具 | ||||||
|  | 1511,leased assets,租賃資產,租赁资产 | ||||||
|  | 1519,accumulated depreciation – leased assets,累積折舊—租賃資產,累积折旧—租赁资产 | ||||||
|  | 1521,leasehold improvements,租賃權益改良,租赁权益改良 | ||||||
|  | 1529,accumulated depreciation – leasehold improvements,累積折舊—租賃權益改良,累积折旧—租赁权益改良 | ||||||
|  | 1561,construction in progress,未完工程,未完工程 | ||||||
|  | 1562,prepayment for equipment,預付購置設備款,预付购置设备款 | ||||||
|  | 1581,"miscellaneous property, plant, and equipment",雜項固定資產,杂项固定资产 | ||||||
|  | 1588,"miscellaneous property, plant, and equipment – revaluation increments",雜項固定資產—重估增值,杂项固定资产—重估增值 | ||||||
|  | 1589,"accumulated depreciation – miscellaneous property, plant, and equipment",累積折舊—雜項固定資產,累积折旧—杂项固定资产 | ||||||
|  | 1611,natural resources,天然資源,天然资源 | ||||||
|  | 1618,natural resources –revaluation increments,天然資源—重估增值,天然资源—重估增值 | ||||||
|  | 1619,accumulated depletion – natural resources,累積折耗—天然資源,累积折耗—天然资源 | ||||||
|  | 1711,trademarks,商標權,商标权 | ||||||
|  | 1721,patents,專利權,专利权 | ||||||
|  | 1731,franchise,特許權,特许权 | ||||||
|  | 1741,copyright,著作權,著作权 | ||||||
|  | 1751,computer software cost,電腦軟體,电脑软体 | ||||||
|  | 1761,goodwill,商譽,商誉 | ||||||
|  | 1771,organization costs,開辦費,开办费 | ||||||
|  | 1781,deferred pension costs,遞延退休金成本,递延退休金成本 | ||||||
|  | 1782,leasehold improvements,租賃權益改良,租赁权益改良 | ||||||
|  | 1788,other intangible assets – other,其他無形資產—其他,其他无形资产—其他 | ||||||
|  | 1811,deferred bond issuance costs,債券發行成本,债券发行成本 | ||||||
|  | 1812,long-term prepaid rent,長期預付租金,长期预付租金 | ||||||
|  | 1813,long-term prepaid insurance,長期預付保險費,长期预付保险费 | ||||||
|  | 1814,deferred income tax assets,遞延所得稅資產,递延所得税资产 | ||||||
|  | 1815,prepaid pension cost,預付退休金,预付退休金 | ||||||
|  | 1818,other deferred assets,其他遞延資產,其他递延资产 | ||||||
|  | 1821,idle assets,閒置資產,闲置资产 | ||||||
|  | 1841,long-term notes receivable,長期應收票據,长期应收票据 | ||||||
|  | 1842,long-term accounts receivable,長期應收帳款,长期应收帐款 | ||||||
|  | 1843,overdue receivables,催收帳款,催收帐款 | ||||||
|  | 1847,"long-term notes, accounts and overdue receivables – related parties",長期應收票據及款項與催收帳款—關係人,长期应收票据及款项与催收帐款—关系人 | ||||||
|  | 1848,other long-term receivables,其他長期應收款項,其他长期应收款项 | ||||||
|  | 1849,"allowance for uncollectible accounts – long-term notes, accounts and overdue receivables",備抵呆帳—長期應收票據及款項與催收帳款,备抵呆帐—长期应收票据及款项与催收帐款 | ||||||
|  | 1851,assets leased to others,出租資產,出租资产 | ||||||
|  | 1858,assets leased to others – incremental value from revaluation,出租資產—重估增值,出租资产—重估增值 | ||||||
|  | 1859,accumulated depreciation – assets leased to others,累積折舊—出租資產,累积折旧—出租资产 | ||||||
|  | 1861,refundable deposits,存出保證金,存出保证金 | ||||||
|  | 1881,certificate of deposit – restricted,受限制存款,受限制存款 | ||||||
|  | 1888,miscellaneous assets – other,雜項資產—其他,杂项资产—其他 | ||||||
|  | 2111,bank overdraft,銀行透支,银行透支 | ||||||
|  | 2112,bank loan,銀行借款,银行借款 | ||||||
|  | 2114,short-term borrowings – owners,短期借款—業主,短期借款—业主 | ||||||
|  | 2115,short-term borrowings – employees,短期借款—員工,短期借款—员工 | ||||||
|  | 2117,short-term borrowings – related parties,短期借款—關係人,短期借款—关系人 | ||||||
|  | 2118,short-term borrowings – other,短期借款—其他,短期借款—其他 | ||||||
|  | 2121,commercial paper payable,應付商業本票,应付商业本票 | ||||||
|  | 2122,bank acceptance,銀行承兌匯票,银行承兑汇票 | ||||||
|  | 2128,other short-term notes and bills payable,其他應付短期票券,其他应付短期票券 | ||||||
|  | 2129,discount on short-term notes and bills payable,應付短期票券折價,应付短期票券折价 | ||||||
|  | 2131,notes payable,應付票據,应付票据 | ||||||
|  | 2137,notes payable – related parties,應付票據—關係人,应付票据—关系人 | ||||||
|  | 2138,other notes payable,其他應付票據,其他应付票据 | ||||||
|  | 2141,accounts payable,應付帳款,应付帐款 | ||||||
|  | 2147,accounts payable – related parties,應付帳款—關係人,应付帐款—关系人 | ||||||
|  | 2161,income tax payable,應付所得稅,应付所得税 | ||||||
|  | 2171,accrued payroll,應付薪工,应付薪工 | ||||||
|  | 2172,accrued rent payable,應付租金,应付租金 | ||||||
|  | 2173,accrued interest payable,應付利息,应付利息 | ||||||
|  | 2174,accrued VAT payable,應付營業稅,应付营业税 | ||||||
|  | 2175,accrued taxes payable – other,應付稅捐—其他,应付税捐—其他 | ||||||
|  | 2178,other accrued expenses payable,其他應付費用,其他应付费用 | ||||||
|  | 2181,forward exchange contract payable,應付購入遠匯款,应付购入远汇款 | ||||||
|  | 2182,forward exchange contract payable – foreign currencies,應付遠匯款—外幣,应付远汇款—外币 | ||||||
|  | 2183,premium on forward exchange contract,買賣遠匯溢價,买卖远汇溢价 | ||||||
|  | 2184,payables on land and building purchased,應付土地房屋款,应付土地房屋款 | ||||||
|  | 2185,Payables on equipment,應付設備款,应付设备款 | ||||||
|  | 2187,other payables – related parties,其他應付款—關係人,其他应付款—关系人 | ||||||
|  | 2191,dividend payable,應付股利,应付股利 | ||||||
|  | 2192,bonus payable,應付紅利,应付红利 | ||||||
|  | 2193,compensation payable to directors and supervisors,應付董監事酬勞,应付董监事酬劳 | ||||||
|  | 2198,other payables – other,其他應付款—其他,其他应付款—其他 | ||||||
|  | 2261,sales revenue received in advance,預收貨款,预收货款 | ||||||
|  | 2262,revenue received in advance,預收收入,预收收入 | ||||||
|  | 2268,other advance receipts,其他預收款,其他预收款 | ||||||
|  | 2271,corporate bonds payable – current portion,一年或一營業週期內到期公司債,一年或一营业周期内到期公司债 | ||||||
|  | 2272,long-term loans payable – current portion,一年或一營業週期內到期長期借款,一年或一营业周期内到期长期借款 | ||||||
|  | 2273,long-term notes and accounts payable due within one year or one operating cycle,一年或一營業週期內到期長期應付票據及款項,一年或一营业周期内到期长期应付票据及款项 | ||||||
|  | 2277,long-term notes and accounts payables to related parties – current portion,一年或一營業週期內到期長期應付票據及款項—關係人,一年或一营业周期内到期长期应付票据及款项—关系人 | ||||||
|  | 2278,other long-term liabilities – current portion,其他一年或一營業週期內到期長期負債,其他一年或一营业周期内到期长期负债 | ||||||
|  | 2281,VAT received (or output tax),銷項稅額,销项税额 | ||||||
|  | 2283,temporary receipts,暫收款,暂收款 | ||||||
|  | 2284,receipts under custody,代收款,代收款 | ||||||
|  | 2285,estimated warranty liabilities,估計售後服務/保固負債,估计售后服务/保固负债 | ||||||
|  | 2291,deferred income tax liabilities,遞延所得稅負債,递延所得税负债 | ||||||
|  | 2292,deferred foreign exchange gain,遞延兌換利益,递延兑换利益 | ||||||
|  | 2293,owners’ current account,業主(股東)往來,业主(股东)往来 | ||||||
|  | 2294,current account with others,同業往來,同业往来 | ||||||
|  | 2298,other current liabilities – others,其他流動負債—其他,其他流动负债—其他 | ||||||
|  | 2311,corporate bonds payable,應付公司債,应付公司债 | ||||||
|  | 2319,premium (discount) on corporate bonds payable,應付公司債溢(折)價,应付公司债溢(折)价 | ||||||
|  | 2321,long-term loans payable – bank,長期銀行借款,长期银行借款 | ||||||
|  | 2324,long-term loans payable – owners,長期借款—業主,长期借款—业主 | ||||||
|  | 2325,long-term loans payable – employees,長期借款—員工,长期借款—员工 | ||||||
|  | 2327,long-term loans payable – related parties,長期借款—關係人,长期借款—关系人 | ||||||
|  | 2328,long-term loans payable – other,長期借款—其他,长期借款—其他 | ||||||
|  | 2331,long-term notes payable,長期應付票據,长期应付票据 | ||||||
|  | 2332,long-term accounts pay-able,長期應付帳款,长期应付帐款 | ||||||
|  | 2333,long-term capital lease liabilities,長期應付租賃負債,长期应付租赁负债 | ||||||
|  | 2337,Long-term notes and accounts payable – related parties,長期應付票據及款項—關係人,长期应付票据及款项—关系人 | ||||||
|  | 2338,other long-term payables,其他長期應付款項,其他长期应付款项 | ||||||
|  | 2341,estimated accrued land value incremental tax pay-able,估計應付土地增值稅,估计应付土地增值税 | ||||||
|  | 2351,accrued pension liabilities,應計退休金負債,应计退休金负债 | ||||||
|  | 2388,other long-term liabilities – other,其他長期負債—其他,其他长期负债—其他 | ||||||
|  | 2811,deferred revenue,遞延收入,递延收入 | ||||||
|  | 2814,deferred income tax liabilities,遞延所得稅負債,递延所得税负债 | ||||||
|  | 2818,other deferred liabilities,其他遞延負債,其他递延负债 | ||||||
|  | 2861,guarantee deposit received,存入保證金,存入保证金 | ||||||
|  | 2888,miscellaneous liabilities – other,雜項負債—其他,杂项负债—其他 | ||||||
|  | 3111,capital – common stock,普通股股本,普通股股本 | ||||||
|  | 3112,capital – preferred stock,特別股股本,特别股股本 | ||||||
|  | 3113,capital collected in advance,預收股本,预收股本 | ||||||
|  | 3114,stock dividends to be distributed,待分配股票股利,待分配股票股利 | ||||||
|  | 3115,capital,資本,资本 | ||||||
|  | 3211,paid-in capital in excess of par- common stock,普通股股票溢價,普通股股票溢价 | ||||||
|  | 3212,paid-in capital in excess of par- preferred stock,特別股股票溢價,特别股股票溢价 | ||||||
|  | 3231,capital surplus from assets revaluation,資產重估增值準備,资产重估增值准备 | ||||||
|  | 3241,capital surplus from gain on disposal of assets,處分資產溢價公積,处分资产溢价公积 | ||||||
|  | 3251,capital surplus from business combination,合併公積,合并公积 | ||||||
|  | 3261,donated surplus,受贈公積,受赠公积 | ||||||
|  | 3281,additional paid-in capital from investee under equity method,權益法長期股權投資資本公積,权益法长期股权投资资本公积 | ||||||
|  | 3282,additional paid-in capital – treasury stock trans-actions,資本公積—庫藏股票交易,资本公积—库藏股票交易 | ||||||
|  | 3311,legal reserve,法定盈餘公積,法定盈余公积 | ||||||
|  | 3321,contingency reserve,意外損失準備,意外损失准备 | ||||||
|  | 3322,improvement and expansion reserve,改良擴充準備,改良扩充准备 | ||||||
|  | 3323,special reserve for redemption of liabilities,償債準備,偿债准备 | ||||||
|  | 3328,other special reserve,其他特別盈餘公積,其他特别盈余公积 | ||||||
|  | 3351,accumulated profit or loss,累積盈虧,累积盈亏 | ||||||
|  | 3352,prior period adjustments,前期損益調整,前期损益调整 | ||||||
|  | 3353,net income or loss for current period,本期損益,本期损益 | ||||||
|  | 3411,unrealized loss on market value decline of long-term equity investments,長期股權投資未實現跌價損失,长期股权投资未实现跌价损失 | ||||||
|  | 3421,cumulative translation adjustments,累積換算調整數,累积换算调整数 | ||||||
|  | 3431,net loss not recognized as pension costs,未認列為退休金成本之淨損失,未认列为退休金成本之净损失 | ||||||
|  | 3511,treasury stock,庫藏股,库藏股 | ||||||
|  | 3611,minority interest,少數股權,少数股权 | ||||||
|  | 4111,sales revenue,銷貨收入,销货收入 | ||||||
|  | 4112,installment sales revenue,分期付款銷貨收入,分期付款销货收入 | ||||||
|  | 4171,sales return,銷貨退回,销货退回 | ||||||
|  | 4191,sales discounts and allowances,銷貨折讓,销货折让 | ||||||
|  | 4611,service revenue,勞務收入,劳务收入 | ||||||
|  | 4711,agency revenue,業務收入,业务收入 | ||||||
|  | 4888,other operating revenue – other,其他營業收入—其他,其他营业收入—其他 | ||||||
|  | 5111,cost of goods sold,銷貨成本,销货成本 | ||||||
|  | 5112,installment cost of goods sold,分期付款銷貨成本,分期付款销货成本 | ||||||
|  | 5121,purchases,進貨,进货 | ||||||
|  | 5122,purchase expenses,進貨費用,进货费用 | ||||||
|  | 5123,purchase returns,進貨退出,进货退出 | ||||||
|  | 5124,charges on purchased merchandise,進貨折讓,进货折让 | ||||||
|  | 5131,material purchased,進料,进料 | ||||||
|  | 5132,charges on purchased material,進料費用,进料费用 | ||||||
|  | 5133,material purchase returns,進料退出,进料退出 | ||||||
|  | 5134,material purchase allowances,進料折讓,进料折让 | ||||||
|  | 5141,direct labor,直接人工,直接人工 | ||||||
|  | 5151,indirect labor,間接人工,间接人工 | ||||||
|  | 5152,"rent expense, rent",租金支出,租金支出 | ||||||
|  | 5153,office supplies (expense),文具用品,文具用品 | ||||||
|  | 5154,"travelling expense, travel",旅費,旅费 | ||||||
|  | 5155,"shipping expenses, freight",運費,运费 | ||||||
|  | 5156,postage (expenses),郵電費,邮电费 | ||||||
|  | 5157,repair (s) and maintenance (expense ),修繕費,修缮费 | ||||||
|  | 5158,packing expenses,包裝費,包装费 | ||||||
|  | 5161,utilities (expense),水電瓦斯費,水电瓦斯费 | ||||||
|  | 5162,insurance (expense),保險費,保险费 | ||||||
|  | 5163,manufacturing overhead – outsourced,加工費,加工费 | ||||||
|  | 5166,taxes,稅捐,税捐 | ||||||
|  | 5168,depreciation expense,折舊,折旧 | ||||||
|  | 5169,various amortization,各項耗竭及攤提,各项耗竭及摊提 | ||||||
|  | 5172,meal (expenses),伙食費,伙食费 | ||||||
|  | 5173,employee benefits/welfare,職工福利,职工福利 | ||||||
|  | 5176,training (expense),訓練費,训练费 | ||||||
|  | 5177,indirect materials,間接材料,间接材料 | ||||||
|  | 5188,other manufacturing expenses,其他製造費用,其他制造费用 | ||||||
|  | 5611,service costs,勞務成本,劳务成本 | ||||||
|  | 5711,agency costs,業務成本,业务成本 | ||||||
|  | 5888,other operating costs – other,其他營業成本—其他,其他营业成本—其他 | ||||||
|  | 6151,payroll expense,薪資支出,薪资支出 | ||||||
|  | 6152,"rent expense, rent",租金支出,租金支出 | ||||||
|  | 6153,office supplies (expense),文具用品,文具用品 | ||||||
|  | 6154,"travelling expense, travel",旅費,旅费 | ||||||
|  | 6155,"shipping expenses, freight",運費,运费 | ||||||
|  | 6156,postage (expenses),郵電費,邮电费 | ||||||
|  | 6157,repair (s) and maintenance (expense),修繕費,修缮费 | ||||||
|  | 6159,"advertisement expense, advertisement",廣告費,广告费 | ||||||
|  | 6161,utilities (expense),水電瓦斯費,水电瓦斯费 | ||||||
|  | 6162,insurance (expense),保險費,保险费 | ||||||
|  | 6164,entertainment (expense),交際費,交际费 | ||||||
|  | 6165,donation (expense),捐贈,捐赠 | ||||||
|  | 6166,taxes,稅捐,税捐 | ||||||
|  | 6167,loss on uncollectible accounts,呆帳損失,呆帐损失 | ||||||
|  | 6168,depreciation expense,折舊,折旧 | ||||||
|  | 6169,various amortization,各項耗竭及攤提,各项耗竭及摊提 | ||||||
|  | 6172,meal (expenses),伙食費,伙食费 | ||||||
|  | 6173,employee benefits/welfare,職工福利,职工福利 | ||||||
|  | 6175,commission (expense),佣金支出,佣金支出 | ||||||
|  | 6176,training (expense),訓練費,训练费 | ||||||
|  | 6188,other selling expenses,其他推銷費用,其他推销费用 | ||||||
|  | 6251,payroll expense,薪資支出,薪资支出 | ||||||
|  | 6252,"rent expense, rent",租金支出,租金支出 | ||||||
|  | 6253,office supplies,文具用品,文具用品 | ||||||
|  | 6254,"travelling expense, travel",旅費,旅费 | ||||||
|  | 6255,"shipping expenses,freight",運費,运费 | ||||||
|  | 6256,postage (expenses),郵電費,邮电费 | ||||||
|  | 6257,repair (s) and maintenance (expense),修繕費,修缮费 | ||||||
|  | 6259,"advertisement expense, advertisement",廣告費,广告费 | ||||||
|  | 6261,utilities (expense),水電瓦斯費,水电瓦斯费 | ||||||
|  | 6262,insurance (expense),保險費,保险费 | ||||||
|  | 6264,entertainment (expense),交際費,交际费 | ||||||
|  | 6265,donation (expense),捐贈,捐赠 | ||||||
|  | 6266,taxes,稅捐,税捐 | ||||||
|  | 6267,loss on uncollectible accounts,呆帳損失,呆帐损失 | ||||||
|  | 6268,depreciation expense,折舊,折旧 | ||||||
|  | 6269,various amortization,各項耗竭及攤提,各项耗竭及摊提 | ||||||
|  | 6271,loss on export sales,外銷損失,外销损失 | ||||||
|  | 6272,meal (expenses),伙食費,伙食费 | ||||||
|  | 6273,employee benefits/welfare,職工福利,职工福利 | ||||||
|  | 6274,research and development expense,研究發展費用,研究发展费用 | ||||||
|  | 6275,commission (expense),佣金支出,佣金支出 | ||||||
|  | 6276,training (expense),訓練費,训练费 | ||||||
|  | 6278,professional service fees,勞務費,劳务费 | ||||||
|  | 6288,other general and administrative expenses,其他管理及總務費用,其他管理及总务费用 | ||||||
|  | 6351,payroll expense,薪資支出,薪资支出 | ||||||
|  | 6352,"rent expense, rent",租金支出,租金支出 | ||||||
|  | 6353,office supplies,文具用品,文具用品 | ||||||
|  | 6354,"travelling expense, travel",旅費,旅费 | ||||||
|  | 6355,"shipping expenses, freight",運費,运费 | ||||||
|  | 6356,postage (expenses),郵電費,邮电费 | ||||||
|  | 6357,repair (s) and maintenance (expense),修繕費,修缮费 | ||||||
|  | 6361,utilities (expense),水電瓦斯費,水电瓦斯费 | ||||||
|  | 6362,insurance (expense),保險費,保险费 | ||||||
|  | 6364,entertainment (expense),交際費,交际费 | ||||||
|  | 6366,taxes,稅捐,税捐 | ||||||
|  | 6368,depreciation expense,折舊,折旧 | ||||||
|  | 6369,various amortization,各項耗竭及攤提,各项耗竭及摊提 | ||||||
|  | 6372,meal (expenses),伙食費,伙食费 | ||||||
|  | 6373,employee benefits/welfare,職工福利,职工福利 | ||||||
|  | 6376,training (expense),訓練費,训练费 | ||||||
|  | 6378,other research and development expenses,其他研究發展費用,其他研究发展费用 | ||||||
|  | 7111,interest revenue/income,利息收入,利息收入 | ||||||
|  | 7121,investment income recognized under equity method,權益法認列之投資收益,权益法认列之投资收益 | ||||||
|  | 7122,dividends income,股利收入,股利收入 | ||||||
|  | 7123,gain on market price recovery of short-term investment,短期投資市價回升利益,短期投资市价回升利益 | ||||||
|  | 7131,foreign exchange gain,兌換利益,兑换利益 | ||||||
|  | 7141,gain on disposal of investments,處分投資收益,处分投资收益 | ||||||
|  | 7151,gain on disposal of assets,處分資產溢價收入,处分资产溢价收入 | ||||||
|  | 7481,donation income,捐贈收入,捐赠收入 | ||||||
|  | 7482,rent revenue/income,租金收入,租金收入 | ||||||
|  | 7483,commission revenue/income,佣金收入,佣金收入 | ||||||
|  | 7484,revenue from sale of scraps,出售下腳及廢料收入,出售下脚及废料收入 | ||||||
|  | 7485,gain on physical inventory,存貨盤盈,存货盘盈 | ||||||
|  | 7486,gain from price recovery of inventory,存貨跌價回升利益,存货跌价回升利益 | ||||||
|  | 7487,gain on reversal of bad debts,壞帳轉回利益,坏帐转回利益 | ||||||
|  | 7488,other non-operating revenue – other items,其他營業外收入—其他,其他营业外收入—其他 | ||||||
|  | 7511,interest expense,利息費用,利息费用 | ||||||
|  | 7521,investment loss recognized under equity method,權益法認列之投資損失,权益法认列之投资损失 | ||||||
|  | 7523,unrealized loss on reduction of short-term investments to market,短期投資未實現跌價損失,短期投资未实现跌价损失 | ||||||
|  | 7531,foreign exchange loss,兌換損失,兑换损失 | ||||||
|  | 7541,loss on disposal of investments,處分投資損失,处分投资损失 | ||||||
|  | 7551,loss on disposal of assets,處分資產損失,处分资产损失 | ||||||
|  | 7881,loss on work stoppages,停工損失,停工损失 | ||||||
|  | 7882,casualty loss,災害損失,灾害损失 | ||||||
|  | 7885,loss on physical inventory,存貨盤損,存货盘损 | ||||||
|  | 7886,loss for market price decline and obsolete and slow-moving inventories,存貨跌價及呆滯損失,存货跌价及呆滞损失 | ||||||
|  | 7888,other non-operating expenses – other,其他營業外費用—其他,其他营业外费用—其他 | ||||||
|  | 8111,income tax expense ( or benefit),所得稅費用(或利益),所得税费用(或利益) | ||||||
|  | 9111,income (loss) from operations of discontinued segment,停業部門損益—停業前營業損益,停业部门损益—停业前营业损益 | ||||||
|  | 9121,gain (loss) from disposal of discontinued segment,停業部門損益—處分損益,停业部门损益—处分损益 | ||||||
|  | 9211,extraordinary gain or loss,非常損益,非常损益 | ||||||
|  | 9311,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数 | ||||||
|  | 9411,minority interest income,少數股權淨利,少数股权净利 | ||||||
| 
 | 
							
								
								
									
										10
									
								
								src/accounting/data/currencies.csv
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/accounting/data/currencies.csv
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | code,name,l10n-zh_Hant,l10n-zh_Hans | ||||||
|  | TWD,New Taiwan dollar,新臺幣,新台币 | ||||||
|  | USD,United States dollar,美元,美元 | ||||||
|  | JPY,Japanese yen,日圓,日圆 | ||||||
|  | CNY,Renminbi,人民幣,人民币 | ||||||
|  | HKD,Hong Kong dollar,港元,港元 | ||||||
|  | EUR,Euro,歐元,欧元 | ||||||
|  | MOP,Macanese pataca,澳門元,澳门元 | ||||||
|  | AUD,Australian dollar,澳洲元,澳大利亚元 | ||||||
|  | ETH,Ethereum,以太坊,以太坊 | ||||||
| 
 | 
| @@ -1,45 +0,0 @@ | |||||||
| # The Mia! Accounting Flask Project. |  | ||||||
| # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 |  | ||||||
|  |  | ||||||
| #  Copyright (c) 2023 imacat. |  | ||||||
| # |  | ||||||
| #  Licensed under the Apache License, Version 2.0 (the "License"); |  | ||||||
| #  you may not use this file except in compliance with the License. |  | ||||||
| #  You may obtain a copy of the License at |  | ||||||
| # |  | ||||||
| #      http://www.apache.org/licenses/LICENSE-2.0 |  | ||||||
| # |  | ||||||
| #  Unless required by applicable law or agreed to in writing, software |  | ||||||
| #  distributed under the License is distributed on an "AS IS" BASIS, |  | ||||||
| #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |  | ||||||
| #  See the License for the specific language governing permissions and |  | ||||||
| #  limitations under the License. |  | ||||||
|  |  | ||||||
| """The database instance factory for the base account management. |  | ||||||
|  |  | ||||||
| This is to overcome the problem that the database instance needs to be |  | ||||||
| initialized at compile time, but as a submodule it is only available at run |  | ||||||
| time. |  | ||||||
|  |  | ||||||
| """ |  | ||||||
|  |  | ||||||
| from flask_sqlalchemy import SQLAlchemy |  | ||||||
|  |  | ||||||
| from accounting import AbstractUserUtils |  | ||||||
|  |  | ||||||
| db: SQLAlchemy |  | ||||||
| """The database instance.""" |  | ||||||
| user_utils: AbstractUserUtils |  | ||||||
| """The user utilities.""" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def set_db(new_db: SQLAlchemy, new_user_utils: AbstractUserUtils) -> None: |  | ||||||
|     """Sets the database instance. |  | ||||||
|  |  | ||||||
|     :param new_db: The database instance. |  | ||||||
|     :param new_user_utils: The user utilities. |  | ||||||
|     :return: None. |  | ||||||
|     """ |  | ||||||
|     global db, user_utils |  | ||||||
|     db = new_db |  | ||||||
|     user_utils = new_user_utils |  | ||||||
| @@ -39,6 +39,17 @@ def gettext(string, **variables) -> str: | |||||||
|     return domain.gettext(string, **variables) |     return domain.gettext(string, **variables) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def pgettext(context, string, **variables) -> str: | ||||||
|  |     """A replacement of the Babel gettext() function.. | ||||||
|  |  | ||||||
|  |     :param context: The context. | ||||||
|  |     :param string: The message to translate. | ||||||
|  |     :param variables: The variable substitution. | ||||||
|  |     :return: The translated message. | ||||||
|  |     """ | ||||||
|  |     return domain.pgettext(context, string, **variables) | ||||||
|  |  | ||||||
|  |  | ||||||
| def lazy_gettext(string, **variables) -> LazyString: | def lazy_gettext(string, **variables) -> LazyString: | ||||||
|     """A replacement of the Babel lazy_gettext() function.. |     """A replacement of the Babel lazy_gettext() function.. | ||||||
|  |  | ||||||
| @@ -105,8 +116,8 @@ def __babel_js_catalog_view() -> Response: | |||||||
| def init_app(app: Flask, bp: Blueprint) -> None: | def init_app(app: Flask, bp: Blueprint) -> None: | ||||||
|     """Initializes the application. |     """Initializes the application. | ||||||
|  |  | ||||||
|     :param bp: The blueprint of the accounting application. |  | ||||||
|     :param app: The Flask application. |     :param app: The Flask application. | ||||||
|  |     :param bp: The blueprint of the accounting application. | ||||||
|     :return: None. |     :return: None. | ||||||
|     """ |     """ | ||||||
|     bp.add_url_rule("/_jstrans.js", "babel_catalog", |     bp.add_url_rule("/_jstrans.js", "babel_catalog", | ||||||
|   | |||||||
| @@ -17,18 +17,20 @@ | |||||||
| """The data models. | """The data models. | ||||||
|  |  | ||||||
| """ | """ | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| import re | import re | ||||||
| import typing as t | import typing as t | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
| import sqlalchemy as sa | import sqlalchemy as sa | ||||||
| from flask import current_app | from flask import current_app | ||||||
| from flask_babel import get_locale | from flask_babel import get_locale | ||||||
| from sqlalchemy import text | from sqlalchemy import text | ||||||
|  |  | ||||||
| from accounting.database import db, user_utils | from accounting import db | ||||||
|  | from accounting.locale import gettext | ||||||
| user_cls: db.Model = user_utils.cls | from accounting.utils.user import user_cls, user_pk_column | ||||||
| user_pk_column: db.Column = user_utils.pk_column |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class BaseAccount(db.Model): | class BaseAccount(db.Model): | ||||||
| @@ -66,13 +68,23 @@ class BaseAccount(db.Model): | |||||||
|                 return l10n.title |                 return l10n.title | ||||||
|         return self.title_l10n |         return self.title_l10n | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def query_values(self) -> list[str]: | ||||||
|  |         """Returns the values to be queried. | ||||||
|  |  | ||||||
|  |         :return: The values to be queried. | ||||||
|  |         """ | ||||||
|  |         return [self.code, self.title_l10n] + [x.title for x in self.l10n] | ||||||
|  |  | ||||||
|  |  | ||||||
| class BaseAccountL10n(db.Model): | class BaseAccountL10n(db.Model): | ||||||
|     """A localized base account title.""" |     """A localized base account title.""" | ||||||
|     __tablename__ = "accounting_base_accounts_l10n" |     __tablename__ = "accounting_base_accounts_l10n" | ||||||
|     """The table name.""" |     """The table name.""" | ||||||
|     account_code = db.Column(db.String, db.ForeignKey(BaseAccount.code, |     account_code = db.Column(db.String, | ||||||
|                                                       ondelete="CASCADE"), |                              db.ForeignKey(BaseAccount.code, | ||||||
|  |                                            onupdate="CASCADE", | ||||||
|  |                                            ondelete="CASCADE"), | ||||||
|                              nullable=False, primary_key=True) |                              nullable=False, primary_key=True) | ||||||
|     """The code of the account.""" |     """The code of the account.""" | ||||||
|     account = db.relationship(BaseAccount, back_populates="l10n") |     account = db.relationship(BaseAccount, back_populates="l10n") | ||||||
| @@ -87,10 +99,12 @@ class Account(db.Model): | |||||||
|     """An account.""" |     """An account.""" | ||||||
|     __tablename__ = "accounting_accounts" |     __tablename__ = "accounting_accounts" | ||||||
|     """The table name.""" |     """The table name.""" | ||||||
|     id = db.Column(db.Integer, nullable=False, primary_key=True) |     id = db.Column(db.Integer, nullable=False, primary_key=True, | ||||||
|  |                    autoincrement=False) | ||||||
|     """The account ID.""" |     """The account ID.""" | ||||||
|     base_code = db.Column(db.String, db.ForeignKey(BaseAccount.code, |     base_code = db.Column(db.String, | ||||||
|                                                    ondelete="CASCADE"), |                           db.ForeignKey(BaseAccount.code, onupdate="CASCADE", | ||||||
|  |                                         ondelete="CASCADE"), | ||||||
|                           nullable=False) |                           nullable=False) | ||||||
|     """The code of the base account.""" |     """The code of the base account.""" | ||||||
|     base = db.relationship(BaseAccount, back_populates="accounts") |     base = db.relationship(BaseAccount, back_populates="accounts") | ||||||
| @@ -99,12 +113,14 @@ class Account(db.Model): | |||||||
|     """The account number under the base account.""" |     """The account number under the base account.""" | ||||||
|     title_l10n = db.Column("title", db.String, nullable=False) |     title_l10n = db.Column("title", db.String, nullable=False) | ||||||
|     """The title.""" |     """The title.""" | ||||||
|     is_offset_needed = db.Column(db.Boolean, nullable=False, default=False) |     is_pay_off_needed = db.Column(db.Boolean, nullable=False, default=False) | ||||||
|     """Whether the entries of this account need offsets.""" |     """Whether the entries of this account need pay-off.""" | ||||||
|     created_at = db.Column(db.DateTime(timezone=True), nullable=False, |     created_at = db.Column(db.DateTime(timezone=True), nullable=False, | ||||||
|                            server_default=db.func.now()) |                            server_default=db.func.now()) | ||||||
|     """The time of creation.""" |     """The time of creation.""" | ||||||
|     created_by_id = db.Column(db.Integer, db.ForeignKey(user_pk_column), |     created_by_id = db.Column(db.Integer, | ||||||
|  |                               db.ForeignKey(user_pk_column, | ||||||
|  |                                             onupdate="CASCADE"), | ||||||
|                               nullable=False) |                               nullable=False) | ||||||
|     """The ID of the creator.""" |     """The ID of the creator.""" | ||||||
|     created_by = db.relationship(user_cls, foreign_keys=created_by_id) |     created_by = db.relationship(user_cls, foreign_keys=created_by_id) | ||||||
| @@ -112,7 +128,9 @@ class Account(db.Model): | |||||||
|     updated_at = db.Column(db.DateTime(timezone=True), nullable=False, |     updated_at = db.Column(db.DateTime(timezone=True), nullable=False, | ||||||
|                            server_default=db.func.now()) |                            server_default=db.func.now()) | ||||||
|     """The time of last update.""" |     """The time of last update.""" | ||||||
|     updated_by_id = db.Column(db.Integer, db.ForeignKey(user_pk_column), |     updated_by_id = db.Column(db.Integer, | ||||||
|  |                               db.ForeignKey(user_pk_column, | ||||||
|  |                                             onupdate="CASCADE"), | ||||||
|                               nullable=False) |                               nullable=False) | ||||||
|     """The ID of the updator.""" |     """The ID of the updator.""" | ||||||
|     updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) |     updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) | ||||||
| @@ -120,7 +138,8 @@ class Account(db.Model): | |||||||
|     l10n = db.relationship("AccountL10n", back_populates="account", |     l10n = db.relationship("AccountL10n", back_populates="account", | ||||||
|                            lazy=False) |                            lazy=False) | ||||||
|     """The localized titles.""" |     """The localized titles.""" | ||||||
|     db.UniqueConstraint(base_code, no) |     entries = db.relationship("JournalEntry", back_populates="account") | ||||||
|  |     """The journal entries.""" | ||||||
|  |  | ||||||
|     __CASH = "1111-001" |     __CASH = "1111-001" | ||||||
|     """The code of the cash account,""" |     """The code of the cash account,""" | ||||||
| @@ -182,18 +201,35 @@ class Account(db.Model): | |||||||
|             if l10n.locale == current_locale: |             if l10n.locale == current_locale: | ||||||
|                 l10n.title = value |                 l10n.title = value | ||||||
|                 return |                 return | ||||||
|         self.l10n.append(AccountL10n( |         self.l10n.append(AccountL10n(locale=current_locale, title=value)) | ||||||
|             locale=current_locale, title=value)) |  | ||||||
|  |     @property | ||||||
|  |     def is_in_use(self) -> bool: | ||||||
|  |         """Returns whether the account is in use. | ||||||
|  |  | ||||||
|  |         :return: True if the account is in use, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         if not hasattr(self, "__is_in_use"): | ||||||
|  |             setattr(self, "__is_in_use", len(self.entries) > 0) | ||||||
|  |         return getattr(self, "__is_in_use") | ||||||
|  |  | ||||||
|  |     @is_in_use.setter | ||||||
|  |     def is_in_use(self, is_in_use: bool) -> None: | ||||||
|  |         """Sets whether the account is in use. | ||||||
|  |  | ||||||
|  |         :param is_in_use: True if the account is in use, or False otherwise. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         setattr(self, "__is_in_use", is_in_use) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def find_by_code(cls, code: str) -> t.Self | None: |     def find_by_code(cls, code: str) -> t.Self | None: | ||||||
|         """Finds an accounting account by its code. |         """Finds an account by its code. | ||||||
|  |  | ||||||
|         :param code: The code. |         :param code: The code. | ||||||
|         :return: The accounting account, or None if this account does not |         :return: The account, or None if this account does not exist. | ||||||
|             exist. |  | ||||||
|         """ |         """ | ||||||
|         m = re.match("^([1-9]{4})-([0-9]{3})$", code) |         m = re.match(r"^([1-9]{4})-(\d{3})$", code) | ||||||
|         if m is None: |         if m is None: | ||||||
|             return None |             return None | ||||||
|         return cls.query.filter(cls.base_code == m.group(1), |         return cls.query.filter(cls.base_code == m.group(1), | ||||||
| @@ -240,6 +276,14 @@ class Account(db.Model): | |||||||
|                                 cls.base_code != "3353")\ |                                 cls.base_code != "3353")\ | ||||||
|             .order_by(cls.base_code, cls.no).all() |             .order_by(cls.base_code, cls.no).all() | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def query_values(self) -> list[str]: | ||||||
|  |         """Returns the values to be queried. | ||||||
|  |  | ||||||
|  |         :return: The values to be queried. | ||||||
|  |         """ | ||||||
|  |         return [self.code, self.title_l10n] + [x.title for x in self.l10n] | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def cash(cls) -> t.Self: |     def cash(cls) -> t.Self: | ||||||
|         """Returns the cash account. |         """Returns the cash account. | ||||||
| @@ -288,8 +332,21 @@ class Account(db.Model): | |||||||
|         """ |         """ | ||||||
|         return cls.find_by_code(cls.__NET_CHANGE) |         return cls.find_by_code(cls.__NET_CHANGE) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_modified(self) -> bool: | ||||||
|  |         """Returns whether a product account was modified. | ||||||
|  |  | ||||||
|  |         :return: True if modified, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         if db.session.is_modified(self): | ||||||
|  |             return True | ||||||
|  |         for l10n in self.l10n: | ||||||
|  |             if db.session.is_modified(l10n): | ||||||
|  |                 return True | ||||||
|  |         return False | ||||||
|  |  | ||||||
|     def delete(self) -> None: |     def delete(self) -> None: | ||||||
|         """Deletes this accounting account. |         """Deletes this account. | ||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
| @@ -301,10 +358,342 @@ class Account(db.Model): | |||||||
| class AccountL10n(db.Model): | class AccountL10n(db.Model): | ||||||
|     """A localized account title.""" |     """A localized account title.""" | ||||||
|     __tablename__ = "accounting_accounts_l10n" |     __tablename__ = "accounting_accounts_l10n" | ||||||
|     account_id = db.Column(db.Integer, db.ForeignKey(Account.id, |     """The table name.""" | ||||||
|                                                      ondelete="CASCADE"), |     account_id = db.Column(db.Integer, | ||||||
|  |                            db.ForeignKey(Account.id, onupdate="CASCADE", | ||||||
|  |                                          ondelete="CASCADE"), | ||||||
|                            nullable=False, primary_key=True) |                            nullable=False, primary_key=True) | ||||||
|  |     """The account ID.""" | ||||||
|     account = db.relationship(Account, back_populates="l10n") |     account = db.relationship(Account, back_populates="l10n") | ||||||
|  |     """The account.""" | ||||||
|     locale = db.Column(db.String, nullable=False, primary_key=True) |     locale = db.Column(db.String, nullable=False, primary_key=True) | ||||||
|  |     """The locale.""" | ||||||
|     title = db.Column(db.String, nullable=False) |     title = db.Column(db.String, nullable=False) | ||||||
|     db.UniqueConstraint(account_id, locale) |     """The localized title.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Currency(db.Model): | ||||||
|  |     """A currency.""" | ||||||
|  |     __tablename__ = "accounting_currencies" | ||||||
|  |     """The table name.""" | ||||||
|  |     code = db.Column(db.String, nullable=False, primary_key=True) | ||||||
|  |     """The code.""" | ||||||
|  |     name_l10n = db.Column("name", db.String, nullable=False) | ||||||
|  |     """The name.""" | ||||||
|  |     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.""" | ||||||
|  |     l10n = db.relationship("CurrencyL10n", back_populates="currency", | ||||||
|  |                            lazy=False) | ||||||
|  |     """The localized names.""" | ||||||
|  |     entries = db.relationship("JournalEntry", back_populates="currency") | ||||||
|  |     """The journal entries.""" | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         """Returns the string representation of the currency. | ||||||
|  |  | ||||||
|  |         :return: The string representation of the currency. | ||||||
|  |         """ | ||||||
|  |         return F"{self.name} ({self.code})" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def name(self) -> str: | ||||||
|  |         """Returns the name in the current locale. | ||||||
|  |  | ||||||
|  |         :return: The name in the current locale. | ||||||
|  |         """ | ||||||
|  |         current_locale = str(get_locale()) | ||||||
|  |         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: | ||||||
|  |             return self.name_l10n | ||||||
|  |         for l10n in self.l10n: | ||||||
|  |             if l10n.locale == current_locale: | ||||||
|  |                 return l10n.name | ||||||
|  |         return self.name_l10n | ||||||
|  |  | ||||||
|  |     @name.setter | ||||||
|  |     def name(self, value: str) -> None: | ||||||
|  |         """Sets the name in the current locale. | ||||||
|  |  | ||||||
|  |         :param value: The new name. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         if self.name_l10n is None: | ||||||
|  |             self.name_l10n = value | ||||||
|  |             return | ||||||
|  |         current_locale = str(get_locale()) | ||||||
|  |         if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]: | ||||||
|  |             self.name_l10n = value | ||||||
|  |             return | ||||||
|  |         for l10n in self.l10n: | ||||||
|  |             if l10n.locale == current_locale: | ||||||
|  |                 l10n.name = value | ||||||
|  |                 return | ||||||
|  |         self.l10n.append(CurrencyL10n(locale=current_locale, name=value)) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_modified(self) -> bool: | ||||||
|  |         """Returns whether a product account was modified. | ||||||
|  |  | ||||||
|  |         :return: True if modified, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         if db.session.is_modified(self): | ||||||
|  |             return True | ||||||
|  |         for l10n in self.l10n: | ||||||
|  |             if db.session.is_modified(l10n): | ||||||
|  |                 return True | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     def delete(self) -> None: | ||||||
|  |         """Deletes the currency. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete() | ||||||
|  |         cls: t.Type[t.Self] = self.__class__ | ||||||
|  |         cls.query.filter(cls.code == self.code).delete() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CurrencyL10n(db.Model): | ||||||
|  |     """A localized currency name.""" | ||||||
|  |     __tablename__ = "accounting_currencies_l10n" | ||||||
|  |     """The table name.""" | ||||||
|  |     currency_code = db.Column(db.String, | ||||||
|  |                               db.ForeignKey(Currency.code, onupdate="CASCADE", | ||||||
|  |                                             ondelete="CASCADE"), | ||||||
|  |                               nullable=False, primary_key=True) | ||||||
|  |     """The currency code.""" | ||||||
|  |     currency = db.relationship(Currency, back_populates="l10n") | ||||||
|  |     """The currency.""" | ||||||
|  |     locale = db.Column(db.String, nullable=False, primary_key=True) | ||||||
|  |     """The locale.""" | ||||||
|  |     name = db.Column(db.String, nullable=False) | ||||||
|  |     """The localized name.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransactionCurrency: | ||||||
|  |     """A currency in a transaction.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, code: str, debit: list[JournalEntry], | ||||||
|  |                  credit: list[JournalEntry]): | ||||||
|  |         """Constructs the currency in the transaction. | ||||||
|  |  | ||||||
|  |         :param code: The currency code. | ||||||
|  |         :param debit: The debit entries. | ||||||
|  |         :param credit: The credit entries. | ||||||
|  |         """ | ||||||
|  |         self.code: str = code | ||||||
|  |         """The currency code.""" | ||||||
|  |         self.debit: list[JournalEntry] = debit | ||||||
|  |         """The debit entries.""" | ||||||
|  |         self.credit: list[JournalEntry] = credit | ||||||
|  |         """The credit entries.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def name(self) -> str: | ||||||
|  |         """Returns the currency name. | ||||||
|  |  | ||||||
|  |         :return: The currency name. | ||||||
|  |         """ | ||||||
|  |         return db.session.get(Currency, self.code).name | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def debit_total(self) -> Decimal: | ||||||
|  |         """Returns the total amount of the debit journal entries. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the debit journal entries. | ||||||
|  |         """ | ||||||
|  |         return sum([x.amount for x in self.debit]) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def credit_total(self) -> str: | ||||||
|  |         """Returns the total amount of the credit journal entries. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the credit journal entries. | ||||||
|  |         """ | ||||||
|  |         return sum([x.amount for x in self.credit]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Transaction(db.Model): | ||||||
|  |     """A transaction.""" | ||||||
|  |     __tablename__ = "accounting_transactions" | ||||||
|  |     """The table name.""" | ||||||
|  |     id = db.Column(db.Integer, nullable=False, primary_key=True, | ||||||
|  |                    autoincrement=False) | ||||||
|  |     """The transaction ID.""" | ||||||
|  |     date = db.Column(db.Date, nullable=False) | ||||||
|  |     """The date.""" | ||||||
|  |     no = db.Column(db.Integer, nullable=False, default=text("1")) | ||||||
|  |     """The account number under the date.""" | ||||||
|  |     note = db.Column(db.String) | ||||||
|  |     """The note.""" | ||||||
|  |     created_at = db.Column(db.DateTime(timezone=True), nullable=False, | ||||||
|  |                            server_default=db.func.now()) | ||||||
|  |     """The time of creation.""" | ||||||
|  |     created_by_id = db.Column(db.Integer, | ||||||
|  |                               db.ForeignKey(user_pk_column, | ||||||
|  |                                             onupdate="CASCADE"), | ||||||
|  |                               nullable=False) | ||||||
|  |     """The ID of the creator.""" | ||||||
|  |     created_by = db.relationship(user_cls, foreign_keys=created_by_id) | ||||||
|  |     """The creator.""" | ||||||
|  |     updated_at = db.Column(db.DateTime(timezone=True), nullable=False, | ||||||
|  |                            server_default=db.func.now()) | ||||||
|  |     """The time of last update.""" | ||||||
|  |     updated_by_id = db.Column(db.Integer, | ||||||
|  |                               db.ForeignKey(user_pk_column, | ||||||
|  |                                             onupdate="CASCADE"), | ||||||
|  |                               nullable=False) | ||||||
|  |     """The ID of the updator.""" | ||||||
|  |     updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) | ||||||
|  |     """The updator.""" | ||||||
|  |     entries = db.relationship("JournalEntry", back_populates="transaction") | ||||||
|  |     """The journal entries.""" | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         """Returns the string representation of this transaction. | ||||||
|  |  | ||||||
|  |         :return: The string representation of this transaction. | ||||||
|  |         """ | ||||||
|  |         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) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def currencies(self) -> list[TransactionCurrency]: | ||||||
|  |         """Returns the journal entries categorized by their currencies. | ||||||
|  |  | ||||||
|  |         :return: The currency categories. | ||||||
|  |         """ | ||||||
|  |         entries: list[JournalEntry] = sorted(self.entries, 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]) | ||||||
|  |                 for x in codes] | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_cash_income(self) -> bool: | ||||||
|  |         """Returns whether this is a cash income transaction. | ||||||
|  |  | ||||||
|  |         :return: True if this is a cash income transaction, or False otherwise. | ||||||
|  |         """ | ||||||
|  |         for currency in self.currencies: | ||||||
|  |             if len(currency.debit) > 1: | ||||||
|  |                 return False | ||||||
|  |             if currency.debit[0].account.code != "1111-001": | ||||||
|  |                 return False | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def is_cash_expense(self) -> bool: | ||||||
|  |         """Returns whether this is a cash expense transaction. | ||||||
|  |  | ||||||
|  |         :return: True if this is a cash expense transaction, or False | ||||||
|  |             otherwise. | ||||||
|  |         """ | ||||||
|  |         for currency in self.currencies: | ||||||
|  |             if len(currency.credit) > 1: | ||||||
|  |                 return False | ||||||
|  |             if currency.credit[0].account.code != "1111-001": | ||||||
|  |                 return False | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     def delete(self) -> None: | ||||||
|  |         """Deletes the transaction. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         JournalEntry.query\ | ||||||
|  |             .filter(JournalEntry.transaction_id == self.id).delete() | ||||||
|  |         db.session.delete(self) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class JournalEntry(db.Model): | ||||||
|  |     """An accounting journal entry.""" | ||||||
|  |     __tablename__ = "accounting_journal_entries" | ||||||
|  |     """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.""" | ||||||
|  |     is_debit = db.Column(db.Boolean, nullable=False) | ||||||
|  |     """True for a debit entry, or False for a credit entry.""" | ||||||
|  |     no = db.Column(db.Integer, nullable=False) | ||||||
|  |     """The entry number under the transaction and debit or credit.""" | ||||||
|  |     pay_off_target_id = db.Column(db.Integer, | ||||||
|  |                                   db.ForeignKey(id, onupdate="CASCADE"), | ||||||
|  |                                   nullable=True) | ||||||
|  |     """The ID of the pay-off target entry.""" | ||||||
|  |     pay_off_target = db.relationship("JournalEntry", back_populates="pay_off", | ||||||
|  |                                      remote_side=id, passive_deletes=True) | ||||||
|  |     """The pay-off target entry.""" | ||||||
|  |     pay_off = db.relationship("JournalEntry", back_populates="pay_off_target") | ||||||
|  |     """The pay-off entries.""" | ||||||
|  |     currency_code = db.Column(db.String, | ||||||
|  |                               db.ForeignKey(Currency.code, onupdate="CASCADE"), | ||||||
|  |                               nullable=False) | ||||||
|  |     """The currency code.""" | ||||||
|  |     currency = db.relationship(Currency, back_populates="entries") | ||||||
|  |     """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) | ||||||
|  |     """The account.""" | ||||||
|  |     summary = db.Column(db.String, nullable=True) | ||||||
|  |     """The summary.""" | ||||||
|  |     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. | ||||||
|  |  | ||||||
|  |         :return: The journal entry ID. | ||||||
|  |         """ | ||||||
|  |         return self.id | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def account_code(self) -> str: | ||||||
|  |         """Returns the account code. | ||||||
|  |  | ||||||
|  |         :return: The account code. | ||||||
|  |         """ | ||||||
|  |         return self.account.code | ||||||
|   | |||||||
| @@ -21,48 +21,101 @@ | |||||||
|  * First written: 2023/2/1 |  * First written: 2023/2/1 | ||||||
|  */ |  */ | ||||||
|  |  | ||||||
| .clickable { | .accounting-clickable { | ||||||
|     cursor: pointer; |     cursor: pointer; | ||||||
| } | } | ||||||
| .btn-group .btn .search-input { | .btn-group .btn .accounting-search-input { | ||||||
|     min-height: calc(1em + .5rem + 2px); |     min-height: calc(1em + .5rem + 2px); | ||||||
|     padding: 0 0.5rem; |     padding: 0 0.5rem; | ||||||
| } | } | ||||||
| .btn-group .btn .search-label button { | .btn-group .btn .accounting-search-label button { | ||||||
|     border: none; |     border: none; | ||||||
|     background-color: transparent; |     background-color: transparent; | ||||||
|     color: inherit; |     color: inherit; | ||||||
|     padding-right: 0; |     padding-right: 0; | ||||||
| } | } | ||||||
|  | .form-floating > textarea.form-control { | ||||||
|  |     height: 6rem; | ||||||
|  | } | ||||||
|  | .accounting-dragged { | ||||||
|  |     color: #141619; | ||||||
|  |     background-color: #D3D3D4; | ||||||
|  | } | ||||||
|  |  | ||||||
| /** The account management */ | /** The card layout */ | ||||||
| .account { | .accounting-card { | ||||||
|     padding: 2em 1.5em; |     padding: 2em 1.5em; | ||||||
|     margin: 1em; |     margin: 1em; | ||||||
|     background-color: #E9ECEF; |     background-color: #E9ECEF; | ||||||
|     border-radius: 0.3em; |     border-radius: 0.3em; | ||||||
|     box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); |     box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); | ||||||
| } | } | ||||||
| .account .account-title { | .accounting-card-title { | ||||||
|     font-size: 1.8rem; |     font-size: 1.8rem; | ||||||
|     font-weight: bolder; |     font-weight: bolder; | ||||||
| } | } | ||||||
| .account .account-code { | .accounting-card-code { | ||||||
|     font-size: 1.4rem; |     font-size: 1.4rem; | ||||||
|     color: #373b3e; |     color: #373b3e; | ||||||
| } | } | ||||||
| .list-base-selector { |  | ||||||
|  | /** The option selector */ | ||||||
|  | .accounting-selector-list { | ||||||
|     height: 20rem; |     height: 20rem; | ||||||
|     overflow-y: scroll; |     overflow-y: scroll; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /** The transaction management */ | ||||||
|  | .accounting-currency-control { | ||||||
|  |     background-color: transparent; | ||||||
|  | } | ||||||
|  | .accounting-currency-content { | ||||||
|  |     width: calc(100% - 3rem); | ||||||
|  | } | ||||||
|  | .accounting-entry-content { | ||||||
|  |     width: calc(100% - 3rem); | ||||||
|  |     background-color: transparent; | ||||||
|  | } | ||||||
|  | .accounting-entry-control { | ||||||
|  |     border-color: transparent; | ||||||
|  | } | ||||||
|  | .accounting-transaction-card { | ||||||
|  |     padding: 2em 1.5em; | ||||||
|  |     margin: 1em; | ||||||
|  |     background-color: #F8F9FA; | ||||||
|  |     box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); | ||||||
|  | } | ||||||
|  | .accounting-transaction-card h2 { | ||||||
|  |     border-bottom: thick double slategray; | ||||||
|  | } | ||||||
|  | .accounting-list-group-stripped .list-group-item:nth-child(2n+1) { | ||||||
|  |     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 { | ||||||
|  |     border: none; | ||||||
|  | } | ||||||
|  | .accounting-transaction-entry-header { | ||||||
|  |     font-weight: bolder; | ||||||
|  |     border-bottom: thick double slategray; | ||||||
|  | } | ||||||
|  | .list-group-item.accounting-transaction-entry-total { | ||||||
|  |     font-weight: bolder; | ||||||
|  |     border-top: thick double slategray; | ||||||
|  | } | ||||||
|  |  | ||||||
| /* The Material Design text field (floating form control in Bootstrap) */ | /* The Material Design text field (floating form control in Bootstrap) */ | ||||||
| .material-text-field { | .accounting-material-text-field { | ||||||
|     position: relative; |     position: relative; | ||||||
|     min-height: calc(3.5rem + 2px); |     min-height: calc(3.5rem + 2px); | ||||||
|     padding-top: 1.625rem; |     padding-top: 1.625rem; | ||||||
| } | } | ||||||
| .material-text-field > .form-label { | .accounting-material-text-field > .form-label { | ||||||
|     position: absolute; |     position: absolute; | ||||||
|     top: 0; |     top: 0; | ||||||
|     left: 0; |     left: 0; | ||||||
| @@ -71,27 +124,57 @@ | |||||||
|     transform-origin: 0 0; |     transform-origin: 0 0; | ||||||
|     transition: opacity .1s ease-in-out,transform .1s ease-in-out; |     transition: opacity .1s ease-in-out,transform .1s ease-in-out; | ||||||
| } | } | ||||||
| .material-text-field.not-empty > .form-label { | .accounting-material-text-field.accounting-not-empty > .form-label { | ||||||
|     opacity: 0.65; |     opacity: 0.65; | ||||||
|     transform: scale(.85) translateY(-.5rem) translateX(.15rem); |     transform: scale(.85) translateY(-.5rem) translateX(.15rem); | ||||||
| } | } | ||||||
|  |  | ||||||
| /* The Material Design floating action buttons */ | /* The Material Design floating action buttons */ | ||||||
| .material-fab { | .accounting-material-fab { | ||||||
|     position: fixed; |     position: fixed; | ||||||
|     right: 2rem; |     right: 2rem; | ||||||
|     bottom: 1rem; |     bottom: 1rem; | ||||||
|     z-index: 10; |     z-index: 10; | ||||||
|     flex-direction: column-reverse; |     flex-direction: column-reverse; | ||||||
| } | } | ||||||
| .material-fab .btn { | .accounting-material-fab .btn { | ||||||
|     border-radius: 50%; |     border-radius: 50%; | ||||||
|     transform: scale(1.5); |     transform: scale(1.5); | ||||||
|     box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12); |     box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12); | ||||||
|     display: block; |     display: block; | ||||||
|     margin-top: 2.5rem; |     margin-top: 2.5rem; | ||||||
| } | } | ||||||
| .material-fab .btn:hover, .material-fab .btn:focus { | .accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus { | ||||||
|  |     box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12); | ||||||
|  | } | ||||||
|  | .accounting-btn-material-fab { | ||||||
|  |     transition: transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out; | ||||||
|  | } | ||||||
|  | .show .accounting-btn-material-fab { | ||||||
|  |     transform: scale(1.5) rotate(-45deg); | ||||||
|  | } | ||||||
|  | .accounting-material-fab-speed-dial-group { | ||||||
|  |     position: absolute; | ||||||
|  |     right: -2rem; | ||||||
|  |     bottom: -7rem; | ||||||
|  |     text-align: right; | ||||||
|  |     opacity: 0; | ||||||
|  |     transform: scale(0.1); | ||||||
|  |     line-height: 5.5rem; | ||||||
|  |     transition: opacity .1s ease-in-out, transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out; | ||||||
|  | } | ||||||
|  | .show .accounting-material-fab-speed-dial-group { | ||||||
|  |     opacity: 1; | ||||||
|  |     transform: scale(0.6); | ||||||
|  |     right: -0.5rem; | ||||||
|  |     bottom: 0.7rem; | ||||||
|  | } | ||||||
|  | .accounting-material-fab-speed-dial-group .btn { | ||||||
|  |     background-color: white; | ||||||
|  |     white-space: nowrap; | ||||||
|  |     box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12); | ||||||
|  | } | ||||||
|  | .accounting-material-fab-speed-dial-group .btn:hover, .accounting-material-fab-speed-dial-group .btn:focus { | ||||||
|     box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12); |     box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -23,12 +23,12 @@ | |||||||
|  |  | ||||||
| // Initializes the page JavaScript. | // Initializes the page JavaScript. | ||||||
| document.addEventListener("DOMContentLoaded", function () { | document.addEventListener("DOMContentLoaded", function () { | ||||||
|     initializeBaseAccountSelector() |     initializeBaseAccountSelector(); | ||||||
|     document.getElementById("account-base-code") |     document.getElementById("accounting-base-code") | ||||||
|         .onchange = validateBase; |         .onchange = validateBase; | ||||||
|     document.getElementById("account-title") |     document.getElementById("accounting-title") | ||||||
|         .onchange = validateTitle; |         .onchange = validateTitle; | ||||||
|     document.getElementById("account-form") |     document.getElementById("accounting-form") | ||||||
|         .onsubmit = validateForm; |         .onsubmit = validateForm; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @@ -38,25 +38,25 @@ document.addEventListener("DOMContentLoaded", function () { | |||||||
|  * @private |  * @private | ||||||
|  */ |  */ | ||||||
| function initializeBaseAccountSelector() { | function initializeBaseAccountSelector() { | ||||||
|     const selector = document.getElementById("select-base-modal"); |     const selector = document.getElementById("accounting-base-selector-modal"); | ||||||
|     const base = document.getElementById("account-base"); |     const base = document.getElementById("accounting-base"); | ||||||
|     const baseCode = document.getElementById("account-base-code"); |     const baseCode = document.getElementById("accounting-base-code"); | ||||||
|     const baseContent = document.getElementById("account-base-content"); |     const baseContent = document.getElementById("accounting-base-content"); | ||||||
|     const options = Array.from(document.getElementsByClassName("list-group-item-base")); |     const options = Array.from(document.getElementsByClassName("accounting-base-option")); | ||||||
|     const btnClear = document.getElementById("btn-clear-base"); |     const btnClear = document.getElementById("accounting-btn-clear-base"); | ||||||
|     selector.addEventListener("show.bs.modal", function () { |     selector.addEventListener("show.bs.modal", function () { | ||||||
|         base.classList.add("not-empty"); |         base.classList.add("accounting-not-empty"); | ||||||
|         options.forEach(function (item) { |         options.forEach(function (item) { | ||||||
|             item.classList.remove("active"); |             item.classList.remove("active"); | ||||||
|         }); |         }); | ||||||
|         const selected = document.getElementById("list-group-item-base-" + baseCode.value); |         const selected = document.getElementById("accounting-base-option-" + baseCode.value); | ||||||
|         if (selected !== null) { |         if (selected !== null) { | ||||||
|             selected.classList.add("active"); |             selected.classList.add("active"); | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|     selector.addEventListener("hidden.bs.modal", function () { |     selector.addEventListener("hidden.bs.modal", function () { | ||||||
|         if (baseCode.value === "") { |         if (baseCode.value === "") { | ||||||
|             base.classList.remove("not-empty"); |             base.classList.remove("accounting-not-empty"); | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|     options.forEach(function (option) { |     options.forEach(function (option) { | ||||||
| @@ -79,6 +79,53 @@ function initializeBaseAccountSelector() { | |||||||
|         validateBase(); |         validateBase(); | ||||||
|         bootstrap.Modal.getInstance(selector).hide(); |         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", function () { | ||||||
|  |         if (query.value === "") { | ||||||
|  |             options.forEach(function (option) { | ||||||
|  |                 option.classList.remove("d-none"); | ||||||
|  |             }); | ||||||
|  |             optionList.classList.remove("d-none"); | ||||||
|  |             queryNoResult.classList.add("d-none"); | ||||||
|  |             return | ||||||
|  |         } | ||||||
|  |         let hasAnyMatched = false; | ||||||
|  |         options.forEach(function (option) { | ||||||
|  |             const queryValues = JSON.parse(option.dataset.queryValues); | ||||||
|  |             let isMatched = false; | ||||||
|  |             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"); | ||||||
|  |         } else { | ||||||
|  |             optionList.classList.remove("d-none"); | ||||||
|  |             queryNoResult.classList.add("d-none"); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -101,9 +148,9 @@ function validateForm() { | |||||||
|  * @private |  * @private | ||||||
|  */ |  */ | ||||||
| function validateBase() { | function validateBase() { | ||||||
|     const field = document.getElementById("account-base-code"); |     const field = document.getElementById("accounting-base-code"); | ||||||
|     const error = document.getElementById("account-base-code-error"); |     const error = document.getElementById("accounting-base-code-error"); | ||||||
|     const displayField = document.getElementById("account-base"); |     const displayField = document.getElementById("accounting-base"); | ||||||
|     field.value = field.value.trim(); |     field.value = field.value.trim(); | ||||||
|     if (field.value === "") { |     if (field.value === "") { | ||||||
|         displayField.classList.add("is-invalid"); |         displayField.classList.add("is-invalid"); | ||||||
| @@ -122,8 +169,8 @@ function validateBase() { | |||||||
|  * @private |  * @private | ||||||
|  */ |  */ | ||||||
| function validateTitle() { | function validateTitle() { | ||||||
|     const field = document.getElementById("account-title"); |     const field = document.getElementById("accounting-title"); | ||||||
|     const error = document.getElementById("account-title-error"); |     const error = document.getElementById("accounting-title-error"); | ||||||
|     field.value = field.value.trim(); |     field.value = field.value.trim(); | ||||||
|     if (field.value === "") { |     if (field.value === "") { | ||||||
|         field.classList.add("is-invalid"); |         field.classList.add("is-invalid"); | ||||||
|   | |||||||
							
								
								
									
										39
									
								
								src/accounting/static/js/account-order.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/accounting/static/js/account-order.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * account-order.js: The JavaScript for the account order | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /*  Copyright (c) 2023 imacat. | ||||||
|  |  * | ||||||
|  |  *  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  *  you may not use this file except in compliance with the License. | ||||||
|  |  *  You may obtain a copy of the License at | ||||||
|  |  * | ||||||
|  |  *      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  * | ||||||
|  |  *  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  *  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  *  WITHOUT 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/2 | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | // Initializes the page JavaScript. | ||||||
|  | document.addEventListener("DOMContentLoaded", function () { | ||||||
|  |     const list = document.getElementById("accounting-order-list"); | ||||||
|  |     if (list !== null) { | ||||||
|  |         const onReorder = function () { | ||||||
|  |             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"); | ||||||
|  |                 no.value = String(i + 1); | ||||||
|  |                 code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3); | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |         initializeDragAndDropReordering(list, onReorder); | ||||||
|  |     } | ||||||
|  | }); | ||||||
							
								
								
									
										174
									
								
								src/accounting/static/js/currency-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								src/accounting/static/js/currency-form.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,174 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * currency-form.js: The JavaScript for the currency 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/6 | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | // Initializes the page JavaScript. | ||||||
|  | document.addEventListener("DOMContentLoaded", function () { | ||||||
|  |     document.getElementById("accounting-code") | ||||||
|  |         .onchange = validateCode; | ||||||
|  |     document.getElementById("accounting-name") | ||||||
|  |         .onchange = validateName; | ||||||
|  |     document.getElementById("accounting-form") | ||||||
|  |         .onsubmit = validateForm; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 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. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function submitFormIfAllAsyncValid() { | ||||||
|  |     let isValid = true; | ||||||
|  |     Object.keys(isAsyncValid).forEach(function (key) { | ||||||
|  |         isValid = isAsyncValid[key] && isValid; | ||||||
|  |     }); | ||||||
|  |     if (isValid) { | ||||||
|  |         document.getElementById("accounting-form").submit() | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 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; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 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; | ||||||
|  |                 } | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |             field.classList.remove("is-invalid"); | ||||||
|  |             error.innerText = ""; | ||||||
|  |             if (isSubmission) { | ||||||
|  |                 isAsyncValid[key] = true; | ||||||
|  |                 submitFormIfAllAsyncValid(); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |     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; | ||||||
|  |     } | ||||||
|  |     field.classList.remove("is-invalid"); | ||||||
|  |     error.innerText = ""; | ||||||
|  |     return true; | ||||||
|  | } | ||||||
							
								
								
									
										108
									
								
								src/accounting/static/js/drag-and-drop-reorder.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								src/accounting/static/js/drag-and-drop-reorder.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,108 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /*  Copyright (c) 2023 imacat. | ||||||
|  |  * | ||||||
|  |  *  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  *  you may not use this file except in compliance with the License. | ||||||
|  |  *  You may obtain a copy of the License at | ||||||
|  |  * | ||||||
|  |  *      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  * | ||||||
|  |  *  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  *  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  *  WITHOUT 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/3 | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the drag-and-drop reordering on a list. | ||||||
|  |  * | ||||||
|  |  * @param list {HTMLElement} the list to be reordered | ||||||
|  |  * @param onReorder {(function())|*} The callback to reorder the items | ||||||
|  |  */ | ||||||
|  | function initializeDragAndDropReordering(list, onReorder) { | ||||||
|  |     initializeMouseDragAndDropReordering(list, onReorder); | ||||||
|  |     initializeTouchDragAndDropReordering(list, onReorder); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the drag-and-drop reordering with mouse. | ||||||
|  |  * | ||||||
|  |  * @param list {HTMLElement} the list to be reordered | ||||||
|  |  * @param onReorder {(function())|*} The callback to reorder the items | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function initializeMouseDragAndDropReordering(list, onReorder) { | ||||||
|  |     const items = Array.from(list.children); | ||||||
|  |     let dragged = null; | ||||||
|  |     items.forEach(function (item) { | ||||||
|  |         item.draggable = true; | ||||||
|  |         item.addEventListener("dragstart", function () { | ||||||
|  |             dragged = item; | ||||||
|  |             dragged.classList.add("accounting-dragged"); | ||||||
|  |         }); | ||||||
|  |         item.addEventListener("dragover", function () { | ||||||
|  |             onDragOver(dragged, item); | ||||||
|  |             onReorder(); | ||||||
|  |         }); | ||||||
|  |         item.addEventListener("dragend", function () { | ||||||
|  |             dragged.classList.remove("accounting-dragged"); | ||||||
|  |             dragged = null; | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the drag-and-drop reordering with touch devices. | ||||||
|  |  * | ||||||
|  |  * @param list {HTMLElement} the list to be reordered | ||||||
|  |  * @param onReorder {(function())|*} The callback to reorder the items | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function initializeTouchDragAndDropReordering(list, onReorder) { | ||||||
|  |     const items = Array.from(list.children); | ||||||
|  |     items.forEach(function (item) { | ||||||
|  |         item.addEventListener("touchstart", function () { | ||||||
|  |             item.classList.add("accounting-dragged"); | ||||||
|  |         }); | ||||||
|  |         item.addEventListener("touchmove", function (event) { | ||||||
|  |             const touch = event.targetTouches[0]; | ||||||
|  |             const target = document.elementFromPoint(touch.pageX, touch.pageY); | ||||||
|  |             onDragOver(item, target); | ||||||
|  |             onReorder(); | ||||||
|  |         }); | ||||||
|  |         item.addEventListener("touchend", function () { | ||||||
|  |             item.classList.remove("accounting-dragged"); | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Handles when an item is dragged over the other item. | ||||||
|  |  * | ||||||
|  |  * @param dragged {Element} the item that was dragged | ||||||
|  |  * @param target {Element} the other item that was dragged over | ||||||
|  |  */ | ||||||
|  | function onDragOver(dragged, target) { | ||||||
|  |     if (dragged === null || target.parentElement !== dragged.parentElement || target === dragged) { | ||||||
|  |         return; | ||||||
|  |     } | ||||||
|  |     let isBefore = false; | ||||||
|  |     for (let p = target; p !== null; p = p.previousSibling) { | ||||||
|  |         if (p === dragged) { | ||||||
|  |             isBefore = true; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     if (isBefore) { | ||||||
|  |         target.parentElement.insertBefore(dragged, target.nextSibling); | ||||||
|  |     } else { | ||||||
|  |         target.parentElement.insertBefore(dragged, target); | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										44
									
								
								src/accounting/static/js/material-fab-speed-dial.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/accounting/static/js/material-fab-speed-dial.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * material-fab-speed-dial.js: The JavaScript for the speed dial for the material floating buttons | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /*  Copyright (c) 2023 imacat. | ||||||
|  |  * | ||||||
|  |  *  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  *  you may not use this file except in compliance with the License. | ||||||
|  |  *  You may obtain a copy of the License at | ||||||
|  |  * | ||||||
|  |  *      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  * | ||||||
|  |  *  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  *  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  |  *  See the License for the specific language governing permissions and | ||||||
|  |  *  limitations under the License. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /* Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
|  |  * First written: 2023/2/25 | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | // Initializes the page JavaScript. | ||||||
|  | document.addEventListener("DOMContentLoaded", function () { | ||||||
|  |     initializeMaterialFabSpeedDial(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the speed dial of the material floating buttons. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function initializeMaterialFabSpeedDial() { | ||||||
|  |     const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial"); | ||||||
|  |     const fab = document.getElementById(btnFab.dataset.target); | ||||||
|  |     btnFab.onclick = function () { | ||||||
|  |         if (fab.classList.contains("show")) { | ||||||
|  |             fab.classList.remove("show"); | ||||||
|  |         } else { | ||||||
|  |             fab.classList.add("show"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										832
									
								
								src/accounting/static/js/transaction-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										832
									
								
								src/accounting/static/js/transaction-form.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,832 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * transaction-transfer-form.js: The JavaScript for the transfer transaction form | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /*  Copyright (c) 2023 imacat. | ||||||
|  |  * | ||||||
|  |  *  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  *  you may not use this file except in compliance with the License. | ||||||
|  |  *  You may obtain a copy of the License at | ||||||
|  |  * | ||||||
|  |  *      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  * | ||||||
|  |  *  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  *  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  |  *  See the License for the specific language governing permissions and | ||||||
|  |  *  limitations under the License. | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /* Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
|  |  * First written: 2023/2/25 | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | // Initializes the page JavaScript. | ||||||
|  | document.addEventListener("DOMContentLoaded", function () { | ||||||
|  |     initializeCurrencyForms(); | ||||||
|  |     initializeJournalEntries(); | ||||||
|  |     initializeAccountSelectors(); | ||||||
|  |     initializeFormValidation(); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Escapes the HTML special characters and returns. | ||||||
|  |  * | ||||||
|  |  * @param s {string} the original string | ||||||
|  |  * @returns {string} the string with HTML special character escaped | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function escapeHtml(s) { | ||||||
|  |     return String(s) | ||||||
|  |          .replaceAll("&", "&") | ||||||
|  |          .replaceAll("<", "<") | ||||||
|  |          .replaceAll(">", ">") | ||||||
|  |          .replaceAll("\"", """); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Formats a Decimal number. | ||||||
|  |  * | ||||||
|  |  * @param number {Decimal} the Decimal number | ||||||
|  |  * @returns {string} the formatted Decimal number | ||||||
|  |  */ | ||||||
|  | function formatDecimal(number) { | ||||||
|  |     if (number.equals(new Decimal("0"))) { | ||||||
|  |         return "-"; | ||||||
|  |     } | ||||||
|  |     const frac = number.modulo(1); | ||||||
|  |     const whole = Number(number.minus(frac)).toLocaleString(); | ||||||
|  |     return whole + String(frac).substring(1); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the currency forms. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function initializeCurrencyForms() { | ||||||
|  |     const form = document.getElementById("accounting-form"); | ||||||
|  |     const btnNew = document.getElementById("accounting-btn-new-currency"); | ||||||
|  |     const currencyList = document.getElementById("accounting-currency-list"); | ||||||
|  |     const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency")); | ||||||
|  |     const onReorder = function () { | ||||||
|  |         const currencies = Array.from(currencyList.children); | ||||||
|  |         for (let i = 0; i < currencies.length; i++) { | ||||||
|  |             const no = document.getElementById(currencies[i].dataset.prefix + "-no"); | ||||||
|  |             no.value = String(i + 1); | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |     btnNew.onclick = function () { | ||||||
|  |         const currencies = Array.from(document.getElementsByClassName("accounting-currency")); | ||||||
|  |         let maxIndex = 0; | ||||||
|  |         currencies.forEach(function (currency) { | ||||||
|  |             const index = parseInt(currency.dataset.index); | ||||||
|  |             if (maxIndex < index) { | ||||||
|  |                 maxIndex = index; | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |         const newIndex = String(maxIndex + 1); | ||||||
|  |         const html = form.dataset.currencyTemplate | ||||||
|  |             .replaceAll("CURRENCY_INDEX", escapeHtml(newIndex)); | ||||||
|  |         currencyList.insertAdjacentHTML("beforeend", html); | ||||||
|  |         const newEntryButtons = Array.from(document.getElementsByClassName("accounting-currency-" + newIndex + "-btn-new-entry")); | ||||||
|  |         const btnDelete = document.getElementById("accounting-btn-delete-currency-" + newIndex); | ||||||
|  |         newEntryButtons.forEach(initializeNewEntryButton); | ||||||
|  |         initializeBtnDeleteCurrency(btnDelete); | ||||||
|  |         resetDeleteCurrencyButtons(); | ||||||
|  |         initializeDragAndDropReordering(currencyList, onReorder); | ||||||
|  |     }; | ||||||
|  |     deleteButtons.forEach(initializeBtnDeleteCurrency); | ||||||
|  |     initializeDragAndDropReordering(currencyList, onReorder); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the button to delete a currency. | ||||||
|  |  * | ||||||
|  |  * @param button {HTMLButtonElement} the button to delete a currency. | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function initializeBtnDeleteCurrency(button) { | ||||||
|  |     const target = document.getElementById(button.dataset.target); | ||||||
|  |     button.onclick = function () { | ||||||
|  |         target.parentElement.removeChild(target); | ||||||
|  |         resetDeleteCurrencyButtons(); | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Resets the status of the delete currency buttons. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function resetDeleteCurrencyButtons() { | ||||||
|  |     const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency")); | ||||||
|  |     if (buttons.length > 1) { | ||||||
|  |         buttons.forEach(function (button) { | ||||||
|  |             button.classList.remove("d-none"); | ||||||
|  |         }); | ||||||
|  |     } else { | ||||||
|  |         buttons[0].classList.add("d-none"); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the journal entry forms. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function initializeJournalEntries() { | ||||||
|  |     const newButtons = Array.from(document.getElementsByClassName("accounting-btn-new-entry")); | ||||||
|  |     const entryLists = Array.from(document.getElementsByClassName("accounting-entry-list")); | ||||||
|  |     const entries = Array.from(document.getElementsByClassName("accounting-entry")) | ||||||
|  |     const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-entry")); | ||||||
|  |     newButtons.forEach(initializeNewEntryButton); | ||||||
|  |     entryLists.forEach(initializeJournalEntryListReorder); | ||||||
|  |     entries.forEach(initializeJournalEntry); | ||||||
|  |     deleteButtons.forEach(initializeDeleteJournalEntryButton); | ||||||
|  |     initializeJournalEntryFormModal(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the button to add a new journal entry. | ||||||
|  |  * | ||||||
|  |  * @param button {HTMLButtonElement} the button to add a new journal entry | ||||||
|  |  */ | ||||||
|  | function initializeNewEntryButton(button) { | ||||||
|  |     const entryForm = document.getElementById("accounting-entry-form"); | ||||||
|  |     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||||
|  |     const formAccount = document.getElementById("accounting-entry-form-account"); | ||||||
|  |     const formAccountError = document.getElementById("accounting-entry-form-account-error") | ||||||
|  |     const formSummary = document.getElementById("accounting-entry-form-summary"); | ||||||
|  |     const formSummaryError = document.getElementById("accounting-entry-form-summary-error"); | ||||||
|  |     const formAmount = document.getElementById("accounting-entry-form-amount"); | ||||||
|  |     const formAmountError = document.getElementById("accounting-entry-form-amount-error"); | ||||||
|  |     button.onclick = function () { | ||||||
|  |         entryForm.dataset.currencyIndex = button.dataset.currencyIndex; | ||||||
|  |         entryForm.dataset.entryType = button.dataset.entryType; | ||||||
|  |         entryForm.dataset.entryIndex = button.dataset.entryIndex; | ||||||
|  |         formAccountControl.classList.remove("accounting-not-empty") | ||||||
|  |         formAccountControl.classList.remove("is-invalid"); | ||||||
|  |         formAccountControl.dataset.bsTarget = button.dataset.accountModal; | ||||||
|  |         formAccount.innerText = ""; | ||||||
|  |         formAccount.dataset.code = ""; | ||||||
|  |         formAccount.dataset.text = ""; | ||||||
|  |         formAccountError.innerText = ""; | ||||||
|  |         formSummary.value = ""; | ||||||
|  |         formSummary.classList.remove("is-invalid"); | ||||||
|  |         formSummaryError.innerText = "" | ||||||
|  |         formAmount.value = ""; | ||||||
|  |         formAmount.classList.remove("is-invalid"); | ||||||
|  |         formAmountError.innerText = ""; | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the reordering of a journal entry list. | ||||||
|  |  * | ||||||
|  |  * @param entryList {HTMLUListElement} the journal entry list. | ||||||
|  |  */ | ||||||
|  | function initializeJournalEntryListReorder(entryList) { | ||||||
|  |     initializeDragAndDropReordering(entryList, function () { | ||||||
|  |         const entries = Array.from(entryList.children); | ||||||
|  |         for (let i = 0; i < entries.length; i++) { | ||||||
|  |             const no = document.getElementById(entries[i].dataset.prefix + "-no"); | ||||||
|  |             no.value = String(i + 1); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the journal entry. | ||||||
|  |  * | ||||||
|  |  * @param entry {HTMLLIElement} the journal entry. | ||||||
|  |  */ | ||||||
|  | function initializeJournalEntry(entry) { | ||||||
|  |     const entryForm = document.getElementById("accounting-entry-form"); | ||||||
|  |     const accountCode = document.getElementById(entry.dataset.prefix + "-account-code"); | ||||||
|  |     const summary = document.getElementById(entry.dataset.prefix + "-summary"); | ||||||
|  |     const amount = document.getElementById(entry.dataset.prefix + "-amount"); | ||||||
|  |     const control = document.getElementById(entry.dataset.prefix + "-control"); | ||||||
|  |     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||||
|  |     const formAccount = document.getElementById("accounting-entry-form-account"); | ||||||
|  |     const formSummary = document.getElementById("accounting-entry-form-summary"); | ||||||
|  |     const formAmount = document.getElementById("accounting-entry-form-amount"); | ||||||
|  |     control.onclick = function () { | ||||||
|  |         entryForm.dataset.currencyIndex = entry.dataset.currencyIndex; | ||||||
|  |         entryForm.dataset.entryType = entry.dataset.entryType; | ||||||
|  |         entryForm.dataset.entryIndex = entry.dataset.entryIndex; | ||||||
|  |         if (accountCode.value === "") { | ||||||
|  |             formAccountControl.classList.remove("accounting-not-empty"); | ||||||
|  |         } else { | ||||||
|  |             formAccountControl.classList.add("accounting-not-empty"); | ||||||
|  |         } | ||||||
|  |         formAccountControl.dataset.bsTarget = entry.dataset.accountModal; | ||||||
|  |         formAccount.innerText = accountCode.dataset.text; | ||||||
|  |         formAccount.dataset.code = accountCode.value; | ||||||
|  |         formAccount.dataset.text = accountCode.dataset.text; | ||||||
|  |         formSummary.value = summary.value; | ||||||
|  |         formAmount.value = amount.value; | ||||||
|  |         validateJournalEntryForm(); | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the journal entry form modal. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function initializeJournalEntryFormModal() { | ||||||
|  |     const entryForm = document.getElementById("accounting-entry-form"); | ||||||
|  |     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||||
|  |     const formAccount = document.getElementById("accounting-entry-form-account"); | ||||||
|  |     const formSummary = document.getElementById("accounting-entry-form-summary"); | ||||||
|  |     const formAmount = document.getElementById("accounting-entry-form-amount"); | ||||||
|  |     const modal = document.getElementById("accounting-entry-form-modal"); | ||||||
|  |     formAccountControl.onclick = function () { | ||||||
|  |         const prefix = "accounting-" + entryForm.dataset.entryType + "-account"; | ||||||
|  |         const query = document.getElementById(prefix + "-selector-query") | ||||||
|  |         const more = document.getElementById(prefix + "-more"); | ||||||
|  |         const options = Array.from(document.getElementsByClassName(prefix + "-option")); | ||||||
|  |         const btnClear = document.getElementById(prefix + "-btn-clear"); | ||||||
|  |         query.value = ""; | ||||||
|  |         more.classList.remove("d-none"); | ||||||
|  |         filterAccountOptions(prefix); | ||||||
|  |         options.forEach(function (option) { | ||||||
|  |             if (option.dataset.code === formAccount.dataset.code) { | ||||||
|  |                 option.classList.add("active"); | ||||||
|  |             } else { | ||||||
|  |                 option.classList.remove("active"); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |         if (formAccount.dataset.code === "") { | ||||||
|  |             btnClear.classList.add("btn-secondary"); | ||||||
|  |             btnClear.classList.remove("btn-danger"); | ||||||
|  |             btnClear.disabled = true; | ||||||
|  |         } else { | ||||||
|  |             btnClear.classList.add("btn-danger"); | ||||||
|  |             btnClear.classList.remove("btn-secondary"); | ||||||
|  |             btnClear.disabled = false; | ||||||
|  |         } | ||||||
|  |     }; | ||||||
|  |     formSummary.onchange = validateJournalEntrySummary; | ||||||
|  |     formAmount.onchange = validateJournalEntryAmount; | ||||||
|  |     entryForm.onsubmit = function () { | ||||||
|  |         if (validateJournalEntryForm()) { | ||||||
|  |             saveJournalEntryForm(); | ||||||
|  |             bootstrap.Modal.getInstance(modal).hide(); | ||||||
|  |         } | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates the journal entry form modal. | ||||||
|  |  * | ||||||
|  |  * @return {boolean} true if the form is valid, or false otherwise. | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateJournalEntryForm() { | ||||||
|  |     let isValid = true; | ||||||
|  |     isValid = validateJournalEntryAccount() && isValid; | ||||||
|  |     isValid = validateJournalEntrySummary() && isValid; | ||||||
|  |     isValid = validateJournalEntryAmount() && isValid | ||||||
|  |     return isValid; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates the account in the journal entry form modal. | ||||||
|  |  * | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateJournalEntryAccount() { | ||||||
|  |     const field = document.getElementById("accounting-entry-form-account"); | ||||||
|  |     const error = document.getElementById("accounting-entry-form-account-error"); | ||||||
|  |     const control = document.getElementById("accounting-entry-form-account-control"); | ||||||
|  |     if (field.dataset.code === "") { | ||||||
|  |         control.classList.add("is-invalid"); | ||||||
|  |         error.innerText = A_("Please select the account."); | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |     control.classList.remove("is-invalid"); | ||||||
|  |     error.innerText = ""; | ||||||
|  |     return true; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates the summary in the journal entry form modal. | ||||||
|  |  * | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateJournalEntrySummary() { | ||||||
|  |     const field = document.getElementById("accounting-entry-form-summary"); | ||||||
|  |     const error = document.getElementById("accounting-entry-form-summary-error"); | ||||||
|  |     field.value = field.value.trim(); | ||||||
|  |     field.classList.remove("is-invalid"); | ||||||
|  |     error.innerText = ""; | ||||||
|  |     return true; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates the amount in the journal entry form modal. | ||||||
|  |  * | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateJournalEntryAmount() { | ||||||
|  |     const field = document.getElementById("accounting-entry-form-amount"); | ||||||
|  |     const error = document.getElementById("accounting-entry-form-amount-error"); | ||||||
|  |     field.value = field.value.trim(); | ||||||
|  |     field.classList.remove("is-invalid"); | ||||||
|  |     if (field.value === "") { | ||||||
|  |         field.classList.add("is-invalid"); | ||||||
|  |         error.innerText = A_("Please fill in the amount."); | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |     error.innerText = ""; | ||||||
|  |     return true; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Saves the journal entry form modal to the form. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function saveJournalEntryForm() { | ||||||
|  |     const form = document.getElementById("accounting-form"); | ||||||
|  |     const entryForm = document.getElementById("accounting-entry-form"); | ||||||
|  |     const formAccount = document.getElementById("accounting-entry-form-account"); | ||||||
|  |     const formSummary = document.getElementById("accounting-entry-form-summary"); | ||||||
|  |     const formAmount = document.getElementById("accounting-entry-form-amount"); | ||||||
|  |     const currencyIndex = entryForm.dataset.currencyIndex; | ||||||
|  |     const entryType = entryForm.dataset.entryType; | ||||||
|  |     let entryIndex; | ||||||
|  |     if (entryForm.dataset.entryIndex === "new") { | ||||||
|  |         const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType)); | ||||||
|  |         const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list") | ||||||
|  |         let maxIndex = 0; | ||||||
|  |         entries.forEach(function (entry) { | ||||||
|  |             const index = parseInt(entry.dataset.entryIndex); | ||||||
|  |             if (maxIndex < index) { | ||||||
|  |                 maxIndex = index; | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |         entryIndex = String(maxIndex + 1); | ||||||
|  |         const html = form.dataset.entryTemplate | ||||||
|  |             .replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex)) | ||||||
|  |             .replaceAll("ENTRY_TYPE", escapeHtml(entryType)) | ||||||
|  |             .replaceAll("ENTRY_INDEX", escapeHtml(entryIndex)); | ||||||
|  |         entryList.insertAdjacentHTML("beforeend", html); | ||||||
|  |         initializeJournalEntryListReorder(entryList); | ||||||
|  |     } else { | ||||||
|  |         entryIndex = entryForm.dataset.entryIndex; | ||||||
|  |     } | ||||||
|  |     const currency = document.getElementById("accounting-currency-" + currencyIndex); | ||||||
|  |     const entry = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-" + entryIndex); | ||||||
|  |     const accountCode = document.getElementById(entry.dataset.prefix + "-account-code"); | ||||||
|  |     const accountText = document.getElementById(entry.dataset.prefix + "-account-text"); | ||||||
|  |     const summary = document.getElementById(entry.dataset.prefix + "-summary"); | ||||||
|  |     const summaryText = document.getElementById(entry.dataset.prefix + "-summary-text"); | ||||||
|  |     const amount = document.getElementById(entry.dataset.prefix + "-amount"); | ||||||
|  |     const amountText = document.getElementById(entry.dataset.prefix + "-amount-text"); | ||||||
|  |     accountCode.value = formAccount.dataset.code; | ||||||
|  |     accountCode.dataset.text = formAccount.dataset.text; | ||||||
|  |     accountText.innerText = formAccount.dataset.text; | ||||||
|  |     summary.value = formSummary.value; | ||||||
|  |     summaryText.innerText = formSummary.value; | ||||||
|  |     amount.value = formAmount.value; | ||||||
|  |     amountText.innerText = formatDecimal(new Decimal(formAmount.value)); | ||||||
|  |     if (entryForm.dataset.entryIndex === "new") { | ||||||
|  |         const btnDelete = document.getElementById(entry.dataset.prefix + "-btn-delete"); | ||||||
|  |         initializeJournalEntry(entry); | ||||||
|  |         initializeDeleteJournalEntryButton(btnDelete); | ||||||
|  |         resetDeleteJournalEntryButtons(btnDelete.dataset.sameClass); | ||||||
|  |     } | ||||||
|  |     updateBalance(currencyIndex, entryType); | ||||||
|  |     validateJournalEntriesReal(currencyIndex, entryType); | ||||||
|  |     validateBalance(currency); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the button to delete a journal entry. | ||||||
|  |  * | ||||||
|  |  * @param button {HTMLButtonElement} the button to delete a journal entry | ||||||
|  |  */ | ||||||
|  | function initializeDeleteJournalEntryButton(button) { | ||||||
|  |     const target = document.getElementById(button.dataset.target); | ||||||
|  |     const currencyIndex = target.dataset.currencyIndex; | ||||||
|  |     const entryType = target.dataset.entryType; | ||||||
|  |     const currency = document.getElementById("accounting-currency-" + currencyIndex); | ||||||
|  |     button.onclick = function () { | ||||||
|  |         target.parentElement.removeChild(target); | ||||||
|  |         resetDeleteJournalEntryButtons(button.dataset.sameClass); | ||||||
|  |         updateBalance(currencyIndex, entryType); | ||||||
|  |         validateJournalEntriesReal(currencyIndex, entryType); | ||||||
|  |         validateBalance(currency); | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Resets the status of the delete journal entry buttons. | ||||||
|  |  * | ||||||
|  |  * @param sameClass {string} the class of the buttons | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function resetDeleteJournalEntryButtons(sameClass) { | ||||||
|  |     const buttons = Array.from(document.getElementsByClassName(sameClass)); | ||||||
|  |     if (buttons.length > 1) { | ||||||
|  |         buttons.forEach(function (button) { | ||||||
|  |             button.classList.remove("d-none"); | ||||||
|  |         }); | ||||||
|  |     } else { | ||||||
|  |         buttons[0].classList.add("d-none"); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Updates the balance. | ||||||
|  |  * | ||||||
|  |  * @param currencyIndex {string} the currency index. | ||||||
|  |  * @param entryType {string} the journal entry type, either "debit" or "credit" | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function updateBalance(currencyIndex, entryType) { | ||||||
|  |     const prefix = "accounting-currency-" + currencyIndex + "-" + entryType; | ||||||
|  |     const amounts = Array.from(document.getElementsByClassName(prefix + "-amount")); | ||||||
|  |     const totalText = document.getElementById(prefix + "-total"); | ||||||
|  |     let total = new Decimal("0"); | ||||||
|  |     amounts.forEach(function (amount) { | ||||||
|  |         if (amount.value !== "") { | ||||||
|  |             total = total.plus(new Decimal(amount.value)); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |     totalText.innerText = formatDecimal(total); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the account selectors. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function initializeAccountSelectors() { | ||||||
|  |     const selectors = Array.from(document.getElementsByClassName("accounting-selector-modal")); | ||||||
|  |     const formAccountControl = document.getElementById("accounting-entry-form-account-control"); | ||||||
|  |     const formAccount = document.getElementById("accounting-entry-form-account"); | ||||||
|  |     selectors.forEach(function (selector) { | ||||||
|  |         const more = document.getElementById(selector.dataset.prefix + "-more"); | ||||||
|  |         const btnClear = document.getElementById(selector.dataset.prefix + "-btn-clear"); | ||||||
|  |         const options = Array.from(document.getElementsByClassName(selector.dataset.prefix + "-option")); | ||||||
|  |         more.onclick = function () { | ||||||
|  |             more.classList.add("d-none"); | ||||||
|  |             filterAccountOptions(selector.dataset.prefix); | ||||||
|  |         }; | ||||||
|  |         initializeAccountQuery(selector); | ||||||
|  |         btnClear.onclick = function () { | ||||||
|  |             formAccountControl.classList.remove("accounting-not-empty"); | ||||||
|  |             formAccount.innerText = ""; | ||||||
|  |             formAccount.dataset.code = ""; | ||||||
|  |             formAccount.dataset.text = ""; | ||||||
|  |             validateJournalEntryAccount(); | ||||||
|  |         }; | ||||||
|  |         options.forEach(function (option) { | ||||||
|  |             option.onclick = function () { | ||||||
|  |                 formAccountControl.classList.add("accounting-not-empty"); | ||||||
|  |                 formAccount.innerText = option.dataset.content; | ||||||
|  |                 formAccount.dataset.code = option.dataset.code; | ||||||
|  |                 formAccount.dataset.text = option.dataset.content; | ||||||
|  |                 validateJournalEntryAccount(); | ||||||
|  |             }; | ||||||
|  |         }); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the query on the account options. | ||||||
|  |  * | ||||||
|  |  * @param selector {HTMLDivElement} the selector modal | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function initializeAccountQuery(selector) { | ||||||
|  |     const query = document.getElementById(selector.dataset.prefix + "-selector-query"); | ||||||
|  |     query.addEventListener("input", function () { | ||||||
|  |         filterAccountOptions(selector.dataset.prefix); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Filters the account options. | ||||||
|  |  * | ||||||
|  |  * @param prefix {string} the HTML ID and class prefix | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function filterAccountOptions(prefix) { | ||||||
|  |     const query = document.getElementById(prefix + "-selector-query"); | ||||||
|  |     const optionList = document.getElementById(prefix + "-option-list"); | ||||||
|  |     if (optionList === null) { | ||||||
|  |         console.log(prefix + "-option-list"); | ||||||
|  |     } | ||||||
|  |     const options = Array.from(document.getElementsByClassName(prefix + "-option")); | ||||||
|  |     const more = document.getElementById(prefix + "-more"); | ||||||
|  |     const queryNoResult = document.getElementById(prefix + "-option-no-result"); | ||||||
|  |     const codesInUse = getAccountCodeUsedInForm(); | ||||||
|  |     let hasAnyMatched = false; | ||||||
|  |     options.forEach(function (option) { | ||||||
|  |         const isMatched = shouldAccountOptionShow(option, more, codesInUse, query); | ||||||
|  |         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"); | ||||||
|  |     } else { | ||||||
|  |         optionList.classList.remove("d-none"); | ||||||
|  |         queryNoResult.classList.add("d-none"); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Returns whether an account option should show. | ||||||
|  |  * | ||||||
|  |  * @param option {HTMLLIElement} the account option | ||||||
|  |  * @param more {HTMLLIElement} the more account element | ||||||
|  |  * @param inUse {string[]} the account codes that are used in the form | ||||||
|  |  * @param query {HTMLInputElement} the query element, if any | ||||||
|  |  * @return {boolean} true if the account option should show, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function shouldAccountOptionShow(option, more, inUse, query) { | ||||||
|  |     const isQueryMatched = function () { | ||||||
|  |         if (query.value === "") { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         const queryValues = JSON.parse(option.dataset.queryValues); | ||||||
|  |         for (const queryValue of queryValues) { | ||||||
|  |             if (queryValue.includes(query.value)) { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return false; | ||||||
|  |     }; | ||||||
|  |     const isMoreMatched = function () { | ||||||
|  |         if (more.classList.contains("d-none")) { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code); | ||||||
|  |     }; | ||||||
|  |     return isMoreMatched() && isQueryMatched(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Returns the account codes that are used in the form. | ||||||
|  |  * | ||||||
|  |  * @return {string[]} the account codes that are used in the form | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function getAccountCodeUsedInForm() { | ||||||
|  |     const accountCodes = Array.from(document.getElementsByClassName("accounting-account-code")); | ||||||
|  |     const formAccount = document.getElementById("accounting-entry-form-account"); | ||||||
|  |     const inUse = [formAccount.dataset.code]; | ||||||
|  |     accountCodes.forEach(function (accountCode) { | ||||||
|  |         inUse.push(accountCode.value); | ||||||
|  |     }); | ||||||
|  |     return inUse | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Initializes the form validation. | ||||||
|  |  * | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function initializeFormValidation() { | ||||||
|  |     const date = document.getElementById("accounting-date"); | ||||||
|  |     const note = document.getElementById("accounting-note"); | ||||||
|  |     const form = document.getElementById("accounting-form"); | ||||||
|  |     date.onchange = validateDate; | ||||||
|  |     note.onchange = validateNote; | ||||||
|  |     form.onsubmit = validateForm; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates the form. | ||||||
|  |  * | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateForm() { | ||||||
|  |     let isValid = true; | ||||||
|  |     isValid = validateDate() && isValid; | ||||||
|  |     isValid = validateCurrencies() && isValid; | ||||||
|  |     isValid = validateNote() && isValid; | ||||||
|  |     return isValid; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates the date. | ||||||
|  |  * | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateDate() { | ||||||
|  |     const field = document.getElementById("accounting-date"); | ||||||
|  |     const error = document.getElementById("accounting-date-error"); | ||||||
|  |     field.value = field.value.trim(); | ||||||
|  |     field.classList.remove("is-invalid"); | ||||||
|  |     if (field.value === "") { | ||||||
|  |         field.classList.add("is-invalid"); | ||||||
|  |         error.innerText = A_("Please fill in the date."); | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |     error.innerText = ""; | ||||||
|  |     return true; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates the currency sub-forms. | ||||||
|  |  * | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateCurrencies() { | ||||||
|  |     const currencies = Array.from(document.getElementsByClassName("accounting-currency")); | ||||||
|  |     let isValid = true; | ||||||
|  |     isValid = validateCurrenciesReal() && isValid; | ||||||
|  |     currencies.forEach(function (currency) { | ||||||
|  |         isValid = validateCurrency(currency) && isValid; | ||||||
|  |     }); | ||||||
|  |     return isValid; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates the currency sub-forms, the validator itself. | ||||||
|  |  * | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateCurrenciesReal() { | ||||||
|  |     const field = document.getElementById("accounting-currencies"); | ||||||
|  |     const error = document.getElementById("accounting-currencies-error"); | ||||||
|  |     const currencies = Array.from(document.getElementsByClassName("accounting-currency")); | ||||||
|  |     if (currencies.length === 0) { | ||||||
|  |         field.classList.add("is-invalid"); | ||||||
|  |         error.innerText = A_("Please add some currencies."); | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |     field.classList.remove("is-invalid"); | ||||||
|  |     error.innerText = ""; | ||||||
|  |     return true; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates a currency sub-form. | ||||||
|  |  * | ||||||
|  |  * @param currency {HTMLDivElement} the currency sub-form | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateCurrency(currency) { | ||||||
|  |     const prefix = "accounting-currency-" + currency.dataset.index; | ||||||
|  |     const debit = document.getElementById(prefix + "-debit"); | ||||||
|  |     const credit = document.getElementById(prefix + "-credit"); | ||||||
|  |     let isValid = true; | ||||||
|  |     if (debit !== null) { | ||||||
|  |         isValid = validateJournalEntries(currency, "debit") && isValid; | ||||||
|  |     } | ||||||
|  |     if (credit !== null) { | ||||||
|  |         isValid = validateJournalEntries(currency, "credit") && isValid; | ||||||
|  |     } | ||||||
|  |     if (debit !== null && credit !== null) { | ||||||
|  |         isValid = validateBalance(currency) && isValid; | ||||||
|  |     } | ||||||
|  |     return isValid; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates the journal entries in a currency sub-form. | ||||||
|  |  * | ||||||
|  |  * @param currency {HTMLDivElement} the currency | ||||||
|  |  * @param entryType {string} the journal entry type, either "debit" or "credit" | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateJournalEntries(currency, entryType) { | ||||||
|  |     const currencyIndex = currency.dataset.index; | ||||||
|  |     const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType)); | ||||||
|  |     let isValid = true; | ||||||
|  |     isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid; | ||||||
|  |     entries.forEach(function (entry) { | ||||||
|  |         isValid = validateJournalEntry(entry) && isValid; | ||||||
|  |     }) | ||||||
|  |     return isValid; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates the journal entries, the validator itself. | ||||||
|  |  * | ||||||
|  |  * @param currencyIndex {string} the currency index | ||||||
|  |  * @param entryType {string} the journal entry type, either "debit" or "credit" | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateJournalEntriesReal(currencyIndex, entryType) { | ||||||
|  |     const prefix = "accounting-currency-" + currencyIndex + "-" + entryType; | ||||||
|  |     const field = document.getElementById(prefix); | ||||||
|  |     const error = document.getElementById(prefix + "-error"); | ||||||
|  |     const entries = Array.from(document.getElementsByClassName(prefix)); | ||||||
|  |     if (entries.length === 0) { | ||||||
|  |         field.classList.add("is-invalid"); | ||||||
|  |         error.innerText = A_("Please add some journal entries."); | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |     field.classList.remove("is-invalid"); | ||||||
|  |     error.innerText = ""; | ||||||
|  |     return true; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates a journal entry sub-form in a currency sub-form. | ||||||
|  |  * | ||||||
|  |  * @param entry {HTMLLIElement} the journal entry | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateJournalEntry(entry) { | ||||||
|  |     const control = document.getElementById(entry.dataset.prefix + "-control"); | ||||||
|  |     const error = document.getElementById(entry.dataset.prefix + "-error"); | ||||||
|  |     const accountCode = document.getElementById(entry.dataset.prefix + "-account-code"); | ||||||
|  |     const amount = document.getElementById(entry.dataset.prefix + "-amount"); | ||||||
|  |     if (accountCode.value === "") { | ||||||
|  |         control.classList.add("is-invalid"); | ||||||
|  |         error.innerText = A_("Please select the account."); | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |     if (amount.value === "") { | ||||||
|  |         control.classList.add("is-invalid"); | ||||||
|  |         error.innerText = A_("Please fill in the amount."); | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |     control.classList.remove("is-invalid"); | ||||||
|  |     error.innerText = ""; | ||||||
|  |     return true; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates the balance of a currency sub-form. | ||||||
|  |  * | ||||||
|  |  * @param currency {HTMLDivElement} the currency sub-form | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateBalance(currency) { | ||||||
|  |     const prefix = "accounting-currency-" + currency.dataset.index; | ||||||
|  |     const control = document.getElementById(prefix + "-control"); | ||||||
|  |     const error = document.getElementById(prefix + "-error"); | ||||||
|  |     const debit = document.getElementById(prefix + "-debit"); | ||||||
|  |     const debitAmounts = Array.from(document.getElementsByClassName(prefix + "-debit-amount")); | ||||||
|  |     const credit = document.getElementById(prefix + "-credit"); | ||||||
|  |     const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount")); | ||||||
|  |     if (debit !== null && credit !== null) { | ||||||
|  |         let debitTotal = new Decimal("0"); | ||||||
|  |         debitAmounts.forEach(function (amount) { | ||||||
|  |             if (amount.value !== "") { | ||||||
|  |                 debitTotal = debitTotal.plus(new Decimal(amount.value)); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |         let creditTotal = new Decimal("0"); | ||||||
|  |         creditAmounts.forEach(function (amount) { | ||||||
|  |             if (amount.value !== "") { | ||||||
|  |                 creditTotal = creditTotal.plus(new Decimal(amount.value)); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |         if (!debitTotal.equals(creditTotal)) { | ||||||
|  |             control.classList.add("is-invalid"); | ||||||
|  |             error.innerText = A_("The totals of the debit and credit amounts do not match."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     control.classList.remove("is-invalid"); | ||||||
|  |     error.innerText = ""; | ||||||
|  |     return true; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Validates the note. | ||||||
|  |  * | ||||||
|  |  * @return {boolean} true if valid, or false otherwise | ||||||
|  |  * @private | ||||||
|  |  */ | ||||||
|  | function validateNote() { | ||||||
|  |     const field = document.getElementById("accounting-note"); | ||||||
|  |     const error = document.getElementById("accounting-note-error"); | ||||||
|  |     field.value = field.value | ||||||
|  |          .replace(/^\s*\n/, "") | ||||||
|  |          .trimEnd(); | ||||||
|  |     field.classList.remove("is-invalid"); | ||||||
|  |     error.innerText = ""; | ||||||
|  |     return true; | ||||||
|  | } | ||||||
							
								
								
									
										37
									
								
								src/accounting/static/js/transaction-order.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/accounting/static/js/transaction-order.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | |||||||
|  | /* The Mia! Accounting Flask Project | ||||||
|  |  * transaction-order.js: The JavaScript for the transaction order | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | /*  Copyright (c) 2023 imacat. | ||||||
|  |  * | ||||||
|  |  *  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  *  you may not use this file except in compliance with the License. | ||||||
|  |  *  You may obtain a copy of the License at | ||||||
|  |  * | ||||||
|  |  *      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  * | ||||||
|  |  *  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  *  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  *  WITHOUT 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/26 | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | // Initializes the page JavaScript. | ||||||
|  | document.addEventListener("DOMContentLoaded", function () { | ||||||
|  |     const list = document.getElementById("accounting-order-list"); | ||||||
|  |     if (list !== null) { | ||||||
|  |         const onReorder = function () { | ||||||
|  |             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"); | ||||||
|  |                 no.value = String(i + 1); | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |         initializeDragAndDropReordering(list, onReorder); | ||||||
|  |     } | ||||||
|  | }); | ||||||
| @@ -26,42 +26,48 @@ First written: 2023/1/31 | |||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
| <div class="btn-group mb-3"> | <div class="btn-group mb-3"> | ||||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}"> |   <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> | ||||||
|     <i class="fa-solid fa-circle-chevron-left"></i> |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|     {{ A_("Back") }} |     {{ A_("Back") }} | ||||||
|   </a> |   </a> | ||||||
|   {% if can_edit_accounting() %} |   {% if accounting_can_edit() %} | ||||||
|     <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}"> |     <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> |       <i class="fa-solid fa-gear"></i> | ||||||
|       {{ A_("Settings") }} |       {{ A_("Settings") }} | ||||||
|     </a> |     </a> | ||||||
|     <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#delete-modal"> |   {% endif %} | ||||||
|  |   <a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}"> | ||||||
|  |     <i class="fa-solid fa-bars-staggered"></i> | ||||||
|  |     {{ A_("Order") }} | ||||||
|  |   </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> |       <i class="fa-solid fa-trash"></i> | ||||||
|       {{ A_("Delete") }} |       {{ A_("Delete") }} | ||||||
|     </button> |     </button> | ||||||
|   {% endif %} |   {% endif %} | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| {% if can_edit_accounting() %} | {% if accounting_can_edit() %} | ||||||
|   <div class="d-md-none material-fab"> |   <div class="d-md-none accounting-material-fab"> | ||||||
|     <a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}"> |     <a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}"> | ||||||
|       <i class="fa-solid fa-pen-to-square"></i> |       <i class="fa-solid fa-pen-to-square"></i> | ||||||
|     </a> |     </a> | ||||||
|   </div> |   </div> | ||||||
| {% endif %} | {% endif %} | ||||||
|  |  | ||||||
| {% if can_edit_accounting() %} | {% if accounting_can_edit() %} | ||||||
|   <form id="delete-form" action="{{ url_for("accounting.account.delete", account=obj) }}" method="post"> |   <form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post"> | ||||||
|     <input id="csrf_token" type="hidden" name="csrf_token" value="{{ csrf_token() }}"> |     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||||
|     {% if "next" in request.args %} |     {% if "next" in request.args %} | ||||||
|       <input type="hidden" name="next" value="{{ request.args["next"] }}"> |       <input type="hidden" name="next" value="{{ request.args["next"] }}"> | ||||||
|     {% endif %} |     {% endif %} | ||||||
|     <div class="modal fade" id="delete-modal" tabindex="-1" aria-labelledby="delete-model-label" aria-hidden="true"> |     <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true"> | ||||||
|       <div class="modal-dialog"> |       <div class="modal-dialog"> | ||||||
|         <div class="modal-content"> |         <div class="modal-content"> | ||||||
|           <div class="modal-header"> |           <div class="modal-header"> | ||||||
|             <h1 class="modal-title fs-5" id="delete-model-label">{{ A_("Delete Account Confirmation") }}</h1> |             <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Account Confirmation") }}</h1> | ||||||
|             <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |             <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> | ||||||
|           </div> |           </div> | ||||||
|           <div class="modal-body"> |           <div class="modal-body"> | ||||||
|             {{ A_("Do you really want to delete this account?") }} |             {{ A_("Do you really want to delete this account?") }} | ||||||
| @@ -76,12 +82,12 @@ First written: 2023/1/31 | |||||||
|   </form> |   </form> | ||||||
| {% endif %} | {% endif %} | ||||||
|  |  | ||||||
| <div class="account col-sm-6"> | <div class="accounting-card col-sm-6"> | ||||||
|   <div class="account-title">{{ obj.title }}</div> |   <div class="accounting-card-title">{{ obj.title }}</div> | ||||||
|   <div class="account-code">{{ obj.code }}</div> |   <div class="accounting-card-code">{{ obj.code }}</div> | ||||||
|   {% if obj.is_offset_needed %} |   {% if obj.is_pay_off_needed %} | ||||||
|     <div> |     <div> | ||||||
|       <span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span> |       <span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span> | ||||||
|     </div> |     </div> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   <div class="small text-secondary fst-italic"> |   <div class="small text-secondary fst-italic"> | ||||||
|   | |||||||
| @@ -23,6 +23,6 @@ First written: 2023/2/1 | |||||||
|  |  | ||||||
| {% block header %}{% block title %}{{ A_("%(account)s Settings", account=account) }}{% endblock %}{% endblock %} | {% block header %}{% block title %}{{ A_("%(account)s Settings", account=account) }}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
| {% block back_url %}{{ url_for("accounting.account.detail", account=account)|inherit_next }}{% endblock %} | {% block back_url %}{{ url_for("accounting.account.detail", account=account)|accounting_inherit_next }}{% endblock %} | ||||||
|  |  | ||||||
| {% block action_url %}{{ url_for("accounting.account.update", account=account) }}{% endblock %} | {% block action_url %}{{ url_for("accounting.account.update", account=account) }}{% endblock %} | ||||||
|   | |||||||
| @@ -34,16 +34,16 @@ First written: 2023/2/1 | |||||||
|   </a> |   </a> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <form id="account-form" action="{% block action_url %}{% endblock %}" method="post"> | <form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post"> | ||||||
|   {{ form.csrf_token }} |   {{ form.csrf_token }} | ||||||
|   {% if "next" in request.args %} |   {% if "next" in request.args %} | ||||||
|     <input type="hidden" name="next" value="{{ request.args["next"] }}"> |     <input type="hidden" name="next" value="{{ request.args["next"] }}"> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   <div class="form-floating mb-3"> |   <div class="form-floating mb-3"> | ||||||
|     <input id="account-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}"> |     <input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}"> | ||||||
|     <div id="account-base" class="form-control clickable material-text-field {% if form.base_code.data %} not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#select-base-modal"> |     <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"> | ||||||
|       <label id="account-base-label" class="form-label" for="account-base">{{ A_("Base account") }}</label> |       <label class="form-label" for="accounting-base">{{ A_("Base account") }}</label> | ||||||
|       <div id="account-base-content"> |       <div id="accounting-base-content"> | ||||||
|         {% if form.base_code.data %} |         {% if form.base_code.data %} | ||||||
|           {% if form.base_code.errors %} |           {% if form.base_code.errors %} | ||||||
|             {{ A_("(Unknown)") }} |             {{ A_("(Unknown)") }} | ||||||
| @@ -53,19 +53,19 @@ First written: 2023/2/1 | |||||||
|         {% endif %} |         {% endif %} | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|     <div id="account-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div> |     <div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <div class="form-floating mb-3"> |   <div class="form-floating mb-3"> | ||||||
|     <input id="account-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required"> |     <input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required"> | ||||||
|     <label class="form-label" for="account-title">{{ A_("Title") }}</label> |     <label class="form-label" for="accounting-title">{{ A_("Title") }}</label> | ||||||
|     <div id="account-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div> |     <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <div class="form-check form-switch mb-3"> |   <div class="form-check form-switch mb-3"> | ||||||
|     <input id="account-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}> |     <input id="accounting-is-pay-off-needed" class="form-check-input" type="checkbox" name="is_pay_off_needed" value="1" {% if form.is_pay_off_needed.data %} checked="checked" {% endif %}> | ||||||
|     <label class="form-check-label" for="account-is-offset-needed"> |     <label class="form-check-label" for="accounting-is-pay-off-needed"> | ||||||
|       {{ A_("The entries in the account need offsets.") }} |       {{ A_("The entries in the account need pay-off.") }} | ||||||
|     </label> |     </label> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
| @@ -76,43 +76,44 @@ First written: 2023/2/1 | |||||||
|     </button> |     </button> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <div class="d-md-none material-fab"> |   <div class="d-md-none accounting-material-fab"> | ||||||
|     <button class="btn btn-primary" type="submit"> |     <button class="btn btn-primary" type="submit"> | ||||||
|       <i class="fa-solid fa-floppy-disk"></i> |       <i class="fa-solid fa-floppy-disk"></i> | ||||||
|     </button> |     </button> | ||||||
|   </div> |   </div> | ||||||
| </form> | </form> | ||||||
|  |  | ||||||
| <div class="modal fade" id="select-base-modal" tabindex="-1" aria-labelledby="select-base-model-label" aria-hidden="true"> | <div class="modal fade" id="accounting-base-selector-modal" tabindex="-1" aria-labelledby="accounting-base-selector-modal-label" aria-hidden="true"> | ||||||
|   <div class="modal-dialog"> |   <div class="modal-dialog"> | ||||||
|     <div class="modal-content"> |     <div class="modal-content"> | ||||||
|       <div class="modal-header"> |       <div class="modal-header"> | ||||||
|         <h1 class="modal-title fs-5" id="base-selector-model-label">{{ A_("Select Base Account") }}</h1> |         <h1 class="modal-title fs-5" id="accounting-base-selector-modal-label">{{ A_("Select Base Account") }}</h1> | ||||||
|         <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |         <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> | ||||||
|       </div> |       </div> | ||||||
|       <div class="modal-body"> |       <div class="modal-body"> | ||||||
|         <div class="input-group mb-2"> |         <div class="input-group mb-2"> | ||||||
|           <input id="select-base-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search"> |           <input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required"> | ||||||
|           <label class="input-group-text" for="select-base-query"> |           <label class="input-group-text" for="accounting-base-selector-query"> | ||||||
|             <i class="fa-solid fa-magnifying-glass"></i> |             <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|             {{ A_("Search") }} |             {{ A_("Search") }} | ||||||
|           </label> |           </label> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|         <ul class="list-group list-base-selector"> |         <ul id="accounting-base-option-list" class="list-group accounting-selector-list"> | ||||||
|           {% for base in form.base_options %} |           {% for base in form.base_options %} | ||||||
|           <li id="list-group-item-base-{{ base.code }}" class="list-group-item list-group-item-base clickable" data-code="{{ base.code }}" data-content="{{ base }}"> |           <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 }} |             {{ base }} | ||||||
|           </li> |           </li> | ||||||
|           {% endfor %} |           {% endfor %} | ||||||
|         </ul> |         </ul> | ||||||
|  |         <p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> | ||||||
|       </div> |       </div> | ||||||
|       <div class="modal-footer"> |       <div class="modal-footer"> | ||||||
|         <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button> |         <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button> | ||||||
|         {% if form.base_code.data %} |         {% if form.base_code.data %} | ||||||
|           <button id="btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button> |           <button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button> | ||||||
|         {% else %} |         {% else %} | ||||||
|           <button id="btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button> |           <button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button> | ||||||
|         {% endif %} |         {% endif %} | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   | |||||||
| @@ -21,20 +21,20 @@ First written: 2023/1/30 | |||||||
| #} | #} | ||||||
| {% extends "accounting/base.html" %} | {% extends "accounting/base.html" %} | ||||||
|  |  | ||||||
| {% block header %}{% block title %}{{ A_("Account Management") }}{% endblock %}{% endblock %} | {% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
| <div class="btn-group mb-2"> | <div class="btn-group mb-2 d-none d-md-inline-flex"> | ||||||
|   {% if can_edit_accounting() %} |   {% if accounting_can_edit() %} | ||||||
|     <a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|append_next }}"> |     <a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.account.create")|accounting_append_next }}"> | ||||||
|       <i class="fa-solid fa-user-plus"></i> |       <i class="fa-solid fa-plus"></i> | ||||||
|       {{ A_("New") }} |       {{ A_("New") }} | ||||||
|     </a> |     </a> | ||||||
|   {% endif %} |   {% endif %} | ||||||
|   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search"> |   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}"> | ||||||
|     <input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search"> |     <input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required"> | ||||||
|     <label for="search-input" class="search-label"> |     <label for="accounting-search-desktop" class="accounting-search-label"> | ||||||
|       <button type="submit"> |       <button type="submit"> | ||||||
|         <i class="fa-solid fa-magnifying-glass"></i> |         <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|         {{ A_("Search") }} |         {{ A_("Search") }} | ||||||
| @@ -43,9 +43,21 @@ First written: 2023/1/30 | |||||||
|   </form> |   </form> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| {% if can_edit_accounting() %} | <div class="btn-group mb-2 d-md-none"> | ||||||
|   <div class="d-md-none material-fab"> |   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}"> | ||||||
|     <a class="btn btn-primary" href="{{ url_for("accounting.account.create")|append_next }}"> |     <input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required"> | ||||||
|  |     <label for="accounting-search-mobile" class="accounting-search-label"> | ||||||
|  |       <button type="submit"> | ||||||
|  |         <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|  |         {{ A_("Search") }} | ||||||
|  |       </button> | ||||||
|  |     </label> | ||||||
|  |   </form> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {% 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 }}"> | ||||||
|       <i class="fa-solid fa-plus"></i> |       <i class="fa-solid fa-plus"></i> | ||||||
|     </a> |     </a> | ||||||
|   </div> |   </div> | ||||||
| @@ -56,10 +68,10 @@ First written: 2023/1/30 | |||||||
|  |  | ||||||
|   <div class="list-group"> |   <div class="list-group"> | ||||||
|   {% for item in list %} |   {% for item in list %} | ||||||
|     <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|append_next }}"> |     <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}"> | ||||||
|       {{ item }} |       {{ item }} | ||||||
|       {% if item.is_offset_needed %} |       {% if item.is_pay_off_needed %} | ||||||
|         <span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span> |         <span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span> | ||||||
|       {% endif %} |       {% endif %} | ||||||
|     </a> |     </a> | ||||||
|   {% endfor %} |   {% endfor %} | ||||||
|   | |||||||
							
								
								
									
										84
									
								
								src/accounting/templates/accounting/account/order.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/accounting/templates/accounting/account/order.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | order.html: The order of the accounts under a same base account | ||||||
|  |  | ||||||
|  |  Copyright (c) 2023 imacat. | ||||||
|  |  | ||||||
|  |  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  you may not use this file except in compliance with the License. | ||||||
|  |  You may obtain a copy of the License at | ||||||
|  |  | ||||||
|  |      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  | ||||||
|  |  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  WITHOUT 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/2 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/account/include/form.html" %} | ||||||
|  |  | ||||||
|  | {% 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/account-order.js") }}"></script> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block header %}{% block title %}{{ A_("The Accounts of %(base)s", base=base) }}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |  | ||||||
|  | <div class="btn-group mb-3"> | ||||||
|  |   <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> | ||||||
|  |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|  |     {{ A_("Back") }} | ||||||
|  |   </a> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {% if base.accounts|length > 1 and accounting_can_edit() %} | ||||||
|  |   <form action="{{ url_for("accounting.account.sort", base=base) }}" method="post"> | ||||||
|  |     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||||
|  |     {% if "next" in request.args %} | ||||||
|  |       <input type="hidden" name="next" value="{{ request.args["next"] }}"> | ||||||
|  |     {% endif %} | ||||||
|  |     <ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}"> | ||||||
|  |       {% for account in base.accounts|sort(attribute="no") %} | ||||||
|  |         <li class="list-group-item d-flex justify-content-between" data-id="{{ account.id }}"> | ||||||
|  |           <input id="accounting-order-{{ account.id }}-no" type="hidden" name="{{ account.id }}-no" value="{{ loop.index }}"> | ||||||
|  |           <div> | ||||||
|  |             <span id="accounting-order-{{ account.id }}-code">{{ account.code }}</span> | ||||||
|  |             {{ account.title }} | ||||||
|  |           </div> | ||||||
|  |           <i class="fa-solid fa-bars"></i> | ||||||
|  |         </li> | ||||||
|  |       {% endfor %} | ||||||
|  |     </ul> | ||||||
|  |  | ||||||
|  |     <div class="d-none d-md-block"> | ||||||
|  |       <button class="btn btn-primary" type="submit"> | ||||||
|  |         <i class="fa-solid fa-floppy-disk"></i> | ||||||
|  |         {{ A_("Save") }} | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div class="d-md-none accounting-material-fab"> | ||||||
|  |       <button class="btn btn-primary" type="submit"> | ||||||
|  |         <i class="fa-solid fa-floppy-disk"></i> | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |   </form> | ||||||
|  | {% elif base.accounts %} | ||||||
|  |   <ul class="list-group mb-3"> | ||||||
|  |     {% for account in base.accounts|sort(attribute="no") %} | ||||||
|  |       <li class="list-group-item"> | ||||||
|  |         {{ account }} | ||||||
|  |       </li> | ||||||
|  |     {% endfor %} | ||||||
|  |   </ul> | ||||||
|  | {% else %} | ||||||
|  |   <p>{{ A_("There is no data.") }}</p> | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
| @@ -26,19 +26,19 @@ First written: 2023/2/1 | |||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
| <div class="btn-group mb-3"> | <div class="btn-group mb-3"> | ||||||
|   <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}"> |   <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}"> | ||||||
|     <i class="fa-solid fa-circle-chevron-left"></i> |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|     {{ A_("Back") }} |     {{ A_("Back") }} | ||||||
|   </a> |   </a> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <div class="account col-sm-6"> | <div class="accounting-card col-sm-6"> | ||||||
|   <div class="account-title">{{ obj.title }}</div> |   <div class="accounting-card-title">{{ obj.title }}</div> | ||||||
|   <div class="account-code">{{ obj.code }}</div> |   <div class="accounting-card-code">{{ obj.code }}</div> | ||||||
|   {% if obj.accounts %} |   {% if obj.accounts %} | ||||||
|     <div> |     <div> | ||||||
|     {% for account in obj.accounts %} |     {% for account in obj.accounts %} | ||||||
|       <a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|append_next }}"> |       <a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}"> | ||||||
|         {{ account }} |         {{ account }} | ||||||
|       </a> |       </a> | ||||||
|     {% endfor %} |     {% endfor %} | ||||||
|   | |||||||
| @@ -21,14 +21,14 @@ First written: 2023/1/26 | |||||||
| #} | #} | ||||||
| {% extends "accounting/base.html" %} | {% extends "accounting/base.html" %} | ||||||
|  |  | ||||||
| {% block header %}{% block title %}{{ A_("Base Account Managements") }}{% endblock %}{% endblock %} | {% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|  |  | ||||||
| <div class="btn-group mb-2"> | <div class="btn-group mb-2"> | ||||||
|   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search"> |   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-label="{{ A_("Search") }}"> | ||||||
|     <input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search"> |     <input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required"> | ||||||
|     <label for="search-input" class="search-label"> |     <label for="accounting-search" class="accounting-search-label"> | ||||||
|       <button type="submit"> |       <button type="submit"> | ||||||
|         <i class="fa-solid fa-magnifying-glass"></i> |         <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|         {{ A_("Search") }} |         {{ A_("Search") }} | ||||||
| @@ -42,7 +42,7 @@ First written: 2023/1/26 | |||||||
|  |  | ||||||
|   <div class="list-group"> |   <div class="list-group"> | ||||||
|     {% for item in list %} |     {% for item in list %} | ||||||
|       <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|append_next }}"> |       <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|accounting_append_next }}"> | ||||||
|         {{ item }} |         {{ item }} | ||||||
|       </a> |       </a> | ||||||
|     {% endfor %} |     {% endfor %} | ||||||
|   | |||||||
							
								
								
									
										28
									
								
								src/accounting/templates/accounting/currency/create.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/accounting/templates/accounting/currency/create.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | create.html: The currency creation 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/6 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/currency/include/form.html" %} | ||||||
|  |  | ||||||
|  | {% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %} | ||||||
							
								
								
									
										90
									
								
								src/accounting/templates/accounting/currency/detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								src/accounting/templates/accounting/currency/detail.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,90 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | detail.html: The currency 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/2/6 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/base.html" %} | ||||||
|  |  | ||||||
|  | {% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |  | ||||||
|  | <div class="btn-group mb-3"> | ||||||
|  |   <a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|accounting_or_next }}"> | ||||||
|  |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|  |     {{ A_("Back") }} | ||||||
|  |   </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> | ||||||
|  |   {% 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> | ||||||
|  |   {% 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 }}"> | ||||||
|  |       <i class="fa-solid fa-pen-to-square"></i> | ||||||
|  |     </a> | ||||||
|  |   </div> | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
|  | {% if accounting_can_edit() %} | ||||||
|  |   <form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post"> | ||||||
|  |     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||||
|  |     {% if "next" in request.args %} | ||||||
|  |       <input type="hidden" name="next" value="{{ request.args["next"] }}"> | ||||||
|  |     {% endif %} | ||||||
|  |     <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true"> | ||||||
|  |       <div class="modal-dialog"> | ||||||
|  |         <div class="modal-content"> | ||||||
|  |           <div class="modal-header"> | ||||||
|  |             <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Currency Confirmation") }}</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 currency?") }} | ||||||
|  |           </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-danger">{{ A_("Confirm") }}</button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </form> | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
|  | <div class="accounting-card col-sm-6"> | ||||||
|  |   <div class="accounting-card-title">{{ obj.name }}</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> | ||||||
|  |     <div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										30
									
								
								src/accounting/templates/accounting/currency/edit.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/accounting/templates/accounting/currency/edit.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | edit.html: The currency 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/6 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/currency/include/form.html" %} | ||||||
|  |  | ||||||
|  | {% block header %}{% block title %}{{ A_("%(currency)s Settings", currency=currency) }}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block back_url %}{{ url_for("accounting.currency.detail", currency=currency)|accounting_inherit_next }}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block action_url %}{{ url_for("accounting.currency.update", currency=currency) }}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block original_code %}{{ currency.code }}{% endblock %} | ||||||
| @@ -0,0 +1,68 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | form.html: The currency 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/6 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/base.html" %} | ||||||
|  |  | ||||||
|  | {% block accounting_scripts %} | ||||||
|  |   <script src="{{ url_for("accounting.static", filename="js/currency-form.js") }}"></script> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |  | ||||||
|  | <div class="btn-group btn-actions mb-3"> | ||||||
|  |   <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}"> | ||||||
|  |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|  |     {{ A_("Back") }} | ||||||
|  |   </a> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | <form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post"> | ||||||
|  |   {{ form.csrf_token }} | ||||||
|  |   {% if "next" in request.args %} | ||||||
|  |     <input type="hidden" name="next" value="{{ request.args["next"] }}"> | ||||||
|  |   {% endif %} | ||||||
|  |   <div class="form-floating mb-3"> | ||||||
|  |     <input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ "" if form.code.data is none else form.code.data }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}"> | ||||||
|  |     <label class="form-label" for="accounting-code">{{ A_("Code") }}</label> | ||||||
|  |     <div id="accounting-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <div class="form-floating mb-3"> | ||||||
|  |     <input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ "" if form.name.data is none else form.name.data }}" placeholder=" " required="required"> | ||||||
|  |     <label class="form-label" for="accounting-name">{{ A_("Name") }}</label> | ||||||
|  |     <div id="accounting-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <div class="d-none d-md-block"> | ||||||
|  |     <button class="btn btn-primary" type="submit"> | ||||||
|  |       <i class="fa-solid fa-floppy-disk"></i> | ||||||
|  |       {{ A_("Save") }} | ||||||
|  |     </button> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <div class="d-md-none accounting-material-fab"> | ||||||
|  |     <button class="btn btn-primary" type="submit"> | ||||||
|  |       <i class="fa-solid fa-floppy-disk"></i> | ||||||
|  |     </button> | ||||||
|  |   </div> | ||||||
|  | </form> | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										80
									
								
								src/accounting/templates/accounting/currency/list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								src/accounting/templates/accounting/currency/list.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,80 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | list.html: The currency list | ||||||
|  |  | ||||||
|  |  Copyright (c) 2023 imacat. | ||||||
|  |  | ||||||
|  |  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  you may not use this file except in compliance with the License. | ||||||
|  |  You may obtain a copy of the License at | ||||||
|  |  | ||||||
|  |      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  | ||||||
|  |  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  WITHOUT 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/6 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/base.html" %} | ||||||
|  |  | ||||||
|  | {% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |  | ||||||
|  | <div class="btn-group mb-2 d-none d-md-inline-flex"> | ||||||
|  |   {% if accounting_can_edit() %} | ||||||
|  |     <a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.currency.create")|accounting_append_next }}"> | ||||||
|  |       <i class="fa-solid fa-plus"></i> | ||||||
|  |       {{ A_("New") }} | ||||||
|  |     </a> | ||||||
|  |   {% endif %} | ||||||
|  |   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}"> | ||||||
|  |     <input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required"> | ||||||
|  |     <label for="accounting-search-desktop" class="accounting-search-label"> | ||||||
|  |       <button type="submit"> | ||||||
|  |         <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|  |         {{ A_("Search") }} | ||||||
|  |       </button> | ||||||
|  |     </label> | ||||||
|  |   </form> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | <div class="btn-group mb-2 d-md-none"> | ||||||
|  |   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}"> | ||||||
|  |     <input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required"> | ||||||
|  |     <label for="accounting-search-mobile" class="accounting-search-label"> | ||||||
|  |       <button type="submit"> | ||||||
|  |         <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|  |         {{ A_("Search") }} | ||||||
|  |       </button> | ||||||
|  |     </label> | ||||||
|  |   </form> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {% 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 }}"> | ||||||
|  |       <i class="fa-solid fa-plus"></i> | ||||||
|  |     </a> | ||||||
|  |   </div> | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
|  | {% if list %} | ||||||
|  |   {% include "accounting/include/pagination.html" %} | ||||||
|  |  | ||||||
|  |   <div class="list-group"> | ||||||
|  |   {% for item in list %} | ||||||
|  |     <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.currency.detail", currency=item)|accounting_append_next }}"> | ||||||
|  |       {{ item }} | ||||||
|  |     </a> | ||||||
|  |   {% endfor %} | ||||||
|  |   </div> | ||||||
|  | {% else %} | ||||||
|  |   <p>{{ A_("There is no data.") }}</p> | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
| @@ -19,13 +19,20 @@ nav.html: The navigation menu for the accounting application. | |||||||
| Author: imacat@mail.imacat.idv.tw (imacat) | Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
| First written: 2023/1/26 | First written: 2023/1/26 | ||||||
| #} | #} | ||||||
| {% if can_view_accounting() %} | {# <ul> For SonarQube not to complain about incorrect HTML #} | ||||||
|  | {% if accounting_can_view() %} | ||||||
|   <li class="nav-item dropdown"> |   <li class="nav-item dropdown"> | ||||||
|     <span class="nav-link dropdown-toggle" data-bs-toggle="dropdown"> |     <span class="nav-link dropdown-toggle" data-bs-toggle="dropdown"> | ||||||
|       <i class="fa-solid fa-gear"></i> |       <i class="fa-solid fa-file-invoice-dollar"></i> | ||||||
|       {{ A_("Accounting") }} |       {{ A_("Accounting") }} | ||||||
|     </span> |     </span> | ||||||
|     <ul class="dropdown-menu"> |     <ul class="dropdown-menu"> | ||||||
|  |       <li> | ||||||
|  |         <a class="dropdown-item {% if request.endpoint.startswith("accounting.transaction.") %} active {% endif %}" href="{{ url_for("accounting.transaction.list") }}"> | ||||||
|  |           <i class="fa-solid fa-receipt"></i> | ||||||
|  |           {{ A_("Transactions") }} | ||||||
|  |         </a> | ||||||
|  |       </li> | ||||||
|       <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.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}"> | ||||||
|           <i class="fa-solid fa-list"></i> |           <i class="fa-solid fa-list"></i> | ||||||
| @@ -38,6 +45,13 @@ First written: 2023/1/26 | |||||||
|           {{ A_("Base Accounts") }} |           {{ A_("Base Accounts") }} | ||||||
|         </a> |         </a> | ||||||
|       </li> |       </li> | ||||||
|  |       <li> | ||||||
|  |         <a class="dropdown-item {% if 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> | ||||||
|     </ul> |     </ul> | ||||||
|   </li> |   </li> | ||||||
| {% endif %} | {% endif %} | ||||||
|  | {# </ul> For SonarQube not to complain about incorrect HTML #} | ||||||
|   | |||||||
| @@ -19,10 +19,10 @@ pagination.html: The pagination navigation bar. | |||||||
| Author: imacat@mail.imacat.idv.tw (imacat) | Author: imacat@mail.imacat.idv.tw (imacat) | ||||||
| First written: 2023/1/26 | First written: 2023/1/26 | ||||||
| #} | #} | ||||||
| {% if pagination.is_needed %} | {% if pagination.is_paged %} | ||||||
|   <nav aria-label="Page navigation"> |   <nav aria-label="{{ A_("Page navigation") }}"> | ||||||
|     <ul class="pagination"> |     <ul class="pagination"> | ||||||
|       {% for link in pagination.page_links %} |       {% for link in pagination.pages %} | ||||||
|         {% if link.uri is none %} |         {% if link.uri is none %} | ||||||
|           <li class="page-item disabled {% if not link.is_for_mobile %} d-none d-md-inline {% endif %}"> |           <li class="page-item disabled {% if not link.is_for_mobile %} d-none d-md-inline {% endif %}"> | ||||||
|             <span class="page-link"> |             <span class="page-link"> | ||||||
| @@ -42,7 +42,7 @@ First written: 2023/1/26 | |||||||
|           {{ pagination.page_size }} |           {{ pagination.page_size }} | ||||||
|         </div> |         </div> | ||||||
|         <ul class="dropdown-menu"> |         <ul class="dropdown-menu"> | ||||||
|           {% for link in pagination.page_sizes %} |           {% for link in pagination.page_size_options %} | ||||||
|             <li> |             <li> | ||||||
|               <a class="dropdown-item {% if link.is_current %} active {% endif %}" href="{{ link.uri }}"> |               <a class="dropdown-item {% if link.is_current %} active {% endif %}" href="{{ link.uri }}"> | ||||||
|                 {{ link.text }} |                 {{ link.text }} | ||||||
|   | |||||||
| @@ -0,0 +1,28 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | create.html: The cash expense transaction creation 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/transaction/expense/include/form.html" %} | ||||||
|  |  | ||||||
|  | {% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %} | ||||||
| @@ -0,0 +1,53 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | detail.html: The account 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/2/26 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/transaction/include/detail.html" %} | ||||||
|  |  | ||||||
|  | {% block transaction_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_txn_format_amount }}</div> | ||||||
|  |             </div> | ||||||
|  |           </li> | ||||||
|  |         {% endfor %} | ||||||
|  |         <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> | ||||||
|  |           <div class="d-flex justify-content-between"> | ||||||
|  |             <div>{{ _("Total") }}</div> | ||||||
|  |             <div>{{ currency.debit_total|accounting_txn_format_amount }}</div> | ||||||
|  |           </div> | ||||||
|  |         </li> | ||||||
|  |       </ul> | ||||||
|  |     </div> | ||||||
|  |   {% endfor %} | ||||||
|  | {% endblock %} | ||||||
| @@ -0,0 +1,28 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | edit.html: The cash expense transaction 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/transaction/expense/include/form.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 %} | ||||||
| @@ -0,0 +1,83 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | currency-sub-form.html: The currency sub-form in the cash expense 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 | ||||||
|  | #} | ||||||
|  | <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}"> | ||||||
|  |   <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}"> | ||||||
|  |   <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" class="form-select" name="currency-{{ currency_index }}-code"> | ||||||
|  |           {% for currency in accounting_txn_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">{{ 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-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% 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> | ||||||
|  |  | ||||||
|  |     <div class="mb-3"> | ||||||
|  |       <div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}"> | ||||||
|  |         <label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Content") }}</label> | ||||||
|  |         <ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list"> | ||||||
|  |           {% for entry_form in debit_forms %} | ||||||
|  |             {% with currency_index = currency_index, | ||||||
|  |                     entry_type = "debit", | ||||||
|  |                     entry_index = loop.index, | ||||||
|  |                     entry_id = entry_form.eid.data, | ||||||
|  |                     only_one_entry_form = debit_forms|length == 1, | ||||||
|  |                     account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data, | ||||||
|  |                     account_code_error = entry_form.account_code.errors, | ||||||
|  |                     account_text = entry_form.account_text, | ||||||
|  |                     summary_data = "" if entry_form.summary.data is none else entry_form.summary.data, | ||||||
|  |                     summary_errors = entry_form.summary.errors, | ||||||
|  |                     amount_data = "" if entry_form.amount.data is none else entry_form.amount.data, | ||||||
|  |                     amount_errors = entry_form.amount.errors, | ||||||
|  |                     amount_text = entry_form.amount.data|accounting_txn_format_amount, | ||||||
|  |                     entry_errors = entry_form.all_errors %} | ||||||
|  |               {% include "accounting/transaction/include/form-entry-item.html" %} | ||||||
|  |             {% endwith %} | ||||||
|  |           {% endfor %} | ||||||
|  |         </ul> | ||||||
|  |  | ||||||
|  |         <div class="d-flex justify-content-between mb-2"> | ||||||
|  |           <div>{{ A_("Total") }}</div> | ||||||
|  |           <div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <div> | ||||||
|  |           <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> | ||||||
|  |             <i class="fas fa-plus"></i> | ||||||
|  |             {{ A_("New") }} | ||||||
|  |           </button> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  |   <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div> | ||||||
|  | </div> | ||||||
| @@ -0,0 +1,50 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | form.html: The cash expense 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 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/transaction/include/form.html" %} | ||||||
|  |  | ||||||
|  | {% block currency_sub_forms %} | ||||||
|  |   {% if form.currencies %} | ||||||
|  |     {% for currency_form in form.currencies %} | ||||||
|  |       {% with currency_index = loop.index, | ||||||
|  |               only_one_currency_form = form.currencies|length == 1, | ||||||
|  |               currency_errors = currency_form.whole_form.errors, | ||||||
|  |               currency_code_data = currency_form.code.data, | ||||||
|  |               currency_code_errors = currency_form.code.errors, | ||||||
|  |               debit_forms = currency_form.debit, | ||||||
|  |               debit_errors = currency_form.debit_errors, | ||||||
|  |               debit_total = currency_form.form.debit_total|accounting_txn_format_amount %} | ||||||
|  |         {% include "accounting/transaction/expense/include/form-currency-item.html" %} | ||||||
|  |       {% endwith %} | ||||||
|  |     {% endfor %} | ||||||
|  |   {% else %} | ||||||
|  |     {% with currency_index = 1, | ||||||
|  |             only_one_currency_form = True, | ||||||
|  |             currency_code_data = accounting_txn_default_currency_code(), | ||||||
|  |             debit_total = "-" %} | ||||||
|  |       {% include "accounting/transaction/expense/include/form-currency-item.html" %} | ||||||
|  |     {% endwith %} | ||||||
|  |   {% endif %} | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block account_selector_modals %} | ||||||
|  |   {% include "accounting/transaction/include/debit-account-modal.html" %} | ||||||
|  | {% endblock %} | ||||||
| @@ -0,0 +1,39 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | add-new-material-fab.html: The material floating action buttons to add a new transaction | ||||||
|  |  | ||||||
|  |  Copyright (c) 2023 imacat. | ||||||
|  |  | ||||||
|  |  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  you may not use this file except in compliance with the License. | ||||||
|  |  You may obtain a copy of the License at | ||||||
|  |  | ||||||
|  |      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  | ||||||
|  |  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  WITHOUT 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 | ||||||
|  | #} | ||||||
|  | {% if accounting_can_edit() %} | ||||||
|  |   <div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab"> | ||||||
|  |     <div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group"> | ||||||
|  |       <a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.expense)|accounting_append_next }}"> | ||||||
|  |         {{ A_("Cash expense") }} | ||||||
|  |       </a> | ||||||
|  |       <a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.income)|accounting_append_next }}"> | ||||||
|  |         {{ A_("Cash income") }} | ||||||
|  |       </a> | ||||||
|  |       <a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.transfer)|accounting_append_next }}"> | ||||||
|  |         {{ A_("Transfer") }} | ||||||
|  |       </a> | ||||||
|  |     </div> | ||||||
|  |     <button id="accounting-btn-material-fab-speed-dial" class="btn btn-primary rounded-circle accounting-btn-material-fab" type="button" data-target="accounting-material-fab-speed-dial"> | ||||||
|  |       <i class="fas fa-plus"></i> | ||||||
|  |     </button> | ||||||
|  |   </div> | ||||||
|  | {% endif %} | ||||||
| @@ -0,0 +1,54 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | credit-modals.html: The modals for the credit journal entry sub-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 | ||||||
|  | #} | ||||||
|  | <div id="accounting-credit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-credit-account" tabindex="-1" aria-labelledby="accounting-credit-account-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-credit-account-selector-modal-label">{{ A_("Select Credit Account") }}</h1> | ||||||
|  |         <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button> | ||||||
|  |       </div> | ||||||
|  |       <div class="modal-body"> | ||||||
|  |         <div class="input-group mb-2"> | ||||||
|  |           <input id="accounting-credit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required"> | ||||||
|  |           <label class="input-group-text" for="accounting-credit-account-selector-query"> | ||||||
|  |             <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|  |             {{ A_("Search") }} | ||||||
|  |           </label> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <ul id="accounting-credit-account-option-list" class="list-group accounting-selector-list"> | ||||||
|  |           {% for account in form.credit_account_options %} | ||||||
|  |           <li id="accounting-credit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-credit-account-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> | ||||||
|  |             {{ account }} | ||||||
|  |           </li> | ||||||
|  |           {% endfor %} | ||||||
|  |           <li id="accounting-credit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li> | ||||||
|  |         </ul> | ||||||
|  |         <p id="accounting-credit-account-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-entry-form-modal">{{ A_("Cancel") }}</button> | ||||||
|  |         <button id="accounting-credit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| @@ -0,0 +1,54 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | credit-modals.html: The modals for the debit journal entry sub-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 | ||||||
|  | #} | ||||||
|  | <div id="accounting-debit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-debit-account" tabindex="-1" aria-labelledby="accounting-debit-account-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-debit-account-selector-modal-label">{{ A_("Select Debit Account") }}</h1> | ||||||
|  |         <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button> | ||||||
|  |       </div> | ||||||
|  |       <div class="modal-body"> | ||||||
|  |         <div class="input-group mb-2"> | ||||||
|  |           <input id="accounting-debit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required"> | ||||||
|  |           <label class="input-group-text" for="accounting-debit-account-selector-query"> | ||||||
|  |             <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|  |             {{ A_("Search") }} | ||||||
|  |           </label> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <ul id="accounting-debit-account-option-list" class="list-group accounting-selector-list"> | ||||||
|  |           {% for account in form.debit_account_options %} | ||||||
|  |           <li id="accounting-debit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-debit-account-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> | ||||||
|  |             {{ account }} | ||||||
|  |           </li> | ||||||
|  |           {% endfor %} | ||||||
|  |           <li id="accounting-debit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li> | ||||||
|  |         </ul> | ||||||
|  |         <p id="accounting-debit-account-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-entry-form-modal">{{ A_("Cancel") }}</button> | ||||||
|  |         <button id="accounting-debit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
| @@ -0,0 +1,111 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | detail.html: The account 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/2/26 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/base.html" %} | ||||||
|  |  | ||||||
|  | {% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |  | ||||||
|  | <div class="btn-group mb-3"> | ||||||
|  |   <a class="btn btn-primary" href="{{ url_for("accounting.transaction.list")|accounting_or_next }}"> | ||||||
|  |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|  |     {{ A_("Back") }} | ||||||
|  |   </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> | ||||||
|  |   {% endif %} | ||||||
|  |   <a class="btn btn-primary" href="{{ url_for("accounting.transaction.order", txn_date=obj.date)|accounting_append_next }}"> | ||||||
|  |     <i class="fa-solid fa-bars-staggered"></i> | ||||||
|  |     {{ A_("Order") }} | ||||||
|  |   </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> | ||||||
|  |   {% 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 }}"> | ||||||
|  |       <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"> | ||||||
|  |     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||||
|  |     {% if "next" in request.args %} | ||||||
|  |       <input type="hidden" name="next" value="{{ request.args["next"] }}"> | ||||||
|  |     {% endif %} | ||||||
|  |     <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true"> | ||||||
|  |       <div class="modal-dialog"> | ||||||
|  |         <div class="modal-content"> | ||||||
|  |           <div class="modal-header"> | ||||||
|  |             <h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Transaction Confirmation") }}</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?") }} | ||||||
|  |           </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-danger">{{ A_("Confirm") }}</button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </form> | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
|  | <div class="accounting-transaction-card"> | ||||||
|  |   <div class="d-none d-sm-flex justify-content-center mb-3"> | ||||||
|  |     <h2 class="text-center">{{ obj }}</h2> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <div class="mb-3"> | ||||||
|  |     {{ obj.date|accounting_txn_format_date }} | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   {% block transaction_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 }} | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   {% endif %} | ||||||
|  |  | ||||||
|  |   <div class="small text-secondary fst-italic"> | ||||||
|  |     <div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div> | ||||||
|  |     <div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
| @@ -0,0 +1,58 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | entry-form-modal.html: The modal of the journal entry sub-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 | ||||||
|  | #} | ||||||
|  | <form id="accounting-entry-form" data-currency-index="" data-entry-type="" data-entry-index=""> | ||||||
|  |   <div id="accounting-entry-form-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-form-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-entry-form-modal-label">{{ A_("Journal Entry Content") }}</h1> | ||||||
|  |           <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button> | ||||||
|  |         </div> | ||||||
|  |         <div class="modal-body"> | ||||||
|  |           <div class="mb-3"> | ||||||
|  |             <div id="accounting-entry-form-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target=""> | ||||||
|  |               <label class="form-label" for="accounting-entry-form-account">{{ A_("Account") }}</label> | ||||||
|  |               <div id="accounting-entry-form-account" data-code="" data-text=""></div> | ||||||
|  |             </div> | ||||||
|  |             <div id="accounting-entry-form-account-error" class="invalid-feedback"></div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <div class="form-floating mb-3"> | ||||||
|  |             <input id="accounting-entry-form-summary" class="form-control" type="text" value="" placeholder=" "> | ||||||
|  |             <label for="accounting-entry-form-summary">{{ A_("Summary") }}</label> | ||||||
|  |             <div id="accounting-entry-form-summary-error" class="invalid-feedback"></div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <div class="form-floating mb-3"> | ||||||
|  |             <input id="accounting-entry-form-amount" class="form-control" type="number" value="" min="0.01" max="" step="0.01" placeholder=" " required="required"> | ||||||
|  |             <label for="accounting-entry-form-amount">{{ A_("Amount") }}</label> | ||||||
|  |             <div id="accounting-entry-form-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 id="accounting-entry-form-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </form> | ||||||
| @@ -0,0 +1,48 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | entry-sub-form.html: The journal entry sub-form in the 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 | ||||||
|  | #} | ||||||
|  | {# <ul> For SonarQube not to complain about incorrect HTML #} | ||||||
|  | <li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-account-modal="#accounting-{{ entry_type }}-account-selector-modal" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}"> | ||||||
|  |   {% if entry_id %} | ||||||
|  |     <input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}"> | ||||||
|  |   {% endif %} | ||||||
|  |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}"> | ||||||
|  |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}"> | ||||||
|  |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}"> | ||||||
|  |   <input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}"> | ||||||
|  |   <div class="accounting-entry-content"> | ||||||
|  |     <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between accounting-entry-control {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> | ||||||
|  |       <div> | ||||||
|  |         <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-text" class="small">{{ account_text }}</div> | ||||||
|  |         <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ "" if summary_data is none else summary_data }}</div> | ||||||
|  |       </div> | ||||||
|  |       <div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_data }}</span></div> | ||||||
|  |     </div> | ||||||
|  |     <div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-error" class="invalid-feedback">{% if entry_errors %}{{ entry_errors[0] }}{% endif %}</div> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <div> | ||||||
|  |     <button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-btn-delete" class="btn btn-danger rounded-circle accounting-btn-delete-entry accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry {% if only_one_entry_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" data-same-class="accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry"> | ||||||
|  |       <i class="fas fa-minus"></i> | ||||||
|  |     </button> | ||||||
|  |   </div> | ||||||
|  | </li> | ||||||
|  | {# </ul> For SonarQube not to complain about incorrect HTML #} | ||||||
| @@ -0,0 +1,90 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | form.html: 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/26 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/base.html" %} | ||||||
|  |  | ||||||
|  | {% 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> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |  | ||||||
|  | <div class="btn-group btn-actions mb-3"> | ||||||
|  |   <a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}"> | ||||||
|  |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|  |     {{ A_("Back") }} | ||||||
|  |   </a> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | <form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-entry-template="{{ entry_template }}"> | ||||||
|  |   {{ form.csrf_token }} | ||||||
|  |   {% if "next" in request.args %} | ||||||
|  |     <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="{{ "" if form.date.data is none else form.date.data }}" 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> | ||||||
|  |  | ||||||
|  |   <div class="mb-3"> | ||||||
|  |     <div id="accounting-currencies" class="form-control accounting-material-text-field accounting-not-empty {% if form.currencies_errors %} is-invalid {% endif %}"> | ||||||
|  |       <label class="form-label" for="accounting-currencies">{{ A_("Content") }}</label> | ||||||
|  |       <div id="accounting-currency-list" class="mt-2"> | ||||||
|  |         {% block currency_sub_forms %}{% endblock %} | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div> | ||||||
|  |         <button id="accounting-btn-new-currency" class="btn btn-primary" type="button"> | ||||||
|  |           <i class="fas fa-plus"></i> | ||||||
|  |           {{ A_("New") }} | ||||||
|  |         </button> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |     <div id="accounting-currencies-error" class="invalid-feedback">{% if form.currencies_errors %}{{ form.currencies_errors[0] }}{% endif %}</div> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <div class="form-floating mb-3"> | ||||||
|  |     <textarea id="accounting-note" class="form-control form-control-lg {% if form.note.errors %} is-invalid {% endif %}" name="note" rows="5" placeholder=" ">{{ "" if form.note.data is none else form.note.data }}</textarea> | ||||||
|  |     <label class="form-label" for="accounting-note">{{ A_("Note") }}</label> | ||||||
|  |     <div id="accounting-note-error" class="invalid-feedback">{% if form.note.errors %}{{ form.note.errors[0] }}{% endif %}</div> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <div class="d-none d-md-block"> | ||||||
|  |     <button class="btn btn-primary" type="submit"> | ||||||
|  |       <i class="fa-solid fa-floppy-disk"></i> | ||||||
|  |       {{ A_("Save") }} | ||||||
|  |     </button> | ||||||
|  |   </div> | ||||||
|  |  | ||||||
|  |   <div class="d-md-none accounting-material-fab"> | ||||||
|  |     <button class="btn btn-primary" type="submit"> | ||||||
|  |       <i class="fa-solid fa-floppy-disk"></i> | ||||||
|  |     </button> | ||||||
|  |   </div> | ||||||
|  | </form> | ||||||
|  |  | ||||||
|  | {% include "accounting/transaction/include/entry-form-modal.html" %} | ||||||
|  | {% block account_selector_modals %}{% endblock %} | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
| @@ -0,0 +1,28 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | create.html: The cash income transaction creation 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/transaction/income/include/form.html" %} | ||||||
|  |  | ||||||
|  | {% block header %}{% block title %}{{ A_("Add a New Cash Income Transaction") }}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %} | ||||||
| @@ -0,0 +1,53 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | detail.html: The account 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/2/26 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/transaction/include/detail.html" %} | ||||||
|  |  | ||||||
|  | {% block transaction_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_txn_format_amount }}</div> | ||||||
|  |             </div> | ||||||
|  |           </li> | ||||||
|  |         {% endfor %} | ||||||
|  |         <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> | ||||||
|  |           <div class="d-flex justify-content-between"> | ||||||
|  |             <div>{{ _("Total") }}</div> | ||||||
|  |             <div>{{ currency.debit_total|accounting_txn_format_amount }}</div> | ||||||
|  |           </div> | ||||||
|  |         </li> | ||||||
|  |       </ul> | ||||||
|  |     </div> | ||||||
|  |   {% endfor %} | ||||||
|  | {% endblock %} | ||||||
| @@ -0,0 +1,28 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | edit.html: The cash income transaction 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/transaction/income/include/form.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 %} | ||||||
| @@ -0,0 +1,83 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | currency-sub-form.html: The currency sub-form in the cash income 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 | ||||||
|  | #} | ||||||
|  | <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}"> | ||||||
|  |   <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}"> | ||||||
|  |   <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" class="form-select" name="currency-{{ currency_index }}-code"> | ||||||
|  |           {% for currency in accounting_txn_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">{{ 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-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% 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> | ||||||
|  |  | ||||||
|  |     <div class="mb-3"> | ||||||
|  |       <div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}"> | ||||||
|  |         <label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Content") }}</label> | ||||||
|  |         <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list"> | ||||||
|  |           {% for entry_form in credit_forms %} | ||||||
|  |             {% with currency_index = currency_index, | ||||||
|  |                     entry_type = "credit", | ||||||
|  |                     entry_index = loop.index, | ||||||
|  |                     only_one_entry_form = debit_forms|length == 1, | ||||||
|  |                     entry_id = entry_form.eid.data, | ||||||
|  |                     account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data, | ||||||
|  |                     account_code_error = entry_form.account_code.errors, | ||||||
|  |                     account_text = entry_form.account_text, | ||||||
|  |                     summary_data = "" if entry_form.summary.data is none else entry_form.summary.data, | ||||||
|  |                     summary_errors = entry_form.summary.errors, | ||||||
|  |                     amount_data = "" if entry_form.amount.data is none else entry_form.amount.data, | ||||||
|  |                     amount_errors = entry_form.amount.errors, | ||||||
|  |                     amount_text = entry_form.amount.data|accounting_txn_format_amount, | ||||||
|  |                     entry_errors = entry_form.all_errors %} | ||||||
|  |               {% include "accounting/transaction/include/form-entry-item.html" %} | ||||||
|  |             {% endwith %} | ||||||
|  |           {% endfor %} | ||||||
|  |         </ul> | ||||||
|  |  | ||||||
|  |         <div class="d-flex justify-content-between mb-2"> | ||||||
|  |           <div>{{ A_("Total") }}</div> | ||||||
|  |           <div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <div> | ||||||
|  |           <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> | ||||||
|  |             <i class="fas fa-plus"></i> | ||||||
|  |             {{ A_("New") }} | ||||||
|  |           </button> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <div id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  |   <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div> | ||||||
|  | </div> | ||||||
| @@ -0,0 +1,50 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | form.html: The cash income 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 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/transaction/include/form.html" %} | ||||||
|  |  | ||||||
|  | {% block currency_sub_forms %} | ||||||
|  |   {% if form.currencies %} | ||||||
|  |     {% for currency_form in form.currencies %} | ||||||
|  |       {% with currency_index = loop.index, | ||||||
|  |               only_one_currency_form = form.currencies|length == 1, | ||||||
|  |               currency_errors = currency_form.whole_form.errors, | ||||||
|  |               currency_code_data = currency_form.code.data, | ||||||
|  |               currency_code_errors = currency_form.code.errors, | ||||||
|  |               credit_forms = currency_form.credit, | ||||||
|  |               credit_errors = currency_form.credit_errors, | ||||||
|  |               credit_total = currency_form.form.credit_total|accounting_txn_format_amount %} | ||||||
|  |         {% include "accounting/transaction/income/include/form-currency-item.html" %} | ||||||
|  |       {% endwith %} | ||||||
|  |     {% endfor %} | ||||||
|  |   {% else %} | ||||||
|  |     {% with currency_index = 1, | ||||||
|  |             only_one_currency_form = True, | ||||||
|  |             currency_code_data = accounting_txn_default_currency_code(), | ||||||
|  |             credit_total = "-" %} | ||||||
|  |       {% include "accounting/transaction/income/include/form-currency-item.html" %} | ||||||
|  |     {% endwith %} | ||||||
|  |   {% endif %} | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block account_selector_modals %} | ||||||
|  |   {% include "accounting/transaction/include/credit-account-modal.html" %} | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										96
									
								
								src/accounting/templates/accounting/transaction/list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/accounting/templates/accounting/transaction/list.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | list.html: The transaction list | ||||||
|  |  | ||||||
|  |  Copyright (c) 2023 imacat. | ||||||
|  |  | ||||||
|  |  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  you may not use this file except in compliance with the License. | ||||||
|  |  You may obtain a copy of the License at | ||||||
|  |  | ||||||
|  |      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  | ||||||
|  |  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  WITHOUT 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/18 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/base.html" %} | ||||||
|  |  | ||||||
|  | {% block accounting_scripts %} | ||||||
|  |   <script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Transaction Management") }}{% endif %}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |  | ||||||
|  | <div class="btn-group mb-2 d-none d-md-inline-flex"> | ||||||
|  |   {% if accounting_can_edit() %} | ||||||
|  |     <div class="btn-group" role="group"> | ||||||
|  |       <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"> | ||||||
|  |         <i class="fa-solid fa-plus"></i> | ||||||
|  |         {{ A_("New") }} | ||||||
|  |       </button> | ||||||
|  |       <ul class="dropdown-menu"> | ||||||
|  |         <li> | ||||||
|  |           <a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.expense)|accounting_append_next }}"> | ||||||
|  |             {{ A_("Cash Expense") }}</a> | ||||||
|  |         </li> | ||||||
|  |         <li> | ||||||
|  |           <a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.income)|accounting_append_next }}"> | ||||||
|  |             {{ A_("Cash Income") }} | ||||||
|  |           </a> | ||||||
|  |         </li> | ||||||
|  |         <li> | ||||||
|  |           <a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.transfer)|accounting_append_next }}"> | ||||||
|  |             {{ A_("Transfer") }} | ||||||
|  |           </a> | ||||||
|  |         </li> | ||||||
|  |       </ul> | ||||||
|  |     </div> | ||||||
|  |   {% endif %} | ||||||
|  |   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.transaction.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}"> | ||||||
|  |     <input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required"> | ||||||
|  |     <label for="accounting-search-desktop" class="accounting-search-label"> | ||||||
|  |       <button type="submit"> | ||||||
|  |         <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|  |         {{ A_("Search") }} | ||||||
|  |       </button> | ||||||
|  |     </label> | ||||||
|  |   </form> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | <div class="btn-group mb-2 d-md-none"> | ||||||
|  |   <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.transaction.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}"> | ||||||
|  |     <input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required"> | ||||||
|  |     <label for="accounting-search-mobile" class="accounting-search-label"> | ||||||
|  |       <button type="submit"> | ||||||
|  |         <i class="fa-solid fa-magnifying-glass"></i> | ||||||
|  |         {{ A_("Search") }} | ||||||
|  |       </button> | ||||||
|  |     </label> | ||||||
|  |   </form> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {% include "accounting/transaction/include/add-new-material-fab.html" %} | ||||||
|  |  | ||||||
|  | {% if list %} | ||||||
|  |   {% include "accounting/include/pagination.html" %} | ||||||
|  |  | ||||||
|  |   <div class="list-group"> | ||||||
|  |   {% for item in list %} | ||||||
|  |     <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=item)|accounting_append_next }}"> | ||||||
|  |       {{ item.date|accounting_txn_format_date }} {{ item }} | ||||||
|  |     </a> | ||||||
|  |   {% endfor %} | ||||||
|  |   </div> | ||||||
|  | {% else %} | ||||||
|  |   <p>{{ A_("There is no data.") }}</p> | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										83
									
								
								src/accounting/templates/accounting/transaction/order.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/accounting/templates/accounting/transaction/order.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | order.html: The order of the transactions in a same day | ||||||
|  |  | ||||||
|  |  Copyright (c) 2023 imacat. | ||||||
|  |  | ||||||
|  |  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |  you may not use this file except in compliance with the License. | ||||||
|  |  You may obtain a copy of the License at | ||||||
|  |  | ||||||
|  |      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  | ||||||
|  |  Unless required by applicable law or agreed to in writing, software | ||||||
|  |  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |  WITHOUT 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/26 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/base.html" %} | ||||||
|  |  | ||||||
|  | {% 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> | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block header %}{% block title %}{{ A_("Transactions on %(date)s", date=date) }}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block content %} | ||||||
|  |  | ||||||
|  | <div class="btn-group mb-3"> | ||||||
|  |   <a class="btn btn-primary" href="{{ url_for("accounting.transaction.list")|accounting_or_next }}"> | ||||||
|  |     <i class="fa-solid fa-circle-chevron-left"></i> | ||||||
|  |     {{ A_("Back") }} | ||||||
|  |   </a> | ||||||
|  | </div> | ||||||
|  |  | ||||||
|  | {% if list|length > 1 and accounting_can_edit() %} | ||||||
|  |   <form action="{{ url_for("accounting.transaction.sort", txn_date=date) }}" method="post"> | ||||||
|  |     <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> | ||||||
|  |     {% if "next" in request.args %} | ||||||
|  |       <input type="hidden" name="next" value="{{ request.args["next"] }}"> | ||||||
|  |     {% endif %} | ||||||
|  |     <ul id="accounting-order-list" class="list-group mb-3"> | ||||||
|  |       {% 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> | ||||||
|  |           <i class="fa-solid fa-bars"></i> | ||||||
|  |         </li> | ||||||
|  |       {% endfor %} | ||||||
|  |     </ul> | ||||||
|  |  | ||||||
|  |     <div class="d-none d-md-block"> | ||||||
|  |       <button class="btn btn-primary" type="submit"> | ||||||
|  |         <i class="fa-solid fa-floppy-disk"></i> | ||||||
|  |         {{ A_("Save") }} | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div class="d-md-none accounting-material-fab"> | ||||||
|  |       <button class="btn btn-primary" type="submit"> | ||||||
|  |         <i class="fa-solid fa-floppy-disk"></i> | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |   </form> | ||||||
|  | {% elif list %} | ||||||
|  |   <ul class="list-group mb-3"> | ||||||
|  |     {% for item in list %} | ||||||
|  |       <li class="list-group-item"> | ||||||
|  |         {{ item }} | ||||||
|  |       </li> | ||||||
|  |     {% endfor %} | ||||||
|  |   </ul> | ||||||
|  | {% else %} | ||||||
|  |   <p>{{ A_("There is no data.") }}</p> | ||||||
|  | {% endif %} | ||||||
|  |  | ||||||
|  | {% endblock %} | ||||||
| @@ -0,0 +1,28 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | create.html: The transfer transaction creation 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/transaction/transfer/include/form.html" %} | ||||||
|  |  | ||||||
|  | {% block header %}{% block title %}{{ A_("Add a New Transfer Transaction") }}{% endblock %}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %} | ||||||
|  |  | ||||||
|  | {% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %} | ||||||
| @@ -0,0 +1,84 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | detail.html: The account 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/2/26 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/transaction/include/detail.html" %} | ||||||
|  |  | ||||||
|  | {% block transaction_currencies %} | ||||||
|  |   {% for currency in obj.currencies %} | ||||||
|  |     <div class="mb-3"> | ||||||
|  |       <div class="mb-2 fw-bolder">{{ currency.name }}</div> | ||||||
|  |  | ||||||
|  |       <div class="row"> | ||||||
|  |         {# The debit entries #} | ||||||
|  |         <div class="col-sm-6 mb-2"> | ||||||
|  |           <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_("Debit") }}</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_txn_format_amount }}</div> | ||||||
|  |                 </div> | ||||||
|  |               </li> | ||||||
|  |             {% endfor %} | ||||||
|  |             <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> | ||||||
|  |               <div class="d-flex justify-content-between"> | ||||||
|  |                 <div>{{ _("Total") }}</div> | ||||||
|  |                 <div>{{ currency.debit_total|accounting_txn_format_amount }}</div> | ||||||
|  |               </div> | ||||||
|  |             </li> | ||||||
|  |           </ul> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         {# The credit entries #} | ||||||
|  |         <div class="col-sm-6 mb-2"> | ||||||
|  |           <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_("Credit") }}</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_txn_format_amount }}</div> | ||||||
|  |                 </div> | ||||||
|  |               </li> | ||||||
|  |             {% endfor %} | ||||||
|  |             <li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total"> | ||||||
|  |               <div class="d-flex justify-content-between"> | ||||||
|  |                 <div>{{ _("Total") }}</div> | ||||||
|  |                 <div>{{ currency.debit_total|accounting_txn_format_amount }}</div> | ||||||
|  |               </div> | ||||||
|  |             </li> | ||||||
|  |           </ul> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   {% endfor %} | ||||||
|  | {% endblock %} | ||||||
| @@ -0,0 +1,28 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | edit.html: The transfer transaction 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/transaction/transfer/include/form.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 %} | ||||||
| @@ -0,0 +1,126 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | currency-sub-form.html: The currency sub-form in 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 | ||||||
|  | #} | ||||||
|  | <div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}"> | ||||||
|  |   <input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}"> | ||||||
|  |   <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" class="form-select" name="currency-{{ currency_index }}-code"> | ||||||
|  |           {% for currency in accounting_txn_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">{{ 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-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% 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> | ||||||
|  |  | ||||||
|  |     <div class="row"> | ||||||
|  |       {# The debit entries #} | ||||||
|  |       <div class="col-sm-6 mb-3"> | ||||||
|  |         <div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}"> | ||||||
|  |           <label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Debit") }}</label> | ||||||
|  |           <ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list accounting-currency-{{ currency_index }}-entry-list"> | ||||||
|  |             {% for entry_form in debit_forms %} | ||||||
|  |               {% with currency_index = currency_index, | ||||||
|  |                       entry_type = "debit", | ||||||
|  |                       entry_index = loop.index, | ||||||
|  |                       only_one_entry_form = debit_forms|length == 1, | ||||||
|  |                       entry_id = entry_form.eid.data, | ||||||
|  |                       account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data, | ||||||
|  |                       account_code_error = entry_form.account_code.errors, | ||||||
|  |                       account_text = entry_form.account_text, | ||||||
|  |                       summary_data = "" if entry_form.summary.data is none else entry_form.summary.data, | ||||||
|  |                       summary_errors = entry_form.summary.errors, | ||||||
|  |                       amount_data = "" if entry_form.amount.data is none else entry_form.amount.data, | ||||||
|  |                       amount_errors = entry_form.amount.errors, | ||||||
|  |                       amount_text = entry_form.amount.data|accounting_txn_format_amount, | ||||||
|  |                       entry_errors = entry_form.all_errors %} | ||||||
|  |                 {% include "accounting/transaction/include/form-entry-item.html" %} | ||||||
|  |               {% endwith %} | ||||||
|  |             {% endfor %} | ||||||
|  |           </ul> | ||||||
|  |  | ||||||
|  |           <div class="d-flex justify-content-between mb-2"> | ||||||
|  |             <div>{{ A_("Total") }}</div> | ||||||
|  |             <div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <div> | ||||||
|  |             <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> | ||||||
|  |               <i class="fas fa-plus"></i> | ||||||
|  |               {{ A_("New") }} | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {# The credit entries #} | ||||||
|  |       <div class="col-sm-6 mb-3"> | ||||||
|  |         <div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}"> | ||||||
|  |           <label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Credit") }}</label> | ||||||
|  |           <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list"> | ||||||
|  |             {% for entry_form in credit_forms %} | ||||||
|  |               {% with currency_index = currency_index, | ||||||
|  |                       entry_id = entry_form.eid.data, | ||||||
|  |                       entry_type = "credit", | ||||||
|  |                       entry_index = loop.index, | ||||||
|  |                       only_one_entry_form = debit_forms|length == 1, | ||||||
|  |                       account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data, | ||||||
|  |                       account_code_error = entry_form.account_code.errors, | ||||||
|  |                       account_text = entry_form.account_text, | ||||||
|  |                       summary_data = "" if entry_form.summary.data is none else entry_form.summary.data, | ||||||
|  |                       summary_errors = entry_form.summary.errors, | ||||||
|  |                       amount_data = "" if entry_form.amount.data is none else entry_form.amount.data, | ||||||
|  |                       amount_errors = entry_form.amount.errors, | ||||||
|  |                       amount_text = entry_form.amount.data|accounting_txn_format_amount, | ||||||
|  |                       entry_errors = entry_form.all_errors %} | ||||||
|  |                 {% include "accounting/transaction/include/form-entry-item.html" %} | ||||||
|  |               {% endwith %} | ||||||
|  |             {% endfor %} | ||||||
|  |           </ul> | ||||||
|  |  | ||||||
|  |           <div class="d-flex justify-content-between mb-2"> | ||||||
|  |             <div>{{ A_("Total") }}</div> | ||||||
|  |             <div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <div> | ||||||
|  |             <button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal"> | ||||||
|  |               <i class="fas fa-plus"></i> | ||||||
|  |               {{ A_("New") }} | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <div id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  |   <div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div> | ||||||
|  | </div> | ||||||
| @@ -0,0 +1,55 @@ | |||||||
|  | {# | ||||||
|  | The Mia! Accounting Flask Project | ||||||
|  | form.html: 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 | ||||||
|  | #} | ||||||
|  | {% extends "accounting/transaction/include/form.html" %} | ||||||
|  |  | ||||||
|  | {% block currency_sub_forms %} | ||||||
|  |   {% if form.currencies %} | ||||||
|  |     {% for currency_form in form.currencies %} | ||||||
|  |       {% with currency_index = loop.index, | ||||||
|  |               only_one_currency_form = form.currencies|length == 1, | ||||||
|  |               currency_errors = currency_form.whole_form.errors, | ||||||
|  |               currency_code_data = currency_form.code.data, | ||||||
|  |               currency_code_errors = currency_form.code.errors, | ||||||
|  |               debit_forms = currency_form.debit, | ||||||
|  |               debit_errors = currency_form.debit_errors, | ||||||
|  |               debit_total = currency_form.form.debit_total|accounting_txn_format_amount, | ||||||
|  |               credit_forms = currency_form.credit, | ||||||
|  |               credit_errors = currency_form.credit_errors, | ||||||
|  |               credit_total = currency_form.form.credit_total|accounting_txn_format_amount %} | ||||||
|  |         {% include "accounting/transaction/transfer/include/form-currency-item.html" %} | ||||||
|  |       {% endwith %} | ||||||
|  |     {% endfor %} | ||||||
|  |   {% else %} | ||||||
|  |     {% with currency_index = 1, | ||||||
|  |             only_one_currency_form = True, | ||||||
|  |             currency_code_data = accounting_txn_default_currency_code(), | ||||||
|  |             debit_total = "-", | ||||||
|  |             credit_total = "-" %} | ||||||
|  |       {% include "accounting/transaction/transfer/include/form-currency-item.html" %} | ||||||
|  |     {% endwith %} | ||||||
|  |   {% endif %} | ||||||
|  | {% endblock %} | ||||||
|  |  | ||||||
|  | {% block account_selector_modals %} | ||||||
|  |   {% include "accounting/transaction/include/debit-account-modal.html" %} | ||||||
|  |   {% include "accounting/transaction/include/credit-account-modal.html" %} | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										37
									
								
								src/accounting/transaction/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/accounting/transaction/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | |||||||
|  | # 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 transaction management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from flask import Flask, Blueprint | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def init_app(app: Flask, bp: Blueprint) -> None: | ||||||
|  |     """Initialize the application. | ||||||
|  |  | ||||||
|  |     :param app: The Flask application. | ||||||
|  |     :param bp: The blueprint of the accounting application. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     from .converters import TransactionConverter, TransactionTypeConverter, \ | ||||||
|  |         DateConverter | ||||||
|  |     app.url_map.converters["transaction"] = TransactionConverter | ||||||
|  |     app.url_map.converters["transactionType"] = TransactionTypeConverter | ||||||
|  |     app.url_map.converters["date"] = DateConverter | ||||||
|  |  | ||||||
|  |     from .views import bp as transaction_bp | ||||||
|  |     bp.register_blueprint(transaction_bp, url_prefix="/transactions") | ||||||
							
								
								
									
										100
									
								
								src/accounting/transaction/converters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								src/accounting/transaction/converters.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | |||||||
|  | # 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 transaction management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date | ||||||
|  |  | ||||||
|  | from flask import abort | ||||||
|  | from werkzeug.routing import BaseConverter | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.models import Transaction | ||||||
|  | from accounting.transaction.dispatcher import TransactionType, \ | ||||||
|  |     TXN_TYPE_DICT | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransactionConverter(BaseConverter): | ||||||
|  |     """The transaction converter to convert the transaction ID from and to the | ||||||
|  |     corresponding transaction in the routes.""" | ||||||
|  |  | ||||||
|  |     def to_python(self, value: str) -> Transaction: | ||||||
|  |         """Converts a transaction ID to a transaction. | ||||||
|  |  | ||||||
|  |         :param value: The transaction ID. | ||||||
|  |         :return: The corresponding transaction. | ||||||
|  |         """ | ||||||
|  |         transaction: Transaction | None = db.session.get(Transaction, value) | ||||||
|  |         if transaction is None: | ||||||
|  |             abort(404) | ||||||
|  |         return transaction | ||||||
|  |  | ||||||
|  |     def to_url(self, value: Transaction) -> str: | ||||||
|  |         """Converts a transaction to its ID. | ||||||
|  |  | ||||||
|  |         :param value: The transaction. | ||||||
|  |         :return: The ID. | ||||||
|  |         """ | ||||||
|  |         return str(value.id) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransactionTypeConverter(BaseConverter): | ||||||
|  |     """The transaction converter to convert the transaction type ID from and to | ||||||
|  |     the corresponding transaction type in the routes.""" | ||||||
|  |  | ||||||
|  |     def to_python(self, value: str) -> TransactionType: | ||||||
|  |         """Converts a transaction ID to a transaction. | ||||||
|  |  | ||||||
|  |         :param value: The transaction ID. | ||||||
|  |         :return: The corresponding transaction. | ||||||
|  |         """ | ||||||
|  |         txn_type: TransactionType | None = TXN_TYPE_DICT.get(value) | ||||||
|  |         if txn_type is None: | ||||||
|  |             abort(404) | ||||||
|  |         return txn_type | ||||||
|  |  | ||||||
|  |     def to_url(self, value: TransactionType) -> str: | ||||||
|  |         """Converts a transaction type to its ID. | ||||||
|  |  | ||||||
|  |         :param value: The transaction type. | ||||||
|  |         :return: The ID. | ||||||
|  |         """ | ||||||
|  |         return str(value.ID) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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() | ||||||
							
								
								
									
										344
									
								
								src/accounting/transaction/dispatcher.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										344
									
								
								src/accounting/transaction/dispatcher.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,344 @@ | |||||||
|  | # 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 transaction type dispatcher. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | 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 Transaction | ||||||
|  | from .forms import TransactionForm, IncomeTransactionForm, \ | ||||||
|  |     ExpenseTransactionForm, TransferTransactionForm | ||||||
|  | from .template import default_currency_code | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransactionType(ABC): | ||||||
|  |     """An abstract transaction type.""" | ||||||
|  |     ID: str = "" | ||||||
|  |     """The transaction type ID.""" | ||||||
|  |     CHECK_ORDER: int = -1 | ||||||
|  |     """The order when checking the transaction type.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     @abstractmethod | ||||||
|  |     def form(self) -> t.Type[TransactionForm]: | ||||||
|  |         """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 transaction. | ||||||
|  |  | ||||||
|  |         :param form: The transaction form. | ||||||
|  |         :return: the form to create a transaction. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def render_detail_template(self, txn: Transaction) -> str: | ||||||
|  |         """Renders the template for the detail page. | ||||||
|  |  | ||||||
|  |         :param txn: The transaction. | ||||||
|  |         :return: the detail page. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def render_edit_template(self, txn: Transaction, form: FlaskForm) -> str: | ||||||
|  |         """Renders the template for the form to edit a transaction. | ||||||
|  |  | ||||||
|  |         :param txn: The transaction. | ||||||
|  |         :param form: The form. | ||||||
|  |         :return: the form to edit a transaction. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def is_my_type(self, txn: Transaction) -> bool: | ||||||
|  |         """Checks and returns whether the transaction belongs to the type. | ||||||
|  |  | ||||||
|  |         :param txn: The transaction. | ||||||
|  |         :return: True if the transaction belongs to the type, or False | ||||||
|  |             otherwise. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def _entry_template(self) -> str: | ||||||
|  |         """Renders and returns the template for the journal entry sub-form. | ||||||
|  |  | ||||||
|  |         :return: The template for the journal entry sub-form. | ||||||
|  |         """ | ||||||
|  |         return render_template( | ||||||
|  |             "accounting/transaction/include/form-entry-item.html", | ||||||
|  |             currency_index="CURRENCY_INDEX", | ||||||
|  |             entry_type="ENTRY_TYPE", | ||||||
|  |             entry_index="ENTRY_INDEX") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class IncomeTransaction(TransactionType): | ||||||
|  |     """An income transaction.""" | ||||||
|  |     ID: str = "income" | ||||||
|  |     """The transaction type ID.""" | ||||||
|  |     CHECK_ORDER: int = 2 | ||||||
|  |     """The order when checking the transaction type.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def form(self) -> t.Type[TransactionForm]: | ||||||
|  |         """Returns the form class. | ||||||
|  |  | ||||||
|  |         :return: The form class. | ||||||
|  |         """ | ||||||
|  |         return IncomeTransactionForm | ||||||
|  |  | ||||||
|  |     def render_create_template(self, form: IncomeTransactionForm) -> str: | ||||||
|  |         """Renders the template for the form to create a transaction. | ||||||
|  |  | ||||||
|  |         :param form: The transaction form. | ||||||
|  |         :return: the form to create a transaction. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/transaction/income/create.html", | ||||||
|  |                                form=form, txn_type=self, | ||||||
|  |                                currency_template=self.__currency_template, | ||||||
|  |                                entry_template=self._entry_template) | ||||||
|  |  | ||||||
|  |     def render_detail_template(self, txn: Transaction) -> str: | ||||||
|  |         """Renders the template for the detail page. | ||||||
|  |  | ||||||
|  |         :param txn: The transaction. | ||||||
|  |         :return: the detail page. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/transaction/income/detail.html", | ||||||
|  |                                obj=txn) | ||||||
|  |  | ||||||
|  |     def render_edit_template(self, txn: Transaction, | ||||||
|  |                              form: IncomeTransactionForm) -> str: | ||||||
|  |         """Renders the template for the form to edit a transaction. | ||||||
|  |  | ||||||
|  |         :param txn: The transaction. | ||||||
|  |         :param form: The form. | ||||||
|  |         :return: the form to edit a transaction. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/transaction/income/edit.html", | ||||||
|  |                                txn=txn, form=form, | ||||||
|  |                                currency_template=self.__currency_template, | ||||||
|  |                                entry_template=self._entry_template) | ||||||
|  |  | ||||||
|  |     def is_my_type(self, txn: Transaction) -> bool: | ||||||
|  |         """Checks and returns whether the transaction belongs to the type. | ||||||
|  |  | ||||||
|  |         :param txn: The transaction. | ||||||
|  |         :return: True if the transaction belongs to the type, or False | ||||||
|  |             otherwise. | ||||||
|  |         """ | ||||||
|  |         return txn.is_cash_income | ||||||
|  |  | ||||||
|  |     @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/transaction/income/include/form-currency-item.html", | ||||||
|  |             currency_index="CURRENCY_INDEX", | ||||||
|  |             currency_code_data=default_currency_code(), | ||||||
|  |             credit_total="-") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ExpenseTransaction(TransactionType): | ||||||
|  |     """An expense transaction.""" | ||||||
|  |     ID: str = "expense" | ||||||
|  |     """The transaction type ID.""" | ||||||
|  |     CHECK_ORDER: int = 1 | ||||||
|  |     """The order when checking the transaction type.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def form(self) -> t.Type[TransactionForm]: | ||||||
|  |         """Returns the form class. | ||||||
|  |  | ||||||
|  |         :return: The form class. | ||||||
|  |         """ | ||||||
|  |         return ExpenseTransactionForm | ||||||
|  |  | ||||||
|  |     def render_create_template(self, form: ExpenseTransactionForm) -> str: | ||||||
|  |         """Renders the template for the form to create a transaction. | ||||||
|  |  | ||||||
|  |         :param form: The transaction form. | ||||||
|  |         :return: the form to create a transaction. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/transaction/expense/create.html", | ||||||
|  |                                form=form, txn_type=self, | ||||||
|  |                                currency_template=self.__currency_template, | ||||||
|  |                                entry_template=self._entry_template) | ||||||
|  |  | ||||||
|  |     def render_detail_template(self, txn: Transaction) -> str: | ||||||
|  |         """Renders the template for the detail page. | ||||||
|  |  | ||||||
|  |         :param txn: The transaction. | ||||||
|  |         :return: the detail page. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/transaction/expense/detail.html", | ||||||
|  |                                obj=txn) | ||||||
|  |  | ||||||
|  |     def render_edit_template(self, txn: Transaction, | ||||||
|  |                              form: ExpenseTransactionForm) -> str: | ||||||
|  |         """Renders the template for the form to edit a transaction. | ||||||
|  |  | ||||||
|  |         :param txn: The transaction. | ||||||
|  |         :param form: The form. | ||||||
|  |         :return: the form to edit a transaction. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/transaction/expense/edit.html", | ||||||
|  |                                txn=txn, form=form, | ||||||
|  |                                currency_template=self.__currency_template, | ||||||
|  |                                entry_template=self._entry_template) | ||||||
|  |  | ||||||
|  |     def is_my_type(self, txn: Transaction) -> bool: | ||||||
|  |         """Checks and returns whether the transaction belongs to the type. | ||||||
|  |  | ||||||
|  |         :param txn: The transaction. | ||||||
|  |         :return: True if the transaction belongs to the type, or False | ||||||
|  |             otherwise. | ||||||
|  |         """ | ||||||
|  |         return txn.is_cash_expense | ||||||
|  |  | ||||||
|  |     @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/transaction/expense/include/form-currency-item.html", | ||||||
|  |             currency_index="CURRENCY_INDEX", | ||||||
|  |             currency_code_data=default_currency_code(), | ||||||
|  |             debit_total="-") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransferTransaction(TransactionType): | ||||||
|  |     """A transfer transaction.""" | ||||||
|  |     ID: str = "transfer" | ||||||
|  |     """The transaction type ID.""" | ||||||
|  |     CHECK_ORDER: int = 3 | ||||||
|  |     """The order when checking the transaction type.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def form(self) -> t.Type[TransactionForm]: | ||||||
|  |         """Returns the form class. | ||||||
|  |  | ||||||
|  |         :return: The form class. | ||||||
|  |         """ | ||||||
|  |         return TransferTransactionForm | ||||||
|  |  | ||||||
|  |     def render_create_template(self, form: TransferTransactionForm) -> str: | ||||||
|  |         """Renders the template for the form to create a transaction. | ||||||
|  |  | ||||||
|  |         :param form: The transaction form. | ||||||
|  |         :return: the form to create a transaction. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/transaction/transfer/create.html", | ||||||
|  |                                form=form, txn_type=self, | ||||||
|  |                                currency_template=self.__currency_template, | ||||||
|  |                                entry_template=self._entry_template) | ||||||
|  |  | ||||||
|  |     def render_detail_template(self, txn: Transaction) -> str: | ||||||
|  |         """Renders the template for the detail page. | ||||||
|  |  | ||||||
|  |         :param txn: The transaction. | ||||||
|  |         :return: the detail page. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/transaction/transfer/detail.html", | ||||||
|  |                                obj=txn) | ||||||
|  |  | ||||||
|  |     def render_edit_template(self, txn: Transaction, | ||||||
|  |                              form: TransferTransactionForm) -> str: | ||||||
|  |         """Renders the template for the form to edit a transaction. | ||||||
|  |  | ||||||
|  |         :param txn: The transaction. | ||||||
|  |         :param form: The form. | ||||||
|  |         :return: the form to edit a transaction. | ||||||
|  |         """ | ||||||
|  |         return render_template("accounting/transaction/transfer/edit.html", | ||||||
|  |                                txn=txn, form=form, | ||||||
|  |                                currency_template=self.__currency_template, | ||||||
|  |                                entry_template=self._entry_template) | ||||||
|  |  | ||||||
|  |     def is_my_type(self, txn: Transaction) -> bool: | ||||||
|  |         """Checks and returns whether the transaction belongs to the type. | ||||||
|  |  | ||||||
|  |         :param txn: The transaction. | ||||||
|  |         :return: True if the transaction 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/transaction/transfer/include/form-currency-item.html", | ||||||
|  |             currency_index="CURRENCY_INDEX", | ||||||
|  |             currency_code_data=default_currency_code(), | ||||||
|  |             debit_total="-", credit_total="-") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransactionTypes: | ||||||
|  |     """The transaction types, as object properties.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, income: IncomeTransaction, expense: ExpenseTransaction, | ||||||
|  |                  transfer: TransferTransaction): | ||||||
|  |         """Constructs the transaction types as object properties. | ||||||
|  |  | ||||||
|  |         :param income: The income transaction type. | ||||||
|  |         :param expense: The expense transaction type. | ||||||
|  |         :param transfer: The transfer transaction type. | ||||||
|  |         """ | ||||||
|  |         self.income: IncomeTransaction = income | ||||||
|  |         self.expense: ExpenseTransaction = expense | ||||||
|  |         self.transfer: TransferTransaction = transfer | ||||||
|  |  | ||||||
|  |  | ||||||
|  | TXN_TYPE_DICT: dict[str, TransactionType] \ | ||||||
|  |     = {x.ID: x() for x in {IncomeTransaction, | ||||||
|  |                            ExpenseTransaction, | ||||||
|  |                            TransferTransaction}} | ||||||
|  | """The transaction types, as a dictionary.""" | ||||||
|  | TXN_TYPE_OBJ: TransactionTypes = TransactionTypes(**TXN_TYPE_DICT) | ||||||
|  | """The transaction types, as an object.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_txn_type(txn: Transaction) -> TransactionType: | ||||||
|  |     """Returns the transaction type that may be specified in the "as" query | ||||||
|  |     parameter.  If it is not specified, check the transaction type from the | ||||||
|  |     transaction. | ||||||
|  |  | ||||||
|  |     :param txn: The transaction. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     if "as" in request.args: | ||||||
|  |         if request.args["as"] not in TXN_TYPE_DICT: | ||||||
|  |             abort(404) | ||||||
|  |         return TXN_TYPE_DICT[request.args["as"]] | ||||||
|  |     for txn_type in sorted(TXN_TYPE_DICT.values(), | ||||||
|  |                            key=lambda x: x.CHECK_ORDER): | ||||||
|  |         if txn_type.is_my_type(txn): | ||||||
|  |             return txn_type | ||||||
							
								
								
									
										832
									
								
								src/accounting/transaction/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										832
									
								
								src/accounting/transaction/forms.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,832 @@ | |||||||
|  | # 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 forms for the transaction management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import re | ||||||
|  | import typing as t | ||||||
|  | from abc import ABC, abstractmethod | ||||||
|  | from datetime import date | ||||||
|  | from decimal import Decimal | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import request | ||||||
|  | from flask_babel import LazyString | ||||||
|  | from flask_wtf import FlaskForm | ||||||
|  | from wtforms import DateField, StringField, FieldList, FormField, \ | ||||||
|  |     IntegerField, TextAreaField, DecimalField, BooleanField | ||||||
|  | from wtforms.validators import DataRequired, ValidationError | ||||||
|  |  | ||||||
|  | from accounting import db | ||||||
|  | from accounting.locale import lazy_gettext | ||||||
|  | from accounting.models import Transaction, Account, JournalEntry, \ | ||||||
|  |     TransactionCurrency, Currency | ||||||
|  | from accounting.utils.random_id import new_id | ||||||
|  | from accounting.utils.strip_text import strip_text, strip_multiline_text | ||||||
|  | from accounting.utils.user import get_current_user_pk | ||||||
|  |  | ||||||
|  | MISSING_CURRENCY: LazyString = lazy_gettext("Please select the currency.") | ||||||
|  | """The error message when the currency code is empty.""" | ||||||
|  | MISSING_ACCOUNT: LazyString = lazy_gettext("Please select the account.") | ||||||
|  | """The error message when the account code is empty.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NeedSomeCurrencies: | ||||||
|  |     """The validator to check if there is any currency sub-form.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: CurrencyForm, field: FieldList) \ | ||||||
|  |             -> None: | ||||||
|  |         if len(field) == 0: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "Please add some currencies.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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 NeedSomeJournalEntries: | ||||||
|  |     """The validator to check if there is any journal entry sub-form.""" | ||||||
|  |  | ||||||
|  |     def __call__(self, form: TransferCurrencyForm, field: FieldList) \ | ||||||
|  |             -> None: | ||||||
|  |         if len(field) == 0: | ||||||
|  |             raise ValidationError(lazy_gettext( | ||||||
|  |                 "Please add some journal entries.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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 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 IsDebitAccount: | ||||||
|  |     """The validator to check if the account is for debit journal entries.""" | ||||||
|  |  | ||||||
|  |     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(lazy_gettext( | ||||||
|  |             "This account is not for debit entries.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class JournalEntryForm(FlaskForm): | ||||||
|  |     """The base form to create or edit a journal entry.""" | ||||||
|  |     eid = IntegerField() | ||||||
|  |     """The existing journal entry ID.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the currency.""" | ||||||
|  |     account_code = StringField() | ||||||
|  |     """The account code.""" | ||||||
|  |     amount = DecimalField() | ||||||
|  |     """The amount.""" | ||||||
|  |  | ||||||
|  |     @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 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 DebitEntryForm(JournalEntryForm): | ||||||
|  |     """The form to create or edit a debit journal entry.""" | ||||||
|  |     eid = IntegerField() | ||||||
|  |     """The existing journal entry ID.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the currency.""" | ||||||
|  |     account_code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[DataRequired(MISSING_ACCOUNT), | ||||||
|  |                     AccountExists(), | ||||||
|  |                     IsDebitAccount()]) | ||||||
|  |     """The account code.""" | ||||||
|  |     summary = StringField(filters=[strip_text]) | ||||||
|  |     """The summary.""" | ||||||
|  |     amount = DecimalField(validators=[PositiveAmount()]) | ||||||
|  |     """The amount.""" | ||||||
|  |  | ||||||
|  |     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) | ||||||
|  |         obj.account_id = Account.find_by_code(self.account_code.data).id | ||||||
|  |         obj.summary = self.summary.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 IsCreditAccount: | ||||||
|  |     """The validator to check if the account is for credit journal entries.""" | ||||||
|  |  | ||||||
|  |     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(lazy_gettext( | ||||||
|  |             "This account is not for credit entries.")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CreditEntryForm(JournalEntryForm): | ||||||
|  |     """The form to create or edit a credit journal entry.""" | ||||||
|  |     eid = IntegerField() | ||||||
|  |     """The existing journal entry ID.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the currency.""" | ||||||
|  |     account_code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[DataRequired(MISSING_ACCOUNT), | ||||||
|  |                     AccountExists(), | ||||||
|  |                     IsCreditAccount()]) | ||||||
|  |     """The account code.""" | ||||||
|  |     summary = StringField(filters=[strip_text]) | ||||||
|  |     """The summary.""" | ||||||
|  |     amount = DecimalField(validators=[PositiveAmount()]) | ||||||
|  |     """The amount.""" | ||||||
|  |  | ||||||
|  |     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) | ||||||
|  |         obj.account_id = Account.find_by_code(self.account_code.data).id | ||||||
|  |         obj.summary = self.summary.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 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CurrencyForm(FlaskForm): | ||||||
|  |     """The form to create or edit a currency in a transaction.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the transaction.""" | ||||||
|  |     code = StringField() | ||||||
|  |     """The currency code.""" | ||||||
|  |     whole_form = BooleanField() | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransactionForm(FlaskForm): | ||||||
|  |     """The base form to create or edit a transaction.""" | ||||||
|  |     date = DateField() | ||||||
|  |     """The date.""" | ||||||
|  |     currencies = FieldList(FormField(CurrencyForm)) | ||||||
|  |     """The journal entries categorized by their currencies.""" | ||||||
|  |     note = TextAreaField() | ||||||
|  |     """The note.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         """Constructs a base transaction form. | ||||||
|  |  | ||||||
|  |         :param args: The arguments. | ||||||
|  |         :param kwargs: The keyword arguments. | ||||||
|  |         """ | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |         self.is_modified: bool = False | ||||||
|  |         """Whether the transaction is modified during populate_obj().""" | ||||||
|  |         self.collector: t.Type[JournalEntryCollector] = JournalEntryCollector | ||||||
|  |         """The journal entry collector.  The default is the base abstract | ||||||
|  |         collector only to provide the correct type.  The subclass forms should | ||||||
|  |         provide their own collectors.""" | ||||||
|  |         self.__in_use_account_id: set[int] | None = None | ||||||
|  |         """The ID of the accounts that are in use.""" | ||||||
|  |  | ||||||
|  |     def populate_obj(self, obj: Transaction) -> None: | ||||||
|  |         """Populates the form data into a transaction object. | ||||||
|  |  | ||||||
|  |         :param obj: The transaction object. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         is_new: bool = obj.id is None | ||||||
|  |         if is_new: | ||||||
|  |             obj.id = new_id(Transaction) | ||||||
|  |         self.__set_date(obj, self.date.data) | ||||||
|  |         obj.note = self.note.data | ||||||
|  |  | ||||||
|  |         entries: list[JournalEntry] = obj.entries | ||||||
|  |         collector_cls: t.Type[JournalEntryCollector] = self.collector | ||||||
|  |         collector: collector_cls = collector_cls(self, obj.id, entries, | ||||||
|  |                                                  obj.currencies) | ||||||
|  |         collector.collect() | ||||||
|  |  | ||||||
|  |         to_delete: set[int] = {x.id for x in entries | ||||||
|  |                                if x.id not in collector.to_keep} | ||||||
|  |         if len(to_delete) > 0: | ||||||
|  |             JournalEntry.query.filter(JournalEntry.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 | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def __set_date(obj: Transaction, new_date: date) -> None: | ||||||
|  |         """Sets the transaction date and number. | ||||||
|  |  | ||||||
|  |         :param obj: The transaction 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_transactions_in(obj.date, obj.id) | ||||||
|  |             sort_transactions_in(new_date, obj.id) | ||||||
|  |             count: int = Transaction.query\ | ||||||
|  |                 .filter(Transaction.date == new_date).count() | ||||||
|  |             obj.date = new_date | ||||||
|  |             obj.no = count + 1 | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def debit_account_options(self) -> list[Account]: | ||||||
|  |         """The selectable debit accounts. | ||||||
|  |  | ||||||
|  |         :return: The selectable debit accounts. | ||||||
|  |         """ | ||||||
|  |         accounts: list[Account] = Account.debit() | ||||||
|  |         in_use: set[int] = self.__get_in_use_account_id() | ||||||
|  |         for account in accounts: | ||||||
|  |             account.is_in_use = account.id in in_use | ||||||
|  |         return accounts | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def credit_account_options(self) -> list[Account]: | ||||||
|  |         """The selectable credit accounts. | ||||||
|  |  | ||||||
|  |         :return: The selectable credit accounts. | ||||||
|  |         """ | ||||||
|  |         accounts: list[Account] = Account.credit() | ||||||
|  |         in_use: set[int] = self.__get_in_use_account_id() | ||||||
|  |         for account in accounts: | ||||||
|  |             account.is_in_use = account.id in in_use | ||||||
|  |         return accounts | ||||||
|  |  | ||||||
|  |     def __get_in_use_account_id(self) -> set[int]: | ||||||
|  |         """Returns the ID of the accounts that are in use. | ||||||
|  |  | ||||||
|  |         :return: The ID of the accounts that are in use. | ||||||
|  |         """ | ||||||
|  |         if self.__in_use_account_id is None: | ||||||
|  |             self.__in_use_account_id = set(db.session.scalars( | ||||||
|  |                 sa.select(JournalEntry.account_id) | ||||||
|  |                 .group_by(JournalEntry.account_id)).all()) | ||||||
|  |         return self.__in_use_account_id | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def currencies_errors(self) -> list[str | LazyString]: | ||||||
|  |         """Returns the currency errors, without the errors in their sub-forms. | ||||||
|  |  | ||||||
|  |         :return: | ||||||
|  |         """ | ||||||
|  |         return [x for x in self.currencies.errors | ||||||
|  |                 if isinstance(x, str) or isinstance(x, LazyString)] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | T = t.TypeVar("T", bound=TransactionForm) | ||||||
|  | """A transaction form variant.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class JournalEntryCollector(t.Generic[T], ABC): | ||||||
|  |     """The journal entry collector.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, form: T, txn_id: int, entries: list[JournalEntry], | ||||||
|  |                  currencies: list[TransactionCurrency]): | ||||||
|  |         """Constructs the journal entry collector. | ||||||
|  |  | ||||||
|  |         :param form: The transaction form. | ||||||
|  |         :param txn_id: The transaction ID. | ||||||
|  |         :param entries: The existing journal entries. | ||||||
|  |         :param currencies: The currencies in the transaction. | ||||||
|  |         """ | ||||||
|  |         self.form: T = form | ||||||
|  |         """The transaction form.""" | ||||||
|  |         self.entries: list[JournalEntry] = entries | ||||||
|  |         """The existing journal entries.""" | ||||||
|  |         self.txn_id: int = txn_id | ||||||
|  |         """The transaction ID.""" | ||||||
|  |         self.__entries_by_id: dict[int, JournalEntry] \ | ||||||
|  |             = {x.id: x for x in entries} | ||||||
|  |         """A dictionary from the entry ID to their entries.""" | ||||||
|  |         self.__no_by_id: dict[int, int] = {x.id: x.no for x in entries} | ||||||
|  |         """A dictionary from the entry number to their entries.""" | ||||||
|  |         self.__currencies: list[TransactionCurrency] = currencies | ||||||
|  |         """The currencies in the transaction.""" | ||||||
|  |         self._debit_no: int = 1 | ||||||
|  |         """The number index for the debit entries.""" | ||||||
|  |         self._credit_no: int = 1 | ||||||
|  |         """The number index for the credit entries.""" | ||||||
|  |         self.to_keep: set[int] = set() | ||||||
|  |         """The ID of the existing journal entries to keep.""" | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def collect(self) -> set[int]: | ||||||
|  |         """Collects the journal entries. | ||||||
|  |  | ||||||
|  |         :return: The ID of the journal entries to keep. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     def _add_entry(self, form: JournalEntryForm, currency_code: str, no: int) \ | ||||||
|  |             -> None: | ||||||
|  |         """Composes a journal entry from the form. | ||||||
|  |  | ||||||
|  |         :param form: The journal entry form. | ||||||
|  |         :param currency_code: The code of the currency. | ||||||
|  |         :param no: The number of the entry. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         entry: JournalEntry | None = self.__entries_by_id.get(form.eid.data) | ||||||
|  |         if entry is not None: | ||||||
|  |             self.to_keep.add(entry.id) | ||||||
|  |             entry.currency_code = currency_code | ||||||
|  |             form.populate_obj(entry) | ||||||
|  |             entry.no = no | ||||||
|  |             if db.session.is_modified(entry): | ||||||
|  |                 self.form.is_modified = True | ||||||
|  |         else: | ||||||
|  |             entry = JournalEntry() | ||||||
|  |             entry.transaction_id = self.txn_id | ||||||
|  |             entry.currency_code = currency_code | ||||||
|  |             form.populate_obj(entry) | ||||||
|  |             entry.no = no | ||||||
|  |             db.session.add(entry) | ||||||
|  |             self.form.is_modified = True | ||||||
|  |  | ||||||
|  |     def _make_cash_entry(self, forms: list[JournalEntryForm], is_debit: bool, | ||||||
|  |                          currency_code: str, no: int) -> None: | ||||||
|  |         """Composes the cash journal entry at the other side of the cash | ||||||
|  |         transaction. | ||||||
|  |  | ||||||
|  |         :param forms: The journal entry forms in the same currency. | ||||||
|  |         :param is_debit: True for a cash income transaction, or False for a | ||||||
|  |             cash expense transaction. | ||||||
|  |         :param currency_code: The code of the currency. | ||||||
|  |         :param no: The number of the entry. | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         candidates: list[JournalEntry] = [x for x in self.entries | ||||||
|  |                                           if x.is_debit == is_debit | ||||||
|  |                                           and x.currency_code == currency_code] | ||||||
|  |         entry: JournalEntry | ||||||
|  |         if len(candidates) > 0: | ||||||
|  |             candidates.sort(key=lambda x: x.no) | ||||||
|  |             entry = candidates[0] | ||||||
|  |             self.to_keep.add(entry.id) | ||||||
|  |             entry.account_id = Account.cash().id | ||||||
|  |             entry.summary = None | ||||||
|  |             entry.amount = sum([x.amount.data for x in forms]) | ||||||
|  |             entry.no = no | ||||||
|  |             if db.session.is_modified(entry): | ||||||
|  |                 self.form.is_modified = True | ||||||
|  |         else: | ||||||
|  |             entry = JournalEntry() | ||||||
|  |             entry.id = new_id(JournalEntry) | ||||||
|  |             entry.transaction_id = self.txn_id | ||||||
|  |             entry.is_debit = is_debit | ||||||
|  |             entry.currency_code = currency_code | ||||||
|  |             entry.account_id = Account.cash().id | ||||||
|  |             entry.summary = None | ||||||
|  |             entry.amount = sum([x.amount.data for x in forms]) | ||||||
|  |             entry.no = no | ||||||
|  |             db.session.add(entry) | ||||||
|  |             self.form.is_modified = True | ||||||
|  |  | ||||||
|  |     def _sort_entry_forms(self, forms: list[JournalEntryForm]) -> None: | ||||||
|  |         """Sorts the journal entry forms. | ||||||
|  |  | ||||||
|  |         :param forms: The journal entry 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[JournalEntryForm, 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.eid.data is None else | ||||||
|  |                                   self.__no_by_id.get(x.eid.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 IncomeCurrencyForm(CurrencyForm): | ||||||
|  |     """The form to create or edit a currency in a cash income transaction.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the transaction.""" | ||||||
|  |     code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[DataRequired(MISSING_CURRENCY), | ||||||
|  |                     CurrencyExists()]) | ||||||
|  |     """The currency code.""" | ||||||
|  |     credit = FieldList(FormField(CreditEntryForm), | ||||||
|  |                        validators=[NeedSomeJournalEntries()]) | ||||||
|  |     """The credit entries.""" | ||||||
|  |     whole_form = BooleanField() | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def credit_total(self) -> Decimal: | ||||||
|  |         """Returns the total amount of the credit journal entries. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the credit journal entries. | ||||||
|  |         """ | ||||||
|  |         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 journal entry 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 IncomeTransactionForm(TransactionForm): | ||||||
|  |     """The form to create or edit a cash income transaction.""" | ||||||
|  |     date = DateField(default=date.today()) | ||||||
|  |     """The date.""" | ||||||
|  |     currencies = FieldList(FormField(IncomeCurrencyForm), name="currency", | ||||||
|  |                            validators=[NeedSomeCurrencies()]) | ||||||
|  |     """The journal entries categorized by their currencies.""" | ||||||
|  |     note = TextAreaField(filters=[strip_multiline_text]) | ||||||
|  |     """The note.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |  | ||||||
|  |         class Collector(JournalEntryCollector[IncomeTransactionForm]): | ||||||
|  |             """The journal entry collector for the cash income transactions.""" | ||||||
|  |  | ||||||
|  |             def collect(self) -> None: | ||||||
|  |                 currencies: list[IncomeCurrencyForm] \ | ||||||
|  |                     = [x.form for x in self.form.currencies] | ||||||
|  |                 self._sort_currency_forms(currencies) | ||||||
|  |                 for currency in currencies: | ||||||
|  |                     # The debit cash entry | ||||||
|  |                     self._make_cash_entry(list(currency.credit), True, | ||||||
|  |                                           currency.code.data, self._debit_no) | ||||||
|  |                     self._debit_no = self._debit_no + 1 | ||||||
|  |  | ||||||
|  |                     # The credit forms | ||||||
|  |                     credit_forms: list[CreditEntryForm] \ | ||||||
|  |                         = [x.form for x in currency.credit] | ||||||
|  |                     self._sort_entry_forms(credit_forms) | ||||||
|  |                     for credit_form in credit_forms: | ||||||
|  |                         self._add_entry(credit_form, currency.code.data, | ||||||
|  |                                         self._credit_no) | ||||||
|  |                         self._credit_no = self._credit_no + 1 | ||||||
|  |  | ||||||
|  |         self.collector = Collector | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ExpenseCurrencyForm(CurrencyForm): | ||||||
|  |     """The form to create or edit a currency in a cash expense transaction.""" | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the transaction.""" | ||||||
|  |     code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[DataRequired(MISSING_CURRENCY), | ||||||
|  |                     CurrencyExists()]) | ||||||
|  |     """The currency code.""" | ||||||
|  |     debit = FieldList(FormField(DebitEntryForm), | ||||||
|  |                       validators=[NeedSomeJournalEntries()]) | ||||||
|  |     """The debit entries.""" | ||||||
|  |     whole_form = BooleanField() | ||||||
|  |     """The pseudo field for the whole form validators.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def debit_total(self) -> Decimal: | ||||||
|  |         """Returns the total amount of the debit journal entries. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the debit journal entries. | ||||||
|  |         """ | ||||||
|  |         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 journal entry 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 ExpenseTransactionForm(TransactionForm): | ||||||
|  |     """The form to create or edit a cash expense transaction.""" | ||||||
|  |     date = DateField(default=date.today()) | ||||||
|  |     """The date.""" | ||||||
|  |     currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency", | ||||||
|  |                            validators=[NeedSomeCurrencies()]) | ||||||
|  |     """The journal entries categorized by their currencies.""" | ||||||
|  |     note = TextAreaField(filters=[strip_multiline_text]) | ||||||
|  |     """The note.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |  | ||||||
|  |         class Collector(JournalEntryCollector[ExpenseTransactionForm]): | ||||||
|  |             """The journal entry collector for the cash expense | ||||||
|  |             transactions.""" | ||||||
|  |  | ||||||
|  |             def collect(self) -> None: | ||||||
|  |                 currencies: list[ExpenseCurrencyForm] \ | ||||||
|  |                     = [x.form for x in self.form.currencies] | ||||||
|  |                 self._sort_currency_forms(currencies) | ||||||
|  |                 for currency in currencies: | ||||||
|  |                     # The debit forms | ||||||
|  |                     debit_forms: list[DebitEntryForm] \ | ||||||
|  |                         = [x.form for x in currency.debit] | ||||||
|  |                     self._sort_entry_forms(debit_forms) | ||||||
|  |                     for debit_form in debit_forms: | ||||||
|  |                         self._add_entry(debit_form, currency.code.data, | ||||||
|  |                                         self._debit_no) | ||||||
|  |                         self._debit_no = self._debit_no + 1 | ||||||
|  |  | ||||||
|  |                     # The credit forms | ||||||
|  |                     self._make_cash_entry(list(currency.debit), False, | ||||||
|  |                                           currency.code.data, self._credit_no) | ||||||
|  |                     self._credit_no = self._credit_no + 1 | ||||||
|  |  | ||||||
|  |         self.collector = Collector | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransferCurrencyForm(CurrencyForm): | ||||||
|  |     """The form to create or edit a currency in a transfer transaction.""" | ||||||
|  |  | ||||||
|  |     class IsBalanced: | ||||||
|  |         """The validator to check that the total amount of the debit and credit | ||||||
|  |         entries are equal.""" | ||||||
|  |         def __call__(self, form: TransferCurrencyForm, field: BooleanField)\ | ||||||
|  |                 -> None: | ||||||
|  |             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.")) | ||||||
|  |  | ||||||
|  |     no = IntegerField() | ||||||
|  |     """The order in the transaction.""" | ||||||
|  |     code = StringField( | ||||||
|  |         filters=[strip_text], | ||||||
|  |         validators=[DataRequired(MISSING_CURRENCY), | ||||||
|  |                     CurrencyExists()]) | ||||||
|  |     """The currency code.""" | ||||||
|  |     debit = FieldList(FormField(DebitEntryForm), | ||||||
|  |                       validators=[NeedSomeJournalEntries()]) | ||||||
|  |     """The debit entries.""" | ||||||
|  |     credit = FieldList(FormField(CreditEntryForm), | ||||||
|  |                        validators=[NeedSomeJournalEntries()]) | ||||||
|  |     """The credit entries.""" | ||||||
|  |     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 journal entries. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the debit journal entries. | ||||||
|  |         """ | ||||||
|  |         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 journal entries. | ||||||
|  |  | ||||||
|  |         :return: The total amount of the credit journal entries. | ||||||
|  |         """ | ||||||
|  |         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 journal entry 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 journal entry 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 TransferTransactionForm(TransactionForm): | ||||||
|  |     """The form to create or edit a transfer transaction.""" | ||||||
|  |     date = DateField(default=date.today()) | ||||||
|  |     """The date.""" | ||||||
|  |     currencies = FieldList(FormField(TransferCurrencyForm), name="currency", | ||||||
|  |                            validators=[NeedSomeCurrencies()]) | ||||||
|  |     """The journal entries categorized by their currencies.""" | ||||||
|  |     note = TextAreaField(filters=[strip_multiline_text]) | ||||||
|  |     """The note.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |  | ||||||
|  |         class Collector(JournalEntryCollector[TransferTransactionForm]): | ||||||
|  |             """The journal entry collector for the transfer transactions.""" | ||||||
|  |  | ||||||
|  |             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[DebitEntryForm] \ | ||||||
|  |                         = [x.form for x in currency.debit] | ||||||
|  |                     self._sort_entry_forms(debit_forms) | ||||||
|  |                     for debit_form in debit_forms: | ||||||
|  |                         self._add_entry(debit_form, currency.code.data, | ||||||
|  |                                         self._debit_no) | ||||||
|  |                         self._debit_no = self._debit_no + 1 | ||||||
|  |  | ||||||
|  |                     # The credit forms | ||||||
|  |                     credit_forms: list[CreditEntryForm] \ | ||||||
|  |                         = [x.form for x in currency.credit] | ||||||
|  |                     self._sort_entry_forms(credit_forms) | ||||||
|  |                     for credit_form in credit_forms: | ||||||
|  |                         self._add_entry(credit_form, currency.code.data, | ||||||
|  |                                         self._credit_no) | ||||||
|  |                         self._credit_no = self._credit_no + 1 | ||||||
|  |  | ||||||
|  |         self.collector = Collector | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def sort_transactions_in(txn_date: date, exclude: int) -> None: | ||||||
|  |     """Sorts the transactions under a date after changing the date or deleting | ||||||
|  |     a transaction. | ||||||
|  |  | ||||||
|  |     :param txn_date: The date of the transaction. | ||||||
|  |     :param exclude: The transaction ID to exclude. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     transactions: list[Transaction] = Transaction.query\ | ||||||
|  |         .filter(Transaction.date == txn_date, | ||||||
|  |                 Transaction.id != exclude)\ | ||||||
|  |         .order_by(Transaction.no).all() | ||||||
|  |     for i in range(len(transactions)): | ||||||
|  |         if transactions[i].no != i + 1: | ||||||
|  |             transactions[i].no = i + 1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TransactionReorderForm: | ||||||
|  |     """The form to reorder the transactions.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, txn_date: date): | ||||||
|  |         """Constructs the form to reorder the transactions in a day. | ||||||
|  |  | ||||||
|  |         :param txn_date: The date. | ||||||
|  |         """ | ||||||
|  |         self.date: date = txn_date | ||||||
|  |         self.is_modified: bool = False | ||||||
|  |  | ||||||
|  |     def save_order(self) -> None: | ||||||
|  |         """Saves the order of the account. | ||||||
|  |  | ||||||
|  |         :return: | ||||||
|  |         """ | ||||||
|  |         transactions: list[Transaction] = Transaction.query\ | ||||||
|  |             .filter(Transaction.date == self.date).all() | ||||||
|  |  | ||||||
|  |         # Collects the specified order. | ||||||
|  |         orders: dict[Transaction, int] = {} | ||||||
|  |         for txn in transactions: | ||||||
|  |             if f"{txn.id}-no" in request.form: | ||||||
|  |                 try: | ||||||
|  |                     orders[txn] = int(request.form[f"{txn.id}-no"]) | ||||||
|  |                 except ValueError: | ||||||
|  |                     pass | ||||||
|  |  | ||||||
|  |         # Missing and invalid orders are appended to the end. | ||||||
|  |         missing: list[Transaction] \ | ||||||
|  |             = [x for x in transactions if x not in orders] | ||||||
|  |         if len(missing) > 0: | ||||||
|  |             next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1 | ||||||
|  |             for txn in missing: | ||||||
|  |                 orders[txn] = next_no | ||||||
|  |  | ||||||
|  |         # Sort by the specified order first, and their original order. | ||||||
|  |         transactions.sort(key=lambda x: (orders[x], x.no)) | ||||||
|  |  | ||||||
|  |         # Update the orders. | ||||||
|  |         with db.session.no_autoflush: | ||||||
|  |             for i in range(len(transactions)): | ||||||
|  |                 if transactions[i].no != i + 1: | ||||||
|  |                     transactions[i].no = i + 1 | ||||||
|  |                     self.is_modified = True | ||||||
							
								
								
									
										65
									
								
								src/accounting/transaction/query.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/accounting/transaction/query.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | |||||||
|  | # 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 transaction query. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import datetime | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import request | ||||||
|  |  | ||||||
|  | from accounting.models import Transaction | ||||||
|  | from accounting.utils.query import parse_query_keywords | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_transaction_query() -> list[Transaction]: | ||||||
|  |     """Returns the transactions, optionally filtered by the query. | ||||||
|  |  | ||||||
|  |     :return: The transactions. | ||||||
|  |     """ | ||||||
|  |     keywords: list[str] = parse_query_keywords(request.args.get("q")) | ||||||
|  |     if len(keywords) == 0: | ||||||
|  |         return Transaction.query\ | ||||||
|  |             .order_by(Transaction.date, Transaction.no).all() | ||||||
|  |     conditions: list[sa.BinaryExpression] = [] | ||||||
|  |     for k in keywords: | ||||||
|  |         sub_conditions: list[sa.BinaryExpression] \ | ||||||
|  |             = [Transaction.note.contains(k)] | ||||||
|  |         date: datetime | ||||||
|  |         try: | ||||||
|  |             date = datetime.strptime(k, "%Y") | ||||||
|  |             sub_conditions.append( | ||||||
|  |                 sa.extract("year", Transaction.date) == date.year) | ||||||
|  |         except ValueError: | ||||||
|  |             pass | ||||||
|  |         try: | ||||||
|  |             date = datetime.strptime(k, "%Y/%m") | ||||||
|  |             sub_conditions.append(sa.and_( | ||||||
|  |                 sa.extract("year", Transaction.date) == date.year, | ||||||
|  |                 sa.extract("month", Transaction.date) == date.month)) | ||||||
|  |         except ValueError: | ||||||
|  |             pass | ||||||
|  |         try: | ||||||
|  |             date = datetime.strptime(f"2000/{k}", "%Y/%m/%d") | ||||||
|  |             sub_conditions.append(sa.and_( | ||||||
|  |                 sa.extract("month", Transaction.date) == date.month, | ||||||
|  |                 sa.extract("day", Transaction.date) == date.day)) | ||||||
|  |         except ValueError: | ||||||
|  |             pass | ||||||
|  |         conditions.append(sa.or_(*sub_conditions)) | ||||||
|  |     return Transaction.query.filter(*conditions)\ | ||||||
|  |         .order_by(Transaction.date, Transaction.no).all() | ||||||
							
								
								
									
										119
									
								
								src/accounting/transaction/template.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								src/accounting/transaction/template.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,119 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The template filters and globals for the transaction management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | from datetime import date, timedelta | ||||||
|  | from decimal import Decimal | ||||||
|  | from html import escape | ||||||
|  | from urllib.parse import ParseResult, urlparse, parse_qsl, urlencode, \ | ||||||
|  |     urlunparse | ||||||
|  |  | ||||||
|  | from flask import request, current_app | ||||||
|  | from flask_babel import get_locale | ||||||
|  |  | ||||||
|  | from accounting.locale import gettext | ||||||
|  | from accounting.models import Currency | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def with_type(uri: str) -> str: | ||||||
|  |     """Adds the transaction type to the URI, if it is specified. | ||||||
|  |  | ||||||
|  |     :param uri: The URI. | ||||||
|  |     :return: The result URL, optionally with the transaction type added. | ||||||
|  |     """ | ||||||
|  |     if "as" not in request.args: | ||||||
|  |         return uri | ||||||
|  |     uri_p: ParseResult = urlparse(uri) | ||||||
|  |     params: list[tuple[str, str]] = parse_qsl(uri_p.query) | ||||||
|  |     params = [x for x in params if x[0] != "next"] | ||||||
|  |     params.append(("as", request.args["as"])) | ||||||
|  |     parts: list[str] = list(uri_p) | ||||||
|  |     parts[4] = urlencode(params) | ||||||
|  |     return urlunparse(parts) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def format_amount(value: Decimal | None) -> str: | ||||||
|  |     """Formats an amount for readability. | ||||||
|  |  | ||||||
|  |     :param value: The amount. | ||||||
|  |     :return: The formatted amount text. | ||||||
|  |     """ | ||||||
|  |     if value is None or value == 0: | ||||||
|  |         return "-" | ||||||
|  |     whole: int = int(value) | ||||||
|  |     frac: Decimal = (value - whole).normalize() | ||||||
|  |     return "{:,}".format(whole) + str(frac)[1:] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def format_date(value: date) -> str: | ||||||
|  |     """Formats a date to be human-friendly. | ||||||
|  |  | ||||||
|  |     :param value: The date. | ||||||
|  |     :return: The human-friendly date text. | ||||||
|  |     """ | ||||||
|  |     today: date = date.today() | ||||||
|  |     if value == today: | ||||||
|  |         return gettext("Today") | ||||||
|  |     if value == today - timedelta(days=1): | ||||||
|  |         return gettext("Yesterday") | ||||||
|  |     if value == today + timedelta(days=1): | ||||||
|  |         return gettext("Tomorrow") | ||||||
|  |     locale = str(get_locale()) | ||||||
|  |     if locale == "zh" or locale.startswith("zh_"): | ||||||
|  |         if value == today - timedelta(days=2): | ||||||
|  |             return gettext("The day before yesterday") | ||||||
|  |         if value == today + timedelta(days=2): | ||||||
|  |             return gettext("The day after tomorrow") | ||||||
|  |     if locale == "zh" or locale.startswith("zh_"): | ||||||
|  |         weekdays = ["一", "二", "三", "四", "五", "六", "日"] | ||||||
|  |         weekday = weekdays[value.weekday()] | ||||||
|  |     else: | ||||||
|  |         weekday = value.strftime("%a") | ||||||
|  |     if value.year != today.year: | ||||||
|  |         return "{}/{}/{}({})".format( | ||||||
|  |             value.year, value.month, value.day, weekday) | ||||||
|  |     return "{}/{}({})".format(value.month, value.day, weekday) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def text2html(value: str) -> str: | ||||||
|  |     """Converts plain text into HTML. | ||||||
|  |  | ||||||
|  |     :param value: The plain text. | ||||||
|  |     :return: The HTML. | ||||||
|  |     """ | ||||||
|  |     s: str = escape(value) | ||||||
|  |     s = s.replace("\n", "<br>") | ||||||
|  |     s = s.replace("  ", "  ") | ||||||
|  |     return s | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def currency_options() -> str: | ||||||
|  |     """Returns the currency options. | ||||||
|  |  | ||||||
|  |     :return: The currency options. | ||||||
|  |     """ | ||||||
|  |     return Currency.query.order_by(Currency.code).all() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def default_currency_code() -> str: | ||||||
|  |     """Returns the default currency code. | ||||||
|  |  | ||||||
|  |     :return: The default currency code. | ||||||
|  |     """ | ||||||
|  |     with current_app.app_context(): | ||||||
|  |         return current_app.config.get("DEFAULT_CURRENCY", "USD") | ||||||
							
								
								
									
										223
									
								
								src/accounting/transaction/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								src/accounting/transaction/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,223 @@ | |||||||
|  | # 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 transaction 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 Transaction | ||||||
|  | from accounting.utils.flash_errors import flash_form_errors | ||||||
|  | from accounting.utils.next_uri import inherit_next, or_next | ||||||
|  | from accounting.utils.pagination import Pagination | ||||||
|  | from accounting.utils.permission import has_permission, can_view, can_edit | ||||||
|  | from accounting.utils.user import get_current_user_pk | ||||||
|  | from .dispatcher import TransactionType, get_txn_type, TXN_TYPE_OBJ | ||||||
|  | from .template import with_type, format_amount, format_date, text2html, \ | ||||||
|  |     currency_options, default_currency_code | ||||||
|  | from .forms import sort_transactions_in, TransactionReorderForm | ||||||
|  | from .query import get_transaction_query | ||||||
|  |  | ||||||
|  | bp: Blueprint = Blueprint("transaction", __name__) | ||||||
|  | """The view blueprint for the transaction management.""" | ||||||
|  | bp.add_app_template_filter(with_type, "accounting_txn_with_type") | ||||||
|  | bp.add_app_template_filter(format_amount, "accounting_txn_format_amount") | ||||||
|  | bp.add_app_template_filter(format_date, "accounting_txn_format_date") | ||||||
|  | bp.add_app_template_filter(text2html, "accounting_txn_text2html") | ||||||
|  | bp.add_app_template_global(currency_options, "accounting_txn_currency_options") | ||||||
|  | bp.add_app_template_global(default_currency_code, | ||||||
|  |                            "accounting_txn_default_currency_code") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("", endpoint="list") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def list_transactions() -> str: | ||||||
|  |     """Lists the transactions. | ||||||
|  |  | ||||||
|  |     :return: The transaction list. | ||||||
|  |     """ | ||||||
|  |     transactions: list[Transaction] = get_transaction_query() | ||||||
|  |     pagination: Pagination = Pagination[Transaction](transactions) | ||||||
|  |     return render_template("accounting/transaction/list.html", | ||||||
|  |                            list=pagination.list, pagination=pagination, | ||||||
|  |                            types=TXN_TYPE_OBJ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("/create/<transactionType:txn_type>", endpoint="create") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def show_add_transaction_form(txn_type: TransactionType) -> str: | ||||||
|  |     """Shows the form to add a transaction. | ||||||
|  |  | ||||||
|  |     :param txn_type: The transaction type. | ||||||
|  |     :return: The form to add a transaction. | ||||||
|  |     """ | ||||||
|  |     form: txn_type.form | ||||||
|  |     if "form" in session: | ||||||
|  |         form = txn_type.form(ImmutableMultiDict(parse_qsl(session["form"]))) | ||||||
|  |         del session["form"] | ||||||
|  |         form.validate() | ||||||
|  |     else: | ||||||
|  |         form = txn_type.form() | ||||||
|  |     return txn_type.render_create_template(form) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("/store/<transactionType:txn_type>", endpoint="store") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def add_transaction(txn_type: TransactionType) -> redirect: | ||||||
|  |     """Adds a transaction. | ||||||
|  |  | ||||||
|  |     :param txn_type: The transaction type. | ||||||
|  |     :return: The redirection to the transaction detail on success, or the | ||||||
|  |         transaction creation form on error. | ||||||
|  |     """ | ||||||
|  |     form: txn_type.form = txn_type.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.transaction.create", txn_type=txn_type)))) | ||||||
|  |     txn: Transaction = Transaction() | ||||||
|  |     form.populate_obj(txn) | ||||||
|  |     db.session.add(txn) | ||||||
|  |     db.session.commit() | ||||||
|  |     flash(lazy_gettext("The transaction is added successfully"), "success") | ||||||
|  |     return redirect(inherit_next(__get_detail_uri(txn))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("/<transaction:txn>", endpoint="detail") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def show_transaction_detail(txn: Transaction) -> str: | ||||||
|  |     """Shows the transaction detail. | ||||||
|  |  | ||||||
|  |     :param txn: The transaction. | ||||||
|  |     :return: The detail. | ||||||
|  |     """ | ||||||
|  |     txn_type: TransactionType = get_txn_type(txn) | ||||||
|  |     return txn_type.render_detail_template(txn) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("/<transaction:txn>/edit", endpoint="edit") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def show_transaction_edit_form(txn: Transaction) -> str: | ||||||
|  |     """Shows the form to edit a transaction. | ||||||
|  |  | ||||||
|  |     :param txn: The transaction. | ||||||
|  |     :return: The form to edit the transaction. | ||||||
|  |     """ | ||||||
|  |     txn_type: TransactionType = get_txn_type(txn) | ||||||
|  |     form: txn_type.form | ||||||
|  |     if "form" in session: | ||||||
|  |         form = txn_type.form(ImmutableMultiDict(parse_qsl(session["form"]))) | ||||||
|  |         del session["form"] | ||||||
|  |         form.validate() | ||||||
|  |     else: | ||||||
|  |         form = txn_type.form(obj=txn) | ||||||
|  |     return txn_type.render_edit_template(txn, form) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("/<transaction:txn>/update", endpoint="update") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def update_transaction(txn: Transaction) -> redirect: | ||||||
|  |     """Updates a transaction. | ||||||
|  |  | ||||||
|  |     :param txn: The transaction. | ||||||
|  |     :return: The redirection to the transaction detail on success, or the | ||||||
|  |         transaction edit form on error. | ||||||
|  |     """ | ||||||
|  |     txn_type: TransactionType = get_txn_type(txn) | ||||||
|  |     form: txn_type.form = txn_type.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.transaction.edit", txn=txn)))) | ||||||
|  |     with db.session.no_autoflush: | ||||||
|  |         form.populate_obj(txn) | ||||||
|  |     if not form.is_modified: | ||||||
|  |         flash(lazy_gettext("The transaction was not modified."), "success") | ||||||
|  |         return redirect(inherit_next(with_type(__get_detail_uri(txn)))) | ||||||
|  |     txn.updated_by_id = get_current_user_pk() | ||||||
|  |     txn.updated_at = sa.func.now() | ||||||
|  |     db.session.commit() | ||||||
|  |     flash(lazy_gettext("The transaction is updated successfully."), "success") | ||||||
|  |     return redirect(inherit_next(with_type(__get_detail_uri(txn)))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("/<transaction:txn>/delete", endpoint="delete") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def delete_transaction(txn: Transaction) -> redirect: | ||||||
|  |     """Deletes a transaction. | ||||||
|  |  | ||||||
|  |     :param txn: The transaction. | ||||||
|  |     :return: The redirection to the transaction list on success, or the | ||||||
|  |         transaction detail on error. | ||||||
|  |     """ | ||||||
|  |     txn.delete() | ||||||
|  |     sort_transactions_in(txn.date, txn.id) | ||||||
|  |     db.session.commit() | ||||||
|  |     flash(lazy_gettext("The transaction is deleted successfully."), "success") | ||||||
|  |     return redirect(or_next(with_type(url_for("accounting.transaction.list")))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.get("/dates/<date:txn_date>", endpoint="order") | ||||||
|  | @has_permission(can_view) | ||||||
|  | def show_transaction_order(txn_date: date) -> str: | ||||||
|  |     """Shows the order of the transactions in a same date. | ||||||
|  |  | ||||||
|  |     :param txn_date: The date. | ||||||
|  |     :return: The order of the transactions in the date. | ||||||
|  |     """ | ||||||
|  |     transactions: list[Transaction] = Transaction.query\ | ||||||
|  |         .filter(Transaction.date == txn_date)\ | ||||||
|  |         .order_by(Transaction.no).all() | ||||||
|  |     return render_template("accounting/transaction/order.html", | ||||||
|  |                            date=txn_date, list=transactions) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @bp.post("/dates/<date:txn_date>", endpoint="sort") | ||||||
|  | @has_permission(can_edit) | ||||||
|  | def sort_accounts(txn_date: date) -> redirect: | ||||||
|  |     """Reorders the transactions in a date. | ||||||
|  |  | ||||||
|  |     :param txn_date: The date. | ||||||
|  |     :return: The redirection to the incoming account or the account list.  The | ||||||
|  |         reordering operation does not fail. | ||||||
|  |     """ | ||||||
|  |     form: TransactionReorderForm = TransactionReorderForm(txn_date) | ||||||
|  |     form.save_order() | ||||||
|  |     if not form.is_modified: | ||||||
|  |         flash(lazy_gettext("The order was not modified."), "success") | ||||||
|  |         return redirect(or_next(url_for("accounting.account.list"))) | ||||||
|  |     db.session.commit() | ||||||
|  |     flash(lazy_gettext("The order is updated successfully."), "success") | ||||||
|  |     return redirect(or_next(url_for("accounting.account.list"))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __get_detail_uri(txn: Transaction) -> str: | ||||||
|  |     """Returns the detail URI of a transaction. | ||||||
|  |  | ||||||
|  |     :param txn: The transaction. | ||||||
|  |     :return: The detail URI of the transaction. | ||||||
|  |     """ | ||||||
|  |     return url_for("accounting.transaction.detail", txn=txn) | ||||||
| @@ -8,8 +8,8 @@ msgid "" | |||||||
| msgstr "" | msgstr "" | ||||||
| "Project-Id-Version: Mia! Accounting Flask 0.0.0\n" | "Project-Id-Version: Mia! Accounting Flask 0.0.0\n" | ||||||
| "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" | "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" | ||||||
| "POT-Creation-Date: 2023-02-01 19:51+0800\n" | "POT-Creation-Date: 2023-02-27 15:28+0800\n" | ||||||
| "PO-Revision-Date: 2023-02-01 19:52+0800\n" | "PO-Revision-Date: 2023-02-27 15:29+0800\n" | ||||||
| "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" | "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" | ||||||
| "Language: zh_Hant\n" | "Language: zh_Hant\n" | ||||||
| "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" | "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" | ||||||
| @@ -19,84 +19,219 @@ msgstr "" | |||||||
| "Content-Transfer-Encoding: 8bit\n" | "Content-Transfer-Encoding: 8bit\n" | ||||||
| "Generated-By: Babel 2.11.0\n" | "Generated-By: Babel 2.11.0\n" | ||||||
|  |  | ||||||
| #: src/accounting/account/forms.py:39 | #: src/accounting/models.py:575 | ||||||
|  | #, python-format | ||||||
|  | msgid "Cash Expense Transaction#%(id)s" | ||||||
|  | msgstr "現金支出傳票#%(id)s" | ||||||
|  |  | ||||||
|  | #: src/accounting/models.py:577 | ||||||
|  | #, python-format | ||||||
|  | msgid "Cash Income Transaction#%(id)s" | ||||||
|  | msgstr "現金收入傳票#%(id)s" | ||||||
|  |  | ||||||
|  | #: src/accounting/models.py:578 | ||||||
|  | #, python-format | ||||||
|  | msgid "Transfer Transaction#%(id)s" | ||||||
|  | msgstr "轉帳傳票#%(id)s" | ||||||
|  |  | ||||||
|  | #: src/accounting/account/forms.py:41 | ||||||
| msgid "The base account does not exist." | msgid "The base account does not exist." | ||||||
| msgstr "沒有這個基本科目。" | msgstr "沒有這個基本科目。" | ||||||
|  |  | ||||||
| #: src/accounting/account/forms.py:48 | #: src/accounting/account/forms.py:52 | ||||||
| #: src/accounting/static/js/account-form.js:110 | msgid "The base account is not available." | ||||||
|  | msgstr "不能選這個基本科目。" | ||||||
|  |  | ||||||
|  | #: src/accounting/account/forms.py:61 | ||||||
|  | #: src/accounting/static/js/account-form.js:157 | ||||||
| msgid "Please select the base account." | msgid "Please select the base account." | ||||||
| msgstr "請選擇基本科目。" | msgstr "請選擇基本科目。" | ||||||
|  |  | ||||||
| #: src/accounting/account/forms.py:53 | #: src/accounting/account/forms.py:67 | ||||||
| msgid "Please fill in the title" | msgid "Please fill in the title" | ||||||
| msgstr "請填上標題。" | msgstr "請填上標題。" | ||||||
|  |  | ||||||
| #: src/accounting/account/views.py:88 | #: src/accounting/account/query.py:50 | ||||||
|  | #: src/accounting/templates/accounting/account/detail.html:90 | ||||||
|  | #: src/accounting/templates/accounting/account/list.html:74 | ||||||
|  | msgid "Pay-off needed" | ||||||
|  | msgstr "逐筆核銷" | ||||||
|  |  | ||||||
|  | #: src/accounting/account/views.py:89 | ||||||
| msgid "The account is added successfully" | msgid "The account is added successfully" | ||||||
| msgstr "科目加好了。" | msgstr "科目加好了。" | ||||||
|  |  | ||||||
| #: src/accounting/account/views.py:140 | #: src/accounting/account/views.py:142 | ||||||
| msgid "The account was not modified." | msgid "The account was not modified." | ||||||
| msgstr "科目未異動。" | msgstr "科目未異動。" | ||||||
|  |  | ||||||
| #: src/accounting/account/views.py:145 | #: src/accounting/account/views.py:148 | ||||||
| msgid "The account is updated successfully." | msgid "The account is updated successfully." | ||||||
| msgstr "科目存好了。" | msgstr "科目存好了。" | ||||||
|  |  | ||||||
| #: src/accounting/account/views.py:163 | #: src/accounting/account/views.py:165 | ||||||
| msgid "The account is deleted successfully." | msgid "The account is deleted successfully." | ||||||
| msgstr "科目刪掉了" | msgstr "科目刪掉了" | ||||||
|  |  | ||||||
| #: src/accounting/static/js/account-form.js:130 | #: src/accounting/account/views.py:192 src/accounting/transaction/views.py:210 | ||||||
|  | msgid "The order was not modified." | ||||||
|  | msgstr "順序未異動。" | ||||||
|  |  | ||||||
|  | #: src/accounting/account/views.py:195 src/accounting/transaction/views.py:213 | ||||||
|  | msgid "The order is updated successfully." | ||||||
|  | msgstr "順序存好了。" | ||||||
|  |  | ||||||
|  | #: src/accounting/currency/forms.py:46 | ||||||
|  | #: src/accounting/static/js/currency-form.js:136 | ||||||
|  | msgid "Code conflicts with another currency." | ||||||
|  | msgstr "代碼與其它貨幣重複。" | ||||||
|  |  | ||||||
|  | #: src/accounting/currency/forms.py:51 | ||||||
|  | #: src/accounting/static/js/currency-form.js:92 | ||||||
|  | msgid "Please fill in the code." | ||||||
|  | msgstr "請填上代碼。" | ||||||
|  |  | ||||||
|  | #: src/accounting/currency/forms.py:53 | ||||||
|  | #: src/accounting/static/js/currency-form.js:103 | ||||||
|  | msgid "Code can only be composed of 3 upper-cased letters." | ||||||
|  | msgstr "代碼限為三個大寫英文字母。" | ||||||
|  |  | ||||||
|  | #: src/accounting/currency/forms.py:56 | ||||||
|  | #: src/accounting/static/js/currency-form.js:98 | ||||||
|  | msgid "This code is not available." | ||||||
|  | msgstr "不能用這個代碼。" | ||||||
|  |  | ||||||
|  | #: src/accounting/currency/forms.py:62 | ||||||
|  | #: src/accounting/static/js/currency-form.js:168 | ||||||
|  | msgid "Please fill in the name." | ||||||
|  | msgstr "請填上名稱。" | ||||||
|  |  | ||||||
|  | #: src/accounting/currency/views.py:91 | ||||||
|  | msgid "The currency is added successfully" | ||||||
|  | msgstr "貨幣加好了。" | ||||||
|  |  | ||||||
|  | #: src/accounting/currency/views.py:145 | ||||||
|  | msgid "The currency was not modified." | ||||||
|  | msgstr "貨幣未異動。" | ||||||
|  |  | ||||||
|  | #: src/accounting/currency/views.py:151 | ||||||
|  | msgid "The currency is updated successfully." | ||||||
|  | msgstr "貨幣存好了。" | ||||||
|  |  | ||||||
|  | #: src/accounting/currency/views.py:167 | ||||||
|  | msgid "The currency is deleted successfully." | ||||||
|  | msgstr "貨幣刪掉了" | ||||||
|  |  | ||||||
|  | #: src/accounting/static/js/account-form.js:177 | ||||||
| msgid "Please fill in the title." | msgid "Please fill in the title." | ||||||
| msgstr "請填上標題。" | msgstr "請填上標題。" | ||||||
|  |  | ||||||
|  | #: src/accounting/static/js/transaction-form.js:308 | ||||||
|  | #: src/accounting/static/js/transaction-form.js:764 | ||||||
|  | #: src/accounting/transaction/forms.py:46 | ||||||
|  | msgid "Please select the account." | ||||||
|  | msgstr "請選擇科目。" | ||||||
|  |  | ||||||
|  | #: src/accounting/static/js/transaction-form.js:344 | ||||||
|  | #: src/accounting/static/js/transaction-form.js:769 | ||||||
|  | msgid "Please fill in the amount." | ||||||
|  | msgstr "請填上金額。" | ||||||
|  |  | ||||||
|  | #: src/accounting/static/js/transaction-form.js:641 | ||||||
|  | msgid "Please fill in the date." | ||||||
|  | msgstr "請填上日期。" | ||||||
|  |  | ||||||
|  | #: src/accounting/static/js/transaction-form.js:676 | ||||||
|  | #: src/accounting/transaction/forms.py:56 | ||||||
|  | msgid "Please add some currencies." | ||||||
|  | msgstr "請加上貨幣。" | ||||||
|  |  | ||||||
|  | #: src/accounting/static/js/transaction-form.js:742 | ||||||
|  | #: src/accounting/transaction/forms.py:77 | ||||||
|  | msgid "Please add some journal entries." | ||||||
|  | msgstr "請加上分錄。" | ||||||
|  |  | ||||||
|  | #: src/accounting/static/js/transaction-form.js:807 | ||||||
|  | #: src/accounting/transaction/forms.py:670 | ||||||
|  | msgid "The totals of the debit and credit amounts do not match." | ||||||
|  | msgstr "借方貸方合計不符。 " | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/create.html:24 | #: src/accounting/templates/accounting/account/create.html:24 | ||||||
| msgid "Add a New Account" | msgid "Add a New Account" | ||||||
| msgstr "新增科目" | msgstr "新增科目" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/detail.html:31 | #: src/accounting/templates/accounting/account/detail.html:31 | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:33 | #: src/accounting/templates/accounting/account/include/form.html:33 | ||||||
|  | #: src/accounting/templates/accounting/account/order.html:36 | ||||||
| #: src/accounting/templates/accounting/base-account/detail.html:31 | #: src/accounting/templates/accounting/base-account/detail.html:31 | ||||||
|  | #: src/accounting/templates/accounting/currency/detail.html:31 | ||||||
|  | #: src/accounting/templates/accounting/currency/include/form.html:33 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/detail.html:31 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/form.html:34 | ||||||
|  | #: src/accounting/templates/accounting/transaction/order.html:36 | ||||||
| msgid "Back" | msgid "Back" | ||||||
| msgstr "回上頁" | msgstr "回上頁" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/detail.html:36 | #: src/accounting/templates/accounting/account/detail.html:36 | ||||||
|  | #: src/accounting/templates/accounting/currency/detail.html:36 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/detail.html:36 | ||||||
| msgid "Settings" | msgid "Settings" | ||||||
| msgstr "設定" | msgstr "設定" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/detail.html:40 | #: src/accounting/templates/accounting/account/detail.html:41 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/detail.html:41 | ||||||
|  | msgid "Order" | ||||||
|  | msgstr "次序" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/account/detail.html:46 | ||||||
|  | #: src/accounting/templates/accounting/currency/detail.html:42 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/detail.html:46 | ||||||
| msgid "Delete" | msgid "Delete" | ||||||
| msgstr "刪除" | msgstr "刪除" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/detail.html:63 | #: src/accounting/templates/accounting/account/detail.html:69 | ||||||
| msgid "Delete Account Confirmation" | msgid "Delete Account Confirmation" | ||||||
| msgstr "科目刪除確認" | msgstr "科目刪除確認" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/detail.html:67 | #: src/accounting/templates/accounting/account/detail.html:70 | ||||||
|  | #: src/accounting/templates/accounting/account/include/form.html:91 | ||||||
|  | #: src/accounting/templates/accounting/currency/detail.html:66 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:27 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:27 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/detail.html:70 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:28 | ||||||
|  | msgid "Close" | ||||||
|  | msgstr "關閉" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/account/detail.html:73 | ||||||
| msgid "Do you really want to delete this account?" | msgid "Do you really want to delete this account?" | ||||||
| msgstr "你確定要刪掉這個科目嗎?" | msgstr "你確定要刪掉這個科目嗎?" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/detail.html:70 | #: src/accounting/templates/accounting/account/detail.html:76 | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:111 | #: src/accounting/templates/accounting/account/include/form.html:112 | ||||||
|  | #: src/accounting/templates/accounting/currency/detail.html:72 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:49 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:49 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/detail.html:76 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:52 | ||||||
| msgid "Cancel" | msgid "Cancel" | ||||||
| msgstr "取消" | msgstr "取消" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/detail.html:71 | #: src/accounting/templates/accounting/account/detail.html:77 | ||||||
|  | #: src/accounting/templates/accounting/currency/detail.html:73 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/detail.html:77 | ||||||
| msgid "Confirm" | msgid "Confirm" | ||||||
| msgstr "確定" | msgstr "確定" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/detail.html:84 | #: src/accounting/templates/accounting/account/detail.html:94 | ||||||
| #: src/accounting/templates/accounting/account/list.html:62 | #: src/accounting/templates/accounting/currency/detail.html:85 | ||||||
| msgid "Offset needed" | #: src/accounting/templates/accounting/transaction/include/detail.html:106 | ||||||
| msgstr "逐筆核銷" |  | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/detail.html:88 |  | ||||||
| msgid "Created" | msgid "Created" | ||||||
| msgstr "建檔" | msgstr "建檔" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/detail.html:89 | #: src/accounting/templates/accounting/account/detail.html:95 | ||||||
|  | #: src/accounting/templates/accounting/currency/detail.html:86 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/detail.html:107 | ||||||
| msgid "Updated" | msgid "Updated" | ||||||
| msgstr "更新" | msgstr "更新" | ||||||
|  |  | ||||||
| @@ -105,25 +240,81 @@ msgstr "更新" | |||||||
| msgid "%(account)s Settings" | msgid "%(account)s Settings" | ||||||
| msgstr "%(account)s設定" | msgstr "%(account)s設定" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/account/list.html:24 | ||||||
|  | #: src/accounting/templates/accounting/base-account/list.html:24 | ||||||
|  | #: src/accounting/templates/accounting/currency/list.html:24 | ||||||
|  | #: src/accounting/templates/accounting/transaction/list.html:28 | ||||||
|  | #, python-format | ||||||
|  | msgid "Search Result for \"%(query)s\"" | ||||||
|  | msgstr "「%(query)s」搜尋結果" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/list.html:24 | #: src/accounting/templates/accounting/account/list.html:24 | ||||||
| msgid "Account Management" | msgid "Account Management" | ||||||
| msgstr "科目管理" | msgstr "科目管理" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/list.html:32 | #: src/accounting/templates/accounting/account/list.html:32 | ||||||
|  | #: src/accounting/templates/accounting/currency/list.html:32 | ||||||
|  | #: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:75 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/form.html:60 | ||||||
|  | #: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:75 | ||||||
|  | #: src/accounting/templates/accounting/transaction/list.html:37 | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:77 | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:117 | ||||||
| msgid "New" | msgid "New" | ||||||
| msgstr "新增" | msgstr "新增" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/account/list.html:35 | ||||||
|  | #: src/accounting/templates/accounting/currency/list.html:35 | ||||||
|  | #: src/accounting/templates/accounting/transaction/list.html:57 | ||||||
|  | msgid "Search for Desktop" | ||||||
|  | msgstr "桌機版檢索" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:98 | #: src/accounting/templates/accounting/account/include/form.html:98 | ||||||
| #: src/accounting/templates/accounting/account/list.html:40 | #: src/accounting/templates/accounting/account/list.html:40 | ||||||
|  | #: src/accounting/templates/accounting/account/list.html:52 | ||||||
|  | #: src/accounting/templates/accounting/base-account/list.html:29 | ||||||
| #: src/accounting/templates/accounting/base-account/list.html:34 | #: src/accounting/templates/accounting/base-account/list.html:34 | ||||||
|  | #: src/accounting/templates/accounting/currency/list.html:40 | ||||||
|  | #: src/accounting/templates/accounting/currency/list.html:52 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:34 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:34 | ||||||
|  | #: src/accounting/templates/accounting/transaction/list.html:62 | ||||||
|  | #: src/accounting/templates/accounting/transaction/list.html:74 | ||||||
| msgid "Search" | msgid "Search" | ||||||
| msgstr "搜尋" | msgstr "搜尋" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/list.html:68 | #: src/accounting/templates/accounting/account/list.html:47 | ||||||
|  | #: src/accounting/templates/accounting/currency/list.html:47 | ||||||
|  | #: src/accounting/templates/accounting/transaction/list.html:69 | ||||||
|  | msgid "Search for Mobile" | ||||||
|  | msgstr "行動版檢索" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/account/include/form.html:109 | ||||||
|  | #: src/accounting/templates/accounting/account/list.html:80 | ||||||
|  | #: src/accounting/templates/accounting/account/order.html:81 | ||||||
| #: src/accounting/templates/accounting/base-account/list.html:51 | #: src/accounting/templates/accounting/base-account/list.html:51 | ||||||
|  | #: src/accounting/templates/accounting/currency/list.html:77 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:46 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:46 | ||||||
|  | #: src/accounting/templates/accounting/transaction/list.html:93 | ||||||
|  | #: src/accounting/templates/accounting/transaction/order.html:80 | ||||||
| msgid "There is no data." | msgid "There is no data." | ||||||
| msgstr "沒有資料。" | msgstr "沒有資料。" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/account/order.html:29 | ||||||
|  | #, python-format | ||||||
|  | msgid "The Accounts of %(base)s" | ||||||
|  | msgstr "%(base)s下的科目" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/account/include/form.html:75 | ||||||
|  | #: src/accounting/templates/accounting/account/order.html:62 | ||||||
|  | #: src/accounting/templates/accounting/currency/include/form.html:57 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:53 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/form.html:76 | ||||||
|  | #: src/accounting/templates/accounting/transaction/order.html:61 | ||||||
|  | msgid "Save" | ||||||
|  | msgstr "儲存" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:45 | #: src/accounting/templates/accounting/account/include/form.html:45 | ||||||
| msgid "Base account" | msgid "Base account" | ||||||
| msgstr "基本科目" | msgstr "基本科目" | ||||||
| @@ -137,19 +328,17 @@ msgid "Title" | |||||||
| msgstr "標題" | msgstr "標題" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:68 | #: src/accounting/templates/accounting/account/include/form.html:68 | ||||||
| msgid "The entries in the account need offsets." | msgid "The entries in the account need pay-off." | ||||||
| msgstr "帳目要逐筆核銷。" | msgstr "帳目要逐筆核銷。" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:75 |  | ||||||
| msgid "Save" |  | ||||||
| msgstr "儲存" |  | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:90 | #: src/accounting/templates/accounting/account/include/form.html:90 | ||||||
| msgid "Select Base Account" | msgid "Select Base Account" | ||||||
| msgstr "選擇基本科目" | msgstr "選擇基本科目" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:113 | #: src/accounting/templates/accounting/account/include/form.html:114 | ||||||
| #: src/accounting/templates/accounting/account/include/form.html:115 | #: src/accounting/templates/accounting/account/include/form.html:116 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:50 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:50 | ||||||
| msgid "Clear" | msgid "Clear" | ||||||
| msgstr "清除" | msgstr "清除" | ||||||
|  |  | ||||||
| @@ -157,23 +346,255 @@ msgstr "清除" | |||||||
| msgid "Base Account Managements" | msgid "Base Account Managements" | ||||||
| msgstr "基本科目管理" | msgstr "基本科目管理" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/currency/create.html:24 | ||||||
|  | msgid "Add a New Currency" | ||||||
|  | msgstr "新增貨幣" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/currency/detail.html:65 | ||||||
|  | msgid "Delete Currency Confirmation" | ||||||
|  | msgstr "貨幣刪除確認" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/currency/detail.html:69 | ||||||
|  | msgid "Do you really want to delete this currency?" | ||||||
|  | msgstr "你確定要刪掉這個貨幣嗎?" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/currency/edit.html:24 | ||||||
|  | #, python-format | ||||||
|  | msgid "%(currency)s Settings" | ||||||
|  | msgstr "%(currency)s設定" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/currency/list.html:24 | ||||||
|  | msgid "Currency Management" | ||||||
|  | msgstr "貨幣管理" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/currency/include/form.html:44 | ||||||
|  | msgid "Code" | ||||||
|  | msgstr "代碼" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/currency/include/form.html:50 | ||||||
|  | msgid "Name" | ||||||
|  | msgstr "名稱" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/include/nav.html:26 | #: src/accounting/templates/accounting/include/nav.html:26 | ||||||
| msgid "Accounting" | msgid "Accounting" | ||||||
| msgstr "記帳" | msgstr "記帳" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/include/nav.html:32 | #: src/accounting/templates/accounting/include/nav.html:32 | ||||||
|  | msgid "Transactions" | ||||||
|  | msgstr "傳票" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/include/nav.html:38 | ||||||
| msgid "Accounts" | msgid "Accounts" | ||||||
| msgstr "科目" | msgstr "科目" | ||||||
|  |  | ||||||
| #: src/accounting/templates/accounting/include/nav.html:38 | #: src/accounting/templates/accounting/include/nav.html:44 | ||||||
| msgid "Base Accounts" | msgid "Base Accounts" | ||||||
| msgstr "基本科目" | msgstr "基本科目" | ||||||
|  |  | ||||||
| #: src/accounting/utils/pagination.py:146 | #: src/accounting/templates/accounting/include/nav.html:50 | ||||||
| msgid "Previous" | msgid "Currencies" | ||||||
| msgstr "前一頁" | msgstr "貨幣" | ||||||
|  |  | ||||||
| #: src/accounting/utils/pagination.py:194 | #: src/accounting/templates/accounting/include/pagination.html:23 | ||||||
|  | msgid "Page navigation" | ||||||
|  | msgstr "分頁瀏覽" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/list.html:28 | ||||||
|  | msgid "Transaction Management" | ||||||
|  | msgstr "傳票管理" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/list.html:42 | ||||||
|  | msgid "Cash Expense" | ||||||
|  | msgstr "現金支出" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/list.html:46 | ||||||
|  | msgid "Cash Income" | ||||||
|  | msgstr "現金收入" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:32 | ||||||
|  | #: src/accounting/templates/accounting/transaction/list.html:51 | ||||||
|  | msgid "Transfer" | ||||||
|  | msgstr "轉帳" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/order.html:29 | ||||||
|  | #, python-format | ||||||
|  | msgid "Transactions on %(date)s" | ||||||
|  | msgstr "%(date)s的傳票" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/expense/create.html:24 | ||||||
|  | msgid "Add a New Cash Expense Transaction" | ||||||
|  | msgstr "新增現金支出傳票" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/expense/detail.html:30 | ||||||
|  | #: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:45 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/form.html:52 | ||||||
|  | #: src/accounting/templates/accounting/transaction/income/detail.html:30 | ||||||
|  | #: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:45 | ||||||
|  | msgid "Content" | ||||||
|  | msgstr "內容" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/expense/detail.html:46 | ||||||
|  | #: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:68 | ||||||
|  | #: src/accounting/templates/accounting/transaction/income/detail.html:46 | ||||||
|  | #: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:68 | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/detail.html:49 | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/detail.html:75 | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:70 | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:110 | ||||||
|  | msgid "Total" | ||||||
|  | msgstr "合計" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/expense/edit.html:24 | ||||||
|  | #: src/accounting/templates/accounting/transaction/income/edit.html:24 | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/edit.html:24 | ||||||
|  | #, python-format | ||||||
|  | msgid "Editing %(txn)s" | ||||||
|  | msgstr "編輯%(txn)s" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:32 | ||||||
|  | #: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:32 | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:32 | ||||||
|  | msgid "Currency" | ||||||
|  | msgstr "貨幣" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:26 | ||||||
|  | msgid "Cash expense" | ||||||
|  | msgstr "現金支出" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:29 | ||||||
|  | msgid "Cash income" | ||||||
|  | msgstr "現金收入" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:26 | ||||||
|  | msgid "Select Credit Account" | ||||||
|  | msgstr "選擇貸方科目科目" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:44 | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:44 | ||||||
|  | msgid "More…" | ||||||
|  | msgstr "更多…" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:26 | ||||||
|  | msgid "Select Debit Account" | ||||||
|  | msgstr "選擇借方科目" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/detail.html:69 | ||||||
|  | msgid "Delete Transaction Confirmation" | ||||||
|  | msgstr "傳票刪除確認" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/detail.html:73 | ||||||
|  | msgid "Do you really want to delete this transaction?" | ||||||
|  | msgstr "你確定要刪掉這張傳票嗎?" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:27 | ||||||
|  | msgid "Journal Entry Content" | ||||||
|  | msgstr "分錄內容" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:33 | ||||||
|  | msgid "Account" | ||||||
|  | msgstr "科目" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:41 | ||||||
|  | msgid "Summary" | ||||||
|  | msgstr "摘要" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:47 | ||||||
|  | msgid "Amount" | ||||||
|  | msgstr "金額" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/form.html:46 | ||||||
|  | msgid "Date" | ||||||
|  | msgstr "日期" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/include/form.html:69 | ||||||
|  | msgid "Note" | ||||||
|  | msgstr "備註" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/income/create.html:24 | ||||||
|  | msgid "Add a New Cash Income Transaction" | ||||||
|  | msgstr "新增現金收入傳票" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/create.html:24 | ||||||
|  | msgid "Add a New Transfer Transaction" | ||||||
|  | msgstr "新增轉帳傳票" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/detail.html:33 | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:47 | ||||||
|  | msgid "Debit" | ||||||
|  | msgstr "借方" | ||||||
|  |  | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/detail.html:59 | ||||||
|  | #: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:87 | ||||||
|  | msgid "Credit" | ||||||
|  | msgstr "貸方" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/forms.py:44 | ||||||
|  | msgid "Please select the currency." | ||||||
|  | msgstr "請選擇貨幣。" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/forms.py:67 | ||||||
|  | msgid "The currency does not exist." | ||||||
|  | msgstr "沒有這個貨幣。" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/forms.py:88 | ||||||
|  | msgid "The account does not exist." | ||||||
|  | msgstr "沒有這個科目。" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/forms.py:99 | ||||||
|  | msgid "Please fill in a positive amount." | ||||||
|  | msgstr "金額請填正數。" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/forms.py:113 | ||||||
|  | msgid "This account is not for debit entries." | ||||||
|  | msgstr "科目不是借方科目。" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/forms.py:200 | ||||||
|  | msgid "This account is not for credit entries." | ||||||
|  | msgstr "科目不是貸方科目。" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/template.py:71 | ||||||
|  | msgid "Today" | ||||||
|  | msgstr "今天" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/template.py:73 | ||||||
|  | msgid "Yesterday" | ||||||
|  | msgstr "昨天" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/template.py:75 | ||||||
|  | msgid "Tomorrow" | ||||||
|  | msgstr "明天" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/template.py:79 | ||||||
|  | msgid "The day before yesterday" | ||||||
|  | msgstr "前天" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/template.py:81 | ||||||
|  | msgid "The day after tomorrow" | ||||||
|  | msgstr "後天" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/views.py:104 | ||||||
|  | msgid "The transaction is added successfully" | ||||||
|  | msgstr "傳票加好了。" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/views.py:158 | ||||||
|  | msgid "The transaction was not modified." | ||||||
|  | msgstr "傳票未異動。" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/views.py:163 | ||||||
|  | msgid "The transaction is updated successfully." | ||||||
|  | msgstr "傳票存好了。" | ||||||
|  |  | ||||||
|  | #: src/accounting/transaction/views.py:179 | ||||||
|  | msgid "The transaction is deleted successfully." | ||||||
|  | msgstr "傳票刪掉了" | ||||||
|  |  | ||||||
|  | #: src/accounting/utils/pagination.py:206 | ||||||
|  | msgctxt "Pagination|" | ||||||
|  | msgid "Previous" | ||||||
|  | msgstr "上一頁" | ||||||
|  |  | ||||||
|  | #: src/accounting/utils/pagination.py:255 | ||||||
|  | msgctxt "Pagination|" | ||||||
| msgid "Next" | msgid "Next" | ||||||
| msgstr "下一頁" | msgstr "下一頁" | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										50
									
								
								src/accounting/utils/flash_errors.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								src/accounting/utils/flash_errors.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The utility to flash all errors from the forms. | ||||||
|  |  | ||||||
|  | This module should not import any other module from the application. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import typing as t | ||||||
|  |  | ||||||
|  | from flask import flash | ||||||
|  | from flask_wtf import FlaskForm | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def flash_form_errors(form: FlaskForm) -> None: | ||||||
|  |     """Flash all errors from a form recursively. | ||||||
|  |  | ||||||
|  |     :param form: The form. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     __flash_errors(form.errors) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __flash_errors(error: t.Any) -> None: | ||||||
|  |     """Flash all errors recursively. | ||||||
|  |  | ||||||
|  |     :param error: The errors. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     if isinstance(error, dict): | ||||||
|  |         for key in error: | ||||||
|  |             __flash_errors(error[key]) | ||||||
|  |     elif isinstance(error, list): | ||||||
|  |         for e in error: | ||||||
|  |             __flash_errors(e) | ||||||
|  |     else: | ||||||
|  |         flash(error, "error") | ||||||
| @@ -14,7 +14,7 @@ | |||||||
| #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
| #  See the License for the specific language governing permissions and | #  See the License for the specific language governing permissions and | ||||||
| #  limitations under the License. | #  limitations under the License. | ||||||
| """The utilities to handle the next URL. | """The utilities to handle the next URI. | ||||||
| 
 | 
 | ||||||
| This module should not import any other module from the application. | This module should not import any other module from the application. | ||||||
| 
 | 
 | ||||||
| @@ -22,7 +22,7 @@ This module should not import any other module from the application. | |||||||
| from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \ | from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \ | ||||||
|     urlunparse |     urlunparse | ||||||
| 
 | 
 | ||||||
| from flask import request | from flask import request, Blueprint | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def append_next(uri: str) -> str: | def append_next(uri: str) -> str: | ||||||
| @@ -68,8 +68,19 @@ def __set_next(uri: str, next_uri: str) -> str: | |||||||
|     """ |     """ | ||||||
|     uri_p: ParseResult = urlparse(uri) |     uri_p: ParseResult = urlparse(uri) | ||||||
|     params: list[tuple[str, str]] = parse_qsl(uri_p.query) |     params: list[tuple[str, str]] = parse_qsl(uri_p.query) | ||||||
|     params = [x for x in params if x[0] == "next"] |     params = [x for x in params if x[0] != "next"] | ||||||
|     params.append(("next", next_uri)) |     params.append(("next", next_uri)) | ||||||
|     parts: list[str] = list(uri_p) |     parts: list[str] = list(uri_p) | ||||||
|     parts[4] = urlencode(params) |     parts[4] = urlencode(params) | ||||||
|     return urlunparse(parts) |     return urlunparse(parts) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def init_app(bp: Blueprint) -> None: | ||||||
|  |     """Initializes the application. | ||||||
|  | 
 | ||||||
|  |     :param bp: The blueprint of the accounting application. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     bp.add_app_template_filter(append_next, "accounting_append_next") | ||||||
|  |     bp.add_app_template_filter(inherit_next, "accounting_inherit_next") | ||||||
|  |     bp.add_app_template_filter(or_next, "accounting_or_next") | ||||||
| @@ -24,12 +24,13 @@ from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \ | |||||||
|     ParseResult |     ParseResult | ||||||
|  |  | ||||||
| from flask import request | from flask import request | ||||||
|  | from werkzeug.routing import RequestRedirect | ||||||
|  |  | ||||||
| from accounting.locale import gettext | from accounting.locale import gettext, pgettext | ||||||
|  |  | ||||||
|  |  | ||||||
| class PageLink: | class Link: | ||||||
|     """A link in the pagination.""" |     """A link.""" | ||||||
|  |  | ||||||
|     def __init__(self, text: str, uri: str | None = None, |     def __init__(self, text: str, uri: str | None = None, | ||||||
|                  is_current: bool = False, is_for_mobile: bool = False): |                  is_current: bool = False, is_for_mobile: bool = False): | ||||||
| @@ -52,15 +53,20 @@ class PageLink: | |||||||
|         """Whether the link should be shown on mobile screens.""" |         """Whether the link should be shown on mobile screens.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Redirection(RequestRedirect): | ||||||
|  |     """The redirection.""" | ||||||
|  |     code = 302 | ||||||
|  |     """The HTTP code.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | DEFAULT_PAGE_SIZE: int = 10 | ||||||
|  | """The default page size.""" | ||||||
|  |  | ||||||
| T = t.TypeVar("T") | T = t.TypeVar("T") | ||||||
|  |  | ||||||
|  |  | ||||||
| class Pagination(t.Generic[T]): | class Pagination(t.Generic[T]): | ||||||
|     """The pagination utilities""" |     """The pagination utility.""" | ||||||
|     AVAILABLE_PAGE_SIZES: list[int] = [10, 100, 200] |  | ||||||
|     """The available page sizes.""" |  | ||||||
|     DEFAULT_PAGE_SIZE: int = 10 |  | ||||||
|     """The default page size.""" |  | ||||||
|  |  | ||||||
|     def __init__(self, items: list[T], is_reversed: bool = False): |     def __init__(self, items: list[T], is_reversed: bool = False): | ||||||
|         """Constructs the pagination. |         """Constructs the pagination. | ||||||
| @@ -68,130 +74,186 @@ class Pagination(t.Generic[T]): | |||||||
|         :param items: The items. |         :param items: The items. | ||||||
|         :param is_reversed: True if the default page is the last page, or False |         :param is_reversed: True if the default page is the last page, or False | ||||||
|             otherwise. |             otherwise. | ||||||
|  |         :raise Redirection: When the pagination parameters are malformed. | ||||||
|         """ |         """ | ||||||
|         self.__items: list[T] = items |         pagination: AbstractPagination[T] = EmptyPagination[T]() \ | ||||||
|         """All the items.""" |             if len(items) == 0 \ | ||||||
|         self.__is_reversed: bool = is_reversed |             else NonEmptyPagination[T](items, is_reversed) | ||||||
|         """Whether the default page is the last page.""" |         self.is_paged: bool = pagination.is_paged | ||||||
|         self.page_size: int = int(request.args.get("page-size", |         """Whether there should be pagination.""" | ||||||
|                                                    self.DEFAULT_PAGE_SIZE)) |         self.list: list[T] = pagination.list | ||||||
|         """The number of items in a page.""" |         """The items shown in the list""" | ||||||
|         self.__total_pages: int = 0 if len(items) == 0 \ |         self.pages: list[Link] = pagination.pages | ||||||
|             else int((len(items) - 1) / self.page_size) + 1 |         """The pages.""" | ||||||
|         """The total number of pages.""" |         self.page_size: int = pagination.page_size | ||||||
|         self.is_needed: bool = self.__total_pages > 1 |         """The number of items in a page.""" | ||||||
|  |         self.page_size_options: list[Link] = pagination.page_size_options | ||||||
|  |         """The options to the number of items in a page.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AbstractPagination(t.Generic[T]): | ||||||
|  |     """An abstract pagination.""" | ||||||
|  |  | ||||||
|  |     def __init__(self): | ||||||
|  |         """Constructs an empty pagination.""" | ||||||
|  |         self.page_size: int = DEFAULT_PAGE_SIZE | ||||||
|  |         """The number of items in a page.""" | ||||||
|  |         self.is_paged: bool = False | ||||||
|         """Whether there should be pagination.""" |         """Whether there should be pagination.""" | ||||||
|         self.__default_page_no: int = 0 |  | ||||||
|         """The default page number.""" |  | ||||||
|         self.page_no: int = 0 |  | ||||||
|         """The current page number.""" |  | ||||||
|         self.list: list[T] = [] |         self.list: list[T] = [] | ||||||
|         """The items shown in the list""" |         """The items shown in the list""" | ||||||
|         if self.__total_pages > 0: |         self.pages: list[Link] = [] | ||||||
|             self.__set_list() |         """The pages.""" | ||||||
|  |         self.page_size_options: list[Link] = [] | ||||||
|  |         """The options to the number of items in a page.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class EmptyPagination(AbstractPagination[T]): | ||||||
|  |     """The pagination from empty data.""" | ||||||
|  |     pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class NonEmptyPagination(AbstractPagination[T]): | ||||||
|  |     """The pagination with real data.""" | ||||||
|  |     PAGE_SIZE_OPTION_VALUES: list[int] = [10, 100, 200] | ||||||
|  |     """The page size options.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, items: list[T], is_reversed: bool = False): | ||||||
|  |         """Constructs the pagination. | ||||||
|  |  | ||||||
|  |         :param items: The items. | ||||||
|  |         :param is_reversed: True if the default page is the last page, or False | ||||||
|  |             otherwise. | ||||||
|  |         :raise Redirection: When the pagination parameters are malformed. | ||||||
|  |         """ | ||||||
|  |         super().__init__() | ||||||
|         self.__current_uri: str = request.full_path if request.query_string \ |         self.__current_uri: str = request.full_path if request.query_string \ | ||||||
|             else request.path |             else request.path | ||||||
|         """The current URI.""" |         """The current URI.""" | ||||||
|         self.__base_uri_params: tuple[list[str], list[tuple[str, str]]] \ |         self.__is_reversed: bool = is_reversed | ||||||
|             = self.__get_base_uri_params() |         """Whether the default page is the last page.""" | ||||||
|         """The base URI parameters.""" |         self.page_size = self.__get_page_size() | ||||||
|         self.page_links: list[PageLink] = self.__get_page_links() |         self.__total_pages: int = int((len(items) - 1) / self.page_size) + 1 | ||||||
|         """The pagination links.""" |         """The total number of pages.""" | ||||||
|         self.page_sizes: list[PageLink] = self.__get_page_sizes() |         self.is_paged = self.__total_pages > 1 | ||||||
|         """The links to switch the number of items in a page.""" |         self.__default_page_no: int = self.__total_pages \ | ||||||
|  |             if self.__is_reversed else 1 | ||||||
|     def __set_list(self) -> None: |         """The default page number.""" | ||||||
|         """Sets the items to show in the list. |         self.__page_no: int = self.__get_page_no() | ||||||
|  |         """The current page number.""" | ||||||
|         :return: None. |         lower_bound: int = (self.__page_no - 1) * self.page_size | ||||||
|         """ |  | ||||||
|         self.__default_page_no = self.__total_pages if self.__is_reversed \ |  | ||||||
|             else 1 |  | ||||||
|         self.page_no = int(request.args.get("page-no", |  | ||||||
|                                             self.__default_page_no)) |  | ||||||
|         if self.page_no < 1: |  | ||||||
|             self.page_no = 1 |  | ||||||
|         if self.page_no > self.__total_pages: |  | ||||||
|             self.page_no = self.__total_pages |  | ||||||
|         lower_bound: int = (self.page_no - 1) * self.page_size |  | ||||||
|         upper_bound: int = lower_bound + self.page_size |         upper_bound: int = lower_bound + self.page_size | ||||||
|         if upper_bound > len(self.__items): |         if upper_bound > len(items): | ||||||
|             upper_bound = len(self.__items) |             upper_bound = len(items) | ||||||
|         self.list = self.__items[lower_bound:upper_bound] |         self.list = items[lower_bound:upper_bound] | ||||||
|  |         self.pages = self.__get_pages() | ||||||
|  |         self.page_size_options = self.__get_page_size_options() | ||||||
|  |  | ||||||
|     def __get_base_uri_params(self) -> tuple[list[str], list[tuple[str, str]]]: |     def __get_page_size(self) -> int: | ||||||
|         """Returns the base URI and its parameters, with the "page-no" and |         """Returns the page size. | ||||||
|         "page-size" parameters removed. |  | ||||||
|  |  | ||||||
|         :return: The URI parts and the cleaned-up query parameters. |         :return: The page size. | ||||||
|  |         :raise Redirection: When the page size is malformed. | ||||||
|         """ |         """ | ||||||
|         uri_p: ParseResult = urlparse(self.__current_uri) |         if "page-size" not in request.args: | ||||||
|         params: list[tuple[str, str]] = parse_qsl(uri_p.query) |             return DEFAULT_PAGE_SIZE | ||||||
|         params = [x for x in params if x[0] not in ["page-no", "page-size"]] |         try: | ||||||
|         parts: list[str] = list(uri_p) |             page_size: int = int(request.args["page-size"]) | ||||||
|         return parts, params |         except ValueError: | ||||||
|  |             raise Redirection(self.__uri_set("page-size", None)) | ||||||
|  |         if page_size == DEFAULT_PAGE_SIZE or page_size < 1: | ||||||
|  |             raise Redirection(self.__uri_set("page-size", None)) | ||||||
|  |         return page_size | ||||||
|  |  | ||||||
|     def __get_page_links(self) -> list[PageLink]: |     def __get_page_no(self) -> int: | ||||||
|  |         """Returns the page number. | ||||||
|  |  | ||||||
|  |         :return: The page number. | ||||||
|  |         :raise Redirection: When the page number is malformed. | ||||||
|  |         """ | ||||||
|  |         if "page-no" not in request.args: | ||||||
|  |             return self.__default_page_no | ||||||
|  |         try: | ||||||
|  |             page_no: int = int(request.args["page-no"]) | ||||||
|  |         except ValueError: | ||||||
|  |             raise Redirection(self.__uri_set("page-no", None)) | ||||||
|  |         if page_no == self.__default_page_no: | ||||||
|  |             raise Redirection(self.__uri_set("page-no", None)) | ||||||
|  |         if page_no < 1: | ||||||
|  |             if not self.__is_reversed: | ||||||
|  |                 raise Redirection(self.__uri_set("page-no", None)) | ||||||
|  |             raise Redirection(self.__uri_set("page-no", "1")) | ||||||
|  |         if page_no > self.__total_pages: | ||||||
|  |             if self.__is_reversed: | ||||||
|  |                 raise Redirection(self.__uri_set("page-no", None)) | ||||||
|  |             raise Redirection(self.__uri_set("page-no", | ||||||
|  |                                              str(self.__total_pages))) | ||||||
|  |         return page_no | ||||||
|  |  | ||||||
|  |     def __get_pages(self) -> list[Link]: | ||||||
|         """Returns the page links in the pagination navigation. |         """Returns the page links in the pagination navigation. | ||||||
|  |  | ||||||
|         :return: The page links in the pagination navigation. |         :return: The page links in the pagination navigation. | ||||||
|         """ |         """ | ||||||
|         if self.__total_pages < 2: |         if not self.is_paged: | ||||||
|             return [] |             return [] | ||||||
|         uri: str | None |         uri: str | None | ||||||
|         links: list[PageLink] = [] |         links: list[Link] = [] | ||||||
|  |  | ||||||
|         # The previous page. |         # The previous page. | ||||||
|         uri = None if self.page_no == 1 else self.__uri_page(self.page_no - 1) |         uri = None if self.__page_no == 1 \ | ||||||
|         links.append(PageLink(gettext("Previous"), uri, is_for_mobile=True)) |             else self.__uri_page(self.__page_no - 1) | ||||||
|  |         links.append(Link(pgettext("Pagination|", "Previous"), uri, | ||||||
|  |                           is_for_mobile=True)) | ||||||
|  |  | ||||||
|         # The first page. |         # The first page. | ||||||
|         if self.page_no > 1: |         if self.__page_no > 1: | ||||||
|             links.append(PageLink("1", self.__uri_page(1))) |             links.append(Link("1", self.__uri_page(1))) | ||||||
|  |  | ||||||
|         # The eclipse of the previous pages. |         # The eclipse of the previous pages. | ||||||
|         if self.page_no - 3 == 2: |         if self.__page_no - 3 == 2: | ||||||
|             links.append(PageLink(str(self.page_no - 3), |             links.append(Link(str(self.__page_no - 3), | ||||||
|                                   self.__uri_page(self.page_no - 3))) |                               self.__uri_page(self.__page_no - 3))) | ||||||
|         elif self.page_no - 3 > 2: |         elif self.__page_no - 3 > 2: | ||||||
|             links.append(PageLink("…")) |             links.append(Link("…")) | ||||||
|  |  | ||||||
|         # The previous two pages. |         # The previous two pages. | ||||||
|         if self.page_no - 2 > 1: |         if self.__page_no - 2 > 1: | ||||||
|             links.append(PageLink(str(self.page_no - 2), |             links.append(Link(str(self.__page_no - 2), | ||||||
|                                   self.__uri_page(self.page_no - 2))) |                               self.__uri_page(self.__page_no - 2))) | ||||||
|         if self.page_no - 1 > 1: |         if self.__page_no - 1 > 1: | ||||||
|             links.append(PageLink(str(self.page_no - 1), |             links.append(Link(str(self.__page_no - 1), | ||||||
|                                   self.__uri_page(self.page_no - 1))) |                               self.__uri_page(self.__page_no - 1))) | ||||||
|  |  | ||||||
|         # The current page. |         # The current page. | ||||||
|         links.append(PageLink(str(self.page_no), self.__uri_page(self.page_no), |         links.append(Link(str(self.__page_no), self.__uri_page(self.__page_no), | ||||||
|                               is_current=True)) |                           is_current=True)) | ||||||
|  |  | ||||||
|         # The next two pages. |         # The next two pages. | ||||||
|         if self.page_no + 1 < self.__total_pages: |         if self.__page_no + 1 < self.__total_pages: | ||||||
|             links.append(PageLink(str(self.page_no + 1), |             links.append(Link(str(self.__page_no + 1), | ||||||
|                                   self.__uri_page(self.page_no + 1))) |                               self.__uri_page(self.__page_no + 1))) | ||||||
|         if self.page_no + 2 < self.__total_pages: |         if self.__page_no + 2 < self.__total_pages: | ||||||
|             links.append(PageLink(str(self.page_no + 2), |             links.append(Link(str(self.__page_no + 2), | ||||||
|                                   self.__uri_page(self.page_no + 2))) |                               self.__uri_page(self.__page_no + 2))) | ||||||
|  |  | ||||||
|         # The eclipse of the next pages. |         # The eclipse of the next pages. | ||||||
|         if self.page_no + 3 == self.__total_pages - 1: |         if self.__page_no + 3 == self.__total_pages - 1: | ||||||
|             links.append(PageLink(str(self.page_no + 3), |             links.append(Link(str(self.__page_no + 3), | ||||||
|                                   self.__uri_page(self.page_no + 3))) |                               self.__uri_page(self.__page_no + 3))) | ||||||
|         elif self.page_no + 3 < self.__total_pages - 1: |         elif self.__page_no + 3 < self.__total_pages - 1: | ||||||
|             links.append(PageLink("…")) |             links.append(Link("…")) | ||||||
|  |  | ||||||
|         # The last page. |         # The last page. | ||||||
|         if self.page_no < self.__total_pages: |         if self.__page_no < self.__total_pages: | ||||||
|             links.append(PageLink(str(self.__total_pages), |             links.append(Link(str(self.__total_pages), | ||||||
|                                   self.__uri_page(self.__total_pages))) |                               self.__uri_page(self.__total_pages))) | ||||||
|  |  | ||||||
|         # The next page. |         # The next page. | ||||||
|         uri = None if self.page_no == self.__total_pages \ |         uri = None if self.__page_no == self.__total_pages \ | ||||||
|             else self.__uri_page(self.page_no + 1) |             else self.__uri_page(self.__page_no + 1) | ||||||
|         links.append(PageLink(gettext("Next"), uri, is_for_mobile=True)) |         links.append(Link(pgettext("Pagination|", "Next"), uri, | ||||||
|  |                           is_for_mobile=True)) | ||||||
|  |  | ||||||
|         return links |         return links | ||||||
|  |  | ||||||
| @@ -201,21 +263,22 @@ class Pagination(t.Generic[T]): | |||||||
|         :param page_no: The page number. |         :param page_no: The page number. | ||||||
|         :return: The URI of the page. |         :return: The URI of the page. | ||||||
|         """ |         """ | ||||||
|         params: list[tuple[str, str]] = [] |         if page_no == self.__page_no: | ||||||
|         if page_no != self.__default_page_no: |             return self.__current_uri | ||||||
|             params.append(("page-no", str(page_no))) |         if page_no == self.__default_page_no: | ||||||
|         if self.page_size != self.DEFAULT_PAGE_SIZE: |             return self.__uri_set("page-no", None) | ||||||
|             params.append(("page-size", str(self.page_size))) |         return self.__uri_set("page-no", str(page_no)) | ||||||
|         return self.__uri_set_params(params) |  | ||||||
|  |  | ||||||
|     def __get_page_sizes(self) -> list[PageLink]: |     def __get_page_size_options(self) -> list[Link]: | ||||||
|         """Returns the available page sizes. |         """Returns the page size options. | ||||||
|  |  | ||||||
|         :return: The available page sizes. |         :return: The page size options. | ||||||
|         """ |         """ | ||||||
|         return [PageLink(str(x), self.__uri_size(x), |         if not self.is_paged: | ||||||
|                          is_current=x == self.page_size) |             return [] | ||||||
|                 for x in self.AVAILABLE_PAGE_SIZES] |         return [Link(str(x), self.__uri_size(x), | ||||||
|  |                      is_current=x == self.page_size) | ||||||
|  |                 for x in self.PAGE_SIZE_OPTION_VALUES] | ||||||
|  |  | ||||||
|     def __uri_size(self, page_size: int) -> str: |     def __uri_size(self, page_size: int) -> str: | ||||||
|         """Returns the URI of a page size. |         """Returns the URI of a page size. | ||||||
| @@ -225,16 +288,34 @@ class Pagination(t.Generic[T]): | |||||||
|         """ |         """ | ||||||
|         if page_size == self.page_size: |         if page_size == self.page_size: | ||||||
|             return self.__current_uri |             return self.__current_uri | ||||||
|         return self.__uri_set_params([("page-size", str(page_size))]) |         if page_size == DEFAULT_PAGE_SIZE: | ||||||
|  |             return self.__uri_set("page-size", None) | ||||||
|  |         return self.__uri_set("page-size", str(page_size)) | ||||||
|  |  | ||||||
|     def __uri_set_params(self, params: list[tuple[str, str]]) -> str: |     def __uri_set(self, name: str, value: str | None) -> str: | ||||||
|         """Returns the URI with the query parameters set. |         """Raises current URI with a parameter set. | ||||||
|  |  | ||||||
|         :param params: The query parameters. |         :param name: The name of the parameter. | ||||||
|         :return: The URI with the query parameters set. |         :param value: The value, or None to remove the parameter. | ||||||
|  |         :return: The URI with the parameter set. | ||||||
|         """ |         """ | ||||||
|         cur_params: list[tuple[str, str]] = self.__base_uri_params[1].copy() |         uri_p: ParseResult = urlparse(self.__current_uri) | ||||||
|         cur_params.extend(params) |         params: list[tuple[str, str]] = parse_qsl(uri_p.query) | ||||||
|         parts: list[str] = self.__base_uri_params[0].copy() |  | ||||||
|         parts[4] = urlencode(cur_params) |         # Try to keep the position of the parameter. | ||||||
|  |         i: int = 0 | ||||||
|  |         is_found: bool = False | ||||||
|  |         while i < len(params): | ||||||
|  |             if params[i][0] == name: | ||||||
|  |                 if is_found or value is None: | ||||||
|  |                     params = params[:i] + params[i + 1:] | ||||||
|  |                     continue | ||||||
|  |                 params[i] = (name, value) | ||||||
|  |                 is_found = True | ||||||
|  |             i = i + 1 | ||||||
|  |         if not is_found and value is not None: | ||||||
|  |             params.append((name, value)) | ||||||
|  |  | ||||||
|  |         parts: list[str] = list(uri_p) | ||||||
|  |         parts[4] = urlencode(params) | ||||||
|         return urlunparse(parts) |         return urlunparse(parts) | ||||||
|   | |||||||
| @@ -21,7 +21,9 @@ This module should not import any other module from the application. | |||||||
| """ | """ | ||||||
| import typing as t | import typing as t | ||||||
|  |  | ||||||
| from flask import Flask, abort | from flask import abort, Blueprint | ||||||
|  |  | ||||||
|  | from accounting.utils.user import get_current_user | ||||||
|  |  | ||||||
|  |  | ||||||
| def has_permission(rule: t.Callable[[], bool]) -> t.Callable: | def has_permission(rule: t.Callable[[], bool]) -> t.Callable: | ||||||
| @@ -75,17 +77,22 @@ def can_view() -> bool: | |||||||
| def can_edit() -> bool: | def can_edit() -> bool: | ||||||
|     """Returns whether the current user can edit the account data. |     """Returns whether the current user can edit the account data. | ||||||
|  |  | ||||||
|  |     The user has to log in. | ||||||
|  |  | ||||||
|     :return: True if the current user can edit the accounting data, or False |     :return: True if the current user can edit the accounting data, or False | ||||||
|         otherwise. |         otherwise. | ||||||
|     """ |     """ | ||||||
|  |     if get_current_user() is None: | ||||||
|  |         return False | ||||||
|     return __can_edit_func() |     return __can_edit_func() | ||||||
|  |  | ||||||
|  |  | ||||||
| def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None, | def init_app(bp: Blueprint, | ||||||
|  |              can_view_func: t.Callable[[], bool] | None = None, | ||||||
|              can_edit_func: t.Callable[[], bool] | None = None) -> None: |              can_edit_func: t.Callable[[], bool] | None = None) -> None: | ||||||
|     """Initializes the application. |     """Initializes the application. | ||||||
|  |  | ||||||
|     :param app: The Flask application. |     :param bp: The blueprint of the accounting application. | ||||||
|     :param can_view_func: A callback that returns whether the current user can |     :param can_view_func: A callback that returns whether the current user can | ||||||
|         view the accounting data. |         view the accounting data. | ||||||
|     :param can_edit_func: A callback that returns whether the current user can |     :param can_edit_func: A callback that returns whether the current user can | ||||||
| @@ -97,5 +104,5 @@ def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None, | |||||||
|         __can_view_func = can_view_func |         __can_view_func = can_view_func | ||||||
|     if can_edit_func is not None: |     if can_edit_func is not None: | ||||||
|         __can_edit_func = can_edit_func |         __can_edit_func = can_edit_func | ||||||
|     app.jinja_env.globals["can_view_accounting"] = __can_view_func |     bp.add_app_template_global(can_view, "accounting_can_view") | ||||||
|     app.jinja_env.globals["can_edit_accounting"] = __can_edit_func |     bp.add_app_template_global(can_edit, "accounting_can_edit") | ||||||
|   | |||||||
| @@ -34,11 +34,22 @@ def parse_query_keywords(q: str | None) -> list[str]: | |||||||
|     if q == "": |     if q == "": | ||||||
|         return [] |         return [] | ||||||
|     keywords: list[str] = [] |     keywords: list[str] = [] | ||||||
|     while q is not None: |     while True: | ||||||
|         m: re.Match = re.match(r"(?:\"([^\"]+)\"|(\S+))(?:\s+(.+)|)$", q) |         m: re.Match | ||||||
|         if m.group(1) is not None: |         m = re.match(r"\"([^\"]+)\"\s+(.+)$", q) | ||||||
|  |         if m is not None: | ||||||
|             keywords.append(m.group(1)) |             keywords.append(m.group(1)) | ||||||
|         else: |             q = m.group(2) | ||||||
|             keywords.append(m.group(2)) |             continue | ||||||
|         q = m.group(3) |         m = re.match(r"\"([^\"]+)\"?$", q) | ||||||
|  |         if m is not None: | ||||||
|  |             keywords.append(m.group(1)) | ||||||
|  |             break | ||||||
|  |         m = re.match(r"(\S+)\s+(.+)$", q) | ||||||
|  |         if m is not None: | ||||||
|  |             keywords.append(m.group(1)) | ||||||
|  |             q = m.group(2) | ||||||
|  |             continue | ||||||
|  |         keywords.append(q) | ||||||
|  |         break | ||||||
|     return keywords |     return keywords | ||||||
|   | |||||||
| @@ -22,7 +22,7 @@ This module should not import any other module from the application. | |||||||
| import typing as t | import typing as t | ||||||
| from secrets import randbelow | from secrets import randbelow | ||||||
|  |  | ||||||
| from accounting.database import db | from accounting import db | ||||||
|  |  | ||||||
|  |  | ||||||
| def new_id(cls: t.Type): | def new_id(cls: t.Type): | ||||||
| @@ -32,6 +32,6 @@ def new_id(cls: t.Type): | |||||||
|     :return: The generated new random ID. |     :return: The generated new random ID. | ||||||
|     """ |     """ | ||||||
|     while True: |     while True: | ||||||
|         new: int = 100000000 + randbelow(900000000) |         obj_id: int = 100000000 + randbelow(900000000) | ||||||
|         if db.session.get(cls, new) is None: |         if db.session.get(cls, obj_id) is None: | ||||||
|             return new |             return obj_id | ||||||
|   | |||||||
| @@ -19,6 +19,7 @@ | |||||||
| This module should not import any other module from the application. | This module should not import any other module from the application. | ||||||
|  |  | ||||||
| """ | """ | ||||||
|  | import re | ||||||
|  |  | ||||||
|  |  | ||||||
| def strip_text(s: str | None) -> str | None: | def strip_text(s: str | None) -> str | None: | ||||||
| @@ -29,4 +30,17 @@ def strip_text(s: str | None) -> str | None: | |||||||
|     """ |     """ | ||||||
|     if s is None: |     if s is None: | ||||||
|         return None |         return None | ||||||
|     return s.strip() |     s = s.strip() | ||||||
|  |     return s if s != "" else None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def strip_multiline_text(s: str | None) -> str | None: | ||||||
|  |     """The filter to strip a piece of multi-line text. | ||||||
|  |  | ||||||
|  |     :param s: The text input string. | ||||||
|  |     :return: The filtered string. | ||||||
|  |     """ | ||||||
|  |     if s is None: | ||||||
|  |         return None | ||||||
|  |     s = re.sub(r"^\s*\n", "", s.rstrip()) | ||||||
|  |     return s if s != "" else None | ||||||
|   | |||||||
							
								
								
									
										129
									
								
								src/accounting/utils/user.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								src/accounting/utils/user.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,129 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The user utilities. | ||||||
|  |  | ||||||
|  | This module should not import any other module from the application. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import typing as t | ||||||
|  | from abc import ABC, abstractmethod | ||||||
|  |  | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from flask import g | ||||||
|  | from flask_sqlalchemy.model import Model | ||||||
|  |  | ||||||
|  | T = t.TypeVar("T", bound=Model) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AbstractUserUtils(t.Generic[T], ABC): | ||||||
|  |     """The abstract user utilities.""" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     @abstractmethod | ||||||
|  |     def cls(self) -> t.Type[T]: | ||||||
|  |         """Returns the user class. | ||||||
|  |  | ||||||
|  |         :return: The user class. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     @abstractmethod | ||||||
|  |     def pk_column(self) -> sa.Column: | ||||||
|  |         """Returns the primary key column. | ||||||
|  |  | ||||||
|  |         :return: The primary key column. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     @abstractmethod | ||||||
|  |     def current_user(self) -> T | None: | ||||||
|  |         """Returns the currently logged-in user. | ||||||
|  |  | ||||||
|  |         :return: The currently logged-in user, or None if the user has not | ||||||
|  |             logged in | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def get_by_username(self, username: str) -> T | None: | ||||||
|  |         """Returns the user by her username. | ||||||
|  |  | ||||||
|  |         :return: The user by her username, or None if the user was not found. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def get_pk(self, user: T) -> int: | ||||||
|  |         """Returns the primary key of the user. | ||||||
|  |  | ||||||
|  |         :return: The primary key of the user. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | __user_utils: AbstractUserUtils | ||||||
|  | """The user utilities.""" | ||||||
|  | user_cls: t.Type[Model] = Model | ||||||
|  | """The user class.""" | ||||||
|  | user_pk_column: sa.Column = sa.Column(sa.Integer) | ||||||
|  | """The primary key column of the user class.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def init_user_utils(utils: AbstractUserUtils) -> None: | ||||||
|  |     """Initializes the user utilities. | ||||||
|  |  | ||||||
|  |     :param utils: The user utilities. | ||||||
|  |     :return: None. | ||||||
|  |     """ | ||||||
|  |     global __user_utils, user_cls, user_pk_column | ||||||
|  |     __user_utils = utils | ||||||
|  |     user_cls = utils.cls | ||||||
|  |     user_pk_column = utils.pk_column | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_current_user_pk() -> int: | ||||||
|  |     """Returns the primary key value of the currently logged-in user. | ||||||
|  |  | ||||||
|  |     :return: The primary key value of the currently logged-in user. | ||||||
|  |     """ | ||||||
|  |     return __user_utils.get_pk(get_current_user()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def has_user(username: str) -> bool: | ||||||
|  |     """Returns whether a user by the username exists. | ||||||
|  |  | ||||||
|  |     :param username: The username. | ||||||
|  |     :return: True if the user by the username exists, or False otherwise. | ||||||
|  |     """ | ||||||
|  |     return __user_utils.get_by_username(username) is not None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_user_pk(username: str) -> int: | ||||||
|  |     """Returns the primary key value of the user by the username. | ||||||
|  |  | ||||||
|  |     :param username: The username. | ||||||
|  |     :return: The primary key value of the user by the username. | ||||||
|  |     """ | ||||||
|  |     return __user_utils.get_pk(__user_utils.get_by_username(username)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_current_user() -> user_cls | None: | ||||||
|  |     """Returns the currently logged-in user.  The result is cached in the | ||||||
|  |     current request. | ||||||
|  |  | ||||||
|  |     :return: The currently logged-in user. | ||||||
|  |     """ | ||||||
|  |     if not hasattr(g, "_accounting_user"): | ||||||
|  |         setattr(g, "_accounting_user", __user_utils.current_user) | ||||||
|  |     return getattr(g, "_accounting_user") | ||||||
| @@ -28,7 +28,7 @@ from babel.messages.frontend import CommandLineInterface | |||||||
| from opencc import OpenCC | from opencc import OpenCC | ||||||
| 
 | 
 | ||||||
| root_dir: Path = Path(__file__).parent.parent | root_dir: Path = Path(__file__).parent.parent | ||||||
| translation_dir: Path = root_dir / "tests" / "testsite" / "translations" | translation_dir: Path = root_dir / "tests" / "test_site" / "translations" | ||||||
| domain: str = "messages" | domain: str = "messages" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @@ -49,7 +49,7 @@ def babel_extract() -> None: | |||||||
|         / f"{domain}.po" |         / f"{domain}.po" | ||||||
|     CommandLineInterface().run([ |     CommandLineInterface().run([ | ||||||
|         "pybabel", "extract", "-F", str(cfg), "-k", "lazy_gettext", "-k", "A_", |         "pybabel", "extract", "-F", str(cfg), "-k", "lazy_gettext", "-k", "A_", | ||||||
|         "-o", str(pot), str(Path("tests") / "testsite")]) |         "-o", str(pot), str(Path("tests") / "test_site")]) | ||||||
|     if not zh_hant.exists(): |     if not zh_hant.exists(): | ||||||
|         zh_hant.touch() |         zh_hant.touch() | ||||||
|     if not zh_hans.exists(): |     if not zh_hans.exists(): | ||||||
							
								
								
									
										766
									
								
								tests/test_account.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										766
									
								
								tests/test_account.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,766 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The test for the account management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import unittest | ||||||
|  | from datetime import timedelta | ||||||
|  |  | ||||||
|  | import httpx | ||||||
|  | import sqlalchemy as sa | ||||||
|  | from click.testing import Result | ||||||
|  | from flask import Flask | ||||||
|  | from flask.testing import FlaskCliRunner | ||||||
|  |  | ||||||
|  | from test_site import create_app, db | ||||||
|  | from testlib import get_client, set_locale | ||||||
|  |  | ||||||
|  | NEXT_URI: str = "/_next" | ||||||
|  | """The next URI.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AccountData: | ||||||
|  |     """The account data.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, base_code: str, no: int, title: str): | ||||||
|  |         """Constructs the account data. | ||||||
|  |  | ||||||
|  |         :param base_code: The base code. | ||||||
|  |         :param no: The number. | ||||||
|  |         :param title: The title. | ||||||
|  |         """ | ||||||
|  |         self.base_code: str = base_code | ||||||
|  |         """The base code.""" | ||||||
|  |         self.no: int = no | ||||||
|  |         """The number.""" | ||||||
|  |         self.title: str = title | ||||||
|  |         """The title.""" | ||||||
|  |         self.code: str = f"{self.base_code}-{self.no:03d}" | ||||||
|  |         """The code.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | cash: AccountData = AccountData("1111", 1, "Cash") | ||||||
|  | """The cash account.""" | ||||||
|  | bank: AccountData = AccountData("1113", 1, "Bank") | ||||||
|  | """The bank account.""" | ||||||
|  | stock: AccountData = AccountData("1121", 1, "Stock") | ||||||
|  | """The stock account.""" | ||||||
|  | loan: AccountData = AccountData("2112", 1, "Loan") | ||||||
|  | """The loan account.""" | ||||||
|  | PREFIX: str = "/accounting/accounts" | ||||||
|  | """The URL prefix of the currency management.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AccountCommandTestCase(unittest.TestCase): | ||||||
|  |     """The account console command test case.""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         """Sets up the test. | ||||||
|  |         This is run once per test. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.app: Flask = create_app(is_testing=True) | ||||||
|  |  | ||||||
|  |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             from accounting.models import BaseAccount, Account, AccountL10n | ||||||
|  |             result: Result | ||||||
|  |             result = runner.invoke(args="init-db") | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |             if BaseAccount.query.first() is None: | ||||||
|  |                 result = runner.invoke(args="accounting-init-base") | ||||||
|  |                 self.assertEqual(result.exit_code, 0) | ||||||
|  |             AccountL10n.query.delete() | ||||||
|  |             Account.query.delete() | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |     def test_init(self) -> None: | ||||||
|  |         """Tests the "accounting-init-account" console command. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import BaseAccount, Account, AccountL10n | ||||||
|  |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             result: Result = runner.invoke(args=["accounting-init-accounts", | ||||||
|  |                                                  "-u", "editor"]) | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             bases: list[BaseAccount] = BaseAccount.query\ | ||||||
|  |                 .filter(sa.func.char_length(BaseAccount.code) == 4).all() | ||||||
|  |             accounts: list[Account] = Account.query.all() | ||||||
|  |             l10n: list[AccountL10n] = AccountL10n.query.all() | ||||||
|  |         self.assertEqual({x.code for x in bases}, | ||||||
|  |                          {x.base_code for x in accounts}) | ||||||
|  |         self.assertEqual(len(accounts), len(bases)) | ||||||
|  |         self.assertEqual(len(l10n), len(bases) * 2) | ||||||
|  |         base_dict: dict[str, BaseAccount] = {x.code: x for x in bases} | ||||||
|  |         for account in accounts: | ||||||
|  |             base: BaseAccount = base_dict[account.base_code] | ||||||
|  |             self.assertEqual(account.no, 1) | ||||||
|  |             self.assertEqual(account.title_l10n, base.title_l10n) | ||||||
|  |             self.assertEqual({x.locale: x.title for x in account.l10n}, | ||||||
|  |                              {x.locale: x.title for x in base.l10n}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AccountTestCase(unittest.TestCase): | ||||||
|  |     """The account test case.""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         """Sets up the test. | ||||||
|  |         This is run once per test. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.app: Flask = create_app(is_testing=True) | ||||||
|  |  | ||||||
|  |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             from accounting.models import BaseAccount, Account, AccountL10n | ||||||
|  |             result: Result | ||||||
|  |             result = runner.invoke(args="init-db") | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |             if BaseAccount.query.first() is None: | ||||||
|  |                 result = runner.invoke(args="accounting-init-base") | ||||||
|  |                 self.assertEqual(result.exit_code, 0) | ||||||
|  |             AccountL10n.query.delete() | ||||||
|  |             Account.query.delete() | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |         self.client, self.csrf_token = get_client(self.app, "editor") | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/store", | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": cash.base_code, | ||||||
|  |                                           "title": cash.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], | ||||||
|  |                          f"{PREFIX}/{cash.code}") | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/store", | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": bank.base_code, | ||||||
|  |                                           "title": bank.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], | ||||||
|  |                          f"{PREFIX}/{bank.code}") | ||||||
|  |  | ||||||
|  |     def test_nobody(self) -> None: | ||||||
|  |         """Test the permission as nobody. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account | ||||||
|  |         client, csrf_token = get_client(self.app, "nobody") | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         response = client.get(PREFIX) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/{cash.code}") | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/create") | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/store", | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "base_code": stock.base_code, | ||||||
|  |                                      "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/{cash.code}/edit") | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/{cash.code}/update", | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "base_code": cash.base_code, | ||||||
|  |                                      "title": f"{cash.title}-2"}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/{cash.code}/delete", | ||||||
|  |                                data={"csrf_token": csrf_token}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/bases/{cash.base_code}") | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             bank_id: int = Account.find_by_code(bank.code).id | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/bases/{bank.base_code}", | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "next": NEXT_URI, | ||||||
|  |                                      f"{bank_id}-no": "5"}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |     def test_viewer(self) -> None: | ||||||
|  |         """Test the permission as viewer. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account | ||||||
|  |         client, csrf_token = get_client(self.app, "viewer") | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         response = client.get(PREFIX) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/{cash.code}") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/create") | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/store", | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "base_code": stock.base_code, | ||||||
|  |                                      "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/{cash.code}/edit") | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/{cash.code}/update", | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "base_code": cash.base_code, | ||||||
|  |                                      "title": f"{cash.title}-2"}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/{cash.code}/delete", | ||||||
|  |                                data={"csrf_token": csrf_token}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/bases/{cash.base_code}") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             bank_id: int = Account.find_by_code(bank.code).id | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/bases/{bank.base_code}", | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "next": NEXT_URI, | ||||||
|  |                                      f"{bank_id}-no": "5"}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |     def test_editor(self) -> None: | ||||||
|  |         """Test the permission as editor. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         response = self.client.get(PREFIX) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.get(f"{PREFIX}/{cash.code}") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.get(f"{PREFIX}/create") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/store", | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": stock.base_code, | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], | ||||||
|  |                          f"{PREFIX}/{stock.code}") | ||||||
|  |  | ||||||
|  |         response = self.client.get(f"{PREFIX}/{cash.code}/edit") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/{cash.code}/update", | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": cash.base_code, | ||||||
|  |                                           "title": f"{cash.title}-2"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}") | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/{cash.code}/delete", | ||||||
|  |                                     data={"csrf_token": self.csrf_token}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], PREFIX) | ||||||
|  |  | ||||||
|  |         response = self.client.get(f"{PREFIX}/bases/{cash.base_code}") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             bank_id: int = Account.find_by_code(bank.code).id | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/bases/{bank.base_code}", | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "next": NEXT_URI, | ||||||
|  |                                           f"{bank_id}-no": "5"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], NEXT_URI) | ||||||
|  |  | ||||||
|  |     def test_add(self) -> None: | ||||||
|  |         """Tests to add the currencies. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account | ||||||
|  |         create_uri: str = f"{PREFIX}/create" | ||||||
|  |         store_uri: str = f"{PREFIX}/store" | ||||||
|  |         detail_uri: str = f"{PREFIX}/{stock.code}" | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             self.assertEqual({x.code for x in Account.query.all()}, | ||||||
|  |                              {cash.code, bank.code}) | ||||||
|  |  | ||||||
|  |         # Missing CSRF token | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"base_code": stock.base_code, | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |  | ||||||
|  |         # CSRF token mismatch | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": f"{self.csrf_token}-2", | ||||||
|  |                                           "base_code": stock.base_code, | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |  | ||||||
|  |         # Empty base account code | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": " ", | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Non-existing base account | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": "9999", | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Unavailable base account | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": "1", | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Empty name | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": stock.base_code, | ||||||
|  |                                           "title": " "}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Success, with spaces to be stripped | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": f" {stock.base_code} ", | ||||||
|  |                                           "title": f" {stock.title} "}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         # Success under the same base | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": stock.base_code, | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], | ||||||
|  |                          f"{PREFIX}/{stock.base_code}-002") | ||||||
|  |  | ||||||
|  |         # Success under the same base, with order in a mess. | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             stock_2: Account = Account.find_by_code(f"{stock.base_code}-002") | ||||||
|  |             stock_2.no = 66 | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": stock.base_code, | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], | ||||||
|  |                          f"{PREFIX}/{stock.base_code}-003") | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             self.assertEqual({x.code for x in Account.query.all()}, | ||||||
|  |                              {cash.code, bank.code, stock.code, | ||||||
|  |                               f"{stock.base_code}-002", | ||||||
|  |                               f"{stock.base_code}-003"}) | ||||||
|  |  | ||||||
|  |             stock_account: Account = Account.find_by_code(stock.code) | ||||||
|  |             self.assertEqual(stock_account.base_code, stock.base_code) | ||||||
|  |             self.assertEqual(stock_account.title_l10n, stock.title) | ||||||
|  |  | ||||||
|  |     def test_basic_update(self) -> None: | ||||||
|  |         """Tests the basic rules to update a user. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account | ||||||
|  |         detail_uri: str = f"{PREFIX}/{cash.code}" | ||||||
|  |         edit_uri: str = f"{PREFIX}/{cash.code}/edit" | ||||||
|  |         update_uri: str = f"{PREFIX}/{cash.code}/update" | ||||||
|  |         detail_c_uri: str = f"{PREFIX}/{stock.code}" | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         # Success, with spaces to be stripped | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": f" {cash.base_code} ", | ||||||
|  |                                           "title": f" {cash.title}-1 "}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             cash_account: Account = Account.find_by_code(cash.code) | ||||||
|  |             self.assertEqual(cash_account.base_code, cash.base_code) | ||||||
|  |             self.assertEqual(cash_account.title_l10n, f"{cash.title}-1") | ||||||
|  |  | ||||||
|  |         # Empty base account code | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": " ", | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Non-existing base account | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": "9999", | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Unavailable base account | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": "1", | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Empty name | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": stock.base_code, | ||||||
|  |                                           "title": " "}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Change the base account | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": stock.base_code, | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_c_uri) | ||||||
|  |  | ||||||
|  |         response = self.client.get(detail_uri) | ||||||
|  |         self.assertEqual(response.status_code, 404) | ||||||
|  |  | ||||||
|  |         response = self.client.get(detail_c_uri) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_update_not_modified(self) -> None: | ||||||
|  |         """Tests that the data is not modified. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account | ||||||
|  |         detail_uri: str = f"{PREFIX}/{cash.code}" | ||||||
|  |         update_uri: str = f"{PREFIX}/{cash.code}/update" | ||||||
|  |         cash_account: Account | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": f" {cash.base_code} ", | ||||||
|  |                                           "title": f" {cash.title} "}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             cash_account = Account.find_by_code(cash.code) | ||||||
|  |             self.assertIsNotNone(cash_account) | ||||||
|  |             cash_account.created_at \ | ||||||
|  |                 = cash_account.created_at - timedelta(seconds=5) | ||||||
|  |             cash_account.updated_at = cash_account.created_at | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": cash.base_code, | ||||||
|  |                                           "title": stock.title}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             cash_account = Account.find_by_code(cash.code) | ||||||
|  |             self.assertIsNotNone(cash_account) | ||||||
|  |             self.assertLess(cash_account.created_at, | ||||||
|  |                             cash_account.updated_at) | ||||||
|  |  | ||||||
|  |     def test_created_updated_by(self) -> None: | ||||||
|  |         """Tests the created-by and updated-by record. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account | ||||||
|  |         editor_username, editor2_username = "editor", "editor2" | ||||||
|  |         client, csrf_token = get_client(self.app, editor2_username) | ||||||
|  |         detail_uri: str = f"{PREFIX}/{cash.code}" | ||||||
|  |         update_uri: str = f"{PREFIX}/{cash.code}/update" | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             cash_account: Account = Account.find_by_code(cash.code) | ||||||
|  |             self.assertEqual(cash_account.created_by.username, editor_username) | ||||||
|  |             self.assertEqual(cash_account.updated_by.username, editor_username) | ||||||
|  |  | ||||||
|  |         response = client.post(update_uri, | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "base_code": cash.base_code, | ||||||
|  |                                      "title": f"{cash.title}-2"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             cash_account: Account = Account.find_by_code(cash.code) | ||||||
|  |             self.assertEqual(cash_account.created_by.username, | ||||||
|  |                              editor_username) | ||||||
|  |             self.assertEqual(cash_account.updated_by.username, | ||||||
|  |                              editor2_username) | ||||||
|  |  | ||||||
|  |     def test_l10n(self) -> None: | ||||||
|  |         """Tests the localization. | ||||||
|  |  | ||||||
|  |         :return: None | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account | ||||||
|  |         detail_uri: str = f"{PREFIX}/{cash.code}" | ||||||
|  |         update_uri: str = f"{PREFIX}/{cash.code}/update" | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             cash_account: Account = Account.find_by_code(cash.code) | ||||||
|  |             self.assertEqual(cash_account.title_l10n, cash.title) | ||||||
|  |             self.assertEqual(cash_account.l10n, []) | ||||||
|  |  | ||||||
|  |         set_locale(self.client, self.csrf_token, "zh_Hant") | ||||||
|  |  | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": cash.base_code, | ||||||
|  |                                           "title": f"{cash.title}-zh_Hant"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             cash_account: Account = Account.find_by_code(cash.code) | ||||||
|  |             self.assertEqual(cash_account.title_l10n, cash.title) | ||||||
|  |             self.assertEqual({(x.locale, x.title) for x in cash_account.l10n}, | ||||||
|  |                              {("zh_Hant", f"{cash.title}-zh_Hant")}) | ||||||
|  |  | ||||||
|  |         set_locale(self.client, self.csrf_token, "en") | ||||||
|  |  | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": cash.base_code, | ||||||
|  |                                           "title": f"{cash.title}-2"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             cash_account: Account = Account.find_by_code(cash.code) | ||||||
|  |             self.assertEqual(cash_account.title_l10n, f"{cash.title}-2") | ||||||
|  |             self.assertEqual({(x.locale, x.title) for x in cash_account.l10n}, | ||||||
|  |                              {("zh_Hant", f"{cash.title}-zh_Hant")}) | ||||||
|  |  | ||||||
|  |         set_locale(self.client, self.csrf_token, "zh_Hant") | ||||||
|  |  | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": cash.base_code, | ||||||
|  |                                           "title": f"{cash.title}-zh_Hant-2"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             cash_account: Account = Account.find_by_code(cash.code) | ||||||
|  |             self.assertEqual(cash_account.title_l10n, f"{cash.title}-2") | ||||||
|  |             self.assertEqual({(x.locale, x.title) for x in cash_account.l10n}, | ||||||
|  |                              {("zh_Hant", f"{cash.title}-zh_Hant-2")}) | ||||||
|  |  | ||||||
|  |     def test_delete(self) -> None: | ||||||
|  |         """Tests to delete a currency. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account | ||||||
|  |         detail_uri: str = f"{PREFIX}/{cash.code}" | ||||||
|  |         delete_uri: str = f"{PREFIX}/{cash.code}/delete" | ||||||
|  |         list_uri: str = PREFIX | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             self.assertEqual({x.code for x in Account.query.all()}, | ||||||
|  |                              {cash.code, bank.code}) | ||||||
|  |  | ||||||
|  |         response = self.client.get(detail_uri) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         response = self.client.post(delete_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], list_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             self.assertEqual({x.code for x in Account.query.all()}, | ||||||
|  |                              {bank.code}) | ||||||
|  |  | ||||||
|  |         response = self.client.get(detail_uri) | ||||||
|  |         self.assertEqual(response.status_code, 404) | ||||||
|  |         response = self.client.post(delete_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token}) | ||||||
|  |         self.assertEqual(response.status_code, 404) | ||||||
|  |  | ||||||
|  |     def test_change_base_code(self) -> None: | ||||||
|  |         """Tests to change the base code of an account. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         for i in range(2, 6): | ||||||
|  |             response = self.client.post(f"{PREFIX}/store", | ||||||
|  |                                         data={"csrf_token": self.csrf_token, | ||||||
|  |                                               "base_code": "1111", | ||||||
|  |                                               "title": "Title"}) | ||||||
|  |             self.assertEqual(response.status_code, 302) | ||||||
|  |             self.assertEqual(response.headers["Location"], | ||||||
|  |                              f"{PREFIX}/1111-00{i}") | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             account_1: Account = Account.find_by_code("1111-001") | ||||||
|  |             id_1: int = account_1.id | ||||||
|  |             account_2: Account = Account.find_by_code("1111-002") | ||||||
|  |             id_2: int = account_2.id | ||||||
|  |             account_3: Account = Account.find_by_code("1111-003") | ||||||
|  |             id_3: int = account_3.id | ||||||
|  |             account_4: Account = Account.find_by_code("1111-004") | ||||||
|  |             id_4: int = account_4.id | ||||||
|  |             account_5: Account = Account.find_by_code("1111-005") | ||||||
|  |             id_5: int = account_5.id | ||||||
|  |             account_1.no = 3 | ||||||
|  |             account_2.no = 5 | ||||||
|  |             account_3.no = 8 | ||||||
|  |             account_4.base_code = "1112" | ||||||
|  |             account_4.no = 2 | ||||||
|  |             account_5.base_code = "1112" | ||||||
|  |             account_5.no = 6 | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/1111-005/update", | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "base_code": "1112", | ||||||
|  |                                           "title": "Title"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], f"{PREFIX}/1112-003") | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             self.assertEqual(db.session.get(Account, id_1).no, 1) | ||||||
|  |             self.assertEqual(db.session.get(Account, id_2).no, 3) | ||||||
|  |             self.assertEqual(db.session.get(Account, id_3).no, 2) | ||||||
|  |             self.assertEqual(db.session.get(Account, id_4).no, 1) | ||||||
|  |             self.assertEqual(db.session.get(Account, id_5).no, 2) | ||||||
|  |  | ||||||
|  |     def test_reorder(self) -> None: | ||||||
|  |         """Tests to reorder the accounts under a same base account. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Account | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         for i in range(2, 6): | ||||||
|  |             response = self.client.post(f"{PREFIX}/store", | ||||||
|  |                                         data={"csrf_token": self.csrf_token, | ||||||
|  |                                               "base_code": "1111", | ||||||
|  |                                               "title": "Title"}) | ||||||
|  |             self.assertEqual(response.status_code, 302) | ||||||
|  |             self.assertEqual(response.headers["Location"], | ||||||
|  |                              f"{PREFIX}/1111-00{i}") | ||||||
|  |  | ||||||
|  |         # Normal reorder | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             id_1: int = Account.find_by_code("1111-001").id | ||||||
|  |             id_2: int = Account.find_by_code("1111-002").id | ||||||
|  |             id_3: int = Account.find_by_code("1111-003").id | ||||||
|  |             id_4: int = Account.find_by_code("1111-004").id | ||||||
|  |             id_5: int = Account.find_by_code("1111-005").id | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/bases/1111", | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "next": NEXT_URI, | ||||||
|  |                                           f"{id_1}-no": "4", | ||||||
|  |                                           f"{id_2}-no": "1", | ||||||
|  |                                           f"{id_3}-no": "5", | ||||||
|  |                                           f"{id_4}-no": "2", | ||||||
|  |                                           f"{id_5}-no": "3"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], NEXT_URI) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             self.assertEqual(db.session.get(Account, id_1).code, "1111-004") | ||||||
|  |             self.assertEqual(db.session.get(Account, id_2).code, "1111-001") | ||||||
|  |             self.assertEqual(db.session.get(Account, id_3).code, "1111-005") | ||||||
|  |             self.assertEqual(db.session.get(Account, id_4).code, "1111-002") | ||||||
|  |             self.assertEqual(db.session.get(Account, id_5).code, "1111-003") | ||||||
|  |  | ||||||
|  |         # Malformed orders | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             db.session.get(Account, id_1).no = 3 | ||||||
|  |             db.session.get(Account, id_2).no = 4 | ||||||
|  |             db.session.get(Account, id_3).no = 6 | ||||||
|  |             db.session.get(Account, id_4).no = 8 | ||||||
|  |             db.session.get(Account, id_5).no = 9 | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/bases/1111", | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "next": NEXT_URI, | ||||||
|  |                                           f"{id_2}-no": "3a", | ||||||
|  |                                           f"{id_3}-no": "5", | ||||||
|  |                                           f"{id_4}-no": "2"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], NEXT_URI) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             self.assertEqual(db.session.get(Account, id_1).code, "1111-003") | ||||||
|  |             self.assertEqual(db.session.get(Account, id_2).code, "1111-004") | ||||||
|  |             self.assertEqual(db.session.get(Account, id_3).code, "1111-002") | ||||||
|  |             self.assertEqual(db.session.get(Account, id_4).code, "1111-001") | ||||||
|  |             self.assertEqual(db.session.get(Account, id_5).code, "1111-005") | ||||||
| @@ -14,10 +14,11 @@ | |||||||
| #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
| #  See the License for the specific language governing permissions and | #  See the License for the specific language governing permissions and | ||||||
| #  limitations under the License. | #  limitations under the License. | ||||||
|  |  | ||||||
| """The test for the base account management. | """The test for the base account management. | ||||||
|  |  | ||||||
| """ | """ | ||||||
|  | import csv | ||||||
|  | import typing as t | ||||||
| import unittest | import unittest | ||||||
|  |  | ||||||
| import httpx | import httpx | ||||||
| @@ -25,8 +26,66 @@ from click.testing import Result | |||||||
| from flask import Flask | from flask import Flask | ||||||
| from flask.testing import FlaskCliRunner | from flask.testing import FlaskCliRunner | ||||||
|  |  | ||||||
| from testlib import get_csrf_token | from test_site import create_app | ||||||
| from testsite import create_app | from testlib import get_client | ||||||
|  |  | ||||||
|  | LIST_URI: str = "/accounting/base-accounts" | ||||||
|  | """The list URI.""" | ||||||
|  | DETAIL_URI: str = "/accounting/base-accounts/1111" | ||||||
|  | """The detail URI.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BaseAccountCommandTestCase(unittest.TestCase): | ||||||
|  |     """The base account console command test case.""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         """Sets up the test. | ||||||
|  |         This is run once per test. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import BaseAccount, BaseAccountL10n | ||||||
|  |         self.app: Flask = create_app(is_testing=True) | ||||||
|  |  | ||||||
|  |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             result: Result = runner.invoke(args="init-db") | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |             BaseAccountL10n.query.delete() | ||||||
|  |             BaseAccount.query.delete() | ||||||
|  |  | ||||||
|  |     def test_init(self) -> None: | ||||||
|  |         """Tests the "accounting-init-base" console command. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting import data_dir | ||||||
|  |         from accounting.models import BaseAccount | ||||||
|  |  | ||||||
|  |         with open(data_dir / "base_accounts.csv") as fp: | ||||||
|  |             data: dict[dict[str, t.Any]] \ | ||||||
|  |                 = {x["code"]: {"code": x["code"], | ||||||
|  |                                "title": x["title"], | ||||||
|  |                                "l10n": {y[5:]: x[y] | ||||||
|  |                                         for y in x if y.startswith("l10n-")}} | ||||||
|  |                    for x in csv.DictReader(fp)} | ||||||
|  |  | ||||||
|  |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|  |         result: Result = runner.invoke(args="accounting-init-base") | ||||||
|  |         self.assertEqual(result.exit_code, 0) | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             accounts: list[BaseAccount] = BaseAccount.query.all() | ||||||
|  |  | ||||||
|  |         self.assertEqual(len(accounts), len(data)) | ||||||
|  |         for account in accounts: | ||||||
|  |             self.assertIn(account.code, data) | ||||||
|  |             self.assertEqual(account.title_l10n, data[account.code]["title"]) | ||||||
|  |             l10n: dict[str, str] = {x.locale: x.title for x in account.l10n} | ||||||
|  |             self.assertEqual(len(l10n), len(data[account.code]["l10n"])) | ||||||
|  |             for locale in l10n: | ||||||
|  |                 self.assertIn(locale, data[account.code]["l10n"]) | ||||||
|  |                 self.assertEqual(l10n[locale], | ||||||
|  |                                  data[account.code]["l10n"][locale]) | ||||||
|  |  | ||||||
|  |  | ||||||
| class BaseAccountTestCase(unittest.TestCase): | class BaseAccountTestCase(unittest.TestCase): | ||||||
| @@ -38,77 +97,55 @@ class BaseAccountTestCase(unittest.TestCase): | |||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|  |         from accounting.models import BaseAccount | ||||||
|         self.app: Flask = create_app(is_testing=True) |         self.app: Flask = create_app(is_testing=True) | ||||||
|  |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|         with self.app.app_context(): |         with self.app.app_context(): | ||||||
|             result: Result = runner.invoke(args="init-db") |             result: Result = runner.invoke(args="init-db") | ||||||
|             self.assertEqual(result.exit_code, 0) |             self.assertEqual(result.exit_code, 0) | ||||||
|         self.client: httpx.Client = httpx.Client(app=self.app, |             if BaseAccount.query.first() is None: | ||||||
|                                                  base_url="https://testserver") |                 result = runner.invoke(args="accounting-init-base") | ||||||
|         self.client.headers["Referer"] = "https://testserver" |                 self.assertEqual(result.exit_code, 0) | ||||||
|         self.csrf_token: str = get_csrf_token(self, self.client, "/login") |  | ||||||
|  |  | ||||||
|     def test_init(self) -> None: |     def test_nobody(self) -> None: | ||||||
|         """Tests the "accounting-init-base" console command. |         """Test the permission as nobody. | ||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         from accounting.models import BaseAccountL10n |         client, csrf_token = get_client(self.app, "nobody") | ||||||
|         from accounting.models import BaseAccount |  | ||||||
|         runner: FlaskCliRunner = self.app.test_cli_runner() |  | ||||||
|         result: Result = runner.invoke(args="accounting-init-base") |  | ||||||
|         self.assertEqual(result.exit_code, 0) |  | ||||||
|         with self.app.app_context(): |  | ||||||
|             accounts: list[BaseAccount] = BaseAccount.query.all() |  | ||||||
|             l10n: list[BaseAccountL10n] = BaseAccountL10n.query.all() |  | ||||||
|         self.assertEqual(len(accounts), 527) |  | ||||||
|         self.assertEqual(len(l10n), 527 * 2) |  | ||||||
|         l10n_keys: set[str] = {f"{x.account_code}-{x.locale}" for x in l10n} |  | ||||||
|         for account in accounts: |  | ||||||
|             self.assertIn(f"{account.code}-zh_Hant", l10n_keys) |  | ||||||
|             self.assertIn(f"{account.code}-zh_Hant", l10n_keys) |  | ||||||
|  |  | ||||||
|         list_uri: str = "/accounting/base-accounts" |  | ||||||
|         response: httpx.Response |         response: httpx.Response | ||||||
|  |  | ||||||
|         self.__logout() |         response = client.get(LIST_URI) | ||||||
|         response = self.client.get(list_uri) |  | ||||||
|         self.assertEqual(response.status_code, 403) |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|         self.__logout() |         response = client.get(DETAIL_URI) | ||||||
|         self.__login_as("viewer") |  | ||||||
|         response = self.client.get(list_uri) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|  |  | ||||||
|         self.__logout() |  | ||||||
|         self.__login_as("editor") |  | ||||||
|         response = self.client.get(list_uri) |  | ||||||
|         self.assertEqual(response.status_code, 200) |  | ||||||
|  |  | ||||||
|         self.__logout() |  | ||||||
|         self.__login_as("nobody") |  | ||||||
|         response = self.client.get(list_uri) |  | ||||||
|         self.assertEqual(response.status_code, 403) |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|     def __logout(self) -> None: |     def test_viewer(self) -> None: | ||||||
|         """Logs out the currently logged-in user. |         """Test the permission as viewer. | ||||||
|  |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         response: httpx.Response = self.client.post( |         client, csrf_token = get_client(self.app, "viewer") | ||||||
|             "/logout", data={"csrf_token": self.csrf_token}) |         response: httpx.Response | ||||||
|         self.assertEqual(response.status_code, 302) |  | ||||||
|         self.assertEqual(response.headers["Location"], "/") |  | ||||||
|  |  | ||||||
|     def __login_as(self, username: str) -> None: |         response = client.get(LIST_URI) | ||||||
|         """Logs in as a specific user. |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = client.get(DETAIL_URI) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_editor(self) -> None: | ||||||
|  |         """Test the permission as editor. | ||||||
|  |  | ||||||
|         :param username: The username. |  | ||||||
|         :return: None. |         :return: None. | ||||||
|         """ |         """ | ||||||
|         response: httpx.Response = self.client.post( |         client, csrf_token = get_client(self.app, "editor") | ||||||
|             "/login", data={"csrf_token": self.csrf_token, |         response: httpx.Response | ||||||
|                             "username": username}) |  | ||||||
|         self.assertEqual(response.status_code, 302) |         response = client.get(LIST_URI) | ||||||
|         self.assertEqual(response.headers["Location"], "/") |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = client.get(DETAIL_URI) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|   | |||||||
							
								
								
									
										607
									
								
								tests/test_currency.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										607
									
								
								tests/test_currency.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,607 @@ | |||||||
|  | # The Mia! Accounting Flask Project. | ||||||
|  | # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 | ||||||
|  |  | ||||||
|  | #  Copyright (c) 2023 imacat. | ||||||
|  | # | ||||||
|  | #  Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  | #  you may not use this file except in compliance with the License. | ||||||
|  | #  You may obtain a copy of the License at | ||||||
|  | # | ||||||
|  | #      http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  | # | ||||||
|  | #  Unless required by applicable law or agreed to in writing, software | ||||||
|  | #  distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  | #  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  | #  See the License for the specific language governing permissions and | ||||||
|  | #  limitations under the License. | ||||||
|  | """The test for the currency management. | ||||||
|  |  | ||||||
|  | """ | ||||||
|  | import csv | ||||||
|  | import typing as t | ||||||
|  | import unittest | ||||||
|  | from datetime import timedelta | ||||||
|  |  | ||||||
|  | import httpx | ||||||
|  | from click.testing import Result | ||||||
|  | from flask import Flask | ||||||
|  | from flask.testing import FlaskCliRunner | ||||||
|  |  | ||||||
|  | from test_site import create_app, db | ||||||
|  | from testlib import get_client, set_locale | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CurrencyData: | ||||||
|  |     """The currency data.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, code: str, name: str): | ||||||
|  |         """Constructs the currency data. | ||||||
|  |  | ||||||
|  |         :param code: The code. | ||||||
|  |         :param name: The name. | ||||||
|  |         """ | ||||||
|  |         self.code: str = code | ||||||
|  |         """The code.""" | ||||||
|  |         self.name: str = name | ||||||
|  |         """The name.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | zza: CurrencyData = CurrencyData("ZZA", "Testing Dollar #A") | ||||||
|  | """The first test currency.""" | ||||||
|  | zzb: CurrencyData = CurrencyData("ZZB", "Testing Dollar #B") | ||||||
|  | """The second test currency.""" | ||||||
|  | zzc: CurrencyData = CurrencyData("ZZC", "Testing Dollar #C") | ||||||
|  | """The third test currency.""" | ||||||
|  | zzd: CurrencyData = CurrencyData("ZZD", "Testing Dollar #D") | ||||||
|  | """The fourth test currency.""" | ||||||
|  | PREFIX: str = "/accounting/currencies" | ||||||
|  | """The URL prefix of the currency management.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CurrencyCommandTestCase(unittest.TestCase): | ||||||
|  |     """The account console command test case.""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         """Sets up the test. | ||||||
|  |         This is run once per test. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.app: Flask = create_app(is_testing=True) | ||||||
|  |  | ||||||
|  |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             from accounting.models import Currency, CurrencyL10n | ||||||
|  |             result: Result | ||||||
|  |             result = runner.invoke(args="init-db") | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |             CurrencyL10n.query.delete() | ||||||
|  |             Currency.query.delete() | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |     def test_init(self) -> None: | ||||||
|  |         """Tests the "accounting-init-currencies" console command. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting import data_dir | ||||||
|  |         from accounting.models import Currency | ||||||
|  |  | ||||||
|  |         with open(data_dir / "currencies.csv") as fp: | ||||||
|  |             data: dict[dict[str, t.Any]] \ | ||||||
|  |                 = {x["code"]: {"code": x["code"], | ||||||
|  |                                "name": x["name"], | ||||||
|  |                                "l10n": {y[5:]: x[y] | ||||||
|  |                                         for y in x if y.startswith("l10n-")}} | ||||||
|  |                    for x in csv.DictReader(fp)} | ||||||
|  |  | ||||||
|  |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             result: Result = runner.invoke( | ||||||
|  |                 args=["accounting-init-currencies", "-u", "editor"]) | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |             currencies: list[Currency] = Currency.query.all() | ||||||
|  |  | ||||||
|  |         self.assertEqual(len(currencies), len(data)) | ||||||
|  |         for currency in currencies: | ||||||
|  |             self.assertIn(currency.code, data) | ||||||
|  |             self.assertEqual(currency.name_l10n, data[currency.code]["name"]) | ||||||
|  |             l10n: dict[str, str] = {x.locale: x.name for x in currency.l10n} | ||||||
|  |             self.assertEqual(len(l10n), len(data[currency.code]["l10n"])) | ||||||
|  |             for locale in l10n: | ||||||
|  |                 self.assertIn(locale, data[currency.code]["l10n"]) | ||||||
|  |                 self.assertEqual(l10n[locale], | ||||||
|  |                                  data[currency.code]["l10n"][locale]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CurrencyTestCase(unittest.TestCase): | ||||||
|  |     """The currency test case.""" | ||||||
|  |  | ||||||
|  |     def setUp(self) -> None: | ||||||
|  |         """Sets up the test. | ||||||
|  |         This is run once per test. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         self.app: Flask = create_app(is_testing=True) | ||||||
|  |  | ||||||
|  |         runner: FlaskCliRunner = self.app.test_cli_runner() | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             from accounting.models import Currency, CurrencyL10n | ||||||
|  |             result: Result | ||||||
|  |             result = runner.invoke(args="init-db") | ||||||
|  |             self.assertEqual(result.exit_code, 0) | ||||||
|  |             CurrencyL10n.query.delete() | ||||||
|  |             Currency.query.delete() | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |         self.client, self.csrf_token = get_client(self.app, "editor") | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/store", | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zza.code, | ||||||
|  |                                           "name": zza.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], f"{PREFIX}/{zza.code}") | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/store", | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zzb.code, | ||||||
|  |                                           "name": zzb.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzb.code}") | ||||||
|  |  | ||||||
|  |     def test_nobody(self) -> None: | ||||||
|  |         """Test the permission as nobody. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         client, csrf_token = get_client(self.app, "nobody") | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         response = client.get(PREFIX) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/{zza.code}") | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/create") | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/store", | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "code": zzc.code, | ||||||
|  |                                      "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/{zza.code}/edit") | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/{zza.code}/update", | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "code": zzd.code, | ||||||
|  |                                      "name": zzd.name}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/{zzb.code}/delete", | ||||||
|  |                                data={"csrf_token": csrf_token}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |     def test_viewer(self) -> None: | ||||||
|  |         """Test the permission as viewer. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         client, csrf_token = get_client(self.app, "viewer") | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         response = client.get(PREFIX) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/{zza.code}") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/create") | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/store", | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "code": zzc.code, | ||||||
|  |                                      "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.get(f"{PREFIX}/{zza.code}/edit") | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/{zza.code}/update", | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "code": zzd.code, | ||||||
|  |                                      "name": zzd.name}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |         response = client.post(f"{PREFIX}/{zzb.code}/delete", | ||||||
|  |                                data={"csrf_token": csrf_token}) | ||||||
|  |         self.assertEqual(response.status_code, 403) | ||||||
|  |  | ||||||
|  |     def test_editor(self) -> None: | ||||||
|  |         """Test the permission as editor. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         response = self.client.get(PREFIX) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.get(f"{PREFIX}/{zza.code}") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.get(f"{PREFIX}/create") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/store", | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zzc.code, | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzc.code}") | ||||||
|  |  | ||||||
|  |         response = self.client.get(f"{PREFIX}/{zza.code}/edit") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/{zza.code}/update", | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zzd.code, | ||||||
|  |                                           "name": zzd.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzd.code}") | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{PREFIX}/{zzb.code}/delete", | ||||||
|  |                                     data={"csrf_token": self.csrf_token}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], PREFIX) | ||||||
|  |  | ||||||
|  |     def test_add(self) -> None: | ||||||
|  |         """Tests to add the currencies. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Currency | ||||||
|  |         create_uri: str = f"{PREFIX}/create" | ||||||
|  |         store_uri: str = f"{PREFIX}/store" | ||||||
|  |         detail_uri: str = f"{PREFIX}/{zzc.code}" | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             self.assertEqual({x.code for x in Currency.query.all()}, | ||||||
|  |                              {zza.code, zzb.code}) | ||||||
|  |  | ||||||
|  |         # Missing CSRF token | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"code": zzc.code, | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |  | ||||||
|  |         # CSRF token mismatch | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": f"{self.csrf_token}-2", | ||||||
|  |                                           "code": zzc.code, | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 400) | ||||||
|  |  | ||||||
|  |         # Empty code | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": " ", | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Blocked code, with spaces to be stripped | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": " create ", | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Bad code | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": " zzc ", | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Empty name | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zzc.code, | ||||||
|  |                                           "name": " "}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         # Success, with spaces to be stripped | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": f" {zzc.code} ", | ||||||
|  |                                           "name": f" {zzc.name} "}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         # Duplicated code | ||||||
|  |         response = self.client.post(store_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zzc.code, | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], create_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             self.assertEqual({x.code for x in Currency.query.all()}, | ||||||
|  |                              {zza.code, zzb.code, zzc.code}) | ||||||
|  |  | ||||||
|  |             zzc_currency: Currency = db.session.get(Currency, zzc.code) | ||||||
|  |             self.assertEqual(zzc_currency.code, zzc.code) | ||||||
|  |             self.assertEqual(zzc_currency.name_l10n, zzc.name) | ||||||
|  |  | ||||||
|  |     def test_basic_update(self) -> None: | ||||||
|  |         """Tests the basic rules to update a user. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Currency | ||||||
|  |         detail_uri: str = f"{PREFIX}/{zza.code}" | ||||||
|  |         edit_uri: str = f"{PREFIX}/{zza.code}/edit" | ||||||
|  |         update_uri: str = f"{PREFIX}/{zza.code}/update" | ||||||
|  |         detail_c_uri: str = f"{PREFIX}/{zzc.code}" | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         # Success, with spaces to be stripped | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": f" {zza.code} ", | ||||||
|  |                                           "name": f" {zza.name}-1 "}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             zza_currency: Currency = db.session.get(Currency, zza.code) | ||||||
|  |             self.assertEqual(zza_currency.code, zza.code) | ||||||
|  |             self.assertEqual(zza_currency.name_l10n, f"{zza.name}-1") | ||||||
|  |  | ||||||
|  |         # Empty code | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": " ", | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Blocked code, with spaces to be stripped | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": " create ", | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Bad code | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": "abc/def", | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Empty name | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zzc.code, | ||||||
|  |                                           "name": " "}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Duplicated code | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zzb.code, | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], edit_uri) | ||||||
|  |  | ||||||
|  |         # Change code | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zzc.code, | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_c_uri) | ||||||
|  |  | ||||||
|  |         response = self.client.get(detail_uri) | ||||||
|  |         self.assertEqual(response.status_code, 404) | ||||||
|  |  | ||||||
|  |         response = self.client.get(detail_c_uri) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |  | ||||||
|  |     def test_update_not_modified(self) -> None: | ||||||
|  |         """Tests that the data is not modified. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Currency | ||||||
|  |         detail_uri: str = f"{PREFIX}/{zza.code}" | ||||||
|  |         update_uri: str = f"{PREFIX}/{zza.code}/update" | ||||||
|  |         zza_currency: Currency | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": f" {zza.code} ", | ||||||
|  |                                           "name": f" {zza.name} "}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             zza_currency = db.session.get(Currency, zza.code) | ||||||
|  |             self.assertIsNotNone(zza_currency) | ||||||
|  |             zza_currency.created_at \ | ||||||
|  |                 = zza_currency.created_at - timedelta(seconds=5) | ||||||
|  |             zza_currency.updated_at = zza_currency.created_at | ||||||
|  |             db.session.commit() | ||||||
|  |  | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zza.code, | ||||||
|  |                                           "name": zzc.name}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             zza_currency = db.session.get(Currency, zza.code) | ||||||
|  |             self.assertIsNotNone(zza_currency) | ||||||
|  |             self.assertLess(zza_currency.created_at, | ||||||
|  |                             zza_currency.updated_at) | ||||||
|  |  | ||||||
|  |     def test_created_updated_by(self) -> None: | ||||||
|  |         """Tests the created-by and updated-by record. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Currency | ||||||
|  |         editor_username, editor2_username = "editor", "editor2" | ||||||
|  |         client, csrf_token = get_client(self.app, editor2_username) | ||||||
|  |         detail_uri: str = f"{PREFIX}/{zza.code}" | ||||||
|  |         update_uri: str = f"{PREFIX}/{zza.code}/update" | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             zza_currency: Currency = db.session.get(Currency, zza.code) | ||||||
|  |             self.assertEqual(zza_currency.created_by.username, editor_username) | ||||||
|  |             self.assertEqual(zza_currency.updated_by.username, editor_username) | ||||||
|  |  | ||||||
|  |         response = client.post(update_uri, | ||||||
|  |                                data={"csrf_token": csrf_token, | ||||||
|  |                                      "code": zza.code, | ||||||
|  |                                      "name": f"{zza.name}-2"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             zza_currency: Currency = db.session.get(Currency, zza.code) | ||||||
|  |             self.assertEqual(zza_currency.created_by.username, editor_username) | ||||||
|  |             self.assertEqual(zza_currency.updated_by.username, editor2_username) | ||||||
|  |  | ||||||
|  |     def test_api_exists(self) -> None: | ||||||
|  |         """Tests the API to check if a code exists. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         response = self.client.get( | ||||||
|  |             f"/accounting/api/currencies/exists-code?q={zza.code}") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         data = response.json() | ||||||
|  |         self.assertEqual(set(data.keys()), {"exists"}) | ||||||
|  |         self.assertTrue(data["exists"]) | ||||||
|  |  | ||||||
|  |         response = self.client.get( | ||||||
|  |             f"/accounting/api/currencies/exists-code?q={zza.code}-1") | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         data = response.json() | ||||||
|  |         self.assertEqual(set(data.keys()), {"exists"}) | ||||||
|  |         self.assertFalse(data["exists"]) | ||||||
|  |  | ||||||
|  |     def test_l10n(self) -> None: | ||||||
|  |         """Tests the localization. | ||||||
|  |  | ||||||
|  |         :return: None | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Currency | ||||||
|  |         detail_uri: str = f"{PREFIX}/{zza.code}" | ||||||
|  |         update_uri: str = f"{PREFIX}/{zza.code}/update" | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             zza_currency: Currency = db.session.get(Currency, zza.code) | ||||||
|  |             self.assertEqual(zza_currency.name_l10n, zza.name) | ||||||
|  |             self.assertEqual(zza_currency.l10n, []) | ||||||
|  |  | ||||||
|  |         set_locale(self.client, self.csrf_token, "zh_Hant") | ||||||
|  |  | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zza.code, | ||||||
|  |                                           "name": f"{zza.name}-zh_Hant"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             zza_currency: Currency = db.session.get(Currency, zza.code) | ||||||
|  |             self.assertEqual(zza_currency.name_l10n, zza.name) | ||||||
|  |             self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n}, | ||||||
|  |                              {("zh_Hant", f"{zza.name}-zh_Hant")}) | ||||||
|  |  | ||||||
|  |         set_locale(self.client, self.csrf_token, "en") | ||||||
|  |  | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zza.code, | ||||||
|  |                                           "name": f"{zza.name}-2"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             zza_currency: Currency = db.session.get(Currency, zza.code) | ||||||
|  |             self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2") | ||||||
|  |             self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n}, | ||||||
|  |                              {("zh_Hant", f"{zza.name}-zh_Hant")}) | ||||||
|  |  | ||||||
|  |         set_locale(self.client, self.csrf_token, "zh_Hant") | ||||||
|  |  | ||||||
|  |         response = self.client.post(update_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token, | ||||||
|  |                                           "code": zza.code, | ||||||
|  |                                           "name": f"{zza.name}-zh_Hant-2"}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], detail_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             zza_currency: Currency = db.session.get(Currency, zza.code) | ||||||
|  |             self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2") | ||||||
|  |             self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n}, | ||||||
|  |                              {("zh_Hant", f"{zza.name}-zh_Hant-2")}) | ||||||
|  |  | ||||||
|  |     def test_delete(self) -> None: | ||||||
|  |         """Tests to delete a currency. | ||||||
|  |  | ||||||
|  |         :return: None. | ||||||
|  |         """ | ||||||
|  |         from accounting.models import Currency | ||||||
|  |         detail_uri: str = f"{PREFIX}/{zza.code}" | ||||||
|  |         delete_uri: str = f"{PREFIX}/{zza.code}/delete" | ||||||
|  |         list_uri: str = PREFIX | ||||||
|  |         response: httpx.Response | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             self.assertEqual({x.code for x in Currency.query.all()}, | ||||||
|  |                              {zza.code, zzb.code}) | ||||||
|  |  | ||||||
|  |         response = self.client.get(detail_uri) | ||||||
|  |         self.assertEqual(response.status_code, 200) | ||||||
|  |         response = self.client.post(delete_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token}) | ||||||
|  |         self.assertEqual(response.status_code, 302) | ||||||
|  |         self.assertEqual(response.headers["Location"], list_uri) | ||||||
|  |  | ||||||
|  |         with self.app.app_context(): | ||||||
|  |             self.assertEqual({x.code for x in Currency.query.all()}, | ||||||
|  |                              {zzb.code}) | ||||||
|  |  | ||||||
|  |         response = self.client.get(detail_uri) | ||||||
|  |         self.assertEqual(response.status_code, 404) | ||||||
|  |         response = self.client.post(delete_uri, | ||||||
|  |                                     data={"csrf_token": self.csrf_token}) | ||||||
|  |         self.assertEqual(response.status_code, 404) | ||||||
| @@ -29,6 +29,8 @@ from flask_sqlalchemy import SQLAlchemy | |||||||
| from flask_wtf import CSRFProtect | from flask_wtf import CSRFProtect | ||||||
| from sqlalchemy import Column | from sqlalchemy import Column | ||||||
| 
 | 
 | ||||||
|  | import accounting.utils.user | ||||||
|  | 
 | ||||||
| bp: Blueprint = Blueprint("home", __name__) | bp: Blueprint = Blueprint("home", __name__) | ||||||
| babel_js: BabelJS = BabelJS() | babel_js: BabelJS = BabelJS() | ||||||
| csrf: CSRFProtect = CSRFProtect() | csrf: CSRFProtect = CSRFProtect() | ||||||
| @@ -53,7 +55,6 @@ def create_app(is_testing: bool = False) -> Flask: | |||||||
|     }) |     }) | ||||||
|     if is_testing: |     if is_testing: | ||||||
|         app.config["TESTING"] = True |         app.config["TESTING"] = True | ||||||
|     app.config["SQLALCHEMY_ECHO"] = True |  | ||||||
| 
 | 
 | ||||||
|     babel_js.init_app(app) |     babel_js.init_app(app) | ||||||
|     csrf.init_app(app) |     csrf.init_app(app) | ||||||
| @@ -68,7 +69,7 @@ def create_app(is_testing: bool = False) -> Flask: | |||||||
|     from . import auth |     from . import auth | ||||||
|     auth.init_app(app) |     auth.init_app(app) | ||||||
| 
 | 
 | ||||||
|     class UserUtils(accounting.AbstractUserUtils[auth.User]): |     class UserUtils(accounting.utils.user.AbstractUserUtils[auth.User]): | ||||||
| 
 | 
 | ||||||
|         @property |         @property | ||||||
|         def cls(self) -> t.Type[auth.User]: |         def cls(self) -> t.Type[auth.User]: | ||||||
| @@ -79,7 +80,7 @@ def create_app(is_testing: bool = False) -> Flask: | |||||||
|             return auth.User.id |             return auth.User.id | ||||||
| 
 | 
 | ||||||
|         @property |         @property | ||||||
|         def current_user(self) -> auth.User: |         def current_user(self) -> auth.User | None: | ||||||
|             return auth.current_user() |             return auth.current_user() | ||||||
| 
 | 
 | ||||||
|         def get_by_username(self, username: str) -> auth.User | None: |         def get_by_username(self, username: str) -> auth.User | None: | ||||||
| @@ -90,9 +91,9 @@ def create_app(is_testing: bool = False) -> Flask: | |||||||
|             return user.id |             return user.id | ||||||
| 
 | 
 | ||||||
|     can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \ |     can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \ | ||||||
|         and auth.current_user().username in ["viewer", "editor"] |         and auth.current_user().username in ["viewer", "editor", "editor2"] | ||||||
|     can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \ |     can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \ | ||||||
|         and auth.current_user().username == "editor" |         and auth.current_user().username in ["editor", "editor2"] | ||||||
|     accounting.init_app(app, user_utils=UserUtils(), |     accounting.init_app(app, user_utils=UserUtils(), | ||||||
|                         can_view_func=can_view, can_edit_func=can_edit) |                         can_view_func=can_view, can_edit_func=can_edit) | ||||||
| 
 | 
 | ||||||
| @@ -105,7 +106,7 @@ def init_db_command() -> None: | |||||||
|     """Initializes the database.""" |     """Initializes the database.""" | ||||||
|     db.create_all() |     db.create_all() | ||||||
|     from .auth import User |     from .auth import User | ||||||
|     for username in ["viewer", "editor", "nobody"]: |     for username in ["viewer", "editor", "editor2", "nobody"]: | ||||||
|         if User.query.filter(User.username == username).first() is None: |         if User.query.filter(User.username == username).first() is None: | ||||||
|             db.session.add(User(username=username)) |             db.session.add(User(username=username)) | ||||||
|     db.session.commit() |     db.session.commit() | ||||||
| @@ -58,7 +58,8 @@ def login() -> redirect: | |||||||
| 
 | 
 | ||||||
|     :return: The redirection to the home page. |     :return: The redirection to the home page. | ||||||
|     """ |     """ | ||||||
|     if request.form.get("username") not in ["viewer", "editor", "nobody"]: |     if request.form.get("username") not in ["viewer", "editor", "editor2", | ||||||
|  |                                             "nobody"]: | ||||||
|         return redirect(url_for("auth.login")) |         return redirect(url_for("auth.login")) | ||||||
|     session["user"] = request.form.get("username") |     session["user"] = request.form.get("username") | ||||||
|     return redirect(url_for("home.home")) |     return redirect(url_for("home.home")) | ||||||
| @@ -29,8 +29,8 @@ First written: 2023/1/27 | |||||||
|   <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css"> |   <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css"> | ||||||
|   {% block styles %}{% endblock %} |   {% block styles %}{% endblock %} | ||||||
|   <script src="{{ url_for("babel_catalog") }}"></script> |   <script src="{{ url_for("babel_catalog") }}"></script> | ||||||
|   <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script> |   <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script> | ||||||
|   <script src="https://cdnjs.cloudflare.com/ajax/libs/decimal.js/9.0.0/decimal.min.js"></script> |   <script src="https://cdn.jsdelivr.net/npm/decimal.js-light@2.5.1/decimal.min.js" integrity="sha384-QdsxGEq4Y0erX8WUIsZJDtfoSSyBF6dmNCnzRNYCa2AOM/xzNsyhHu0RbdFBAm+l" crossorigin="anonymous"></script> | ||||||
|   {% block scripts %}{% endblock %} |   {% block scripts %}{% endblock %} | ||||||
|   <title>{% block title %}{% endblock %}</title> |   <title>{% block title %}{% endblock %}</title> | ||||||
| </head> | </head> | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user