Compare commits
168 Commits
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 | |||
15007ada4f | |||
e50d6267d5 | |||
2359842e80 | |||
9497fa371e | |||
b0ef4fb059 | |||
9f63db174c | |||
9b22331a5a | |||
cb0dea58f1 | |||
241ad337c8 | |||
2964f206a6 | |||
9118b631e4 | |||
ce6c8508df | |||
e29b99b0a7 | |||
e9f6b769f4 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -37,4 +37,4 @@ excludes
|
||||
*.pot
|
||||
*.mo
|
||||
zh_Hans
|
||||
node_modules
|
||||
test_temp.py
|
||||
|
@ -22,7 +22,7 @@ include docs/source/*
|
||||
include docs/source/_static/*
|
||||
include docs/source/_templates/*
|
||||
include tests/*
|
||||
include tests/testsite/*
|
||||
include tests/testsite/templates/*
|
||||
include tests/testsite/translations/*
|
||||
include tests/testsite/translations/*/LC_MESSAGES/*
|
||||
include tests/test_site/*
|
||||
include tests/test_site/templates/*
|
||||
include tests/test_site/translations/*
|
||||
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:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.base\_account.database module
|
||||
----------------------------------------
|
||||
accounting.base\_account.converters module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: accounting.base_account.database
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.base\_account.models module
|
||||
--------------------------------------
|
||||
|
||||
.. automodule:: accounting.base_account.models
|
||||
.. automodule:: accounting.base_account.converters
|
||||
:members:
|
||||
:undoc-members:
|
||||
: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::
|
||||
:maxdepth: 4
|
||||
|
||||
accounting.account
|
||||
accounting.base_account
|
||||
accounting.currency
|
||||
accounting.transaction
|
||||
accounting.utils
|
||||
|
||||
Submodules
|
||||
@ -21,6 +24,14 @@ accounting.locale module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.models module
|
||||
------------------------
|
||||
|
||||
.. automodule:: accounting.models
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
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
|
||||
----------
|
||||
|
||||
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
|
||||
----------------------------------
|
||||
|
||||
@ -28,6 +44,30 @@ accounting.utils.query module
|
||||
:undoc-members:
|
||||
: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
|
||||
---------------
|
||||
|
||||
|
@ -17,7 +17,7 @@
|
||||
|
||||
[metadata]
|
||||
name = mia-accounting-flask
|
||||
version = 0.0.0
|
||||
version = 0.3.0
|
||||
author = imacat
|
||||
author_email = imacat@mail.imacat.idv.tw
|
||||
description = The Mia! Accounting Flask project.
|
||||
@ -36,7 +36,7 @@ classifiers =
|
||||
[options]
|
||||
package_dir =
|
||||
= src
|
||||
python_requires = >=3.10
|
||||
python_requires = >=3.11
|
||||
install_requires =
|
||||
flask
|
||||
Flask-SQLAlchemy
|
||||
@ -50,7 +50,7 @@ tests_require =
|
||||
|
||||
[options.package_data]
|
||||
accounting =
|
||||
static/**
|
||||
templates/**
|
||||
translations/*/LC_MESSAGES/*.mo
|
||||
accounting.base_account =
|
||||
templates/**
|
||||
data/**
|
||||
|
@ -18,16 +18,27 @@
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
from accounting.utils.user import AbstractUserUtils
|
||||
|
||||
db: SQLAlchemy = SQLAlchemy()
|
||||
"""The database instance."""
|
||||
data_dir: Path = Path(__file__).parent / "data"
|
||||
"""The data directory."""
|
||||
|
||||
|
||||
def init_app(app: Flask, url_prefix: str = "/accounting",
|
||||
def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
url_prefix: str = "/accounting",
|
||||
can_view_func: t.Callable[[], bool] | None = None,
|
||||
can_edit_func: t.Callable[[], bool] | None = None) -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param app: The Flask application.
|
||||
:param user_utils: The user utilities.
|
||||
:param url_prefix: The URL prefix of the accounting application.
|
||||
:param can_view_func: A callback that returns whether the current user can
|
||||
view the accounting data.
|
||||
@ -37,8 +48,10 @@ def init_app(app: Flask, url_prefix: str = "/accounting",
|
||||
"""
|
||||
# The database instance must be set before loading everything
|
||||
# in the application.
|
||||
from .database import set_db
|
||||
set_db(app.extensions["sqlalchemy"])
|
||||
global db
|
||||
db = app.extensions["sqlalchemy"]
|
||||
from .utils.user import init_user_utils
|
||||
init_user_utils(user_utils)
|
||||
|
||||
bp: Blueprint = Blueprint("accounting", __name__,
|
||||
url_prefix=url_prefix,
|
||||
@ -49,9 +62,21 @@ def init_app(app: Flask, url_prefix: str = "/accounting",
|
||||
locale.init_app(app, bp)
|
||||
|
||||
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
|
||||
base_account.init_app(app, bp)
|
||||
|
||||
from . import account
|
||||
account.init_app(app, bp)
|
||||
|
||||
from . import currency
|
||||
currency.init_app(app, bp)
|
||||
|
||||
from . import transaction
|
||||
transaction.init_app(app, bp)
|
||||
|
||||
from .utils import next_uri
|
||||
next_uri.init_app(bp)
|
||||
|
||||
app.register_blueprint(bp)
|
||||
|
37
src/accounting/account/__init__.py
Normal file
37
src/accounting/account/__init__.py
Normal file
@ -0,0 +1,37 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The account management.
|
||||
|
||||
"""
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
|
||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param app: The Flask application.
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
from .converters import AccountConverter
|
||||
app.url_map.converters["account"] = AccountConverter
|
||||
|
||||
from .views import bp as account_bp
|
||||
bp.register_blueprint(account_bp, url_prefix="/accounts")
|
||||
|
||||
from .commands import init_accounts_command
|
||||
app.cli.add_command(init_accounts_command)
|
127
src/accounting/account/commands.py
Normal file
127
src/accounting/account/commands.py
Normal file
@ -0,0 +1,127 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The console commands for the account management.
|
||||
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from secrets import randbelow
|
||||
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from accounting import db
|
||||
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]
|
||||
"""The format of the account data, as a list of (ID, base account code, number,
|
||||
English, Traditional Chinese, Simplified Chinese, is-pay-off-needed) tuples."""
|
||||
|
||||
|
||||
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-accounts")
|
||||
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||
help="The username.", callback=__validate_username,
|
||||
default=lambda: os.getlogin())
|
||||
@with_appcontext
|
||||
def init_accounts_command(username: str) -> None:
|
||||
"""Initializes the accounts."""
|
||||
creator_pk: int = get_user_pk(username)
|
||||
|
||||
bases: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(db.func.length(BaseAccount.code) == 4)\
|
||||
.order_by(BaseAccount.code).all()
|
||||
if len(bases) == 0:
|
||||
click.echo("Please initialize the base accounts with "
|
||||
"\"flask accounting-init-base\" first.")
|
||||
raise click.Abort
|
||||
|
||||
existing: list[Account] = Account.query.all()
|
||||
|
||||
existing_base_code: set[str] = {x.base_code for x in existing}
|
||||
bases_to_add: list[BaseAccount] = [x for x in bases
|
||||
if x.code not in existing_base_code]
|
||||
if len(bases_to_add) == 0:
|
||||
click.echo("No more account to import.")
|
||||
return
|
||||
|
||||
existing_id: set[int] = {x.id for x in existing}
|
||||
|
||||
def get_new_id() -> int:
|
||||
"""Returns a new random account ID.
|
||||
|
||||
:return: The newly-generated random account ID.
|
||||
"""
|
||||
while True:
|
||||
new_id: int = 100000000 + randbelow(900000000)
|
||||
if new_id not in existing_id:
|
||||
existing_id.add(new_id)
|
||||
return new_id
|
||||
|
||||
data: list[AccountData] = []
|
||||
for base in bases_to_add:
|
||||
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
||||
is_pay_off_needed: bool = True if re.match("^[12]1[34]", base.code) \
|
||||
else False
|
||||
data.append((get_new_id(), base.code, 1, base.title_l10n,
|
||||
l10n["zh_Hant"], l10n["zh_Hans"], is_pay_off_needed))
|
||||
__add_accounting_accounts(data, creator_pk)
|
||||
click.echo(F"{len(data)} added. Accounting accounts initialized.")
|
||||
|
||||
|
||||
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
|
||||
-> None:
|
||||
"""Adds the accounts.
|
||||
|
||||
:param data: A list of (base code, number, title) tuples.
|
||||
:param creator_pk: The primary key of the creator.
|
||||
:return: None.
|
||||
"""
|
||||
accounts: list[Account] = [Account(id=x[0],
|
||||
base_code=x[1],
|
||||
no=x[2],
|
||||
title_l10n=x[3],
|
||||
is_pay_off_needed=x[6],
|
||||
created_by_id=creator_pk,
|
||||
updated_by_id=creator_pk)
|
||||
for x in data]
|
||||
l10n: list[AccountL10n] = [AccountL10n(account_id=x[0],
|
||||
locale=y[0],
|
||||
title=y[1])
|
||||
for x in data
|
||||
for y in (("zh_Hant", x[4]), ("zh_Hans", x[5]))]
|
||||
db.session.bulk_save_objects(accounts)
|
||||
db.session.bulk_save_objects(l10n)
|
||||
db.session.commit()
|
47
src/accounting/account/converters.py
Normal file
47
src/accounting/account/converters.py
Normal file
@ -0,0 +1,47 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The path converters for the account management.
|
||||
|
||||
"""
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting.models import Account
|
||||
|
||||
|
||||
class AccountConverter(BaseConverter):
|
||||
"""The account converter to convert the account code and to the
|
||||
corresponding account in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> Account:
|
||||
"""Converts an account code to an account.
|
||||
|
||||
:param value: The account code.
|
||||
:return: The corresponding account.
|
||||
"""
|
||||
account: Account | None = Account.find_by_code(value)
|
||||
if account is None:
|
||||
abort(404)
|
||||
return account
|
||||
|
||||
def to_url(self, value: Account) -> str:
|
||||
"""Converts an account to its code.
|
||||
|
||||
:param value: The account.
|
||||
:return: The code.
|
||||
"""
|
||||
return value.code
|
186
src/accounting/account/forms.py
Normal file
186
src/accounting/account/forms.py
Normal file
@ -0,0 +1,186 @@
|
||||
# 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 forms for the account management.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField
|
||||
from wtforms.validators import DataRequired, ValidationError
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import BaseAccount, Account
|
||||
from accounting.utils.random_id import new_id
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
|
||||
|
||||
class BaseAccountExists:
|
||||
"""The validator to check if the base account exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data == "":
|
||||
return
|
||||
if db.session.get(BaseAccount, field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The base account does not exist."))
|
||||
|
||||
|
||||
class BaseAccountAvailable:
|
||||
"""The validator to check if the base account is available."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data == "":
|
||||
return
|
||||
if len(field.data) != 4:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The base account is not available."))
|
||||
|
||||
|
||||
class AccountForm(FlaskForm):
|
||||
"""The form to create or edit an account."""
|
||||
base_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
DataRequired(lazy_gettext("Please select the base account.")),
|
||||
BaseAccountExists(),
|
||||
BaseAccountAvailable()])
|
||||
"""The code of the base account."""
|
||||
title = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
|
||||
"""The title."""
|
||||
is_pay_off_needed = BooleanField()
|
||||
"""Whether the the entries of this account need pay-off."""
|
||||
|
||||
def populate_obj(self, obj: Account) -> None:
|
||||
"""Populates the form data into an account object.
|
||||
|
||||
:param obj: The account object.
|
||||
:return: None.
|
||||
"""
|
||||
is_new: bool = obj.id is None
|
||||
if is_new:
|
||||
obj.id = new_id(Account)
|
||||
if obj.base_code != self.base_code.data:
|
||||
if obj.base_code is not None:
|
||||
sort_accounts_in(obj.base_code, obj.id)
|
||||
sort_accounts_in(self.base_code.data, obj.id)
|
||||
count: int = Account.query\
|
||||
.filter(Account.base_code == self.base_code.data).count()
|
||||
obj.base_code = self.base_code.data
|
||||
obj.no = count + 1
|
||||
obj.title = self.title.data
|
||||
obj.is_pay_off_needed = self.is_pay_off_needed.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
|
||||
|
||||
def post_update(self, obj: Account) -> None:
|
||||
"""The post-processing after the update.
|
||||
|
||||
:param obj: The account object.
|
||||
:return: None
|
||||
"""
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.updated_by_id = current_user_pk
|
||||
obj.updated_at = sa.func.now()
|
||||
if hasattr(self, "__post_update"):
|
||||
getattr(self, "__post_update")()
|
||||
|
||||
@property
|
||||
def selected_base(self) -> BaseAccount | None:
|
||||
"""The selected base account in the form.
|
||||
|
||||
:return: The selected base account in the form.
|
||||
"""
|
||||
return db.session.get(BaseAccount, self.base_code.data)
|
||||
|
||||
@property
|
||||
def base_options(self) -> list[BaseAccount]:
|
||||
"""The selectable base accounts.
|
||||
|
||||
:return: The selectable base accounts.
|
||||
"""
|
||||
return BaseAccount.query\
|
||||
.filter(sa.func.char_length(BaseAccount.code) == 4)\
|
||||
.order_by(BaseAccount.code).all()
|
||||
|
||||
|
||||
def sort_accounts_in(base_code: str, exclude: int) -> None:
|
||||
"""Sorts the accounts under a base account after changing the base
|
||||
account or deleting an account.
|
||||
|
||||
:param base_code: The code of the base account.
|
||||
:param exclude: The account ID to exclude.
|
||||
:return: None.
|
||||
"""
|
||||
accounts: list[Account] = Account.query\
|
||||
.filter(Account.base_code == base_code,
|
||||
Account.id != exclude)\
|
||||
.order_by(Account.no).all()
|
||||
for i in range(len(accounts)):
|
||||
if accounts[i].no != i + 1:
|
||||
accounts[i].no = i + 1
|
||||
|
||||
|
||||
class AccountReorderForm:
|
||||
"""The form to reorder the accounts."""
|
||||
|
||||
def __init__(self, base: BaseAccount):
|
||||
"""Constructs the form to reorder the accounts under a base account.
|
||||
|
||||
:param base: The base account.
|
||||
"""
|
||||
self.base: BaseAccount = base
|
||||
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
|
55
src/accounting/account/query.py
Normal file
55
src/accounting/account/query.py
Normal file
@ -0,0 +1,55 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The account query.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Account, AccountL10n
|
||||
from accounting.utils.query import parse_query_keywords
|
||||
|
||||
|
||||
def get_account_query() -> list[Account]:
|
||||
"""Returns the accounts, optionally filtered by the query.
|
||||
|
||||
:return: The accounts.
|
||||
"""
|
||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||
if len(keywords) == 0:
|
||||
return Account.query.order_by(Account.base_code, Account.no).all()
|
||||
code: sa.BinaryExpression = Account.base_code + "-" \
|
||||
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
|
||||
sa.func.char_length(sa.cast(Account.no,
|
||||
sa.String)) + 1)
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
for k in keywords:
|
||||
l10n: list[AccountL10n] = AccountL10n.query\
|
||||
.filter(AccountL10n.title.contains(k)).all()
|
||||
l10n_matches: set[str] = {x.account_id for x in l10n}
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
= [Account.base_code.contains(k),
|
||||
Account.title_l10n.contains(k),
|
||||
code.contains(k),
|
||||
Account.id.in_(l10n_matches)]
|
||||
if k in gettext("Pay-off needed"):
|
||||
sub_conditions.append(Account.is_pay_off_needed)
|
||||
conditions.append(sa.or_(*sub_conditions))
|
||||
|
||||
return Account.query.filter(*conditions)\
|
||||
.order_by(Account.base_code, Account.no).all()
|
210
src/accounting/account/views.py
Normal file
210
src/accounting/account/views.py
Normal file
@ -0,0 +1,210 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The views for the account management.
|
||||
|
||||
"""
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import Blueprint, render_template, session, redirect, flash, \
|
||||
url_for, request
|
||||
from werkzeug.datastructures import ImmutableMultiDict
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Account, BaseAccount
|
||||
from accounting.utils.flash_errors import flash_form_errors
|
||||
from accounting.utils.next_uri import inherit_next, or_next
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import can_view, has_permission, can_edit
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
|
||||
from .query import get_account_query
|
||||
|
||||
bp: Blueprint = Blueprint("account", __name__)
|
||||
"""The view blueprint for the account management."""
|
||||
|
||||
|
||||
@bp.get("", endpoint="list")
|
||||
@has_permission(can_view)
|
||||
def list_accounts() -> str:
|
||||
"""Lists the accounts.
|
||||
|
||||
:return: The account list.
|
||||
"""
|
||||
accounts: list[BaseAccount] = get_account_query()
|
||||
pagination: Pagination = Pagination[BaseAccount](accounts)
|
||||
return render_template("accounting/account/list.html",
|
||||
list=pagination.list, pagination=pagination)
|
||||
|
||||
|
||||
@bp.get("/create", endpoint="create")
|
||||
@has_permission(can_edit)
|
||||
def show_add_account_form() -> str:
|
||||
"""Shows the form to add an account.
|
||||
|
||||
:return: The form to add an account.
|
||||
"""
|
||||
if "form" in session:
|
||||
form = AccountForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||
del session["form"]
|
||||
form.validate()
|
||||
else:
|
||||
form = AccountForm()
|
||||
return render_template("accounting/account/create.html",
|
||||
form=form)
|
||||
|
||||
|
||||
@bp.post("/store", endpoint="store")
|
||||
@has_permission(can_edit)
|
||||
def add_account() -> redirect:
|
||||
"""Adds an account.
|
||||
|
||||
:return: The redirection to the account detail on success, or the account
|
||||
creation form on error.
|
||||
"""
|
||||
form = AccountForm(request.form)
|
||||
if not form.validate():
|
||||
flash_form_errors(form)
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(inherit_next(url_for("accounting.account.create")))
|
||||
account: Account = Account()
|
||||
form.populate_obj(account)
|
||||
db.session.add(account)
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The account is added successfully"), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(account)))
|
||||
|
||||
|
||||
@bp.get("/<account:account>", endpoint="detail")
|
||||
@has_permission(can_view)
|
||||
def show_account_detail(account: Account) -> str:
|
||||
"""Shows the account detail.
|
||||
|
||||
:param account: The account.
|
||||
:return: The detail.
|
||||
"""
|
||||
return render_template("accounting/account/detail.html", obj=account)
|
||||
|
||||
|
||||
@bp.get("/<account:account>/edit", endpoint="edit")
|
||||
@has_permission(can_edit)
|
||||
def show_account_edit_form(account: Account) -> str:
|
||||
"""Shows the form to edit an account.
|
||||
|
||||
:param account: The account.
|
||||
:return: The form to edit the account.
|
||||
"""
|
||||
form: AccountForm
|
||||
if "form" in session:
|
||||
form = AccountForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||
del session["form"]
|
||||
form.validate()
|
||||
else:
|
||||
form = AccountForm(obj=account)
|
||||
return render_template("accounting/account/edit.html",
|
||||
account=account, form=form)
|
||||
|
||||
|
||||
@bp.post("/<account:account>/update", endpoint="update")
|
||||
@has_permission(can_edit)
|
||||
def update_account(account: Account) -> redirect:
|
||||
"""Updates an account.
|
||||
|
||||
:param account: The account.
|
||||
:return: The redirection to the account detail on success, or the account
|
||||
edit form on error.
|
||||
"""
|
||||
form = AccountForm(request.form)
|
||||
if not form.validate():
|
||||
flash_form_errors(form)
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(inherit_next(url_for("accounting.account.edit",
|
||||
account=account)))
|
||||
with db.session.no_autoflush:
|
||||
form.populate_obj(account)
|
||||
if not account.is_modified:
|
||||
flash(lazy_gettext("The account was not modified."), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(account)))
|
||||
account.updated_by_id = get_current_user_pk()
|
||||
account.updated_at = sa.func.now()
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The account is updated successfully."), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(account)))
|
||||
|
||||
|
||||
@bp.post("/<account:account>/delete", endpoint="delete")
|
||||
@has_permission(can_edit)
|
||||
def delete_account(account: Account) -> redirect:
|
||||
"""Deletes an account.
|
||||
|
||||
:param account: The account.
|
||||
:return: The redirection to the account list on success, or the account
|
||||
detail on error.
|
||||
"""
|
||||
account.delete()
|
||||
sort_accounts_in(account.base_code, account.id)
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The account is deleted successfully."), "success")
|
||||
return redirect(or_next(__get_list_uri()))
|
||||
|
||||
|
||||
@bp.get("/bases/<baseAccount:base>", endpoint="order")
|
||||
@has_permission(can_view)
|
||||
def show_account_order(base: BaseAccount) -> str:
|
||||
"""Shows the order of the accounts under a same base account.
|
||||
|
||||
:param base: The base account.
|
||||
:return: The order of the accounts under the base account.
|
||||
"""
|
||||
return render_template("accounting/account/order.html", base=base)
|
||||
|
||||
|
||||
@bp.post("/bases/<baseAccount:base>", endpoint="sort")
|
||||
@has_permission(can_edit)
|
||||
def sort_accounts(base: BaseAccount) -> redirect:
|
||||
"""Reorders the accounts under a base account.
|
||||
|
||||
:param base: The base account.
|
||||
:return: The redirection to the incoming account or the account list. The
|
||||
reordering operation does not fail.
|
||||
"""
|
||||
form: AccountReorderForm = AccountReorderForm(base)
|
||||
form.save_order()
|
||||
if not form.is_modified:
|
||||
flash(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,10 +23,13 @@ from flask import Flask, Blueprint
|
||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:param app: The Flask application.
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
from .converters import BaseAccountConverter
|
||||
app.url_map.converters["baseAccount"] = BaseAccountConverter
|
||||
|
||||
from .views import bp as base_account_bp
|
||||
bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")
|
||||
|
||||
|
@ -17,15 +17,14 @@
|
||||
"""The console commands for the base account management.
|
||||
|
||||
"""
|
||||
import csv
|
||||
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from accounting.database import db
|
||||
from .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."""
|
||||
from accounting import data_dir
|
||||
from accounting import db
|
||||
from accounting.models import BaseAccount, BaseAccountL10n
|
||||
|
||||
|
||||
@click.command("accounting-init-base")
|
||||
@ -36,674 +35,17 @@ def init_base_accounts_command() -> None:
|
||||
click.echo("Base accounts already exist.")
|
||||
raise click.Abort
|
||||
|
||||
db.session.bulk_save_objects(
|
||||
[BaseAccount(code=str(x[0]), title_l10n=x[1]) for x in DATA])
|
||||
db.session.bulk_save_objects(
|
||||
[BaseAccountL10n(account_code=x[0], locale=y[0], title=y[1])
|
||||
for x in DATA for y in (("zh_Hant", x[2]), ("zh_Hans", x[3]))])
|
||||
with open(data_dir / "base_accounts.csv") as fp:
|
||||
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
||||
account_data: list[dict[str, str]] = [{"code": x["code"],
|
||||
"title_l10n": x["title"]}
|
||||
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()
|
||||
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."""
|
||||
|
48
src/accounting/base_account/converters.py
Normal file
48
src/accounting/base_account/converters.py
Normal file
@ -0,0 +1,48 @@
|
||||
# The Mia! Accounting 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 path converters for the base account management.
|
||||
|
||||
"""
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import BaseAccount
|
||||
|
||||
|
||||
class BaseAccountConverter(BaseConverter):
|
||||
"""The account converter to convert the account code and to the
|
||||
corresponding base account in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> BaseAccount:
|
||||
"""Converts an account code to a base account.
|
||||
|
||||
:param value: The account code.
|
||||
:return: The corresponding base account.
|
||||
"""
|
||||
account: BaseAccount | None = db.session.get(BaseAccount, value)
|
||||
if account is None:
|
||||
abort(404)
|
||||
return account
|
||||
|
||||
def to_url(self, value: BaseAccount) -> str:
|
||||
"""Converts a base account to its code.
|
||||
|
||||
:param value: The base account.
|
||||
:return: The code.
|
||||
"""
|
||||
return value.code
|
@ -1,73 +0,0 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The data models for the base account management.
|
||||
|
||||
"""
|
||||
from flask import current_app
|
||||
from flask_babel import get_locale
|
||||
|
||||
from accounting.database import db
|
||||
|
||||
|
||||
class BaseAccount(db.Model):
|
||||
"""A base account."""
|
||||
__tablename__ = "accounting_base_accounts"
|
||||
"""The table name."""
|
||||
code = db.Column(db.String, nullable=False, primary_key=True)
|
||||
"""The code."""
|
||||
title_l10n = db.Column("title", db.String, nullable=False)
|
||||
"""The title."""
|
||||
l10n = db.relationship("BaseAccountL10n", back_populates="account",
|
||||
lazy=False)
|
||||
"""The localized titles."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the string representation of the base account.
|
||||
|
||||
:return: The string representation of the base account.
|
||||
"""
|
||||
return F"{self.code} {self.title}"
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
"""Returns the title in the current locale.
|
||||
|
||||
:return: The title in the current locale.
|
||||
"""
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
return self.title_l10n
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
return l10n.title
|
||||
return self.title_l10n
|
||||
|
||||
|
||||
class BaseAccountL10n(db.Model):
|
||||
"""A localized base account title."""
|
||||
__tablename__ = "accounting_base_accounts_l10n"
|
||||
"""The table name."""
|
||||
account_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
|
||||
ondelete="CASCADE"),
|
||||
nullable=False, primary_key=True)
|
||||
"""The code of the account."""
|
||||
account = db.relationship(BaseAccount, back_populates="l10n")
|
||||
"""The account."""
|
||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
||||
"""The locale."""
|
||||
title = db.Column(db.String, nullable=False)
|
||||
"""The localized title."""
|
@ -20,8 +20,8 @@
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
|
||||
from accounting.models import BaseAccount, BaseAccountL10n
|
||||
from accounting.utils.query import parse_query_keywords
|
||||
from .models import BaseAccount, BaseAccountL10n
|
||||
|
||||
|
||||
def get_base_account_query() -> list[BaseAccount]:
|
||||
|
@ -19,6 +19,7 @@
|
||||
"""
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
from accounting.models import BaseAccount
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import has_permission, can_view
|
||||
|
||||
@ -33,9 +34,20 @@ def list_accounts() -> str:
|
||||
|
||||
:return: The account list.
|
||||
"""
|
||||
from .models import BaseAccount
|
||||
from .query import get_base_account_query
|
||||
accounts: list[BaseAccount] = get_base_account_query()
|
||||
pagination: Pagination = Pagination[BaseAccount](accounts)
|
||||
return render_template("accounting/base-account/list.html",
|
||||
list=pagination.list, pagination=pagination)
|
||||
|
||||
|
||||
@bp.get("/<baseAccount:account>", endpoint="detail")
|
||||
@has_permission(can_view)
|
||||
def show_account_detail(account: BaseAccount) -> str:
|
||||
"""Shows the account detail.
|
||||
|
||||
:param account: The account.
|
||||
:return: The detail.
|
||||
"""
|
||||
return render_template("accounting/base-account/detail.html", obj=account)
|
||||
|
||||
|
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,以太坊,以太坊
|
|
@ -39,6 +39,17 @@ def gettext(string, **variables) -> str:
|
||||
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:
|
||||
"""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:
|
||||
"""Initializes the application.
|
||||
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:param app: The Flask application.
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
bp.add_url_rule("/_jstrans.js", "babel_catalog",
|
||||
|
699
src/accounting/models.py
Normal file
699
src/accounting/models.py
Normal file
@ -0,0 +1,699 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The data models.
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import typing as t
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import current_app
|
||||
from flask_babel import get_locale
|
||||
from sqlalchemy import text
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.utils.user import user_cls, user_pk_column
|
||||
|
||||
|
||||
class BaseAccount(db.Model):
|
||||
"""A base account."""
|
||||
__tablename__ = "accounting_base_accounts"
|
||||
"""The table name."""
|
||||
code = db.Column(db.String, nullable=False, primary_key=True)
|
||||
"""The code."""
|
||||
title_l10n = db.Column("title", db.String, nullable=False)
|
||||
"""The title."""
|
||||
l10n = db.relationship("BaseAccountL10n", back_populates="account",
|
||||
lazy=False)
|
||||
"""The localized titles."""
|
||||
accounts = db.relationship("Account", back_populates="base")
|
||||
"""The descendant accounts under the base account."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the string representation of the base account.
|
||||
|
||||
:return: The string representation of the base account.
|
||||
"""
|
||||
return F"{self.code} {self.title}"
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
"""Returns the title in the current locale.
|
||||
|
||||
:return: The title in the current locale.
|
||||
"""
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
return self.title_l10n
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
return l10n.title
|
||||
return self.title_l10n
|
||||
|
||||
@property
|
||||
def query_values(self) -> list[str]:
|
||||
"""Returns the values to be queried.
|
||||
|
||||
:return: The values to be queried.
|
||||
"""
|
||||
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
||||
|
||||
|
||||
class BaseAccountL10n(db.Model):
|
||||
"""A localized base account title."""
|
||||
__tablename__ = "accounting_base_accounts_l10n"
|
||||
"""The table name."""
|
||||
account_code = db.Column(db.String,
|
||||
db.ForeignKey(BaseAccount.code,
|
||||
onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
nullable=False, primary_key=True)
|
||||
"""The code of the account."""
|
||||
account = db.relationship(BaseAccount, back_populates="l10n")
|
||||
"""The account."""
|
||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
||||
"""The locale."""
|
||||
title = db.Column(db.String, nullable=False)
|
||||
"""The localized title."""
|
||||
|
||||
|
||||
class Account(db.Model):
|
||||
"""An account."""
|
||||
__tablename__ = "accounting_accounts"
|
||||
"""The table name."""
|
||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||
autoincrement=False)
|
||||
"""The account ID."""
|
||||
base_code = db.Column(db.String,
|
||||
db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
"""The code of the base account."""
|
||||
base = db.relationship(BaseAccount, back_populates="accounts")
|
||||
"""The base account."""
|
||||
no = db.Column(db.Integer, nullable=False, default=text("1"))
|
||||
"""The account number under the base account."""
|
||||
title_l10n = db.Column("title", db.String, nullable=False)
|
||||
"""The title."""
|
||||
is_pay_off_needed = db.Column(db.Boolean, nullable=False, default=False)
|
||||
"""Whether the entries of this account need pay-off."""
|
||||
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("AccountL10n", back_populates="account",
|
||||
lazy=False)
|
||||
"""The localized titles."""
|
||||
entries = db.relationship("JournalEntry", back_populates="account")
|
||||
"""The journal entries."""
|
||||
|
||||
__CASH = "1111-001"
|
||||
"""The code of the cash account,"""
|
||||
__RECEIVABLE = "1141-001"
|
||||
"""The code of the receivable account,"""
|
||||
__PAYABLE = "2141-001"
|
||||
"""The code of the payable account,"""
|
||||
__ACCUMULATED_CHANGE = "3351-001"
|
||||
"""The code of the accumulated-change account,"""
|
||||
__BROUGHT_FORWARD = "3352-001"
|
||||
"""The code of the brought-forward account,"""
|
||||
__NET_CHANGE = "3353-001"
|
||||
"""The code of the net-change account,"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the string representation of this account.
|
||||
|
||||
:return: The string representation of this account.
|
||||
"""
|
||||
return F"{self.base_code}-{self.no:03d} {self.title}"
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
"""Returns the code.
|
||||
|
||||
:return: The code.
|
||||
"""
|
||||
return F"{self.base_code}-{self.no:03d}"
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
"""Returns the title in the current locale.
|
||||
|
||||
:return: The title in the current locale.
|
||||
"""
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
return self.title_l10n
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
return l10n.title
|
||||
return self.title_l10n
|
||||
|
||||
@title.setter
|
||||
def title(self, value: str) -> None:
|
||||
"""Sets the title in the current locale.
|
||||
|
||||
:param value: The new title.
|
||||
:return: None.
|
||||
"""
|
||||
if self.title_l10n is None:
|
||||
self.title_l10n = value
|
||||
return
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
self.title_l10n = value
|
||||
return
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
l10n.title = value
|
||||
return
|
||||
self.l10n.append(AccountL10n(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
|
||||
def find_by_code(cls, code: str) -> t.Self | None:
|
||||
"""Finds an account by its code.
|
||||
|
||||
:param code: The code.
|
||||
:return: The account, or None if this account does not exist.
|
||||
"""
|
||||
m = re.match(r"^([1-9]{4})-(\d{3})$", code)
|
||||
if m is None:
|
||||
return None
|
||||
return cls.query.filter(cls.base_code == m.group(1),
|
||||
cls.no == int(m.group(2))).first()
|
||||
|
||||
@classmethod
|
||||
def debit(cls) -> list[t.Self]:
|
||||
"""Returns the debit accounts.
|
||||
|
||||
:return: The debit accounts.
|
||||
"""
|
||||
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
||||
cls.base_code.startswith("2"),
|
||||
cls.base_code.startswith("3"),
|
||||
cls.base_code.startswith("5"),
|
||||
cls.base_code.startswith("6"),
|
||||
cls.base_code.startswith("75"),
|
||||
cls.base_code.startswith("76"),
|
||||
cls.base_code.startswith("77"),
|
||||
cls.base_code.startswith("78"),
|
||||
cls.base_code.startswith("8"),
|
||||
cls.base_code.startswith("9")),
|
||||
cls.base_code != "3351",
|
||||
cls.base_code != "3353")\
|
||||
.order_by(cls.base_code, cls.no).all()
|
||||
|
||||
@classmethod
|
||||
def credit(cls) -> list[t.Self]:
|
||||
"""Returns the debit accounts.
|
||||
|
||||
:return: The debit accounts.
|
||||
"""
|
||||
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
||||
cls.base_code.startswith("2"),
|
||||
cls.base_code.startswith("3"),
|
||||
cls.base_code.startswith("4"),
|
||||
cls.base_code.startswith("71"),
|
||||
cls.base_code.startswith("72"),
|
||||
cls.base_code.startswith("73"),
|
||||
cls.base_code.startswith("74"),
|
||||
cls.base_code.startswith("8"),
|
||||
cls.base_code.startswith("9")),
|
||||
cls.base_code != "3351",
|
||||
cls.base_code != "3353")\
|
||||
.order_by(cls.base_code, cls.no).all()
|
||||
|
||||
@property
|
||||
def query_values(self) -> list[str]:
|
||||
"""Returns the values to be queried.
|
||||
|
||||
:return: The values to be queried.
|
||||
"""
|
||||
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
||||
|
||||
@classmethod
|
||||
def cash(cls) -> t.Self:
|
||||
"""Returns the cash account.
|
||||
|
||||
:return: The cash account
|
||||
"""
|
||||
return cls.find_by_code(cls.__CASH)
|
||||
|
||||
@classmethod
|
||||
def receivable(cls) -> t.Self:
|
||||
"""Returns the receivable account.
|
||||
|
||||
:return: The receivable account
|
||||
"""
|
||||
return cls.find_by_code(cls.__RECEIVABLE)
|
||||
|
||||
@classmethod
|
||||
def payable(cls) -> t.Self:
|
||||
"""Returns the payable account.
|
||||
|
||||
:return: The payable account
|
||||
"""
|
||||
return cls.find_by_code(cls.__PAYABLE)
|
||||
|
||||
@classmethod
|
||||
def accumulated_change(cls) -> t.Self:
|
||||
"""Returns the accumulated-change account.
|
||||
|
||||
:return: The accumulated-change account
|
||||
"""
|
||||
return cls.find_by_code(cls.__ACCUMULATED_CHANGE)
|
||||
|
||||
@classmethod
|
||||
def brought_forward(cls) -> t.Self:
|
||||
"""Returns the brought-forward account.
|
||||
|
||||
:return: The brought-forward account
|
||||
"""
|
||||
return cls.find_by_code(cls.__BROUGHT_FORWARD)
|
||||
|
||||
@classmethod
|
||||
def net_change(cls) -> t.Self:
|
||||
"""Returns the net-change account.
|
||||
|
||||
:return: The net-change account
|
||||
"""
|
||||
return cls.find_by_code(cls.__NET_CHANGE)
|
||||
|
||||
@property
|
||||
def is_modified(self) -> bool:
|
||||
"""Returns whether a product account was modified.
|
||||
|
||||
:return: True if modified, or False otherwise.
|
||||
"""
|
||||
if db.session.is_modified(self):
|
||||
return True
|
||||
for l10n in self.l10n:
|
||||
if db.session.is_modified(l10n):
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Deletes this account.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
AccountL10n.query.filter(AccountL10n.account == self).delete()
|
||||
cls: t.Type[t.Self] = self.__class__
|
||||
cls.query.filter(cls.id == self.id).delete()
|
||||
|
||||
|
||||
class AccountL10n(db.Model):
|
||||
"""A localized account title."""
|
||||
__tablename__ = "accounting_accounts_l10n"
|
||||
"""The table name."""
|
||||
account_id = db.Column(db.Integer,
|
||||
db.ForeignKey(Account.id, onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
nullable=False, primary_key=True)
|
||||
"""The account ID."""
|
||||
account = db.relationship(Account, back_populates="l10n")
|
||||
"""The account."""
|
||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
||||
"""The locale."""
|
||||
title = db.Column(db.String, nullable=False)
|
||||
"""The localized title."""
|
||||
|
||||
|
||||
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
|
189
src/accounting/static/css/style.css
Normal file
189
src/accounting/static/css/style.css
Normal file
@ -0,0 +1,189 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* style.css: The style sheet for the accounting application.
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT 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/1
|
||||
*/
|
||||
|
||||
.accounting-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-group .btn .accounting-search-input {
|
||||
min-height: calc(1em + .5rem + 2px);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
.btn-group .btn .accounting-search-label button {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
padding-right: 0;
|
||||
}
|
||||
.form-floating > textarea.form-control {
|
||||
height: 6rem;
|
||||
}
|
||||
.accounting-dragged {
|
||||
color: #141619;
|
||||
background-color: #D3D3D4;
|
||||
}
|
||||
|
||||
/** The card layout */
|
||||
.accounting-card {
|
||||
padding: 2em 1.5em;
|
||||
margin: 1em;
|
||||
background-color: #E9ECEF;
|
||||
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);
|
||||
}
|
||||
.accounting-card-title {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bolder;
|
||||
}
|
||||
.accounting-card-code {
|
||||
font-size: 1.4rem;
|
||||
color: #373b3e;
|
||||
}
|
||||
|
||||
/** The option selector */
|
||||
.accounting-selector-list {
|
||||
height: 20rem;
|
||||
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) */
|
||||
.accounting-material-text-field {
|
||||
position: relative;
|
||||
min-height: calc(3.5rem + 2px);
|
||||
padding-top: 1.625rem;
|
||||
}
|
||||
.accounting-material-text-field > .form-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: calc(3.5rem + 2px);
|
||||
padding: 1rem 0.75rem;
|
||||
transform-origin: 0 0;
|
||||
transition: opacity .1s ease-in-out,transform .1s ease-in-out;
|
||||
}
|
||||
.accounting-material-text-field.accounting-not-empty > .form-label {
|
||||
opacity: 0.65;
|
||||
transform: scale(.85) translateY(-.5rem) translateX(.15rem);
|
||||
}
|
||||
|
||||
/* The Material Design floating action buttons */
|
||||
.accounting-material-fab {
|
||||
position: fixed;
|
||||
right: 2rem;
|
||||
bottom: 1rem;
|
||||
z-index: 10;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
.accounting-material-fab .btn {
|
||||
border-radius: 50%;
|
||||
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);
|
||||
display: block;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
/* The Material Design form switch */
|
||||
@media(max-width:767px) {
|
||||
.form-switch {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
183
src/accounting/static/js/account-form.js
Normal file
183
src/accounting/static/js/account-form.js
Normal file
@ -0,0 +1,183 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* account-form.js: The JavaScript for the account 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/1
|
||||
*/
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initializeBaseAccountSelector();
|
||||
document.getElementById("accounting-base-code")
|
||||
.onchange = validateBase;
|
||||
document.getElementById("accounting-title")
|
||||
.onchange = validateTitle;
|
||||
document.getElementById("accounting-form")
|
||||
.onsubmit = validateForm;
|
||||
});
|
||||
|
||||
/**
|
||||
* Initializes the base account selector.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function initializeBaseAccountSelector() {
|
||||
const selector = document.getElementById("accounting-base-selector-modal");
|
||||
const base = document.getElementById("accounting-base");
|
||||
const baseCode = document.getElementById("accounting-base-code");
|
||||
const baseContent = document.getElementById("accounting-base-content");
|
||||
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
||||
const btnClear = document.getElementById("accounting-btn-clear-base");
|
||||
selector.addEventListener("show.bs.modal", function () {
|
||||
base.classList.add("accounting-not-empty");
|
||||
options.forEach(function (item) {
|
||||
item.classList.remove("active");
|
||||
});
|
||||
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
|
||||
if (selected !== null) {
|
||||
selected.classList.add("active");
|
||||
}
|
||||
});
|
||||
selector.addEventListener("hidden.bs.modal", function () {
|
||||
if (baseCode.value === "") {
|
||||
base.classList.remove("accounting-not-empty");
|
||||
}
|
||||
});
|
||||
options.forEach(function (option) {
|
||||
option.onclick = function () {
|
||||
baseCode.value = option.dataset.code;
|
||||
baseContent.innerText = option.dataset.content;
|
||||
btnClear.classList.add("btn-danger");
|
||||
btnClear.classList.remove("btn-secondary")
|
||||
btnClear.disabled = false;
|
||||
validateBase();
|
||||
bootstrap.Modal.getInstance(selector).hide();
|
||||
};
|
||||
});
|
||||
btnClear.onclick = function () {
|
||||
baseCode.value = "";
|
||||
baseContent.innerText = "";
|
||||
btnClear.classList.add("btn-secondary")
|
||||
btnClear.classList.remove("btn-danger");
|
||||
btnClear.disabled = true;
|
||||
validateBase();
|
||||
bootstrap.Modal.getInstance(selector).hide();
|
||||
}
|
||||
initializeBaseAccountQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the query on the base account options.
|
||||
*
|
||||
* @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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateForm() {
|
||||
let isValid = true;
|
||||
isValid = validateBase() && isValid;
|
||||
isValid = validateTitle() && isValid;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the base account.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateBase() {
|
||||
const field = document.getElementById("accounting-base-code");
|
||||
const error = document.getElementById("accounting-base-code-error");
|
||||
const displayField = document.getElementById("accounting-base");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
displayField.classList.add("is-invalid");
|
||||
error.innerText = A_("Please select the base account.");
|
||||
return false;
|
||||
}
|
||||
displayField.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the title.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateTitle() {
|
||||
const field = document.getElementById("accounting-title");
|
||||
const error = document.getElementById("accounting-title-error");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the title.");
|
||||
return false;
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
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);
|
||||
}
|
||||
});
|
28
src/accounting/templates/accounting/account/create.html
Normal file
28
src/accounting/templates/accounting/account/create.html
Normal file
@ -0,0 +1,28 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
create.html: The account 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/1
|
||||
#}
|
||||
{% extends "accounting/account/include/form.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.account.list") }}{% endblock %}
|
||||
|
||||
{% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %}
|
99
src/accounting/templates/accounting/account/detail.html
Normal file
99
src/accounting/templates/accounting/account/detail.html
Normal file
@ -0,0 +1,99 @@
|
||||
{#
|
||||
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/1/31
|
||||
#}
|
||||
{% 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.account.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.account.edit", account=obj)|accounting_inherit_next }}">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
{{ A_("Settings") }}
|
||||
</a>
|
||||
{% 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>
|
||||
{{ 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.account.edit", account=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.account.delete", account=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 Account 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 account?") }}
|
||||
</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.title }}</div>
|
||||
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||
{% if obj.is_pay_off_needed %}
|
||||
<div>
|
||||
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
|
||||
</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 %}
|
28
src/accounting/templates/accounting/account/edit.html
Normal file
28
src/accounting/templates/accounting/account/edit.html
Normal file
@ -0,0 +1,28 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
edit.html: The account 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/1
|
||||
#}
|
||||
{% extends "accounting/account/include/form.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("%(account)s Settings", account=account) }}{% endblock %}{% 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 %}
|
123
src/accounting/templates/accounting/account/include/form.html
Normal file
123
src/accounting/templates/accounting/account/include/form.html
Normal file
@ -0,0 +1,123 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
form.html: The account 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/1
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/account-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-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
|
||||
<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 class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
|
||||
<div id="accounting-base-content">
|
||||
{% if form.base_code.data %}
|
||||
{% if form.base_code.errors %}
|
||||
{{ A_("(Unknown)") }}
|
||||
{% else %}
|
||||
{{ form.selected_base }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
|
||||
<label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
|
||||
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-3">
|
||||
<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="accounting-is-pay-off-needed">
|
||||
{{ A_("The entries in the account need pay-off.") }}
|
||||
</label>
|
||||
</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>
|
||||
|
||||
<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-content">
|
||||
<div class="modal-header">
|
||||
<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="{{ A_("Close") }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="input-group mb-2">
|
||||
<input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
|
||||
<label class="input-group-text" for="accounting-base-selector-query">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ul id="accounting-base-option-list" class="list-group accounting-selector-list">
|
||||
{% for base in form.base_options %}
|
||||
<li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
|
||||
{{ base }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
||||
{% if form.base_code.data %}
|
||||
<button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
|
||||
{% else %}
|
||||
<button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
83
src/accounting/templates/accounting/account/list.html
Normal file
83
src/accounting/templates/accounting/account/list.html
Normal file
@ -0,0 +1,83 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
list.html: The account 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/1/30
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block 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.account.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.account.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.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
|
||||
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
||||
<label for="accounting-search-mobile" class="accounting-search-label">
|
||||
<button type="submit">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
</button>
|
||||
</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>
|
||||
</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.account.detail", account=item)|accounting_append_next }}">
|
||||
{{ item }}
|
||||
{% if item.is_pay_off_needed %}
|
||||
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
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 %}
|
49
src/accounting/templates/accounting/base-account/detail.html
Normal file
49
src/accounting/templates/accounting/base-account/detail.html
Normal file
@ -0,0 +1,49 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
detail.html: The base 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/1
|
||||
#}
|
||||
{% 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.account.list")|accounting_or_next }}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="accounting-card col-sm-6">
|
||||
<div class="accounting-card-title">{{ obj.title }}</div>
|
||||
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||
{% if obj.accounts %}
|
||||
<div>
|
||||
{% for account in obj.accounts %}
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
|
||||
{{ account }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
@ -21,36 +21,32 @@ First written: 2023/1/26
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Base Accounts") }}{% 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 %}
|
||||
|
||||
<form action="{{ url_for("accounting.base-account.list") }}" method="get" role="search">
|
||||
<div class="row">
|
||||
<div class="col-sm-3">
|
||||
<div class="input-group mb-2">
|
||||
<input id="query" class="form-control form-control-sm" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
|
||||
<button class="input-group-text" type="submit">
|
||||
<label for="query">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<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" aria-label="{{ A_("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="accounting-search" class="accounting-search-label">
|
||||
<button type="submit">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
</button>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if list %}
|
||||
{% include "accounting/include/pagination.html" %}
|
||||
|
||||
<ul class="list-group">
|
||||
{% for item in list %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
{{ item }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="list-group">
|
||||
{% for item in list %}
|
||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|accounting_append_next }}">
|
||||
{{ item }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
@ -21,6 +21,10 @@ First written: 2023/1/27
|
||||
#}
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for("accounting.static", filename="css/style.css") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for("accounting.babel_catalog") }}"></script>
|
||||
{% block accounting_scripts %}{% endblock %}
|
||||
|
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,19 +19,39 @@ nav.html: The navigation menu for the accounting application.
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
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">
|
||||
<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") }}
|
||||
</span>
|
||||
<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>
|
||||
<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>
|
||||
{{ A_("Accounts") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
|
||||
<i class="fa-solid fa-list"></i>
|
||||
{{ A_("Base Accounts") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
|
||||
<i class="fa-solid fa-money-bill-wave"></i>
|
||||
{{ A_("Currencies") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% 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)
|
||||
First written: 2023/1/26
|
||||
#}
|
||||
{% if pagination.is_needed %}
|
||||
<nav aria-label="Page navigation">
|
||||
{% if pagination.is_paged %}
|
||||
<nav aria-label="{{ A_("Page navigation") }}">
|
||||
<ul class="pagination">
|
||||
{% for link in pagination.page_links %}
|
||||
{% for link in pagination.pages %}
|
||||
{% if link.uri is none %}
|
||||
<li class="page-item disabled {% if not link.is_for_mobile %} d-none d-md-inline {% endif %}">
|
||||
<span class="page-link">
|
||||
@ -42,7 +42,7 @@ First written: 2023/1/26
|
||||
{{ pagination.page_size }}
|
||||
</div>
|
||||
<ul class="dropdown-menu">
|
||||
{% for link in pagination.page_sizes %}
|
||||
{% for link in pagination.page_size_options %}
|
||||
<li>
|
||||
<a class="dropdown-item {% if link.is_current %} active {% endif %}" href="{{ link.uri }}">
|
||||
{{ 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 ""
|
||||
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
|
||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||
"POT-Creation-Date: 2023-01-28 13:37+0800\n"
|
||||
"PO-Revision-Date: 2023-01-28 13:37+0800\n"
|
||||
"POT-Creation-Date: 2023-02-27 15:28+0800\n"
|
||||
"PO-Revision-Date: 2023-02-27 15:29+0800\n"
|
||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||
"Language: zh_Hant\n"
|
||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||
@ -19,28 +19,582 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.11.0\n"
|
||||
|
||||
#: src/accounting/base_account/templates/accounting/base-account/list.html:24
|
||||
#: src/accounting/templates/accounting/include/nav.html:32
|
||||
msgid "Base Accounts"
|
||||
msgstr "基本科目"
|
||||
#: src/accounting/models.py:575
|
||||
#, python-format
|
||||
msgid "Cash Expense Transaction#%(id)s"
|
||||
msgstr "現金支出傳票#%(id)s"
|
||||
|
||||
#: src/accounting/base_account/templates/accounting/base-account/list.html:35
|
||||
#: 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."
|
||||
msgstr "沒有這個基本科目。"
|
||||
|
||||
#: src/accounting/account/forms.py:52
|
||||
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."
|
||||
msgstr "請選擇基本科目。"
|
||||
|
||||
#: src/accounting/account/forms.py:67
|
||||
msgid "Please fill in the title"
|
||||
msgstr "請填上標題。"
|
||||
|
||||
#: 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"
|
||||
msgstr "科目加好了。"
|
||||
|
||||
#: src/accounting/account/views.py:142
|
||||
msgid "The account was not modified."
|
||||
msgstr "科目未異動。"
|
||||
|
||||
#: src/accounting/account/views.py:148
|
||||
msgid "The account is updated successfully."
|
||||
msgstr "科目存好了。"
|
||||
|
||||
#: src/accounting/account/views.py:165
|
||||
msgid "The account is deleted successfully."
|
||||
msgstr "科目刪掉了"
|
||||
|
||||
#: 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."
|
||||
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
|
||||
msgid "Add a New Account"
|
||||
msgstr "新增科目"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:31
|
||||
#: 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/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"
|
||||
msgstr "回上頁"
|
||||
|
||||
#: 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"
|
||||
msgstr "設定"
|
||||
|
||||
#: 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"
|
||||
msgstr "刪除"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:69
|
||||
msgid "Delete Account Confirmation"
|
||||
msgstr "科目刪除確認"
|
||||
|
||||
#: 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?"
|
||||
msgstr "你確定要刪掉這個科目嗎?"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:76
|
||||
#: 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"
|
||||
msgstr "取消"
|
||||
|
||||
#: 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"
|
||||
msgstr "確定"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:94
|
||||
#: src/accounting/templates/accounting/currency/detail.html:85
|
||||
#: src/accounting/templates/accounting/transaction/include/detail.html:106
|
||||
msgid "Created"
|
||||
msgstr "建檔"
|
||||
|
||||
#: 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"
|
||||
msgstr "更新"
|
||||
|
||||
#: src/accounting/templates/accounting/account/edit.html:24
|
||||
#, python-format
|
||||
msgid "%(account)s Settings"
|
||||
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
|
||||
msgid "Account Management"
|
||||
msgstr "科目管理"
|
||||
|
||||
#: 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"
|
||||
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/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/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"
|
||||
msgstr "搜尋"
|
||||
|
||||
#: src/accounting/base_account/templates/accounting/base-account/list.html:53
|
||||
#: 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/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."
|
||||
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
|
||||
msgid "Base account"
|
||||
msgstr "基本科目"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:49
|
||||
msgid "(Unknown)"
|
||||
msgstr "(不明)"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:61
|
||||
msgid "Title"
|
||||
msgstr "標題"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:68
|
||||
msgid "The entries in the account need pay-off."
|
||||
msgstr "帳目要逐筆核銷。"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:90
|
||||
msgid "Select Base Account"
|
||||
msgstr "選擇基本科目"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:114
|
||||
#: 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"
|
||||
msgstr "清除"
|
||||
|
||||
#: src/accounting/templates/accounting/base-account/list.html:24
|
||||
msgid "Base Account Managements"
|
||||
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
|
||||
msgid "Accounting"
|
||||
msgstr "記帳"
|
||||
|
||||
#: src/accounting/utils/pagination.py:146
|
||||
msgid "Previous"
|
||||
msgstr "前一頁"
|
||||
#: src/accounting/templates/accounting/include/nav.html:32
|
||||
msgid "Transactions"
|
||||
msgstr "傳票"
|
||||
|
||||
#: src/accounting/utils/pagination.py:194
|
||||
#: src/accounting/templates/accounting/include/nav.html:38
|
||||
msgid "Accounts"
|
||||
msgstr "科目"
|
||||
|
||||
#: src/accounting/templates/accounting/include/nav.html:44
|
||||
msgid "Base Accounts"
|
||||
msgstr "基本科目"
|
||||
|
||||
#: src/accounting/templates/accounting/include/nav.html:50
|
||||
msgid "Currencies"
|
||||
msgstr "貨幣"
|
||||
|
||||
#: 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"
|
||||
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")
|
86
src/accounting/utils/next_uri.py
Normal file
86
src/accounting/utils/next_uri.py
Normal file
@ -0,0 +1,86 @@
|
||||
# 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 utilities to handle the next URI.
|
||||
|
||||
This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
|
||||
urlunparse
|
||||
|
||||
from flask import request, Blueprint
|
||||
|
||||
|
||||
def append_next(uri: str) -> str:
|
||||
"""Appends the current URI as the next URI to the query argument.
|
||||
|
||||
:param uri: The URI.
|
||||
:return: The URI with the current URI appended as the next URI.
|
||||
"""
|
||||
next_uri: str = request.full_path if request.query_string else request.path
|
||||
return __set_next(uri, next_uri)
|
||||
|
||||
|
||||
def inherit_next(uri: str) -> str:
|
||||
"""Inherits the current next URI to the query argument, if exists.
|
||||
|
||||
:param uri: The URI.
|
||||
:return: The URI with the current next URI added at the query argument.
|
||||
"""
|
||||
next_uri: str | None = request.form.get("next") \
|
||||
if request.method == "POST" else request.args.get("next")
|
||||
if next_uri is None:
|
||||
return uri
|
||||
return __set_next(uri, next_uri)
|
||||
|
||||
|
||||
def or_next(uri: str) -> str:
|
||||
"""Returns the next URI, if exists, or the supplied URI.
|
||||
|
||||
:param uri: The URI.
|
||||
:return: The next URI or the supplied URI.
|
||||
"""
|
||||
next_uri: str | None = request.form.get("next") \
|
||||
if request.method == "POST" else request.args.get("next")
|
||||
return uri if next_uri is None else next_uri
|
||||
|
||||
|
||||
def __set_next(uri: str, next_uri: str) -> str:
|
||||
"""Sets the next URI to the query arguments.
|
||||
|
||||
:param uri: The URI.
|
||||
:param next_uri: The next URI.
|
||||
:return: The URI with the next URI set.
|
||||
"""
|
||||
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(("next", next_uri))
|
||||
parts: list[str] = list(uri_p)
|
||||
parts[4] = urlencode(params)
|
||||
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
|
||||
|
||||
from flask import request
|
||||
from werkzeug.routing import RequestRedirect
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.locale import gettext, pgettext
|
||||
|
||||
|
||||
class PageLink:
|
||||
"""A link in the pagination."""
|
||||
class Link:
|
||||
"""A link."""
|
||||
|
||||
def __init__(self, text: str, uri: str | None = None,
|
||||
is_current: bool = False, is_for_mobile: bool = False):
|
||||
@ -52,15 +53,20 @@ class PageLink:
|
||||
"""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")
|
||||
|
||||
|
||||
class Pagination(t.Generic[T]):
|
||||
"""The pagination utilities"""
|
||||
AVAILABLE_PAGE_SIZES: list[int] = [10, 100, 200]
|
||||
"""The available page sizes."""
|
||||
DEFAULT_PAGE_SIZE: int = 10
|
||||
"""The default page size."""
|
||||
"""The pagination utility."""
|
||||
|
||||
def __init__(self, items: list[T], is_reversed: bool = False):
|
||||
"""Constructs the pagination.
|
||||
@ -68,130 +74,186 @@ class Pagination(t.Generic[T]):
|
||||
: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.
|
||||
"""
|
||||
self.__items: list[T] = items
|
||||
"""All the items."""
|
||||
self.__is_reversed: bool = is_reversed
|
||||
"""Whether the default page is the last page."""
|
||||
self.page_size: int = int(request.args.get("page-size",
|
||||
self.DEFAULT_PAGE_SIZE))
|
||||
"""The number of items in a page."""
|
||||
self.__total_pages: int = 0 if len(items) == 0 \
|
||||
else int((len(items) - 1) / self.page_size) + 1
|
||||
"""The total number of pages."""
|
||||
self.is_needed: bool = self.__total_pages > 1
|
||||
pagination: AbstractPagination[T] = EmptyPagination[T]() \
|
||||
if len(items) == 0 \
|
||||
else NonEmptyPagination[T](items, is_reversed)
|
||||
self.is_paged: bool = pagination.is_paged
|
||||
"""Whether there should be pagination."""
|
||||
self.list: list[T] = pagination.list
|
||||
"""The items shown in the list"""
|
||||
self.pages: list[Link] = pagination.pages
|
||||
"""The pages."""
|
||||
self.page_size: int = pagination.page_size
|
||||
"""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."""
|
||||
self.__default_page_no: int = 0
|
||||
"""The default page number."""
|
||||
self.page_no: int = 0
|
||||
"""The current page number."""
|
||||
self.list: list[T] = []
|
||||
"""The items shown in the list"""
|
||||
if self.__total_pages > 0:
|
||||
self.__set_list()
|
||||
self.pages: list[Link] = []
|
||||
"""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 \
|
||||
else request.path
|
||||
"""The current URI."""
|
||||
self.__base_uri_params: tuple[list[str], list[tuple[str, str]]] \
|
||||
= self.__get_base_uri_params()
|
||||
"""The base URI parameters."""
|
||||
self.page_links: list[PageLink] = self.__get_page_links()
|
||||
"""The pagination links."""
|
||||
self.page_sizes: list[PageLink] = self.__get_page_sizes()
|
||||
"""The links to switch the number of items in a page."""
|
||||
|
||||
def __set_list(self) -> None:
|
||||
"""Sets the items to show in the list.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
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
|
||||
self.__is_reversed: bool = is_reversed
|
||||
"""Whether the default page is the last page."""
|
||||
self.page_size = self.__get_page_size()
|
||||
self.__total_pages: int = int((len(items) - 1) / self.page_size) + 1
|
||||
"""The total number of pages."""
|
||||
self.is_paged = self.__total_pages > 1
|
||||
self.__default_page_no: int = self.__total_pages \
|
||||
if self.__is_reversed else 1
|
||||
"""The default page number."""
|
||||
self.__page_no: int = self.__get_page_no()
|
||||
"""The current page number."""
|
||||
lower_bound: int = (self.__page_no - 1) * self.page_size
|
||||
upper_bound: int = lower_bound + self.page_size
|
||||
if upper_bound > len(self.__items):
|
||||
upper_bound = len(self.__items)
|
||||
self.list = self.__items[lower_bound:upper_bound]
|
||||
if upper_bound > len(items):
|
||||
upper_bound = len(items)
|
||||
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]]]:
|
||||
"""Returns the base URI and its parameters, with the "page-no" and
|
||||
"page-size" parameters removed.
|
||||
def __get_page_size(self) -> int:
|
||||
"""Returns the page size.
|
||||
|
||||
: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)
|
||||
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
||||
params = [x for x in params if x[0] not in ["page-no", "page-size"]]
|
||||
parts: list[str] = list(uri_p)
|
||||
return parts, params
|
||||
if "page-size" not in request.args:
|
||||
return DEFAULT_PAGE_SIZE
|
||||
try:
|
||||
page_size: int = int(request.args["page-size"])
|
||||
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.
|
||||
|
||||
:return: The page links in the pagination navigation.
|
||||
"""
|
||||
if self.__total_pages < 2:
|
||||
if not self.is_paged:
|
||||
return []
|
||||
uri: str | None
|
||||
links: list[PageLink] = []
|
||||
links: list[Link] = []
|
||||
|
||||
# The previous page.
|
||||
uri = None if self.page_no == 1 else self.__uri_page(self.page_no - 1)
|
||||
links.append(PageLink(gettext("Previous"), uri, is_for_mobile=True))
|
||||
uri = None if self.__page_no == 1 \
|
||||
else self.__uri_page(self.__page_no - 1)
|
||||
links.append(Link(pgettext("Pagination|", "Previous"), uri,
|
||||
is_for_mobile=True))
|
||||
|
||||
# The first page.
|
||||
if self.page_no > 1:
|
||||
links.append(PageLink("1", self.__uri_page(1)))
|
||||
if self.__page_no > 1:
|
||||
links.append(Link("1", self.__uri_page(1)))
|
||||
|
||||
# The eclipse of the previous pages.
|
||||
if self.page_no - 3 == 2:
|
||||
links.append(PageLink(str(self.page_no - 3),
|
||||
self.__uri_page(self.page_no - 3)))
|
||||
elif self.page_no - 3 > 2:
|
||||
links.append(PageLink("…"))
|
||||
if self.__page_no - 3 == 2:
|
||||
links.append(Link(str(self.__page_no - 3),
|
||||
self.__uri_page(self.__page_no - 3)))
|
||||
elif self.__page_no - 3 > 2:
|
||||
links.append(Link("…"))
|
||||
|
||||
# The previous two pages.
|
||||
if self.page_no - 2 > 1:
|
||||
links.append(PageLink(str(self.page_no - 2),
|
||||
self.__uri_page(self.page_no - 2)))
|
||||
if self.page_no - 1 > 1:
|
||||
links.append(PageLink(str(self.page_no - 1),
|
||||
self.__uri_page(self.page_no - 1)))
|
||||
if self.__page_no - 2 > 1:
|
||||
links.append(Link(str(self.__page_no - 2),
|
||||
self.__uri_page(self.__page_no - 2)))
|
||||
if self.__page_no - 1 > 1:
|
||||
links.append(Link(str(self.__page_no - 1),
|
||||
self.__uri_page(self.__page_no - 1)))
|
||||
|
||||
# The current page.
|
||||
links.append(PageLink(str(self.page_no), self.__uri_page(self.page_no),
|
||||
is_current=True))
|
||||
links.append(Link(str(self.__page_no), self.__uri_page(self.__page_no),
|
||||
is_current=True))
|
||||
|
||||
# The next two pages.
|
||||
if self.page_no + 1 < self.__total_pages:
|
||||
links.append(PageLink(str(self.page_no + 1),
|
||||
self.__uri_page(self.page_no + 1)))
|
||||
if self.page_no + 2 < self.__total_pages:
|
||||
links.append(PageLink(str(self.page_no + 2),
|
||||
self.__uri_page(self.page_no + 2)))
|
||||
if self.__page_no + 1 < self.__total_pages:
|
||||
links.append(Link(str(self.__page_no + 1),
|
||||
self.__uri_page(self.__page_no + 1)))
|
||||
if self.__page_no + 2 < self.__total_pages:
|
||||
links.append(Link(str(self.__page_no + 2),
|
||||
self.__uri_page(self.__page_no + 2)))
|
||||
|
||||
# The eclipse of the next pages.
|
||||
if self.page_no + 3 == self.__total_pages - 1:
|
||||
links.append(PageLink(str(self.page_no + 3),
|
||||
self.__uri_page(self.page_no + 3)))
|
||||
elif self.page_no + 3 < self.__total_pages - 1:
|
||||
links.append(PageLink("…"))
|
||||
if self.__page_no + 3 == self.__total_pages - 1:
|
||||
links.append(Link(str(self.__page_no + 3),
|
||||
self.__uri_page(self.__page_no + 3)))
|
||||
elif self.__page_no + 3 < self.__total_pages - 1:
|
||||
links.append(Link("…"))
|
||||
|
||||
# The last page.
|
||||
if self.page_no < self.__total_pages:
|
||||
links.append(PageLink(str(self.__total_pages),
|
||||
self.__uri_page(self.__total_pages)))
|
||||
if self.__page_no < self.__total_pages:
|
||||
links.append(Link(str(self.__total_pages),
|
||||
self.__uri_page(self.__total_pages)))
|
||||
|
||||
# The next page.
|
||||
uri = None if self.page_no == self.__total_pages \
|
||||
else self.__uri_page(self.page_no + 1)
|
||||
links.append(PageLink(gettext("Next"), uri, is_for_mobile=True))
|
||||
uri = None if self.__page_no == self.__total_pages \
|
||||
else self.__uri_page(self.__page_no + 1)
|
||||
links.append(Link(pgettext("Pagination|", "Next"), uri,
|
||||
is_for_mobile=True))
|
||||
|
||||
return links
|
||||
|
||||
@ -201,21 +263,22 @@ class Pagination(t.Generic[T]):
|
||||
:param page_no: The page number.
|
||||
:return: The URI of the page.
|
||||
"""
|
||||
params: list[tuple[str, str]] = []
|
||||
if page_no != self.__default_page_no:
|
||||
params.append(("page-no", str(page_no)))
|
||||
if self.page_size != self.DEFAULT_PAGE_SIZE:
|
||||
params.append(("page-size", str(self.page_size)))
|
||||
return self.__uri_set_params(params)
|
||||
if page_no == self.__page_no:
|
||||
return self.__current_uri
|
||||
if page_no == self.__default_page_no:
|
||||
return self.__uri_set("page-no", None)
|
||||
return self.__uri_set("page-no", str(page_no))
|
||||
|
||||
def __get_page_sizes(self) -> list[PageLink]:
|
||||
"""Returns the available page sizes.
|
||||
def __get_page_size_options(self) -> list[Link]:
|
||||
"""Returns the page size options.
|
||||
|
||||
:return: The available page sizes.
|
||||
:return: The page size options.
|
||||
"""
|
||||
return [PageLink(str(x), self.__uri_size(x),
|
||||
is_current=x == self.page_size)
|
||||
for x in self.AVAILABLE_PAGE_SIZES]
|
||||
if not self.is_paged:
|
||||
return []
|
||||
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:
|
||||
"""Returns the URI of a page size.
|
||||
@ -225,16 +288,34 @@ class Pagination(t.Generic[T]):
|
||||
"""
|
||||
if page_size == self.page_size:
|
||||
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:
|
||||
"""Returns the URI with the query parameters set.
|
||||
def __uri_set(self, name: str, value: str | None) -> str:
|
||||
"""Raises current URI with a parameter set.
|
||||
|
||||
:param params: The query parameters.
|
||||
:return: The URI with the query parameters set.
|
||||
:param name: The name of the parameter.
|
||||
: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()
|
||||
cur_params.extend(params)
|
||||
parts: list[str] = self.__base_uri_params[0].copy()
|
||||
parts[4] = urlencode(cur_params)
|
||||
uri_p: ParseResult = urlparse(self.__current_uri)
|
||||
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
||||
|
||||
# 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)
|
||||
|
@ -21,7 +21,9 @@ This module should not import any other module from the application.
|
||||
"""
|
||||
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:
|
||||
@ -75,17 +77,22 @@ def can_view() -> bool:
|
||||
def can_edit() -> bool:
|
||||
"""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
|
||||
otherwise.
|
||||
"""
|
||||
if get_current_user() is None:
|
||||
return False
|
||||
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:
|
||||
"""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
|
||||
view the accounting data.
|
||||
:param can_edit_func: A callback that returns whether the current user can
|
||||
@ -97,4 +104,5 @@ def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None,
|
||||
__can_view_func = can_view_func
|
||||
if can_edit_func is not None:
|
||||
__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")
|
||||
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 == "":
|
||||
return []
|
||||
keywords: list[str] = []
|
||||
while q is not None:
|
||||
m: re.Match = re.match(r"(?:\"([^\"]+)\"|(\S+))(?:\s+(.+)|)$", q)
|
||||
if m.group(1) is not None:
|
||||
while True:
|
||||
m: re.Match
|
||||
m = re.match(r"\"([^\"]+)\"\s+(.+)$", q)
|
||||
if m is not None:
|
||||
keywords.append(m.group(1))
|
||||
else:
|
||||
keywords.append(m.group(2))
|
||||
q = m.group(3)
|
||||
q = m.group(2)
|
||||
continue
|
||||
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
|
||||
|
@ -1,5 +1,5 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
@ -14,25 +14,24 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The random ID mixin for the data models.
|
||||
|
||||
"""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.
|
||||
This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
import typing as t
|
||||
from secrets import randbelow
|
||||
|
||||
db: SQLAlchemy
|
||||
"""The database instance."""
|
||||
from accounting import db
|
||||
|
||||
|
||||
def set_db(new_db: SQLAlchemy) -> None:
|
||||
"""Sets the database instance.
|
||||
def new_id(cls: t.Type):
|
||||
"""Returns a new random ID for the data model.
|
||||
|
||||
:param new_db: The database instance.
|
||||
:return: None.
|
||||
:param cls: The data model.
|
||||
:return: The generated new random ID.
|
||||
"""
|
||||
global db
|
||||
db = new_db
|
||||
while True:
|
||||
obj_id: int = 100000000 + randbelow(900000000)
|
||||
if db.session.get(cls, obj_id) is None:
|
||||
return obj_id
|
46
src/accounting/utils/strip_text.py
Normal file
46
src/accounting/utils/strip_text.py
Normal file
@ -0,0 +1,46 @@
|
||||
# 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 text stripper for the form fields.
|
||||
|
||||
This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
import re
|
||||
|
||||
|
||||
def strip_text(s: str | None) -> str | None:
|
||||
"""The filter to strip the leading and trailing white spaces of text.
|
||||
|
||||
:param s: The text input string.
|
||||
:return: The filtered string.
|
||||
"""
|
||||
if s is None:
|
||||
return None
|
||||
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
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@ def babel_extract() -> None:
|
||||
/ f"{domain}.po"
|
||||
CommandLineInterface().run([
|
||||
"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():
|
||||
zh_hant.touch()
|
||||
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.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""The test for the base account management.
|
||||
|
||||
"""
|
||||
import csv
|
||||
import typing as t
|
||||
import unittest
|
||||
|
||||
import httpx
|
||||
@ -25,8 +26,66 @@ from click.testing import Result
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from testlib import get_csrf_token
|
||||
from testsite import create_app
|
||||
from test_site 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):
|
||||
@ -38,76 +97,55 @@ class BaseAccountTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import BaseAccount
|
||||
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)
|
||||
self.client: httpx.Client = httpx.Client(app=self.app,
|
||||
base_url="https://testserver")
|
||||
self.client.headers["Referer"] = "https://testserver"
|
||||
self.csrf_token: str = get_csrf_token(self, self.client, "/login")
|
||||
if BaseAccount.query.first() is None:
|
||||
result = runner.invoke(args="accounting-init-base")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
def test_init(self) -> None:
|
||||
"""Tests the "accounting-init-base" console command.
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.base_account.models import BaseAccount, BaseAccountL10n
|
||||
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"
|
||||
client, csrf_token = get_client(self.app, "nobody")
|
||||
response: httpx.Response
|
||||
|
||||
self.__logout()
|
||||
response = self.client.get(list_uri)
|
||||
response = client.get(LIST_URI)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
self.__logout()
|
||||
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)
|
||||
response = client.get(DETAIL_URI)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def __logout(self) -> None:
|
||||
"""Logs out the currently logged-in user.
|
||||
def test_viewer(self) -> None:
|
||||
"""Test the permission as viewer.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
response: httpx.Response = self.client.post(
|
||||
"/logout", data={"csrf_token": self.csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], "/")
|
||||
client, csrf_token = get_client(self.app, "viewer")
|
||||
response: httpx.Response
|
||||
|
||||
def __login_as(self, username: str) -> None:
|
||||
"""Logs in as a specific user.
|
||||
response = client.get(LIST_URI)
|
||||
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.
|
||||
"""
|
||||
response: httpx.Response = self.client.post(
|
||||
"/login", data={"csrf_token": self.csrf_token,
|
||||
"username": username})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], "/")
|
||||
client, csrf_token = get_client(self.app, "editor")
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(LIST_URI)
|
||||
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)
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user