Compare commits
126 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 |
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.1.1
|
||||
version = 0.3.0
|
||||
author = imacat
|
||||
author_email = imacat@mail.imacat.idv.tw
|
||||
description = The Mia! Accounting Flask project.
|
||||
@ -53,3 +53,4 @@ accounting =
|
||||
static/**
|
||||
templates/**
|
||||
translations/*/LC_MESSAGES/*.mo
|
||||
data/**
|
||||
|
@ -18,12 +18,18 @@
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
from flask_sqlalchemy.model import Model
|
||||
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, user_utils: AbstractUserUtils,
|
||||
url_prefix: str = "/accounting",
|
||||
@ -42,8 +48,8 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
"""
|
||||
# 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)
|
||||
|
||||
@ -56,7 +62,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
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)
|
||||
@ -64,9 +70,13 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
from . import account
|
||||
account.init_app(app, bp)
|
||||
|
||||
from .utils.next_url import append_next, inherit_next, or_next
|
||||
bp.add_app_template_filter(append_next, "append_next")
|
||||
bp.add_app_template_filter(inherit_next, "inherit_next")
|
||||
bp.add_app_template_filter(or_next, "or_next")
|
||||
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)
|
||||
|
@ -23,8 +23,8 @@ 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 AccountConverter
|
||||
|
@ -24,13 +24,13 @@ from secrets import randbelow
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from accounting.database import db
|
||||
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-offset-needed) tuples."""
|
||||
English, Traditional Chinese, Simplified Chinese, is-pay-off-needed) tuples."""
|
||||
|
||||
|
||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||
@ -93,10 +93,10 @@ def init_accounts_command(username: str) -> None:
|
||||
data: list[AccountData] = []
|
||||
for base in bases_to_add:
|
||||
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
||||
is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \
|
||||
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_offset_needed))
|
||||
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.")
|
||||
|
||||
@ -113,7 +113,7 @@ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
|
||||
base_code=x[1],
|
||||
no=x[2],
|
||||
title_l10n=x[3],
|
||||
is_offset_needed=x[6],
|
||||
is_pay_off_needed=x[6],
|
||||
created_by_id=creator_pk,
|
||||
updated_by_id=creator_pk)
|
||||
for x in data]
|
||||
|
@ -23,7 +23,7 @@ from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField
|
||||
from wtforms.validators import DataRequired, ValidationError
|
||||
|
||||
from accounting.database import db
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import BaseAccount, Account
|
||||
from accounting.utils.random_id import new_id
|
||||
@ -32,7 +32,7 @@ from accounting.utils.user import get_current_user_pk
|
||||
|
||||
|
||||
class BaseAccountExists:
|
||||
"""The validator to check if the base account code exists."""
|
||||
"""The validator to check if the base account exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data == "":
|
||||
@ -42,20 +42,32 @@ class BaseAccountExists:
|
||||
"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()])
|
||||
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_offset_needed = BooleanField()
|
||||
"""Whether the the entries of this account need offsets."""
|
||||
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.
|
||||
@ -64,29 +76,27 @@ class AccountForm(FlaskForm):
|
||||
:return: None.
|
||||
"""
|
||||
is_new: bool = obj.id is None
|
||||
prev_base_code: str | None = obj.base_code
|
||||
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
|
||||
if prev_base_code != self.base_code.data:
|
||||
max_no: int = db.session.scalars(
|
||||
sa.select(sa.func.max(Account.no))
|
||||
.filter(Account.base_code == self.base_code.data)).one()
|
||||
obj.no = 1 if max_no is None else max_no + 1
|
||||
obj.no = count + 1
|
||||
obj.title = self.title.data
|
||||
obj.is_offset_needed = self.is_offset_needed.data
|
||||
obj.is_pay_off_needed = self.is_pay_off_needed.data
|
||||
if is_new:
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.created_by_id = current_user_pk
|
||||
obj.updated_by_id = current_user_pk
|
||||
if prev_base_code is not None \
|
||||
and prev_base_code != self.base_code.data:
|
||||
setattr(self, "__post_update",
|
||||
lambda: sort_accounts_in(prev_base_code, obj.id))
|
||||
|
||||
def post_update(self, obj) -> None:
|
||||
def post_update(self, obj: Account) -> None:
|
||||
"""The post-processing after the update.
|
||||
|
||||
:param obj: The account object.
|
||||
:return: None
|
||||
"""
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
|
@ -47,8 +47,8 @@ def get_account_query() -> list[Account]:
|
||||
Account.title_l10n.contains(k),
|
||||
code.contains(k),
|
||||
Account.id.in_(l10n_matches)]
|
||||
if k in gettext("Offset needed"):
|
||||
sub_conditions.append(Account.is_offset_needed)
|
||||
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)\
|
||||
|
@ -19,17 +19,21 @@
|
||||
"""
|
||||
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.database import db
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Account, BaseAccount
|
||||
from accounting.utils.next_url import inherit_next, or_next
|
||||
from accounting.utils.flash_errors import flash_form_errors
|
||||
from accounting.utils.next_uri import inherit_next, or_next
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.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."""
|
||||
@ -38,11 +42,10 @@ bp: Blueprint = Blueprint("account", __name__)
|
||||
@bp.get("", endpoint="list")
|
||||
@has_permission(can_view)
|
||||
def list_accounts() -> str:
|
||||
"""Lists the base accounts.
|
||||
"""Lists the accounts.
|
||||
|
||||
:return: The account list.
|
||||
"""
|
||||
from .query import get_account_query
|
||||
accounts: list[BaseAccount] = get_account_query()
|
||||
pagination: Pagination = Pagination[BaseAccount](accounts)
|
||||
return render_template("accounting/account/list.html",
|
||||
@ -76,9 +79,7 @@ def add_account() -> redirect:
|
||||
"""
|
||||
form = AccountForm(request.form)
|
||||
if not form.validate():
|
||||
for key in form.errors:
|
||||
for error in form.errors[key]:
|
||||
flash(error, "error")
|
||||
flash_form_errors(form)
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(inherit_next(url_for("accounting.account.create")))
|
||||
account: Account = Account()
|
||||
@ -86,8 +87,7 @@ def add_account() -> redirect:
|
||||
db.session.add(account)
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The account is added successfully"), "success")
|
||||
return redirect(inherit_next(url_for("accounting.account.detail",
|
||||
account=account)))
|
||||
return redirect(inherit_next(__get_detail_uri(account)))
|
||||
|
||||
|
||||
@bp.get("/<account:account>", endpoint="detail")
|
||||
@ -131,23 +131,20 @@ def update_account(account: Account) -> redirect:
|
||||
"""
|
||||
form = AccountForm(request.form)
|
||||
if not form.validate():
|
||||
for key in form.errors:
|
||||
for error in form.errors[key]:
|
||||
flash(error, "error")
|
||||
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 db.session.is_modified(account):
|
||||
if not account.is_modified:
|
||||
flash(lazy_gettext("The account was not modified."), "success")
|
||||
return redirect(inherit_next(url_for("accounting.account.detail",
|
||||
account=account)))
|
||||
form.post_update(account)
|
||||
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(url_for("accounting.account.detail",
|
||||
account=account)))
|
||||
return redirect(inherit_next(__get_detail_uri(account)))
|
||||
|
||||
|
||||
@bp.post("/<account:account>/delete", endpoint="delete")
|
||||
@ -159,13 +156,11 @@ def delete_account(account: Account) -> redirect:
|
||||
:return: The redirection to the account list on success, or the account
|
||||
detail on error.
|
||||
"""
|
||||
for l10n in account.l10n:
|
||||
db.session.delete(l10n)
|
||||
db.session.delete(account)
|
||||
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(url_for("accounting.account.list")))
|
||||
return redirect(or_next(__get_list_uri()))
|
||||
|
||||
|
||||
@bp.get("/bases/<baseAccount:base>", endpoint="order")
|
||||
@ -192,7 +187,24 @@ def sort_accounts(base: BaseAccount) -> redirect:
|
||||
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")))
|
||||
return redirect(or_next(__get_list_uri()))
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The order is updated successfully."), "success")
|
||||
return redirect(or_next(url_for("accounting.account.list")))
|
||||
return redirect(or_next(__get_list_uri()))
|
||||
|
||||
|
||||
def __get_detail_uri(account: Account) -> str:
|
||||
"""Returns the detail URI of an account.
|
||||
|
||||
:param account: The account.
|
||||
:return: The detail URI of the account.
|
||||
"""
|
||||
return url_for("accounting.account.detail", account=account)
|
||||
|
||||
|
||||
def __get_list_uri() -> str:
|
||||
"""Returns the account list URI.
|
||||
|
||||
:return: The account list URI.
|
||||
"""
|
||||
return url_for("accounting.account.list")
|
||||
|
@ -23,8 +23,8 @@ from flask import Flask, Blueprint
|
||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
"""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
|
||||
|
@ -17,16 +17,15 @@
|
||||
"""The console commands for the base account management.
|
||||
|
||||
"""
|
||||
import csv
|
||||
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from accounting.database import db
|
||||
from accounting import data_dir
|
||||
from accounting import db
|
||||
from accounting.models import BaseAccount, BaseAccountL10n
|
||||
|
||||
BaseAccountData = tuple[int, str, str, str]
|
||||
"""The format of the base account data, as a list of (code, English,
|
||||
Traditional Chinese, Simplified Chinese) tuples."""
|
||||
|
||||
|
||||
@click.command("accounting-init-base")
|
||||
@with_appcontext
|
||||
@ -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."""
|
||||
|
@ -20,7 +20,7 @@
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting.database import db
|
||||
from accounting import db
|
||||
from accounting.models import BaseAccount
|
||||
|
||||
|
||||
|
38
src/accounting/currency/__init__.py
Normal file
38
src/accounting/currency/__init__.py
Normal file
@ -0,0 +1,38 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The currency management.
|
||||
|
||||
"""
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
|
||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param app: The Flask application.
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
from .converters import CurrencyConverter
|
||||
app.url_map.converters["currency"] = CurrencyConverter
|
||||
|
||||
from .views import bp as currency_bp, api_bp as currency_api_bp
|
||||
bp.register_blueprint(currency_bp, url_prefix="/currencies")
|
||||
bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
|
||||
|
||||
from .commands import init_currencies_command
|
||||
app.cli.add_command(init_currencies_command)
|
84
src/accounting/currency/commands.py
Normal file
84
src/accounting/currency/commands.py
Normal file
@ -0,0 +1,84 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The console commands for the currency management.
|
||||
|
||||
"""
|
||||
import csv
|
||||
import os
|
||||
import typing as t
|
||||
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from accounting import db, data_dir
|
||||
from accounting.models import Currency, CurrencyL10n
|
||||
from accounting.utils.user import has_user, get_user_pk
|
||||
|
||||
CurrencyData = tuple[str, str, str, str]
|
||||
|
||||
|
||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||
value: str) -> str:
|
||||
"""Validates the username for the click console command.
|
||||
|
||||
:param ctx: The console command context.
|
||||
:param param: The console command option.
|
||||
:param value: The username.
|
||||
:raise click.BadParameter: When validation fails.
|
||||
:return: The username.
|
||||
"""
|
||||
value = value.strip()
|
||||
if value == "":
|
||||
raise click.BadParameter("Username empty.")
|
||||
if not has_user(value):
|
||||
raise click.BadParameter(f"User {value} does not exist.")
|
||||
return value
|
||||
|
||||
|
||||
@click.command("accounting-init-currencies")
|
||||
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||
help="The username.", callback=__validate_username,
|
||||
default=lambda: os.getlogin())
|
||||
@with_appcontext
|
||||
def init_currencies_command(username: str) -> None:
|
||||
"""Initializes the currencies."""
|
||||
existing_codes: set[str] = {x.code for x in Currency.query.all()}
|
||||
|
||||
with open(data_dir / "currencies.csv") as fp:
|
||||
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
||||
to_add: list[dict[str, str]] = [x for x in data
|
||||
if x["code"] not in existing_codes]
|
||||
if len(to_add) == 0:
|
||||
click.echo("No more currency to add.")
|
||||
return
|
||||
|
||||
creator_pk: int = get_user_pk(username)
|
||||
currency_data: list[dict[str, t.Any]] = [{"code": x["code"],
|
||||
"name_l10n": x["name"],
|
||||
"created_by_id": creator_pk,
|
||||
"updated_by_id": creator_pk}
|
||||
for x in to_add]
|
||||
locales: list[str] = [x[5:] for x in to_add[0] if x.startswith("l10n-")]
|
||||
l10n_data: list[dict[str, str]] = [{"currency_code": x["code"],
|
||||
"locale": y,
|
||||
"name": x[f"l10n-{y}"]}
|
||||
for x in to_add for y in locales]
|
||||
db.session.bulk_insert_mappings(Currency, currency_data)
|
||||
db.session.bulk_insert_mappings(CurrencyL10n, l10n_data)
|
||||
db.session.commit()
|
||||
|
||||
click.echo(F"{len(to_add)} added. Currencies initialized.")
|
48
src/accounting/currency/converters.py
Normal file
48
src/accounting/currency/converters.py
Normal file
@ -0,0 +1,48 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The path converters for the currency management.
|
||||
|
||||
"""
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Currency
|
||||
|
||||
|
||||
class CurrencyConverter(BaseConverter):
|
||||
"""The currency converter to convert the currency code and to the
|
||||
corresponding currency in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> Currency:
|
||||
"""Converts a currency code to a currency.
|
||||
|
||||
:param value: The currency code.
|
||||
:return: The corresponding currency.
|
||||
"""
|
||||
currency: Currency | None = db.session.get(Currency, value)
|
||||
if currency is None:
|
||||
abort(404)
|
||||
return currency
|
||||
|
||||
def to_url(self, value: Currency) -> str:
|
||||
"""Converts a currency to its code.
|
||||
|
||||
:param value: The currency.
|
||||
:return: The code.
|
||||
"""
|
||||
return value.code
|
83
src/accounting/currency/forms.py
Normal file
83
src/accounting/currency/forms.py
Normal file
@ -0,0 +1,83 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The forms for the currency management.
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, ValidationError
|
||||
from wtforms.validators import DataRequired, Regexp, NoneOf
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
|
||||
|
||||
class CurrencyForm(FlaskForm):
|
||||
"""The form to create or edit a currency."""
|
||||
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
|
||||
"""The reserved codes that are not available."""
|
||||
|
||||
class CodeUnique:
|
||||
"""The validator to check if the code is unique."""
|
||||
def __call__(self, form: CurrencyForm, field: StringField) -> None:
|
||||
if field.data == "":
|
||||
return
|
||||
if form.obj_code is not None and form.obj_code == field.data:
|
||||
return
|
||||
if db.session.get(Currency, field.data) is not None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"Code conflicts with another currency."))
|
||||
|
||||
code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(lazy_gettext("Please fill in the code.")),
|
||||
Regexp(r"^[A-Z]{3}$",
|
||||
message=lazy_gettext(
|
||||
"Code can only be composed of 3 upper-cased"
|
||||
" letters.")),
|
||||
NoneOf(CODE_BLOCKLIST, message=lazy_gettext(
|
||||
"This code is not available.")),
|
||||
CodeUnique()])
|
||||
"""The code. It may not conflict with another currency."""
|
||||
name = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
|
||||
"""The name."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.obj_code: str | None = None
|
||||
"""The current code of the currency, or None when adding a new
|
||||
currency."""
|
||||
|
||||
def populate_obj(self, obj: Currency) -> None:
|
||||
"""Populates the form data into a currency object.
|
||||
|
||||
:param obj: The currency object.
|
||||
:return: None.
|
||||
"""
|
||||
is_new: bool = obj.code is None
|
||||
obj.code = self.code.data
|
||||
obj.name = self.name.data
|
||||
if is_new:
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.created_by_id = current_user_pk
|
||||
obj.updated_by_id = current_user_pk
|
44
src/accounting/currency/query.py
Normal file
44
src/accounting/currency/query.py
Normal file
@ -0,0 +1,44 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The currency query.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
|
||||
from accounting.models import Currency, CurrencyL10n
|
||||
from accounting.utils.query import parse_query_keywords
|
||||
|
||||
|
||||
def get_currency_query() -> list[Currency]:
|
||||
"""Returns the base accounts, optionally filtered by the query.
|
||||
|
||||
:return: The base accounts.
|
||||
"""
|
||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||
if len(keywords) == 0:
|
||||
return Currency.query.order_by(Currency.code).all()
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
for k in keywords:
|
||||
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
||||
.filter(CurrencyL10n.name.contains(k)).all()
|
||||
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||
conditions.append(sa.or_(Currency.code.contains(k),
|
||||
Currency.name_l10n.contains(k),
|
||||
Currency.code.in_(l10n_matches)))
|
||||
return Currency.query.filter(*conditions)\
|
||||
.order_by(Currency.code).all()
|
185
src/accounting/currency/views.py
Normal file
185
src/accounting/currency/views.py
Normal file
@ -0,0 +1,185 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The views for the currency management.
|
||||
|
||||
"""
|
||||
from urllib.parse import urlencode, parse_qsl
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import Blueprint, render_template, redirect, session, request, \
|
||||
flash, url_for
|
||||
from werkzeug.datastructures import ImmutableMultiDict
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency
|
||||
from accounting.utils.flash_errors import flash_form_errors
|
||||
from accounting.utils.next_uri import inherit_next, or_next
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .forms import CurrencyForm
|
||||
|
||||
bp: Blueprint = Blueprint("currency", __name__)
|
||||
"""The view blueprint for the currency management."""
|
||||
api_bp: Blueprint = Blueprint("currency-api", __name__)
|
||||
"""The view blueprint for the currency management API."""
|
||||
|
||||
|
||||
@bp.get("", endpoint="list")
|
||||
@has_permission(can_view)
|
||||
def list_currencies() -> str:
|
||||
"""Lists the currencies.
|
||||
|
||||
:return: The currency list.
|
||||
"""
|
||||
from .query import get_currency_query
|
||||
currencies: list[Currency] = get_currency_query()
|
||||
pagination: Pagination = Pagination[Currency](currencies)
|
||||
return render_template("accounting/currency/list.html",
|
||||
list=pagination.list, pagination=pagination)
|
||||
|
||||
|
||||
@bp.get("/create", endpoint="create")
|
||||
@has_permission(can_edit)
|
||||
def show_add_currency_form() -> str:
|
||||
"""Shows the form to add a currency.
|
||||
|
||||
:return: The form to add a currency.
|
||||
"""
|
||||
if "form" in session:
|
||||
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||
del session["form"]
|
||||
form.validate()
|
||||
else:
|
||||
form = CurrencyForm()
|
||||
return render_template("accounting/currency/create.html",
|
||||
form=form)
|
||||
|
||||
|
||||
@bp.post("/store", endpoint="store")
|
||||
@has_permission(can_edit)
|
||||
def add_currency() -> redirect:
|
||||
"""Adds a currency.
|
||||
|
||||
:return: The redirection to the currency detail on success, or the currency
|
||||
creation form on error.
|
||||
"""
|
||||
form = CurrencyForm(request.form)
|
||||
if not form.validate():
|
||||
flash_form_errors(form)
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(inherit_next(url_for("accounting.currency.create")))
|
||||
currency: Currency = Currency()
|
||||
form.populate_obj(currency)
|
||||
db.session.add(currency)
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The currency is added successfully"), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||
|
||||
|
||||
@bp.get("/<currency:currency>", endpoint="detail")
|
||||
@has_permission(can_view)
|
||||
def show_currency_detail(currency: Currency) -> str:
|
||||
"""Shows the currency detail.
|
||||
|
||||
:param currency: The currency.
|
||||
:return: The detail.
|
||||
"""
|
||||
return render_template("accounting/currency/detail.html", obj=currency)
|
||||
|
||||
|
||||
@bp.get("/<currency:currency>/edit", endpoint="edit")
|
||||
@has_permission(can_edit)
|
||||
def show_currency_edit_form(currency: Currency) -> str:
|
||||
"""Shows the form to edit a currency.
|
||||
|
||||
:param currency: The currency.
|
||||
:return: The form to edit the currency.
|
||||
"""
|
||||
form: CurrencyForm
|
||||
if "form" in session:
|
||||
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||
del session["form"]
|
||||
form.validate()
|
||||
else:
|
||||
form = CurrencyForm(obj=currency)
|
||||
return render_template("accounting/currency/edit.html",
|
||||
currency=currency, form=form)
|
||||
|
||||
|
||||
@bp.post("/<currency:currency>/update", endpoint="update")
|
||||
@has_permission(can_edit)
|
||||
def update_currency(currency: Currency) -> redirect:
|
||||
"""Updates a currency.
|
||||
|
||||
:param currency: The currency.
|
||||
:return: The redirection to the currency detail on success, or the currency
|
||||
edit form on error.
|
||||
"""
|
||||
form = CurrencyForm(request.form)
|
||||
form.obj_code = currency.code
|
||||
if not form.validate():
|
||||
flash_form_errors(form)
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(inherit_next(url_for("accounting.currency.edit",
|
||||
currency=currency)))
|
||||
with db.session.no_autoflush:
|
||||
form.populate_obj(currency)
|
||||
if not currency.is_modified:
|
||||
flash(lazy_gettext("The currency was not modified."), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||
currency.updated_by_id = get_current_user_pk()
|
||||
currency.updated_at = sa.func.now()
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The currency is updated successfully."), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||
|
||||
|
||||
@bp.post("/<currency:currency>/delete", endpoint="delete")
|
||||
@has_permission(can_edit)
|
||||
def delete_currency(currency: Currency) -> redirect:
|
||||
"""Deletes a currency.
|
||||
|
||||
:param currency: The currency.
|
||||
:return: The redirection to the currency list on success, or the currency
|
||||
detail on error.
|
||||
"""
|
||||
currency.delete()
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The currency is deleted successfully."), "success")
|
||||
return redirect(or_next(url_for("accounting.currency.list")))
|
||||
|
||||
|
||||
@api_bp.get("/exists-code", endpoint="exists")
|
||||
@has_permission(can_edit)
|
||||
def exists_code() -> dict[str, bool]:
|
||||
"""Validates whether a currency code exists.
|
||||
|
||||
:return: Whether the currency code exists.
|
||||
"""
|
||||
return {"exists": db.session.get(Currency, request.args["q"]) is not None}
|
||||
|
||||
|
||||
def __get_detail_uri(currency: Currency) -> str:
|
||||
"""Returns the detail URI of a currency.
|
||||
|
||||
:param currency: The currency.
|
||||
:return: The detail URI of the currency.
|
||||
"""
|
||||
return url_for("accounting.currency.detail", currency=currency)
|
||||
|
528
src/accounting/data/base_accounts.csv
Normal file
528
src/accounting/data/base_accounts.csv
Normal file
@ -0,0 +1,528 @@
|
||||
code,title,l10n-zh_Hant,l10n-zh_Hans
|
||||
1,assets,資產,资产
|
||||
2,liabilities,負債,负债
|
||||
3,owners’ equity,業主權益,业主权益
|
||||
4,operating revenue,營業收入,营业收入
|
||||
5,operating costs,營業成本,营业成本
|
||||
6,operating expenses,營業費用,营业费用
|
||||
7,"non-operating revenue and expenses, other income (expense)",營業外收入及費用,营业外收入及费用
|
||||
8,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益)
|
||||
9,nonrecurring gain or loss,非經常營業損益,非经常营业损益
|
||||
11,current assets,流動資產,流动资产
|
||||
12,current assets,流動資產,流动资产
|
||||
13,funds and long-term investments,基金及長期投資,基金及长期投资
|
||||
14,"property , plant, and equipment",固定資產,固定资产
|
||||
15,"property , plant, and equipment",固定資產,固定资产
|
||||
16,depletable assets,遞耗資產,递耗资产
|
||||
17,intangible assets,無形資產,无形资产
|
||||
18,other assets,其他資產,其他资产
|
||||
21,current liabilities,流動負債,流动负债
|
||||
22,current liabilities,流動負債,流动负债
|
||||
23,long-term liabilities,長期負債,长期负债
|
||||
28,other liabilities,其他負債,其他负债
|
||||
31,capital,資本,资本
|
||||
32,additional paid-in capital,資本公積,资本公积
|
||||
33,retained earnings (accumulated deficit),保留盈餘(或累積虧損),保留盈余(或累积亏损)
|
||||
34,equity adjustments,權益調整,权益调整
|
||||
35,treasury stock,庫藏股,库藏股
|
||||
36,minority interest,少數股權,少数股权
|
||||
41,sales revenue,銷貨收入,销货收入
|
||||
46,service revenue,勞務收入,劳务收入
|
||||
47,agency revenue,業務收入,业务收入
|
||||
48,other operating revenue,其他營業收入,其他营业收入
|
||||
51,cost of goods sold,銷貨成本,销货成本
|
||||
56,service costs,勞務成本,劳务成本
|
||||
57,agency costs,業務成本,业务成本
|
||||
58,other operating costs,其他營業成本,其他营业成本
|
||||
61,selling expenses,推銷費用,推销费用
|
||||
62,general & administrative expenses,管理及總務費用,管理及总务费用
|
||||
63,research and development expenses,研究發展費用,研究发展费用
|
||||
71,non-operating revenue,營業外收入,营业外收入
|
||||
72,non-operating revenue,營業外收入,营业外收入
|
||||
73,non-operating revenue,營業外收入,营业外收入
|
||||
74,non-operating revenue,營業外收入,营业外收入
|
||||
75,non-operating expenses,營業外費用,营业外费用
|
||||
76,non-operating expenses,營業外費用,营业外费用
|
||||
77,non-operating expenses,營業外費用,营业外费用
|
||||
78,non-operating expenses,營業外費用,营业外费用
|
||||
81,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益)
|
||||
91,gain (loss) from discontinued operations,停業部門損益,停业部门损益
|
||||
92,extraordinary gain or loss,非常損益,非常损益
|
||||
93,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数
|
||||
94,minority interest income,少數股權淨利,少数股权净利
|
||||
111,cash and cash equivalents,現金及約當現金,现金及约当现金
|
||||
112,short-term investments,短期投資,短期投资
|
||||
113,notes receivable,應收票據,应收票据
|
||||
114,accounts receivable,應收帳款,应收帐款
|
||||
118,other receivables,其他應收款,其他应收款
|
||||
121,inventories,存貨,存货
|
||||
122,inventories,存貨,存货
|
||||
125,prepaid expenses,預付費用,预付费用
|
||||
126,prepayments,預付款項,预付款项
|
||||
128,other current assets,其他流動資產,其他流动资产
|
||||
129,other current assets,其他流動資產,其他流动资产
|
||||
131,funds,基金,基金
|
||||
132,long-term investments,長期投資,长期投资
|
||||
141,land,土地,土地
|
||||
142,land improvements,土地改良物,土地改良物
|
||||
143,buildings,房屋及建物,房屋及建物
|
||||
144,machinery and equipment,機(器)具及設備,机(器)具及设备
|
||||
145,machinery and equipment,機(器)具及設備,机(器)具及设备
|
||||
146,machinery and equipment,機(器)具及設備,机(器)具及设备
|
||||
151,leased assets,租賃資產,租赁资产
|
||||
152,leasehold improvements,租賃權益改良,租赁权益改良
|
||||
156,construction in progress and prepayments for equipment,未完工程及預付購置設備款,未完工程及预付购置设备款
|
||||
158,"miscellaneous property, plant, and equipment",雜項固定資產,杂项固定资产
|
||||
161,depletable assets,遞耗資產,递耗资产
|
||||
171,trademarks,商標權,商标权
|
||||
172,patents,專利權,专利权
|
||||
173,franchise,特許權,特许权
|
||||
174,copyright,著作權,著作权
|
||||
175,computer software,電腦軟體,电脑软体
|
||||
176,goodwill,商譽,商誉
|
||||
177,organization costs,開辦費,开办费
|
||||
178,other intangibles,其他無形資產,其他无形资产
|
||||
181,deferred assets,遞延資產,递延资产
|
||||
182,idle assets,閒置資產,闲置资产
|
||||
184,"long-term notes , accounts and overdue receivables",長期應收票據及款項與催收帳款,长期应收票据及款项与催收帐款
|
||||
185,assets leased to others,出租資產,出租资产
|
||||
186,refundable deposit,存出保證金,存出保证金
|
||||
188,miscellaneous assets,雜項資產,杂项资产
|
||||
211,short-term borrowings (debt),短期借款,短期借款
|
||||
212,short-term notes and bills payable,應付短期票券,应付短期票券
|
||||
213,notes payable,應付票據,应付票据
|
||||
214,accounts pay able,應付帳款,应付帐款
|
||||
216,income taxes payable,應付所得稅,应付所得税
|
||||
217,accrued expenses,應付費用,应付费用
|
||||
218,other payables,其他應付款,其他应付款
|
||||
219,other payables,其他應付款,其他应付款
|
||||
226,advance receipts,預收款項,预收款项
|
||||
227,long-term liabilities -current portion,一年或一營業週期內到期長期負債,一年或一营业周期内到期长期负债
|
||||
228,other current liabilities,其他流動負債,其他流动负债
|
||||
229,other current liabilities,其他流動負債,其他流动负债
|
||||
231,corporate bonds payable,應付公司債,应付公司债
|
||||
232,long-term loans payable,長期借款,长期借款
|
||||
233,long-term notes and accounts payable,長期應付票據及款項,长期应付票据及款项
|
||||
234,accrued liabilities for land value increment tax,估計應付土地增值稅,估计应付土地增值税
|
||||
235,accrued pension liabilities,應計退休金負債,应计退休金负债
|
||||
238,other long-term liabilities,其他長期負債,其他长期负债
|
||||
281,deferred liabilities,遞延負債,递延负债
|
||||
286,deposits received,存入保證金,存入保证金
|
||||
288,miscellaneous liabilities,雜項負債,杂项负债
|
||||
311,capital,資本(或股本),资本(或股本)
|
||||
321,paid-in capital in excess of par,股票溢價,股票溢价
|
||||
323,capital surplus from assets revaluation,資產重估增值準備,资产重估增值准备
|
||||
324,capital surplus from gain on disposal of assets,處分資產溢價公積,处分资产溢价公积
|
||||
325,capital surplus from business combination,合併公積,合并公积
|
||||
326,donated surplus,受贈公積,受赠公积
|
||||
328,other additional paid-in capital,其他資本公積,其他资本公积
|
||||
331,legal reserve,法定盈餘公積,法定盈余公积
|
||||
332,special reserve,特別盈餘公積,特别盈余公积
|
||||
335,retained earnings-unappropriated (or accumulated deficit),未分配盈餘(或累積虧損),未分配盈余(或累积亏损)
|
||||
341,unrealized loss on market value decline of long-term equity investments,長期股權投資未實現跌價損失,长期股权投资未实现跌价损失
|
||||
342,cumulative translation adjustment,累積換算調整數,累积换算调整数
|
||||
343,net loss not recognized as pension cost,未認列為退休金成本之淨損失,未认列为退休金成本之净损失
|
||||
351,treasury stock,庫藏股,库藏股
|
||||
361,minority interest,少數股權,少数股权
|
||||
411,sales revenue,銷貨收入,销货收入
|
||||
417,sales return,銷貨退回,销货退回
|
||||
419,sales allowances,銷貨折讓,销货折让
|
||||
461,service revenue,勞務收入,劳务收入
|
||||
471,agency revenue,業務收入,业务收入
|
||||
488,other operating revenue,其他營業收入—其他,其他营业收入—其他
|
||||
511,cost of goods sold,銷貨成本,销货成本
|
||||
512,purchases,進貨,进货
|
||||
513,materials purchased,進料,进料
|
||||
514,direct labor,直接人工,直接人工
|
||||
515,manufacturing overhead,製造費用,制造费用
|
||||
516,manufacturing overhead,製造費用,制造费用
|
||||
517,manufacturing overhead,製造費用,制造费用
|
||||
518,manufacturing overhead,製造費用,制造费用
|
||||
561,service costs,勞務成本,劳务成本
|
||||
571,agency costs,業務成本,业务成本
|
||||
588,other operating costs-other,其他營業成本—其他,其他营业成本—其他
|
||||
615,selling expenses,推銷費用,推销费用
|
||||
616,selling expenses,推銷費用,推销费用
|
||||
617,selling expenses,推銷費用,推销费用
|
||||
618,selling expenses,推銷費用,推销费用
|
||||
625,general & administrative expenses,管理及總務費用,管理及总务费用
|
||||
626,general & administrative expenses,管理及總務費用,管理及总务费用
|
||||
627,general & administrative expenses,管理及總務費用,管理及总务费用
|
||||
628,general & administrative expenses,管理及總務費用,管理及总务费用
|
||||
635,research and development expenses,研究發展費用,研究发展费用
|
||||
636,research and development expenses,研究發展費用,研究发展费用
|
||||
637,research and development expenses,研究發展費用,研究发展费用
|
||||
638,research and development expenses,研究發展費用,研究发展费用
|
||||
711,interest revenue,利息收入,利息收入
|
||||
712,investment income,投資收益,投资收益
|
||||
713,foreign exchange gain,兌換利益,兑换利益
|
||||
714,gain on disposal of investments,處分投資收益,处分投资收益
|
||||
715,gain on disposal of assets,處分資產溢價收入,处分资产溢价收入
|
||||
748,other non-operating revenue,其他營業外收入,其他营业外收入
|
||||
751,interest expense,利息費用,利息费用
|
||||
752,investment loss,投資損失,投资损失
|
||||
753,foreign exchange loss,兌換損失,兑换损失
|
||||
754,loss on disposal of investments,處分投資損失,处分投资损失
|
||||
755,loss on disposal of assets,處分資產損失,处分资产损失
|
||||
788,other non-operating expenses,其他營業外費用,其他营业外费用
|
||||
811,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益)
|
||||
911,income (loss) from operations of discontinued segments,停業部門損益—停業前營業損益,停业部门损益—停业前营业损益
|
||||
912,gain (loss) from disposal of discontinued segments,停業部門損益—處分損益,停业部门损益—处分损益
|
||||
921,extraordinary gain or loss,非常損益,非常损益
|
||||
931,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数
|
||||
941,minority interest income,少數股權淨利,少数股权净利
|
||||
1111,cash on hand,庫存現金,库存现金
|
||||
1112,petty cash/revolving funds,零用金/週轉金,零用金/周转金
|
||||
1113,cash in banks,銀行存款,银行存款
|
||||
1116,cash in transit,在途現金,在途现金
|
||||
1117,cash equivalents,約當現金,约当现金
|
||||
1118,other cash and cash equivalents,其他現金及約當現金,其他现金及约当现金
|
||||
1121,short-term investments – stock,短期投資—股票,短期投资—股票
|
||||
1122,short-term investments – short-term notes and bills,短期投資—短期票券,短期投资—短期票券
|
||||
1123,short-term investments – government bonds,短期投資—政府債券,短期投资—政府债券
|
||||
1124,short-term investments – beneficiary certificates,短期投資—受益憑證,短期投资—受益凭证
|
||||
1125,short-term investments – corporate bonds,短期投資—公司債,短期投资—公司债
|
||||
1128,short-term investments – other,短期投資—其他,短期投资—其他
|
||||
1129,allowance for reduction of short-term investment to market,備抵短期投資跌價損失,备抵短期投资跌价损失
|
||||
1131,notes receivable,應收票據,应收票据
|
||||
1132,discounted notes receivable,應收票據貼現,应收票据贴现
|
||||
1137,notes receivable – related parties,應收票據—關係人,应收票据—关系人
|
||||
1138,other notes receivable,其他應收票據,其他应收票据
|
||||
1139,allowance for uncollectible accounts – notes receivable,備抵呆帳-應收票據,备抵呆帐-应收票据
|
||||
1141,accounts receivable,應收帳款,应收帐款
|
||||
1142,installment accounts receivable,應收分期帳款,应收分期帐款
|
||||
1147,accounts receivable – related parties,應收帳款—關係人,应收帐款—关系人
|
||||
1149,allowance for uncollectible accounts – accounts receivable,備抵呆帳-應收帳款,备抵呆帐-应收帐款
|
||||
1181,forward exchange contract receivable,應收出售遠匯款,应收出售远汇款
|
||||
1182,forward exchange contract receivable – foreign currencies,應收遠匯款—外幣,应收远汇款—外币
|
||||
1183,discount on forward ex-change contract,買賣遠匯折價,买卖远汇折价
|
||||
1184,earned revenue receivable,應收收益,应收收益
|
||||
1185,income tax refund receivable,應收退稅款,应收退税款
|
||||
1187,other receivables – related parties,其他應收款—關係人,其他应收款—关系人
|
||||
1188,other receivables – other,其他應收款—其他,其他应收款—其他
|
||||
1189,allowance for uncollectible accounts – other receivables,備抵呆帳—其他應收款,备抵呆帐—其他应收款
|
||||
1211,merchandise inventory,商品存貨,商品存货
|
||||
1212,consigned goods,寄銷商品,寄销商品
|
||||
1213,goods in transit,在途商品,在途商品
|
||||
1219,allowance for reduction of inventory to market,備抵存貨跌價損失,备抵存货跌价损失
|
||||
1221,finished goods,製成品,制成品
|
||||
1222,consigned finished goods,寄銷製成品,寄销制成品
|
||||
1223,by-products,副產品,副产品
|
||||
1224,work in process,在製品,在制品
|
||||
1225,work in process – outsourced,委外加工,委外加工
|
||||
1226,raw materials,原料,原料
|
||||
1227,supplies,物料,物料
|
||||
1228,materials and supplies in transit,在途原物料,在途原物料
|
||||
1229,allowance for reduction of inventory to market,備抵存貨跌價損失,备抵存货跌价损失
|
||||
1251,prepaid payroll,預付薪資,预付薪资
|
||||
1252,prepaid rents,預付租金,预付租金
|
||||
1253,prepaid insurance,預付保險費,预付保险费
|
||||
1254,office supplies,用品盤存,用品盘存
|
||||
1255,prepaid income tax,預付所得稅,预付所得税
|
||||
1258,other prepaid expenses,其他預付費用,其他预付费用
|
||||
1261,prepayment for purchases,預付貨款,预付货款
|
||||
1268,other prepayments,其他預付款項,其他预付款项
|
||||
1281,VAT paid ( or input tax),進項稅額,进项税额
|
||||
1282,excess VAT paid (or overpaid VAT),留抵稅額,留抵税额
|
||||
1283,temporary payments,暫付款,暂付款
|
||||
1284,payment on behalf of others,代付款,代付款
|
||||
1285,advances to employees,員工借支,员工借支
|
||||
1286,refundable deposits,存出保證金,存出保证金
|
||||
1287,certificate of deposit-restricted,受限制存款,受限制存款
|
||||
1291,deferred income tax assets,遞延所得稅資產,递延所得税资产
|
||||
1292,deferred foreign exchange losses,遞延兌換損失,递延兑换损失
|
||||
1293,owners’ (stockholders’) current account,業主(股東)往來,业主(股东)往来
|
||||
1294,current account with others,同業往來,同业往来
|
||||
1298,other current assets – other,其他流動資產—其他,其他流动资产—其他
|
||||
1311,redemption fund (or sinking fund),償債基金,偿债基金
|
||||
1312,fund for improvement and expansion,改良及擴充基金,改良及扩充基金
|
||||
1313,contingency fund,意外損失準備基金,意外损失准备基金
|
||||
1314,pension fund,退休基金,退休基金
|
||||
1318,other funds,其他基金,其他基金
|
||||
1321,long-term equity investments,長期股權投資,长期股权投资
|
||||
1322,long-term bond investments,長期債券投資,长期债券投资
|
||||
1323,long-term real estate in-vestments,長期不動產投資,长期不动产投资
|
||||
1324,cash surrender value of life insurance,人壽保險現金解約價值,人寿保险现金解约价值
|
||||
1328,other long-term investments,其他長期投資,其他长期投资
|
||||
1329,allowance for excess of cost over market value of long-term investments,備抵長期投資跌價損失,备抵长期投资跌价损失
|
||||
1411,land,土地,土地
|
||||
1418,land – revaluation increments,土地—重估增值,土地—重估增值
|
||||
1421,land improvements,土地改良物,土地改良物
|
||||
1428,land improvements – revaluation increments,土地改良物—重估增值,土地改良物—重估增值
|
||||
1429,accumulated depreciation – land improvements,累積折舊—土地改良物,累积折旧—土地改良物
|
||||
1431,buildings,房屋及建物,房屋及建物
|
||||
1438,buildings –revaluation increments,房屋及建物—重估增值,房屋及建物—重估增值
|
||||
1439,accumulated depreciation – buildings,累積折舊—房屋及建物,累积折旧—房屋及建物
|
||||
1441,machinery,機(器)具,机(器)具
|
||||
1448,machinery – revaluation increments,機(器)具—重估增值,机(器)具—重估增值
|
||||
1449,accumulated depreciation – machinery,累積折舊—機(器)具,累积折旧—机(器)具
|
||||
1511,leased assets,租賃資產,租赁资产
|
||||
1519,accumulated depreciation – leased assets,累積折舊—租賃資產,累积折旧—租赁资产
|
||||
1521,leasehold improvements,租賃權益改良,租赁权益改良
|
||||
1529,accumulated depreciation – leasehold improvements,累積折舊—租賃權益改良,累积折旧—租赁权益改良
|
||||
1561,construction in progress,未完工程,未完工程
|
||||
1562,prepayment for equipment,預付購置設備款,预付购置设备款
|
||||
1581,"miscellaneous property, plant, and equipment",雜項固定資產,杂项固定资产
|
||||
1588,"miscellaneous property, plant, and equipment – revaluation increments",雜項固定資產—重估增值,杂项固定资产—重估增值
|
||||
1589,"accumulated depreciation – miscellaneous property, plant, and equipment",累積折舊—雜項固定資產,累积折旧—杂项固定资产
|
||||
1611,natural resources,天然資源,天然资源
|
||||
1618,natural resources –revaluation increments,天然資源—重估增值,天然资源—重估增值
|
||||
1619,accumulated depletion – natural resources,累積折耗—天然資源,累积折耗—天然资源
|
||||
1711,trademarks,商標權,商标权
|
||||
1721,patents,專利權,专利权
|
||||
1731,franchise,特許權,特许权
|
||||
1741,copyright,著作權,著作权
|
||||
1751,computer software cost,電腦軟體,电脑软体
|
||||
1761,goodwill,商譽,商誉
|
||||
1771,organization costs,開辦費,开办费
|
||||
1781,deferred pension costs,遞延退休金成本,递延退休金成本
|
||||
1782,leasehold improvements,租賃權益改良,租赁权益改良
|
||||
1788,other intangible assets – other,其他無形資產—其他,其他无形资产—其他
|
||||
1811,deferred bond issuance costs,債券發行成本,债券发行成本
|
||||
1812,long-term prepaid rent,長期預付租金,长期预付租金
|
||||
1813,long-term prepaid insurance,長期預付保險費,长期预付保险费
|
||||
1814,deferred income tax assets,遞延所得稅資產,递延所得税资产
|
||||
1815,prepaid pension cost,預付退休金,预付退休金
|
||||
1818,other deferred assets,其他遞延資產,其他递延资产
|
||||
1821,idle assets,閒置資產,闲置资产
|
||||
1841,long-term notes receivable,長期應收票據,长期应收票据
|
||||
1842,long-term accounts receivable,長期應收帳款,长期应收帐款
|
||||
1843,overdue receivables,催收帳款,催收帐款
|
||||
1847,"long-term notes, accounts and overdue receivables – related parties",長期應收票據及款項與催收帳款—關係人,长期应收票据及款项与催收帐款—关系人
|
||||
1848,other long-term receivables,其他長期應收款項,其他长期应收款项
|
||||
1849,"allowance for uncollectible accounts – long-term notes, accounts and overdue receivables",備抵呆帳—長期應收票據及款項與催收帳款,备抵呆帐—长期应收票据及款项与催收帐款
|
||||
1851,assets leased to others,出租資產,出租资产
|
||||
1858,assets leased to others – incremental value from revaluation,出租資產—重估增值,出租资产—重估增值
|
||||
1859,accumulated depreciation – assets leased to others,累積折舊—出租資產,累积折旧—出租资产
|
||||
1861,refundable deposits,存出保證金,存出保证金
|
||||
1881,certificate of deposit – restricted,受限制存款,受限制存款
|
||||
1888,miscellaneous assets – other,雜項資產—其他,杂项资产—其他
|
||||
2111,bank overdraft,銀行透支,银行透支
|
||||
2112,bank loan,銀行借款,银行借款
|
||||
2114,short-term borrowings – owners,短期借款—業主,短期借款—业主
|
||||
2115,short-term borrowings – employees,短期借款—員工,短期借款—员工
|
||||
2117,short-term borrowings – related parties,短期借款—關係人,短期借款—关系人
|
||||
2118,short-term borrowings – other,短期借款—其他,短期借款—其他
|
||||
2121,commercial paper payable,應付商業本票,应付商业本票
|
||||
2122,bank acceptance,銀行承兌匯票,银行承兑汇票
|
||||
2128,other short-term notes and bills payable,其他應付短期票券,其他应付短期票券
|
||||
2129,discount on short-term notes and bills payable,應付短期票券折價,应付短期票券折价
|
||||
2131,notes payable,應付票據,应付票据
|
||||
2137,notes payable – related parties,應付票據—關係人,应付票据—关系人
|
||||
2138,other notes payable,其他應付票據,其他应付票据
|
||||
2141,accounts payable,應付帳款,应付帐款
|
||||
2147,accounts payable – related parties,應付帳款—關係人,应付帐款—关系人
|
||||
2161,income tax payable,應付所得稅,应付所得税
|
||||
2171,accrued payroll,應付薪工,应付薪工
|
||||
2172,accrued rent payable,應付租金,应付租金
|
||||
2173,accrued interest payable,應付利息,应付利息
|
||||
2174,accrued VAT payable,應付營業稅,应付营业税
|
||||
2175,accrued taxes payable – other,應付稅捐—其他,应付税捐—其他
|
||||
2178,other accrued expenses payable,其他應付費用,其他应付费用
|
||||
2181,forward exchange contract payable,應付購入遠匯款,应付购入远汇款
|
||||
2182,forward exchange contract payable – foreign currencies,應付遠匯款—外幣,应付远汇款—外币
|
||||
2183,premium on forward exchange contract,買賣遠匯溢價,买卖远汇溢价
|
||||
2184,payables on land and building purchased,應付土地房屋款,应付土地房屋款
|
||||
2185,Payables on equipment,應付設備款,应付设备款
|
||||
2187,other payables – related parties,其他應付款—關係人,其他应付款—关系人
|
||||
2191,dividend payable,應付股利,应付股利
|
||||
2192,bonus payable,應付紅利,应付红利
|
||||
2193,compensation payable to directors and supervisors,應付董監事酬勞,应付董监事酬劳
|
||||
2198,other payables – other,其他應付款—其他,其他应付款—其他
|
||||
2261,sales revenue received in advance,預收貨款,预收货款
|
||||
2262,revenue received in advance,預收收入,预收收入
|
||||
2268,other advance receipts,其他預收款,其他预收款
|
||||
2271,corporate bonds payable – current portion,一年或一營業週期內到期公司債,一年或一营业周期内到期公司债
|
||||
2272,long-term loans payable – current portion,一年或一營業週期內到期長期借款,一年或一营业周期内到期长期借款
|
||||
2273,long-term notes and accounts payable due within one year or one operating cycle,一年或一營業週期內到期長期應付票據及款項,一年或一营业周期内到期长期应付票据及款项
|
||||
2277,long-term notes and accounts payables to related parties – current portion,一年或一營業週期內到期長期應付票據及款項—關係人,一年或一营业周期内到期长期应付票据及款项—关系人
|
||||
2278,other long-term liabilities – current portion,其他一年或一營業週期內到期長期負債,其他一年或一营业周期内到期长期负债
|
||||
2281,VAT received (or output tax),銷項稅額,销项税额
|
||||
2283,temporary receipts,暫收款,暂收款
|
||||
2284,receipts under custody,代收款,代收款
|
||||
2285,estimated warranty liabilities,估計售後服務/保固負債,估计售后服务/保固负债
|
||||
2291,deferred income tax liabilities,遞延所得稅負債,递延所得税负债
|
||||
2292,deferred foreign exchange gain,遞延兌換利益,递延兑换利益
|
||||
2293,owners’ current account,業主(股東)往來,业主(股东)往来
|
||||
2294,current account with others,同業往來,同业往来
|
||||
2298,other current liabilities – others,其他流動負債—其他,其他流动负债—其他
|
||||
2311,corporate bonds payable,應付公司債,应付公司债
|
||||
2319,premium (discount) on corporate bonds payable,應付公司債溢(折)價,应付公司债溢(折)价
|
||||
2321,long-term loans payable – bank,長期銀行借款,长期银行借款
|
||||
2324,long-term loans payable – owners,長期借款—業主,长期借款—业主
|
||||
2325,long-term loans payable – employees,長期借款—員工,长期借款—员工
|
||||
2327,long-term loans payable – related parties,長期借款—關係人,长期借款—关系人
|
||||
2328,long-term loans payable – other,長期借款—其他,长期借款—其他
|
||||
2331,long-term notes payable,長期應付票據,长期应付票据
|
||||
2332,long-term accounts pay-able,長期應付帳款,长期应付帐款
|
||||
2333,long-term capital lease liabilities,長期應付租賃負債,长期应付租赁负债
|
||||
2337,Long-term notes and accounts payable – related parties,長期應付票據及款項—關係人,长期应付票据及款项—关系人
|
||||
2338,other long-term payables,其他長期應付款項,其他长期应付款项
|
||||
2341,estimated accrued land value incremental tax pay-able,估計應付土地增值稅,估计应付土地增值税
|
||||
2351,accrued pension liabilities,應計退休金負債,应计退休金负债
|
||||
2388,other long-term liabilities – other,其他長期負債—其他,其他长期负债—其他
|
||||
2811,deferred revenue,遞延收入,递延收入
|
||||
2814,deferred income tax liabilities,遞延所得稅負債,递延所得税负债
|
||||
2818,other deferred liabilities,其他遞延負債,其他递延负债
|
||||
2861,guarantee deposit received,存入保證金,存入保证金
|
||||
2888,miscellaneous liabilities – other,雜項負債—其他,杂项负债—其他
|
||||
3111,capital – common stock,普通股股本,普通股股本
|
||||
3112,capital – preferred stock,特別股股本,特别股股本
|
||||
3113,capital collected in advance,預收股本,预收股本
|
||||
3114,stock dividends to be distributed,待分配股票股利,待分配股票股利
|
||||
3115,capital,資本,资本
|
||||
3211,paid-in capital in excess of par- common stock,普通股股票溢價,普通股股票溢价
|
||||
3212,paid-in capital in excess of par- preferred stock,特別股股票溢價,特别股股票溢价
|
||||
3231,capital surplus from assets revaluation,資產重估增值準備,资产重估增值准备
|
||||
3241,capital surplus from gain on disposal of assets,處分資產溢價公積,处分资产溢价公积
|
||||
3251,capital surplus from business combination,合併公積,合并公积
|
||||
3261,donated surplus,受贈公積,受赠公积
|
||||
3281,additional paid-in capital from investee under equity method,權益法長期股權投資資本公積,权益法长期股权投资资本公积
|
||||
3282,additional paid-in capital – treasury stock trans-actions,資本公積—庫藏股票交易,资本公积—库藏股票交易
|
||||
3311,legal reserve,法定盈餘公積,法定盈余公积
|
||||
3321,contingency reserve,意外損失準備,意外损失准备
|
||||
3322,improvement and expansion reserve,改良擴充準備,改良扩充准备
|
||||
3323,special reserve for redemption of liabilities,償債準備,偿债准备
|
||||
3328,other special reserve,其他特別盈餘公積,其他特别盈余公积
|
||||
3351,accumulated profit or loss,累積盈虧,累积盈亏
|
||||
3352,prior period adjustments,前期損益調整,前期损益调整
|
||||
3353,net income or loss for current period,本期損益,本期损益
|
||||
3411,unrealized loss on market value decline of long-term equity investments,長期股權投資未實現跌價損失,长期股权投资未实现跌价损失
|
||||
3421,cumulative translation adjustments,累積換算調整數,累积换算调整数
|
||||
3431,net loss not recognized as pension costs,未認列為退休金成本之淨損失,未认列为退休金成本之净损失
|
||||
3511,treasury stock,庫藏股,库藏股
|
||||
3611,minority interest,少數股權,少数股权
|
||||
4111,sales revenue,銷貨收入,销货收入
|
||||
4112,installment sales revenue,分期付款銷貨收入,分期付款销货收入
|
||||
4171,sales return,銷貨退回,销货退回
|
||||
4191,sales discounts and allowances,銷貨折讓,销货折让
|
||||
4611,service revenue,勞務收入,劳务收入
|
||||
4711,agency revenue,業務收入,业务收入
|
||||
4888,other operating revenue – other,其他營業收入—其他,其他营业收入—其他
|
||||
5111,cost of goods sold,銷貨成本,销货成本
|
||||
5112,installment cost of goods sold,分期付款銷貨成本,分期付款销货成本
|
||||
5121,purchases,進貨,进货
|
||||
5122,purchase expenses,進貨費用,进货费用
|
||||
5123,purchase returns,進貨退出,进货退出
|
||||
5124,charges on purchased merchandise,進貨折讓,进货折让
|
||||
5131,material purchased,進料,进料
|
||||
5132,charges on purchased material,進料費用,进料费用
|
||||
5133,material purchase returns,進料退出,进料退出
|
||||
5134,material purchase allowances,進料折讓,进料折让
|
||||
5141,direct labor,直接人工,直接人工
|
||||
5151,indirect labor,間接人工,间接人工
|
||||
5152,"rent expense, rent",租金支出,租金支出
|
||||
5153,office supplies (expense),文具用品,文具用品
|
||||
5154,"travelling expense, travel",旅費,旅费
|
||||
5155,"shipping expenses, freight",運費,运费
|
||||
5156,postage (expenses),郵電費,邮电费
|
||||
5157,repair (s) and maintenance (expense ),修繕費,修缮费
|
||||
5158,packing expenses,包裝費,包装费
|
||||
5161,utilities (expense),水電瓦斯費,水电瓦斯费
|
||||
5162,insurance (expense),保險費,保险费
|
||||
5163,manufacturing overhead – outsourced,加工費,加工费
|
||||
5166,taxes,稅捐,税捐
|
||||
5168,depreciation expense,折舊,折旧
|
||||
5169,various amortization,各項耗竭及攤提,各项耗竭及摊提
|
||||
5172,meal (expenses),伙食費,伙食费
|
||||
5173,employee benefits/welfare,職工福利,职工福利
|
||||
5176,training (expense),訓練費,训练费
|
||||
5177,indirect materials,間接材料,间接材料
|
||||
5188,other manufacturing expenses,其他製造費用,其他制造费用
|
||||
5611,service costs,勞務成本,劳务成本
|
||||
5711,agency costs,業務成本,业务成本
|
||||
5888,other operating costs – other,其他營業成本—其他,其他营业成本—其他
|
||||
6151,payroll expense,薪資支出,薪资支出
|
||||
6152,"rent expense, rent",租金支出,租金支出
|
||||
6153,office supplies (expense),文具用品,文具用品
|
||||
6154,"travelling expense, travel",旅費,旅费
|
||||
6155,"shipping expenses, freight",運費,运费
|
||||
6156,postage (expenses),郵電費,邮电费
|
||||
6157,repair (s) and maintenance (expense),修繕費,修缮费
|
||||
6159,"advertisement expense, advertisement",廣告費,广告费
|
||||
6161,utilities (expense),水電瓦斯費,水电瓦斯费
|
||||
6162,insurance (expense),保險費,保险费
|
||||
6164,entertainment (expense),交際費,交际费
|
||||
6165,donation (expense),捐贈,捐赠
|
||||
6166,taxes,稅捐,税捐
|
||||
6167,loss on uncollectible accounts,呆帳損失,呆帐损失
|
||||
6168,depreciation expense,折舊,折旧
|
||||
6169,various amortization,各項耗竭及攤提,各项耗竭及摊提
|
||||
6172,meal (expenses),伙食費,伙食费
|
||||
6173,employee benefits/welfare,職工福利,职工福利
|
||||
6175,commission (expense),佣金支出,佣金支出
|
||||
6176,training (expense),訓練費,训练费
|
||||
6188,other selling expenses,其他推銷費用,其他推销费用
|
||||
6251,payroll expense,薪資支出,薪资支出
|
||||
6252,"rent expense, rent",租金支出,租金支出
|
||||
6253,office supplies,文具用品,文具用品
|
||||
6254,"travelling expense, travel",旅費,旅费
|
||||
6255,"shipping expenses,freight",運費,运费
|
||||
6256,postage (expenses),郵電費,邮电费
|
||||
6257,repair (s) and maintenance (expense),修繕費,修缮费
|
||||
6259,"advertisement expense, advertisement",廣告費,广告费
|
||||
6261,utilities (expense),水電瓦斯費,水电瓦斯费
|
||||
6262,insurance (expense),保險費,保险费
|
||||
6264,entertainment (expense),交際費,交际费
|
||||
6265,donation (expense),捐贈,捐赠
|
||||
6266,taxes,稅捐,税捐
|
||||
6267,loss on uncollectible accounts,呆帳損失,呆帐损失
|
||||
6268,depreciation expense,折舊,折旧
|
||||
6269,various amortization,各項耗竭及攤提,各项耗竭及摊提
|
||||
6271,loss on export sales,外銷損失,外销损失
|
||||
6272,meal (expenses),伙食費,伙食费
|
||||
6273,employee benefits/welfare,職工福利,职工福利
|
||||
6274,research and development expense,研究發展費用,研究发展费用
|
||||
6275,commission (expense),佣金支出,佣金支出
|
||||
6276,training (expense),訓練費,训练费
|
||||
6278,professional service fees,勞務費,劳务费
|
||||
6288,other general and administrative expenses,其他管理及總務費用,其他管理及总务费用
|
||||
6351,payroll expense,薪資支出,薪资支出
|
||||
6352,"rent expense, rent",租金支出,租金支出
|
||||
6353,office supplies,文具用品,文具用品
|
||||
6354,"travelling expense, travel",旅費,旅费
|
||||
6355,"shipping expenses, freight",運費,运费
|
||||
6356,postage (expenses),郵電費,邮电费
|
||||
6357,repair (s) and maintenance (expense),修繕費,修缮费
|
||||
6361,utilities (expense),水電瓦斯費,水电瓦斯费
|
||||
6362,insurance (expense),保險費,保险费
|
||||
6364,entertainment (expense),交際費,交际费
|
||||
6366,taxes,稅捐,税捐
|
||||
6368,depreciation expense,折舊,折旧
|
||||
6369,various amortization,各項耗竭及攤提,各项耗竭及摊提
|
||||
6372,meal (expenses),伙食費,伙食费
|
||||
6373,employee benefits/welfare,職工福利,职工福利
|
||||
6376,training (expense),訓練費,训练费
|
||||
6378,other research and development expenses,其他研究發展費用,其他研究发展费用
|
||||
7111,interest revenue/income,利息收入,利息收入
|
||||
7121,investment income recognized under equity method,權益法認列之投資收益,权益法认列之投资收益
|
||||
7122,dividends income,股利收入,股利收入
|
||||
7123,gain on market price recovery of short-term investment,短期投資市價回升利益,短期投资市价回升利益
|
||||
7131,foreign exchange gain,兌換利益,兑换利益
|
||||
7141,gain on disposal of investments,處分投資收益,处分投资收益
|
||||
7151,gain on disposal of assets,處分資產溢價收入,处分资产溢价收入
|
||||
7481,donation income,捐贈收入,捐赠收入
|
||||
7482,rent revenue/income,租金收入,租金收入
|
||||
7483,commission revenue/income,佣金收入,佣金收入
|
||||
7484,revenue from sale of scraps,出售下腳及廢料收入,出售下脚及废料收入
|
||||
7485,gain on physical inventory,存貨盤盈,存货盘盈
|
||||
7486,gain from price recovery of inventory,存貨跌價回升利益,存货跌价回升利益
|
||||
7487,gain on reversal of bad debts,壞帳轉回利益,坏帐转回利益
|
||||
7488,other non-operating revenue – other items,其他營業外收入—其他,其他营业外收入—其他
|
||||
7511,interest expense,利息費用,利息费用
|
||||
7521,investment loss recognized under equity method,權益法認列之投資損失,权益法认列之投资损失
|
||||
7523,unrealized loss on reduction of short-term investments to market,短期投資未實現跌價損失,短期投资未实现跌价损失
|
||||
7531,foreign exchange loss,兌換損失,兑换损失
|
||||
7541,loss on disposal of investments,處分投資損失,处分投资损失
|
||||
7551,loss on disposal of assets,處分資產損失,处分资产损失
|
||||
7881,loss on work stoppages,停工損失,停工损失
|
||||
7882,casualty loss,災害損失,灾害损失
|
||||
7885,loss on physical inventory,存貨盤損,存货盘损
|
||||
7886,loss for market price decline and obsolete and slow-moving inventories,存貨跌價及呆滯損失,存货跌价及呆滞损失
|
||||
7888,other non-operating expenses – other,其他營業外費用—其他,其他营业外费用—其他
|
||||
8111,income tax expense ( or benefit),所得稅費用(或利益),所得税费用(或利益)
|
||||
9111,income (loss) from operations of discontinued segment,停業部門損益—停業前營業損益,停业部门损益—停业前营业损益
|
||||
9121,gain (loss) from disposal of discontinued segment,停業部門損益—處分損益,停业部门损益—处分损益
|
||||
9211,extraordinary gain or loss,非常損益,非常损益
|
||||
9311,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数
|
||||
9411,minority interest income,少數股權淨利,少数股权净利
|
|
10
src/accounting/data/currencies.csv
Normal file
10
src/accounting/data/currencies.csv
Normal file
@ -0,0 +1,10 @@
|
||||
code,name,l10n-zh_Hant,l10n-zh_Hans
|
||||
TWD,New Taiwan dollar,新臺幣,新台币
|
||||
USD,United States dollar,美元,美元
|
||||
JPY,Japanese yen,日圓,日圆
|
||||
CNY,Renminbi,人民幣,人民币
|
||||
HKD,Hong Kong dollar,港元,港元
|
||||
EUR,Euro,歐元,欧元
|
||||
MOP,Macanese pataca,澳門元,澳门元
|
||||
AUD,Australian dollar,澳洲元,澳大利亚元
|
||||
ETH,Ethereum,以太坊,以太坊
|
|
@ -1,39 +0,0 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""The database instance factory for the base account management.
|
||||
|
||||
This is to overcome the problem that the database instance needs to be
|
||||
initialized at compile time, but as a submodule it is only available at run
|
||||
time.
|
||||
|
||||
"""
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db: SQLAlchemy
|
||||
"""The database instance."""
|
||||
|
||||
|
||||
def set_db(new_db: SQLAlchemy) -> None:
|
||||
"""Sets the database instance.
|
||||
|
||||
:param new_db: The database instance.
|
||||
:return: None.
|
||||
"""
|
||||
global db
|
||||
db = new_db
|
@ -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",
|
||||
|
@ -17,15 +17,19 @@
|
||||
"""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.database import db
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.utils.user import user_cls, user_pk_column
|
||||
|
||||
|
||||
@ -64,6 +68,14 @@ class BaseAccount(db.Model):
|
||||
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."""
|
||||
@ -101,8 +113,8 @@ class Account(db.Model):
|
||||
"""The account number under the base account."""
|
||||
title_l10n = db.Column("title", db.String, nullable=False)
|
||||
"""The title."""
|
||||
is_offset_needed = db.Column(db.Boolean, nullable=False, default=False)
|
||||
"""Whether the entries of this account need offsets."""
|
||||
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."""
|
||||
@ -126,6 +138,8 @@ class Account(db.Model):
|
||||
l10n = db.relationship("AccountL10n", back_populates="account",
|
||||
lazy=False)
|
||||
"""The localized titles."""
|
||||
entries = db.relationship("JournalEntry", back_populates="account")
|
||||
"""The journal entries."""
|
||||
|
||||
__CASH = "1111-001"
|
||||
"""The code of the cash account,"""
|
||||
@ -187,18 +201,35 @@ class Account(db.Model):
|
||||
if l10n.locale == current_locale:
|
||||
l10n.title = value
|
||||
return
|
||||
self.l10n.append(AccountL10n(
|
||||
locale=current_locale, title=value))
|
||||
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 accounting account by its code.
|
||||
"""Finds an account by its code.
|
||||
|
||||
:param code: The code.
|
||||
:return: The accounting account, or None if this account does not
|
||||
exist.
|
||||
:return: The account, or None if this account does not exist.
|
||||
"""
|
||||
m = re.match("^([1-9]{4})-([0-9]{3})$", code)
|
||||
m = re.match(r"^([1-9]{4})-(\d{3})$", code)
|
||||
if m is None:
|
||||
return None
|
||||
return cls.query.filter(cls.base_code == m.group(1),
|
||||
@ -245,6 +276,14 @@ class Account(db.Model):
|
||||
cls.base_code != "3353")\
|
||||
.order_by(cls.base_code, cls.no).all()
|
||||
|
||||
@property
|
||||
def query_values(self) -> list[str]:
|
||||
"""Returns the values to be queried.
|
||||
|
||||
:return: The values to be queried.
|
||||
"""
|
||||
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
||||
|
||||
@classmethod
|
||||
def cash(cls) -> t.Self:
|
||||
"""Returns the cash account.
|
||||
@ -293,8 +332,21 @@ class Account(db.Model):
|
||||
"""
|
||||
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 accounting account.
|
||||
"""Deletes this account.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
@ -306,11 +358,342 @@ class Account(db.Model):
|
||||
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)
|
||||
db.UniqueConstraint(account_id, locale)
|
||||
"""The localized title."""
|
||||
|
||||
|
||||
class Currency(db.Model):
|
||||
"""A currency."""
|
||||
__tablename__ = "accounting_currencies"
|
||||
"""The table name."""
|
||||
code = db.Column(db.String, nullable=False, primary_key=True)
|
||||
"""The code."""
|
||||
name_l10n = db.Column("name", db.String, nullable=False)
|
||||
"""The name."""
|
||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||
server_default=db.func.now())
|
||||
"""The time of creation."""
|
||||
created_by_id = db.Column(db.Integer,
|
||||
db.ForeignKey(user_pk_column,
|
||||
onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The ID of the creator."""
|
||||
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
||||
"""The creator."""
|
||||
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||
server_default=db.func.now())
|
||||
"""The time of last update."""
|
||||
updated_by_id = db.Column(db.Integer,
|
||||
db.ForeignKey(user_pk_column,
|
||||
onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The ID of the updator."""
|
||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||
"""The updator."""
|
||||
l10n = db.relationship("CurrencyL10n", back_populates="currency",
|
||||
lazy=False)
|
||||
"""The localized names."""
|
||||
entries = db.relationship("JournalEntry", back_populates="currency")
|
||||
"""The journal entries."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the string representation of the currency.
|
||||
|
||||
:return: The string representation of the currency.
|
||||
"""
|
||||
return F"{self.name} ({self.code})"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Returns the name in the current locale.
|
||||
|
||||
:return: The name in the current locale.
|
||||
"""
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
return self.name_l10n
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
return l10n.name
|
||||
return self.name_l10n
|
||||
|
||||
@name.setter
|
||||
def name(self, value: str) -> None:
|
||||
"""Sets the name in the current locale.
|
||||
|
||||
:param value: The new name.
|
||||
:return: None.
|
||||
"""
|
||||
if self.name_l10n is None:
|
||||
self.name_l10n = value
|
||||
return
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
self.name_l10n = value
|
||||
return
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
l10n.name = value
|
||||
return
|
||||
self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
|
||||
|
||||
@property
|
||||
def is_modified(self) -> bool:
|
||||
"""Returns whether a product account was modified.
|
||||
|
||||
:return: True if modified, or False otherwise.
|
||||
"""
|
||||
if db.session.is_modified(self):
|
||||
return True
|
||||
for l10n in self.l10n:
|
||||
if db.session.is_modified(l10n):
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Deletes the currency.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
|
||||
cls: t.Type[t.Self] = self.__class__
|
||||
cls.query.filter(cls.code == self.code).delete()
|
||||
|
||||
|
||||
class CurrencyL10n(db.Model):
|
||||
"""A localized currency name."""
|
||||
__tablename__ = "accounting_currencies_l10n"
|
||||
"""The table name."""
|
||||
currency_code = db.Column(db.String,
|
||||
db.ForeignKey(Currency.code, onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
nullable=False, primary_key=True)
|
||||
"""The currency code."""
|
||||
currency = db.relationship(Currency, back_populates="l10n")
|
||||
"""The currency."""
|
||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
||||
"""The locale."""
|
||||
name = db.Column(db.String, nullable=False)
|
||||
"""The localized name."""
|
||||
|
||||
|
||||
class TransactionCurrency:
|
||||
"""A currency in a transaction."""
|
||||
|
||||
def __init__(self, code: str, debit: list[JournalEntry],
|
||||
credit: list[JournalEntry]):
|
||||
"""Constructs the currency in the transaction.
|
||||
|
||||
:param code: The currency code.
|
||||
:param debit: The debit entries.
|
||||
:param credit: The credit entries.
|
||||
"""
|
||||
self.code: str = code
|
||||
"""The currency code."""
|
||||
self.debit: list[JournalEntry] = debit
|
||||
"""The debit entries."""
|
||||
self.credit: list[JournalEntry] = credit
|
||||
"""The credit entries."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Returns the currency name.
|
||||
|
||||
:return: The currency name.
|
||||
"""
|
||||
return db.session.get(Currency, self.code).name
|
||||
|
||||
@property
|
||||
def debit_total(self) -> Decimal:
|
||||
"""Returns the total amount of the debit journal entries.
|
||||
|
||||
:return: The total amount of the debit journal entries.
|
||||
"""
|
||||
return sum([x.amount for x in self.debit])
|
||||
|
||||
@property
|
||||
def credit_total(self) -> str:
|
||||
"""Returns the total amount of the credit journal entries.
|
||||
|
||||
:return: The total amount of the credit journal entries.
|
||||
"""
|
||||
return sum([x.amount for x in self.credit])
|
||||
|
||||
|
||||
class Transaction(db.Model):
|
||||
"""A transaction."""
|
||||
__tablename__ = "accounting_transactions"
|
||||
"""The table name."""
|
||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||
autoincrement=False)
|
||||
"""The transaction ID."""
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
"""The date."""
|
||||
no = db.Column(db.Integer, nullable=False, default=text("1"))
|
||||
"""The account number under the date."""
|
||||
note = db.Column(db.String)
|
||||
"""The note."""
|
||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||
server_default=db.func.now())
|
||||
"""The time of creation."""
|
||||
created_by_id = db.Column(db.Integer,
|
||||
db.ForeignKey(user_pk_column,
|
||||
onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The ID of the creator."""
|
||||
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
||||
"""The creator."""
|
||||
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||
server_default=db.func.now())
|
||||
"""The time of last update."""
|
||||
updated_by_id = db.Column(db.Integer,
|
||||
db.ForeignKey(user_pk_column,
|
||||
onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The ID of the updator."""
|
||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||
"""The updator."""
|
||||
entries = db.relationship("JournalEntry", back_populates="transaction")
|
||||
"""The journal entries."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the string representation of this transaction.
|
||||
|
||||
:return: The string representation of this transaction.
|
||||
"""
|
||||
if self.is_cash_expense:
|
||||
return gettext("Cash Expense Transaction#%(id)s", id=self.id)
|
||||
if self.is_cash_income:
|
||||
return gettext("Cash Income Transaction#%(id)s", id=self.id)
|
||||
return gettext("Transfer Transaction#%(id)s", id=self.id)
|
||||
|
||||
@property
|
||||
def currencies(self) -> list[TransactionCurrency]:
|
||||
"""Returns the journal entries categorized by their currencies.
|
||||
|
||||
:return: The currency categories.
|
||||
"""
|
||||
entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no)
|
||||
codes: list[str] = []
|
||||
by_currency: dict[str, list[JournalEntry]] = {}
|
||||
for entry in entries:
|
||||
if entry.currency_code not in by_currency:
|
||||
codes.append(entry.currency_code)
|
||||
by_currency[entry.currency_code] = []
|
||||
by_currency[entry.currency_code].append(entry)
|
||||
return [TransactionCurrency(code=x,
|
||||
debit=[y for y in by_currency[x]
|
||||
if y.is_debit],
|
||||
credit=[y for y in by_currency[x]
|
||||
if not y.is_debit])
|
||||
for x in codes]
|
||||
|
||||
@property
|
||||
def is_cash_income(self) -> bool:
|
||||
"""Returns whether this is a cash income transaction.
|
||||
|
||||
:return: True if this is a cash income transaction, or False otherwise.
|
||||
"""
|
||||
for currency in self.currencies:
|
||||
if len(currency.debit) > 1:
|
||||
return False
|
||||
if currency.debit[0].account.code != "1111-001":
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_cash_expense(self) -> bool:
|
||||
"""Returns whether this is a cash expense transaction.
|
||||
|
||||
:return: True if this is a cash expense transaction, or False
|
||||
otherwise.
|
||||
"""
|
||||
for currency in self.currencies:
|
||||
if len(currency.credit) > 1:
|
||||
return False
|
||||
if currency.credit[0].account.code != "1111-001":
|
||||
return False
|
||||
return True
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Deletes the transaction.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
JournalEntry.query\
|
||||
.filter(JournalEntry.transaction_id == self.id).delete()
|
||||
db.session.delete(self)
|
||||
|
||||
|
||||
class JournalEntry(db.Model):
|
||||
"""An accounting journal entry."""
|
||||
__tablename__ = "accounting_journal_entries"
|
||||
"""The table name."""
|
||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||
autoincrement=False)
|
||||
"""The entry ID."""
|
||||
transaction_id = db.Column(db.Integer,
|
||||
db.ForeignKey(Transaction.id,
|
||||
onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
"""The transaction ID."""
|
||||
transaction = db.relationship(Transaction, back_populates="entries")
|
||||
"""The transaction."""
|
||||
is_debit = db.Column(db.Boolean, nullable=False)
|
||||
"""True for a debit entry, or False for a credit entry."""
|
||||
no = db.Column(db.Integer, nullable=False)
|
||||
"""The entry number under the transaction and debit or credit."""
|
||||
pay_off_target_id = db.Column(db.Integer,
|
||||
db.ForeignKey(id, onupdate="CASCADE"),
|
||||
nullable=True)
|
||||
"""The ID of the pay-off target entry."""
|
||||
pay_off_target = db.relationship("JournalEntry", back_populates="pay_off",
|
||||
remote_side=id, passive_deletes=True)
|
||||
"""The pay-off target entry."""
|
||||
pay_off = db.relationship("JournalEntry", back_populates="pay_off_target")
|
||||
"""The pay-off entries."""
|
||||
currency_code = db.Column(db.String,
|
||||
db.ForeignKey(Currency.code, onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The currency code."""
|
||||
currency = db.relationship(Currency, back_populates="entries")
|
||||
"""The currency."""
|
||||
account_id = db.Column(db.Integer,
|
||||
db.ForeignKey(Account.id,
|
||||
onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The account ID."""
|
||||
account = db.relationship(Account, back_populates="entries", lazy=False)
|
||||
"""The account."""
|
||||
summary = db.Column(db.String, nullable=True)
|
||||
"""The summary."""
|
||||
amount = db.Column(db.Numeric(14, 2), nullable=False)
|
||||
"""The amount."""
|
||||
|
||||
@property
|
||||
def eid(self) -> int | None:
|
||||
"""Returns the journal entry ID. This is the alternative name of the
|
||||
ID field, to work with WTForms.
|
||||
|
||||
:return: The journal entry ID.
|
||||
"""
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def account_code(self) -> str:
|
||||
"""Returns the account code.
|
||||
|
||||
:return: The account code.
|
||||
"""
|
||||
return self.account.code
|
||||
|
@ -21,48 +21,101 @@
|
||||
* First written: 2023/2/1
|
||||
*/
|
||||
|
||||
.clickable {
|
||||
.accounting-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-group .btn .search-input {
|
||||
.btn-group .btn .accounting-search-input {
|
||||
min-height: calc(1em + .5rem + 2px);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
.btn-group .btn .search-label button {
|
||||
.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 account management */
|
||||
.account {
|
||||
/** 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);
|
||||
}
|
||||
.account .account-title {
|
||||
.accounting-card-title {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bolder;
|
||||
}
|
||||
.account .account-code {
|
||||
.accounting-card-code {
|
||||
font-size: 1.4rem;
|
||||
color: #373b3e;
|
||||
}
|
||||
.list-base-selector {
|
||||
|
||||
/** 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) */
|
||||
.material-text-field {
|
||||
.accounting-material-text-field {
|
||||
position: relative;
|
||||
min-height: calc(3.5rem + 2px);
|
||||
padding-top: 1.625rem;
|
||||
}
|
||||
.material-text-field > .form-label {
|
||||
.accounting-material-text-field > .form-label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@ -71,27 +124,57 @@
|
||||
transform-origin: 0 0;
|
||||
transition: opacity .1s ease-in-out,transform .1s ease-in-out;
|
||||
}
|
||||
.material-text-field.not-empty > .form-label {
|
||||
.accounting-material-text-field.accounting-not-empty > .form-label {
|
||||
opacity: 0.65;
|
||||
transform: scale(.85) translateY(-.5rem) translateX(.15rem);
|
||||
}
|
||||
|
||||
/* The Material Design floating action buttons */
|
||||
.material-fab {
|
||||
.accounting-material-fab {
|
||||
position: fixed;
|
||||
right: 2rem;
|
||||
bottom: 1rem;
|
||||
z-index: 10;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
.material-fab .btn {
|
||||
.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;
|
||||
}
|
||||
.material-fab .btn:hover, .material-fab .btn:focus {
|
||||
.accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus {
|
||||
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
|
||||
}
|
||||
.accounting-btn-material-fab {
|
||||
transition: transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out;
|
||||
}
|
||||
.show .accounting-btn-material-fab {
|
||||
transform: scale(1.5) rotate(-45deg);
|
||||
}
|
||||
.accounting-material-fab-speed-dial-group {
|
||||
position: absolute;
|
||||
right: -2rem;
|
||||
bottom: -7rem;
|
||||
text-align: right;
|
||||
opacity: 0;
|
||||
transform: scale(0.1);
|
||||
line-height: 5.5rem;
|
||||
transition: opacity .1s ease-in-out, transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out;
|
||||
}
|
||||
.show .accounting-material-fab-speed-dial-group {
|
||||
opacity: 1;
|
||||
transform: scale(0.6);
|
||||
right: -0.5rem;
|
||||
bottom: 0.7rem;
|
||||
}
|
||||
.accounting-material-fab-speed-dial-group .btn {
|
||||
background-color: white;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
|
||||
}
|
||||
.accounting-material-fab-speed-dial-group .btn:hover, .accounting-material-fab-speed-dial-group .btn:focus {
|
||||
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
|
@ -24,11 +24,11 @@
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initializeBaseAccountSelector();
|
||||
document.getElementById("account-base-code")
|
||||
document.getElementById("accounting-base-code")
|
||||
.onchange = validateBase;
|
||||
document.getElementById("account-title")
|
||||
document.getElementById("accounting-title")
|
||||
.onchange = validateTitle;
|
||||
document.getElementById("account-form")
|
||||
document.getElementById("accounting-form")
|
||||
.onsubmit = validateForm;
|
||||
});
|
||||
|
||||
@ -38,25 +38,25 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
* @private
|
||||
*/
|
||||
function initializeBaseAccountSelector() {
|
||||
const selector = document.getElementById("select-base-modal");
|
||||
const base = document.getElementById("account-base");
|
||||
const baseCode = document.getElementById("account-base-code");
|
||||
const baseContent = document.getElementById("account-base-content");
|
||||
const options = Array.from(document.getElementsByClassName("list-group-item-base"));
|
||||
const btnClear = document.getElementById("btn-clear-base");
|
||||
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("not-empty");
|
||||
base.classList.add("accounting-not-empty");
|
||||
options.forEach(function (item) {
|
||||
item.classList.remove("active");
|
||||
});
|
||||
const selected = document.getElementById("list-group-item-base-" + baseCode.value);
|
||||
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
|
||||
if (selected !== null) {
|
||||
selected.classList.add("active");
|
||||
}
|
||||
});
|
||||
selector.addEventListener("hidden.bs.modal", function () {
|
||||
if (baseCode.value === "") {
|
||||
base.classList.remove("not-empty");
|
||||
base.classList.remove("accounting-not-empty");
|
||||
}
|
||||
});
|
||||
options.forEach(function (option) {
|
||||
@ -79,6 +79,53 @@ function initializeBaseAccountSelector() {
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -101,9 +148,9 @@ function validateForm() {
|
||||
* @private
|
||||
*/
|
||||
function validateBase() {
|
||||
const field = document.getElementById("account-base-code");
|
||||
const error = document.getElementById("account-base-code-error");
|
||||
const displayField = document.getElementById("account-base");
|
||||
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");
|
||||
@ -122,8 +169,8 @@ function validateBase() {
|
||||
* @private
|
||||
*/
|
||||
function validateTitle() {
|
||||
const field = document.getElementById("account-title");
|
||||
const error = document.getElementById("account-title-error");
|
||||
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");
|
||||
|
@ -23,13 +23,13 @@
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const list = document.getElementById("account-order-list");
|
||||
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("account-order-" + accounts[i].dataset.id + "-no");
|
||||
const code = document.getElementById("account-order-" + accounts[i].dataset.id + "-code");
|
||||
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
||||
const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code");
|
||||
no.value = String(i + 1);
|
||||
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3);
|
||||
}
|
||||
|
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;
|
||||
}
|
@ -46,14 +46,14 @@ function initializeMouseDragAndDropReordering(list, onReorder) {
|
||||
item.draggable = true;
|
||||
item.addEventListener("dragstart", function () {
|
||||
dragged = item;
|
||||
dragged.classList.add("list-group-item-dark");
|
||||
dragged.classList.add("accounting-dragged");
|
||||
});
|
||||
item.addEventListener("dragover", function () {
|
||||
onDragOver(dragged, item);
|
||||
onReorder();
|
||||
});
|
||||
item.addEventListener("dragend", function () {
|
||||
dragged.classList.remove("list-group-item-dark");
|
||||
dragged.classList.remove("accounting-dragged");
|
||||
dragged = null;
|
||||
});
|
||||
});
|
||||
@ -70,7 +70,7 @@ function initializeTouchDragAndDropReordering(list, onReorder) {
|
||||
const items = Array.from(list.children);
|
||||
items.forEach(function (item) {
|
||||
item.addEventListener("touchstart", function () {
|
||||
item.classList.add("list-group-item-dark");
|
||||
item.classList.add("accounting-dragged");
|
||||
});
|
||||
item.addEventListener("touchmove", function (event) {
|
||||
const touch = event.targetTouches[0];
|
||||
@ -79,7 +79,7 @@ function initializeTouchDragAndDropReordering(list, onReorder) {
|
||||
onReorder();
|
||||
});
|
||||
item.addEventListener("touchend", function () {
|
||||
item.classList.remove("list-group-item-dark");
|
||||
item.classList.remove("accounting-dragged");
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -91,7 +91,7 @@ function initializeTouchDragAndDropReordering(list, onReorder) {
|
||||
* @param target {Element} the other item that was dragged over
|
||||
*/
|
||||
function onDragOver(dragged, target) {
|
||||
if (target.parentElement !== dragged.parentElement || target === dragged) {
|
||||
if (dragged === null || target.parentElement !== dragged.parentElement || target === dragged) {
|
||||
return;
|
||||
}
|
||||
let isBefore = false;
|
||||
|
44
src/accounting/static/js/material-fab-speed-dial.js
Normal file
44
src/accounting/static/js/material-fab-speed-dial.js
Normal file
@ -0,0 +1,44 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* material-fab-speed-dial.js: The JavaScript for the speed dial for the material floating buttons
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/25
|
||||
*/
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initializeMaterialFabSpeedDial();
|
||||
});
|
||||
|
||||
/**
|
||||
* Initializes the speed dial of the material floating buttons.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function initializeMaterialFabSpeedDial() {
|
||||
const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial");
|
||||
const fab = document.getElementById(btnFab.dataset.target);
|
||||
btnFab.onclick = function () {
|
||||
if (fab.classList.contains("show")) {
|
||||
fab.classList.remove("show");
|
||||
} else {
|
||||
fab.classList.add("show");
|
||||
}
|
||||
}
|
||||
}
|
832
src/accounting/static/js/transaction-form.js
Normal file
832
src/accounting/static/js/transaction-form.js
Normal file
@ -0,0 +1,832 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/25
|
||||
*/
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initializeCurrencyForms();
|
||||
initializeJournalEntries();
|
||||
initializeAccountSelectors();
|
||||
initializeFormValidation();
|
||||
});
|
||||
|
||||
/**
|
||||
* Escapes the HTML special characters and returns.
|
||||
*
|
||||
* @param s {string} the original string
|
||||
* @returns {string} the string with HTML special character escaped
|
||||
* @private
|
||||
*/
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Decimal number.
|
||||
*
|
||||
* @param number {Decimal} the Decimal number
|
||||
* @returns {string} the formatted Decimal number
|
||||
*/
|
||||
function formatDecimal(number) {
|
||||
if (number.equals(new Decimal("0"))) {
|
||||
return "-";
|
||||
}
|
||||
const frac = number.modulo(1);
|
||||
const whole = Number(number.minus(frac)).toLocaleString();
|
||||
return whole + String(frac).substring(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the currency forms.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function initializeCurrencyForms() {
|
||||
const form = document.getElementById("accounting-form");
|
||||
const btnNew = document.getElementById("accounting-btn-new-currency");
|
||||
const currencyList = document.getElementById("accounting-currency-list");
|
||||
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
|
||||
const onReorder = function () {
|
||||
const currencies = Array.from(currencyList.children);
|
||||
for (let i = 0; i < currencies.length; i++) {
|
||||
const no = document.getElementById(currencies[i].dataset.prefix + "-no");
|
||||
no.value = String(i + 1);
|
||||
}
|
||||
};
|
||||
btnNew.onclick = function () {
|
||||
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
|
||||
let maxIndex = 0;
|
||||
currencies.forEach(function (currency) {
|
||||
const index = parseInt(currency.dataset.index);
|
||||
if (maxIndex < index) {
|
||||
maxIndex = index;
|
||||
}
|
||||
});
|
||||
const newIndex = String(maxIndex + 1);
|
||||
const html = form.dataset.currencyTemplate
|
||||
.replaceAll("CURRENCY_INDEX", escapeHtml(newIndex));
|
||||
currencyList.insertAdjacentHTML("beforeend", html);
|
||||
const newEntryButtons = Array.from(document.getElementsByClassName("accounting-currency-" + newIndex + "-btn-new-entry"));
|
||||
const btnDelete = document.getElementById("accounting-btn-delete-currency-" + newIndex);
|
||||
newEntryButtons.forEach(initializeNewEntryButton);
|
||||
initializeBtnDeleteCurrency(btnDelete);
|
||||
resetDeleteCurrencyButtons();
|
||||
initializeDragAndDropReordering(currencyList, onReorder);
|
||||
};
|
||||
deleteButtons.forEach(initializeBtnDeleteCurrency);
|
||||
initializeDragAndDropReordering(currencyList, onReorder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the button to delete a currency.
|
||||
*
|
||||
* @param button {HTMLButtonElement} the button to delete a currency.
|
||||
* @private
|
||||
*/
|
||||
function initializeBtnDeleteCurrency(button) {
|
||||
const target = document.getElementById(button.dataset.target);
|
||||
button.onclick = function () {
|
||||
target.parentElement.removeChild(target);
|
||||
resetDeleteCurrencyButtons();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the status of the delete currency buttons.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function resetDeleteCurrencyButtons() {
|
||||
const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
|
||||
if (buttons.length > 1) {
|
||||
buttons.forEach(function (button) {
|
||||
button.classList.remove("d-none");
|
||||
});
|
||||
} else {
|
||||
buttons[0].classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the journal entry forms.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function initializeJournalEntries() {
|
||||
const newButtons = Array.from(document.getElementsByClassName("accounting-btn-new-entry"));
|
||||
const entryLists = Array.from(document.getElementsByClassName("accounting-entry-list"));
|
||||
const entries = Array.from(document.getElementsByClassName("accounting-entry"))
|
||||
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-entry"));
|
||||
newButtons.forEach(initializeNewEntryButton);
|
||||
entryLists.forEach(initializeJournalEntryListReorder);
|
||||
entries.forEach(initializeJournalEntry);
|
||||
deleteButtons.forEach(initializeDeleteJournalEntryButton);
|
||||
initializeJournalEntryFormModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the button to add a new journal entry.
|
||||
*
|
||||
* @param button {HTMLButtonElement} the button to add a new journal entry
|
||||
*/
|
||||
function initializeNewEntryButton(button) {
|
||||
const entryForm = document.getElementById("accounting-entry-form");
|
||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||
const formAccountError = document.getElementById("accounting-entry-form-account-error")
|
||||
const formSummary = document.getElementById("accounting-entry-form-summary");
|
||||
const formSummaryError = document.getElementById("accounting-entry-form-summary-error");
|
||||
const formAmount = document.getElementById("accounting-entry-form-amount");
|
||||
const formAmountError = document.getElementById("accounting-entry-form-amount-error");
|
||||
button.onclick = function () {
|
||||
entryForm.dataset.currencyIndex = button.dataset.currencyIndex;
|
||||
entryForm.dataset.entryType = button.dataset.entryType;
|
||||
entryForm.dataset.entryIndex = button.dataset.entryIndex;
|
||||
formAccountControl.classList.remove("accounting-not-empty")
|
||||
formAccountControl.classList.remove("is-invalid");
|
||||
formAccountControl.dataset.bsTarget = button.dataset.accountModal;
|
||||
formAccount.innerText = "";
|
||||
formAccount.dataset.code = "";
|
||||
formAccount.dataset.text = "";
|
||||
formAccountError.innerText = "";
|
||||
formSummary.value = "";
|
||||
formSummary.classList.remove("is-invalid");
|
||||
formSummaryError.innerText = ""
|
||||
formAmount.value = "";
|
||||
formAmount.classList.remove("is-invalid");
|
||||
formAmountError.innerText = "";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the reordering of a journal entry list.
|
||||
*
|
||||
* @param entryList {HTMLUListElement} the journal entry list.
|
||||
*/
|
||||
function initializeJournalEntryListReorder(entryList) {
|
||||
initializeDragAndDropReordering(entryList, function () {
|
||||
const entries = Array.from(entryList.children);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const no = document.getElementById(entries[i].dataset.prefix + "-no");
|
||||
no.value = String(i + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the journal entry.
|
||||
*
|
||||
* @param entry {HTMLLIElement} the journal entry.
|
||||
*/
|
||||
function initializeJournalEntry(entry) {
|
||||
const entryForm = document.getElementById("accounting-entry-form");
|
||||
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
|
||||
const summary = document.getElementById(entry.dataset.prefix + "-summary");
|
||||
const amount = document.getElementById(entry.dataset.prefix + "-amount");
|
||||
const control = document.getElementById(entry.dataset.prefix + "-control");
|
||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||
const formSummary = document.getElementById("accounting-entry-form-summary");
|
||||
const formAmount = document.getElementById("accounting-entry-form-amount");
|
||||
control.onclick = function () {
|
||||
entryForm.dataset.currencyIndex = entry.dataset.currencyIndex;
|
||||
entryForm.dataset.entryType = entry.dataset.entryType;
|
||||
entryForm.dataset.entryIndex = entry.dataset.entryIndex;
|
||||
if (accountCode.value === "") {
|
||||
formAccountControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
formAccountControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
formAccountControl.dataset.bsTarget = entry.dataset.accountModal;
|
||||
formAccount.innerText = accountCode.dataset.text;
|
||||
formAccount.dataset.code = accountCode.value;
|
||||
formAccount.dataset.text = accountCode.dataset.text;
|
||||
formSummary.value = summary.value;
|
||||
formAmount.value = amount.value;
|
||||
validateJournalEntryForm();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the journal entry form modal.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function initializeJournalEntryFormModal() {
|
||||
const entryForm = document.getElementById("accounting-entry-form");
|
||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||
const formSummary = document.getElementById("accounting-entry-form-summary");
|
||||
const formAmount = document.getElementById("accounting-entry-form-amount");
|
||||
const modal = document.getElementById("accounting-entry-form-modal");
|
||||
formAccountControl.onclick = function () {
|
||||
const prefix = "accounting-" + entryForm.dataset.entryType + "-account";
|
||||
const query = document.getElementById(prefix + "-selector-query")
|
||||
const more = document.getElementById(prefix + "-more");
|
||||
const options = Array.from(document.getElementsByClassName(prefix + "-option"));
|
||||
const btnClear = document.getElementById(prefix + "-btn-clear");
|
||||
query.value = "";
|
||||
more.classList.remove("d-none");
|
||||
filterAccountOptions(prefix);
|
||||
options.forEach(function (option) {
|
||||
if (option.dataset.code === formAccount.dataset.code) {
|
||||
option.classList.add("active");
|
||||
} else {
|
||||
option.classList.remove("active");
|
||||
}
|
||||
});
|
||||
if (formAccount.dataset.code === "") {
|
||||
btnClear.classList.add("btn-secondary");
|
||||
btnClear.classList.remove("btn-danger");
|
||||
btnClear.disabled = true;
|
||||
} else {
|
||||
btnClear.classList.add("btn-danger");
|
||||
btnClear.classList.remove("btn-secondary");
|
||||
btnClear.disabled = false;
|
||||
}
|
||||
};
|
||||
formSummary.onchange = validateJournalEntrySummary;
|
||||
formAmount.onchange = validateJournalEntryAmount;
|
||||
entryForm.onsubmit = function () {
|
||||
if (validateJournalEntryForm()) {
|
||||
saveJournalEntryForm();
|
||||
bootstrap.Modal.getInstance(modal).hide();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the journal entry form modal.
|
||||
*
|
||||
* @return {boolean} true if the form is valid, or false otherwise.
|
||||
* @private
|
||||
*/
|
||||
function validateJournalEntryForm() {
|
||||
let isValid = true;
|
||||
isValid = validateJournalEntryAccount() && isValid;
|
||||
isValid = validateJournalEntrySummary() && isValid;
|
||||
isValid = validateJournalEntryAmount() && isValid
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the account in the journal entry form modal.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateJournalEntryAccount() {
|
||||
const field = document.getElementById("accounting-entry-form-account");
|
||||
const error = document.getElementById("accounting-entry-form-account-error");
|
||||
const control = document.getElementById("accounting-entry-form-account-control");
|
||||
if (field.dataset.code === "") {
|
||||
control.classList.add("is-invalid");
|
||||
error.innerText = A_("Please select the account.");
|
||||
return false;
|
||||
}
|
||||
control.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the summary in the journal entry form modal.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateJournalEntrySummary() {
|
||||
const field = document.getElementById("accounting-entry-form-summary");
|
||||
const error = document.getElementById("accounting-entry-form-summary-error");
|
||||
field.value = field.value.trim();
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the amount in the journal entry form modal.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateJournalEntryAmount() {
|
||||
const field = document.getElementById("accounting-entry-form-amount");
|
||||
const error = document.getElementById("accounting-entry-form-amount-error");
|
||||
field.value = field.value.trim();
|
||||
field.classList.remove("is-invalid");
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the amount.");
|
||||
return false;
|
||||
}
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the journal entry form modal to the form.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function saveJournalEntryForm() {
|
||||
const form = document.getElementById("accounting-form");
|
||||
const entryForm = document.getElementById("accounting-entry-form");
|
||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||
const formSummary = document.getElementById("accounting-entry-form-summary");
|
||||
const formAmount = document.getElementById("accounting-entry-form-amount");
|
||||
const currencyIndex = entryForm.dataset.currencyIndex;
|
||||
const entryType = entryForm.dataset.entryType;
|
||||
let entryIndex;
|
||||
if (entryForm.dataset.entryIndex === "new") {
|
||||
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
|
||||
const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list")
|
||||
let maxIndex = 0;
|
||||
entries.forEach(function (entry) {
|
||||
const index = parseInt(entry.dataset.entryIndex);
|
||||
if (maxIndex < index) {
|
||||
maxIndex = index;
|
||||
}
|
||||
});
|
||||
entryIndex = String(maxIndex + 1);
|
||||
const html = form.dataset.entryTemplate
|
||||
.replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex))
|
||||
.replaceAll("ENTRY_TYPE", escapeHtml(entryType))
|
||||
.replaceAll("ENTRY_INDEX", escapeHtml(entryIndex));
|
||||
entryList.insertAdjacentHTML("beforeend", html);
|
||||
initializeJournalEntryListReorder(entryList);
|
||||
} else {
|
||||
entryIndex = entryForm.dataset.entryIndex;
|
||||
}
|
||||
const currency = document.getElementById("accounting-currency-" + currencyIndex);
|
||||
const entry = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-" + entryIndex);
|
||||
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
|
||||
const accountText = document.getElementById(entry.dataset.prefix + "-account-text");
|
||||
const summary = document.getElementById(entry.dataset.prefix + "-summary");
|
||||
const summaryText = document.getElementById(entry.dataset.prefix + "-summary-text");
|
||||
const amount = document.getElementById(entry.dataset.prefix + "-amount");
|
||||
const amountText = document.getElementById(entry.dataset.prefix + "-amount-text");
|
||||
accountCode.value = formAccount.dataset.code;
|
||||
accountCode.dataset.text = formAccount.dataset.text;
|
||||
accountText.innerText = formAccount.dataset.text;
|
||||
summary.value = formSummary.value;
|
||||
summaryText.innerText = formSummary.value;
|
||||
amount.value = formAmount.value;
|
||||
amountText.innerText = formatDecimal(new Decimal(formAmount.value));
|
||||
if (entryForm.dataset.entryIndex === "new") {
|
||||
const btnDelete = document.getElementById(entry.dataset.prefix + "-btn-delete");
|
||||
initializeJournalEntry(entry);
|
||||
initializeDeleteJournalEntryButton(btnDelete);
|
||||
resetDeleteJournalEntryButtons(btnDelete.dataset.sameClass);
|
||||
}
|
||||
updateBalance(currencyIndex, entryType);
|
||||
validateJournalEntriesReal(currencyIndex, entryType);
|
||||
validateBalance(currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the button to delete a journal entry.
|
||||
*
|
||||
* @param button {HTMLButtonElement} the button to delete a journal entry
|
||||
*/
|
||||
function initializeDeleteJournalEntryButton(button) {
|
||||
const target = document.getElementById(button.dataset.target);
|
||||
const currencyIndex = target.dataset.currencyIndex;
|
||||
const entryType = target.dataset.entryType;
|
||||
const currency = document.getElementById("accounting-currency-" + currencyIndex);
|
||||
button.onclick = function () {
|
||||
target.parentElement.removeChild(target);
|
||||
resetDeleteJournalEntryButtons(button.dataset.sameClass);
|
||||
updateBalance(currencyIndex, entryType);
|
||||
validateJournalEntriesReal(currencyIndex, entryType);
|
||||
validateBalance(currency);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the status of the delete journal entry buttons.
|
||||
*
|
||||
* @param sameClass {string} the class of the buttons
|
||||
* @private
|
||||
*/
|
||||
function resetDeleteJournalEntryButtons(sameClass) {
|
||||
const buttons = Array.from(document.getElementsByClassName(sameClass));
|
||||
if (buttons.length > 1) {
|
||||
buttons.forEach(function (button) {
|
||||
button.classList.remove("d-none");
|
||||
});
|
||||
} else {
|
||||
buttons[0].classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the balance.
|
||||
*
|
||||
* @param currencyIndex {string} the currency index.
|
||||
* @param entryType {string} the journal entry type, either "debit" or "credit"
|
||||
* @private
|
||||
*/
|
||||
function updateBalance(currencyIndex, entryType) {
|
||||
const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
|
||||
const amounts = Array.from(document.getElementsByClassName(prefix + "-amount"));
|
||||
const totalText = document.getElementById(prefix + "-total");
|
||||
let total = new Decimal("0");
|
||||
amounts.forEach(function (amount) {
|
||||
if (amount.value !== "") {
|
||||
total = total.plus(new Decimal(amount.value));
|
||||
}
|
||||
});
|
||||
totalText.innerText = formatDecimal(total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the account selectors.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function initializeAccountSelectors() {
|
||||
const selectors = Array.from(document.getElementsByClassName("accounting-selector-modal"));
|
||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||
selectors.forEach(function (selector) {
|
||||
const more = document.getElementById(selector.dataset.prefix + "-more");
|
||||
const btnClear = document.getElementById(selector.dataset.prefix + "-btn-clear");
|
||||
const options = Array.from(document.getElementsByClassName(selector.dataset.prefix + "-option"));
|
||||
more.onclick = function () {
|
||||
more.classList.add("d-none");
|
||||
filterAccountOptions(selector.dataset.prefix);
|
||||
};
|
||||
initializeAccountQuery(selector);
|
||||
btnClear.onclick = function () {
|
||||
formAccountControl.classList.remove("accounting-not-empty");
|
||||
formAccount.innerText = "";
|
||||
formAccount.dataset.code = "";
|
||||
formAccount.dataset.text = "";
|
||||
validateJournalEntryAccount();
|
||||
};
|
||||
options.forEach(function (option) {
|
||||
option.onclick = function () {
|
||||
formAccountControl.classList.add("accounting-not-empty");
|
||||
formAccount.innerText = option.dataset.content;
|
||||
formAccount.dataset.code = option.dataset.code;
|
||||
formAccount.dataset.text = option.dataset.content;
|
||||
validateJournalEntryAccount();
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the query on the account options.
|
||||
*
|
||||
* @param selector {HTMLDivElement} the selector modal
|
||||
* @private
|
||||
*/
|
||||
function initializeAccountQuery(selector) {
|
||||
const query = document.getElementById(selector.dataset.prefix + "-selector-query");
|
||||
query.addEventListener("input", function () {
|
||||
filterAccountOptions(selector.dataset.prefix);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the account options.
|
||||
*
|
||||
* @param prefix {string} the HTML ID and class prefix
|
||||
* @private
|
||||
*/
|
||||
function filterAccountOptions(prefix) {
|
||||
const query = document.getElementById(prefix + "-selector-query");
|
||||
const optionList = document.getElementById(prefix + "-option-list");
|
||||
if (optionList === null) {
|
||||
console.log(prefix + "-option-list");
|
||||
}
|
||||
const options = Array.from(document.getElementsByClassName(prefix + "-option"));
|
||||
const more = document.getElementById(prefix + "-more");
|
||||
const queryNoResult = document.getElementById(prefix + "-option-no-result");
|
||||
const codesInUse = getAccountCodeUsedInForm();
|
||||
let hasAnyMatched = false;
|
||||
options.forEach(function (option) {
|
||||
const isMatched = shouldAccountOptionShow(option, more, codesInUse, query);
|
||||
if (isMatched) {
|
||||
option.classList.remove("d-none");
|
||||
hasAnyMatched = true;
|
||||
} else {
|
||||
option.classList.add("d-none");
|
||||
}
|
||||
});
|
||||
if (!hasAnyMatched) {
|
||||
optionList.classList.add("d-none");
|
||||
queryNoResult.classList.remove("d-none");
|
||||
} else {
|
||||
optionList.classList.remove("d-none");
|
||||
queryNoResult.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether an account option should show.
|
||||
*
|
||||
* @param option {HTMLLIElement} the account option
|
||||
* @param more {HTMLLIElement} the more account element
|
||||
* @param inUse {string[]} the account codes that are used in the form
|
||||
* @param query {HTMLInputElement} the query element, if any
|
||||
* @return {boolean} true if the account option should show, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function shouldAccountOptionShow(option, more, inUse, query) {
|
||||
const isQueryMatched = function () {
|
||||
if (query.value === "") {
|
||||
return true;
|
||||
}
|
||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||
for (const queryValue of queryValues) {
|
||||
if (queryValue.includes(query.value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const isMoreMatched = function () {
|
||||
if (more.classList.contains("d-none")) {
|
||||
return true;
|
||||
}
|
||||
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
|
||||
};
|
||||
return isMoreMatched() && isQueryMatched();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account codes that are used in the form.
|
||||
*
|
||||
* @return {string[]} the account codes that are used in the form
|
||||
* @private
|
||||
*/
|
||||
function getAccountCodeUsedInForm() {
|
||||
const accountCodes = Array.from(document.getElementsByClassName("accounting-account-code"));
|
||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||
const inUse = [formAccount.dataset.code];
|
||||
accountCodes.forEach(function (accountCode) {
|
||||
inUse.push(accountCode.value);
|
||||
});
|
||||
return inUse
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the form validation.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function initializeFormValidation() {
|
||||
const date = document.getElementById("accounting-date");
|
||||
const note = document.getElementById("accounting-note");
|
||||
const form = document.getElementById("accounting-form");
|
||||
date.onchange = validateDate;
|
||||
note.onchange = validateNote;
|
||||
form.onsubmit = validateForm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateForm() {
|
||||
let isValid = true;
|
||||
isValid = validateDate() && isValid;
|
||||
isValid = validateCurrencies() && isValid;
|
||||
isValid = validateNote() && isValid;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the date.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateDate() {
|
||||
const field = document.getElementById("accounting-date");
|
||||
const error = document.getElementById("accounting-date-error");
|
||||
field.value = field.value.trim();
|
||||
field.classList.remove("is-invalid");
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the date.");
|
||||
return false;
|
||||
}
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the currency sub-forms.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateCurrencies() {
|
||||
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
|
||||
let isValid = true;
|
||||
isValid = validateCurrenciesReal() && isValid;
|
||||
currencies.forEach(function (currency) {
|
||||
isValid = validateCurrency(currency) && isValid;
|
||||
});
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the currency sub-forms, the validator itself.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateCurrenciesReal() {
|
||||
const field = document.getElementById("accounting-currencies");
|
||||
const error = document.getElementById("accounting-currencies-error");
|
||||
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
|
||||
if (currencies.length === 0) {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please add some currencies.");
|
||||
return false;
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a currency sub-form.
|
||||
*
|
||||
* @param currency {HTMLDivElement} the currency sub-form
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateCurrency(currency) {
|
||||
const prefix = "accounting-currency-" + currency.dataset.index;
|
||||
const debit = document.getElementById(prefix + "-debit");
|
||||
const credit = document.getElementById(prefix + "-credit");
|
||||
let isValid = true;
|
||||
if (debit !== null) {
|
||||
isValid = validateJournalEntries(currency, "debit") && isValid;
|
||||
}
|
||||
if (credit !== null) {
|
||||
isValid = validateJournalEntries(currency, "credit") && isValid;
|
||||
}
|
||||
if (debit !== null && credit !== null) {
|
||||
isValid = validateBalance(currency) && isValid;
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the journal entries in a currency sub-form.
|
||||
*
|
||||
* @param currency {HTMLDivElement} the currency
|
||||
* @param entryType {string} the journal entry type, either "debit" or "credit"
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateJournalEntries(currency, entryType) {
|
||||
const currencyIndex = currency.dataset.index;
|
||||
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
|
||||
let isValid = true;
|
||||
isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid;
|
||||
entries.forEach(function (entry) {
|
||||
isValid = validateJournalEntry(entry) && isValid;
|
||||
})
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the journal entries, the validator itself.
|
||||
*
|
||||
* @param currencyIndex {string} the currency index
|
||||
* @param entryType {string} the journal entry type, either "debit" or "credit"
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateJournalEntriesReal(currencyIndex, entryType) {
|
||||
const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
|
||||
const field = document.getElementById(prefix);
|
||||
const error = document.getElementById(prefix + "-error");
|
||||
const entries = Array.from(document.getElementsByClassName(prefix));
|
||||
if (entries.length === 0) {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please add some journal entries.");
|
||||
return false;
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a journal entry sub-form in a currency sub-form.
|
||||
*
|
||||
* @param entry {HTMLLIElement} the journal entry
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateJournalEntry(entry) {
|
||||
const control = document.getElementById(entry.dataset.prefix + "-control");
|
||||
const error = document.getElementById(entry.dataset.prefix + "-error");
|
||||
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
|
||||
const amount = document.getElementById(entry.dataset.prefix + "-amount");
|
||||
if (accountCode.value === "") {
|
||||
control.classList.add("is-invalid");
|
||||
error.innerText = A_("Please select the account.");
|
||||
return false;
|
||||
}
|
||||
if (amount.value === "") {
|
||||
control.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the amount.");
|
||||
return false;
|
||||
}
|
||||
control.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the balance of a currency sub-form.
|
||||
*
|
||||
* @param currency {HTMLDivElement} the currency sub-form
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateBalance(currency) {
|
||||
const prefix = "accounting-currency-" + currency.dataset.index;
|
||||
const control = document.getElementById(prefix + "-control");
|
||||
const error = document.getElementById(prefix + "-error");
|
||||
const debit = document.getElementById(prefix + "-debit");
|
||||
const debitAmounts = Array.from(document.getElementsByClassName(prefix + "-debit-amount"));
|
||||
const credit = document.getElementById(prefix + "-credit");
|
||||
const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount"));
|
||||
if (debit !== null && credit !== null) {
|
||||
let debitTotal = new Decimal("0");
|
||||
debitAmounts.forEach(function (amount) {
|
||||
if (amount.value !== "") {
|
||||
debitTotal = debitTotal.plus(new Decimal(amount.value));
|
||||
}
|
||||
});
|
||||
let creditTotal = new Decimal("0");
|
||||
creditAmounts.forEach(function (amount) {
|
||||
if (amount.value !== "") {
|
||||
creditTotal = creditTotal.plus(new Decimal(amount.value));
|
||||
}
|
||||
});
|
||||
if (!debitTotal.equals(creditTotal)) {
|
||||
control.classList.add("is-invalid");
|
||||
error.innerText = A_("The totals of the debit and credit amounts do not match.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
control.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validates the note.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateNote() {
|
||||
const field = document.getElementById("accounting-note");
|
||||
const error = document.getElementById("accounting-note-error");
|
||||
field.value = field.value
|
||||
.replace(/^\s*\n/, "")
|
||||
.trimEnd();
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
37
src/accounting/static/js/transaction-order.js
Normal file
37
src/accounting/static/js/transaction-order.js
Normal file
@ -0,0 +1,37 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* transaction-order.js: The JavaScript for the transaction order
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/26
|
||||
*/
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const list = document.getElementById("accounting-order-list");
|
||||
if (list !== null) {
|
||||
const onReorder = function () {
|
||||
const accounts = Array.from(list.children);
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
||||
no.value = String(i + 1);
|
||||
}
|
||||
};
|
||||
initializeDragAndDropReordering(list, onReorder);
|
||||
}
|
||||
});
|
@ -26,48 +26,48 @@ First written: 2023/1/31
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-3">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
</a>
|
||||
{% if can_edit_accounting() %}
|
||||
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}">
|
||||
{% 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)|append_next }}">
|
||||
<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 can_edit_accounting() %}
|
||||
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#delete-modal">
|
||||
{% 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 can_edit_accounting() %}
|
||||
<div class="d-md-none material-fab">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}">
|
||||
{% 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 can_edit_accounting() %}
|
||||
<form id="delete-form" action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
|
||||
<input id="csrf_token" type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% 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="delete-modal" tabindex="-1" aria-labelledby="delete-model-label" aria-hidden="true">
|
||||
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="delete-model-label">{{ A_("Delete Account Confirmation") }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
<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?") }}
|
||||
@ -82,12 +82,12 @@ First written: 2023/1/31
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="account col-sm-6">
|
||||
<div class="account-title">{{ obj.title }}</div>
|
||||
<div class="account-code">{{ obj.code }}</div>
|
||||
{% if obj.is_offset_needed %}
|
||||
<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_("Offset needed") }}</span>
|
||||
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="small text-secondary fst-italic">
|
||||
|
@ -23,6 +23,6 @@ First written: 2023/2/1
|
||||
|
||||
{% block header %}{% block title %}{{ A_("%(account)s Settings", account=account) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block back_url %}{{ url_for("accounting.account.detail", account=account)|inherit_next }}{% endblock %}
|
||||
{% block back_url %}{{ url_for("accounting.account.detail", account=account)|accounting_inherit_next }}{% endblock %}
|
||||
|
||||
{% block action_url %}{{ url_for("accounting.account.update", account=account) }}{% endblock %}
|
||||
|
@ -34,16 +34,16 @@ First written: 2023/2/1
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form id="account-form" action="{% block action_url %}{% endblock %}" method="post">
|
||||
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
|
||||
{{ form.csrf_token }}
|
||||
{% if "next" in request.args %}
|
||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||
{% endif %}
|
||||
<div class="form-floating mb-3">
|
||||
<input id="account-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
|
||||
<div id="account-base" class="form-control clickable material-text-field {% if form.base_code.data %} not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#select-base-modal">
|
||||
<label id="account-base-label" class="form-label" for="account-base">{{ A_("Base account") }}</label>
|
||||
<div id="account-base-content">
|
||||
<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)") }}
|
||||
@ -53,19 +53,19 @@ First written: 2023/2/1
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="account-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
|
||||
<div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input id="account-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
|
||||
<label class="form-label" for="account-title">{{ A_("Title") }}</label>
|
||||
<div id="account-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
|
||||
<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="account-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
|
||||
<label class="form-check-label" for="account-is-offset-needed">
|
||||
{{ A_("The entries in the account need offsets.") }}
|
||||
<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>
|
||||
|
||||
@ -76,43 +76,44 @@ First written: 2023/2/1
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-md-none material-fab">
|
||||
<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="select-base-modal" tabindex="-1" aria-labelledby="select-base-model-label" aria-hidden="true">
|
||||
<div class="modal fade" id="accounting-base-selector-modal" tabindex="-1" aria-labelledby="accounting-base-selector-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="base-selector-model-label">{{ A_("Select Base Account") }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
<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="select-base-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search">
|
||||
<label class="input-group-text" for="select-base-query">
|
||||
<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 class="list-group list-base-selector">
|
||||
<ul id="accounting-base-option-list" class="list-group accounting-selector-list">
|
||||
{% for base in form.base_options %}
|
||||
<li id="list-group-item-base-{{ base.code }}" class="list-group-item list-group-item-base clickable" data-code="{{ base.code }}" data-content="{{ base }}">
|
||||
<li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
|
||||
{{ base }}
|
||||
</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="btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
|
||||
<button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
|
||||
{% else %}
|
||||
<button id="btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
|
||||
<button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -21,20 +21,20 @@ First written: 2023/1/30
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Account Management") }}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-2">
|
||||
{% if can_edit_accounting() %}
|
||||
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|append_next }}">
|
||||
<i class="fa-solid fa-user-plus"></i>
|
||||
<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">
|
||||
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
|
||||
<label for="search-input" class="search-label">
|
||||
<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") }}
|
||||
@ -43,9 +43,21 @@ First written: 2023/1/30
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if can_edit_accounting() %}
|
||||
<div class="d-md-none material-fab">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.create")|append_next }}">
|
||||
<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>
|
||||
@ -56,10 +68,10 @@ First written: 2023/1/30
|
||||
|
||||
<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)|append_next }}">
|
||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
|
||||
{{ item }}
|
||||
{% if item.is_offset_needed %}
|
||||
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
|
||||
{% if item.is_pay_off_needed %}
|
||||
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
@ -31,24 +31,24 @@ First written: 2023/2/2
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-3">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if base.accounts|length > 1 and can_edit_accounting() %}
|
||||
{% if base.accounts|length > 1 and accounting_can_edit() %}
|
||||
<form action="{{ url_for("accounting.account.sort", base=base) }}" method="post">
|
||||
<input id="csrf_token" type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if "next" in request.args %}
|
||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||
{% endif %}
|
||||
<ul id="account-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
|
||||
<ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
|
||||
{% for account in base.accounts|sort(attribute="no") %}
|
||||
<li class="list-group-item d-flex justify-content-between" data-id="{{ account.id }}">
|
||||
<input id="account-order-{{ account.id }}-no" type="hidden" name="{{ account.id }}-no" value="{{ loop.index }}">
|
||||
<input id="accounting-order-{{ account.id }}-no" type="hidden" name="{{ account.id }}-no" value="{{ loop.index }}">
|
||||
<div>
|
||||
<span id="account-order-{{ account.id }}-code">{{ account.code }}</span>
|
||||
<span id="accounting-order-{{ account.id }}-code">{{ account.code }}</span>
|
||||
{{ account.title }}
|
||||
</div>
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
@ -63,7 +63,7 @@ First written: 2023/2/2
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-md-none material-fab">
|
||||
<div class="d-md-none accounting-material-fab">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<i class="fa-solid fa-floppy-disk"></i>
|
||||
</button>
|
||||
|
@ -26,19 +26,19 @@ First written: 2023/2/1
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-3">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="account col-sm-6">
|
||||
<div class="account-title">{{ obj.title }}</div>
|
||||
<div class="account-code">{{ obj.code }}</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)|append_next }}">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
|
||||
{{ account }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
@ -21,14 +21,14 @@ First written: 2023/1/26
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Base Account Managements") }}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<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">
|
||||
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
|
||||
<label for="search-input" class="search-label">
|
||||
<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") }}
|
||||
@ -42,7 +42,7 @@ First written: 2023/1/26
|
||||
|
||||
<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)|append_next }}">
|
||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|accounting_append_next }}">
|
||||
{{ item }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
28
src/accounting/templates/accounting/currency/create.html
Normal file
28
src/accounting/templates/accounting/currency/create.html
Normal file
@ -0,0 +1,28 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
create.html: The currency creation form
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/2/6
|
||||
#}
|
||||
{% extends "accounting/currency/include/form.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %}
|
||||
|
||||
{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}
|
90
src/accounting/templates/accounting/currency/detail.html
Normal file
90
src/accounting/templates/accounting/currency/detail.html
Normal file
@ -0,0 +1,90 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
detail.html: The currency detail
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/2/6
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-3">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
</a>
|
||||
{% if accounting_can_edit() %}
|
||||
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
{{ A_("Settings") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if accounting_can_edit() %}
|
||||
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
{{ A_("Delete") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if accounting_can_edit() %}
|
||||
<div class="d-md-none accounting-material-fab">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if accounting_can_edit() %}
|
||||
<form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if "next" in request.args %}
|
||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||
{% endif %}
|
||||
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Currency Confirmation") }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ A_("Do you really want to delete this currency?") }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
||||
<button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="accounting-card col-sm-6">
|
||||
<div class="accounting-card-title">{{ obj.name }}</div>
|
||||
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||
<div class="small text-secondary fst-italic">
|
||||
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
|
||||
<div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
30
src/accounting/templates/accounting/currency/edit.html
Normal file
30
src/accounting/templates/accounting/currency/edit.html
Normal file
@ -0,0 +1,30 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
edit.html: The currency edit form
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/2/6
|
||||
#}
|
||||
{% extends "accounting/currency/include/form.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("%(currency)s Settings", currency=currency) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block back_url %}{{ url_for("accounting.currency.detail", currency=currency)|accounting_inherit_next }}{% endblock %}
|
||||
|
||||
{% block action_url %}{{ url_for("accounting.currency.update", currency=currency) }}{% endblock %}
|
||||
|
||||
{% block original_code %}{{ currency.code }}{% endblock %}
|
@ -0,0 +1,68 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
form.html: The currency form
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/2/6
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/currency-form.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group btn-actions mb-3">
|
||||
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
|
||||
{{ form.csrf_token }}
|
||||
{% if "next" in request.args %}
|
||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||
{% endif %}
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ "" if form.code.data is none else form.code.data }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
|
||||
<label class="form-label" for="accounting-code">{{ A_("Code") }}</label>
|
||||
<div id="accounting-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ "" if form.name.data is none else form.name.data }}" placeholder=" " required="required">
|
||||
<label class="form-label" for="accounting-name">{{ A_("Name") }}</label>
|
||||
<div id="accounting-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<div class="d-none d-md-block">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<i class="fa-solid fa-floppy-disk"></i>
|
||||
{{ A_("Save") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-md-none accounting-material-fab">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<i class="fa-solid fa-floppy-disk"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
80
src/accounting/templates/accounting/currency/list.html
Normal file
80
src/accounting/templates/accounting/currency/list.html
Normal file
@ -0,0 +1,80 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
list.html: The currency list
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/2/6
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-2 d-none d-md-inline-flex">
|
||||
{% if accounting_can_edit() %}
|
||||
<a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
{{ A_("New") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
|
||||
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
||||
<label for="accounting-search-desktop" class="accounting-search-label">
|
||||
<button type="submit">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
</button>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="btn-group mb-2 d-md-none">
|
||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
|
||||
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
|
||||
<label for="accounting-search-mobile" class="accounting-search-label">
|
||||
<button type="submit">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
{{ A_("Search") }}
|
||||
</button>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if accounting_can_edit() %}
|
||||
<div class="d-md-none accounting-material-fab">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if list %}
|
||||
{% include "accounting/include/pagination.html" %}
|
||||
|
||||
<div class="list-group">
|
||||
{% for item in list %}
|
||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.currency.detail", currency=item)|accounting_append_next }}">
|
||||
{{ item }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -19,13 +19,20 @@ nav.html: The navigation menu for the accounting application.
|
||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
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>
|
||||
@ -38,6 +45,13 @@ First written: 2023/1/26
|
||||
{{ 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-02-03 10:15+0800\n"
|
||||
"PO-Revision-Date: 2023-02-03 10:16+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,30 +19,49 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.11.0\n"
|
||||
|
||||
#: src/accounting/models.py:575
|
||||
#, python-format
|
||||
msgid "Cash Expense Transaction#%(id)s"
|
||||
msgstr "現金支出傳票#%(id)s"
|
||||
|
||||
#: src/accounting/models.py:577
|
||||
#, python-format
|
||||
msgid "Cash Income Transaction#%(id)s"
|
||||
msgstr "現金收入傳票#%(id)s"
|
||||
|
||||
#: src/accounting/models.py:578
|
||||
#, python-format
|
||||
msgid "Transfer Transaction#%(id)s"
|
||||
msgstr "轉帳傳票#%(id)s"
|
||||
|
||||
#: src/accounting/account/forms.py:41
|
||||
msgid "The base account does not exist."
|
||||
msgstr "沒有這個基本科目。"
|
||||
|
||||
#: src/accounting/account/forms.py:50
|
||||
#: src/accounting/static/js/account-form.js:110
|
||||
#: 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:55
|
||||
#: 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:88
|
||||
#: src/accounting/templates/accounting/account/list.html:62
|
||||
msgid "Offset needed"
|
||||
#: 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:88
|
||||
#: src/accounting/account/views.py:89
|
||||
msgid "The account is added successfully"
|
||||
msgstr "科目加好了。"
|
||||
|
||||
#: src/accounting/account/views.py:143
|
||||
#: src/accounting/account/views.py:142
|
||||
msgid "The account was not modified."
|
||||
msgstr "科目未異動。"
|
||||
|
||||
@ -50,22 +69,93 @@ msgstr "科目未異動。"
|
||||
msgid "The account is updated successfully."
|
||||
msgstr "科目存好了。"
|
||||
|
||||
#: src/accounting/account/views.py:167
|
||||
#: src/accounting/account/views.py:165
|
||||
msgid "The account is deleted successfully."
|
||||
msgstr "科目刪掉了"
|
||||
|
||||
#: src/accounting/account/views.py:194
|
||||
#: src/accounting/account/views.py:192 src/accounting/transaction/views.py:210
|
||||
msgid "The order was not modified."
|
||||
msgstr "順序未異動。"
|
||||
|
||||
#: src/accounting/account/views.py:197
|
||||
#: src/accounting/account/views.py:195 src/accounting/transaction/views.py:213
|
||||
msgid "The order is updated successfully."
|
||||
msgstr "順序存好了。"
|
||||
|
||||
#: src/accounting/static/js/account-form.js:130
|
||||
#: 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 "新增科目"
|
||||
@ -74,43 +164,74 @@ msgstr "新增科目"
|
||||
#: 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:40
|
||||
#: src/accounting/templates/accounting/account/detail.html:41
|
||||
#: src/accounting/templates/accounting/transaction/include/detail.html:41
|
||||
msgid "Order"
|
||||
msgstr "次序"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:44
|
||||
#: 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:67
|
||||
#: src/accounting/templates/accounting/account/detail.html:69
|
||||
msgid "Delete Account Confirmation"
|
||||
msgstr "科目刪除確認"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:71
|
||||
#: 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:74
|
||||
#: src/accounting/templates/accounting/account/include/form.html:111
|
||||
#: 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:75
|
||||
#: 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:92
|
||||
#: 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:93
|
||||
#: 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 "更新"
|
||||
|
||||
@ -119,22 +240,64 @@ msgstr "更新"
|
||||
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/templates/accounting/account/list.html:68
|
||||
#: src/accounting/templates/accounting/account/list.html:47
|
||||
#: src/accounting/templates/accounting/currency/list.html:47
|
||||
#: src/accounting/templates/accounting/transaction/list.html:69
|
||||
msgid "Search for Mobile"
|
||||
msgstr "行動版檢索"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:109
|
||||
#: src/accounting/templates/accounting/account/list.html:80
|
||||
#: src/accounting/templates/accounting/account/order.html:81
|
||||
#: src/accounting/templates/accounting/base-account/list.html:51
|
||||
#: src/accounting/templates/accounting/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 "沒有資料。"
|
||||
|
||||
@ -144,7 +307,11 @@ 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:61
|
||||
#: 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 "儲存"
|
||||
|
||||
@ -161,15 +328,17 @@ msgid "Title"
|
||||
msgstr "標題"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:68
|
||||
msgid "The entries in the account need offsets."
|
||||
msgid "The entries in the account need pay-off."
|
||||
msgstr "帳目要逐筆核銷。"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:90
|
||||
msgid "Select Base Account"
|
||||
msgstr "選擇基本科目"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:113
|
||||
#: src/accounting/templates/accounting/account/include/form.html:115
|
||||
#: 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 "清除"
|
||||
|
||||
@ -177,23 +346,255 @@ msgstr "清除"
|
||||
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/templates/accounting/include/nav.html:32
|
||||
msgid "Transactions"
|
||||
msgstr "傳票"
|
||||
|
||||
#: src/accounting/templates/accounting/include/nav.html:38
|
||||
msgid "Accounts"
|
||||
msgstr "科目"
|
||||
|
||||
#: src/accounting/templates/accounting/include/nav.html:38
|
||||
#: src/accounting/templates/accounting/include/nav.html:44
|
||||
msgid "Base Accounts"
|
||||
msgstr "基本科目"
|
||||
|
||||
#: src/accounting/utils/pagination.py:146
|
||||
msgid "Previous"
|
||||
msgstr "前一頁"
|
||||
#: src/accounting/templates/accounting/include/nav.html:50
|
||||
msgid "Currencies"
|
||||
msgstr "貨幣"
|
||||
|
||||
#: src/accounting/utils/pagination.py:194
|
||||
#: src/accounting/templates/accounting/include/pagination.html:23
|
||||
msgid "Page navigation"
|
||||
msgstr "分頁瀏覽"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/list.html:28
|
||||
msgid "Transaction Management"
|
||||
msgstr "傳票管理"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/list.html:42
|
||||
msgid "Cash Expense"
|
||||
msgstr "現金支出"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/list.html:46
|
||||
msgid "Cash Income"
|
||||
msgstr "現金收入"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:32
|
||||
#: src/accounting/templates/accounting/transaction/list.html:51
|
||||
msgid "Transfer"
|
||||
msgstr "轉帳"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/order.html:29
|
||||
#, python-format
|
||||
msgid "Transactions on %(date)s"
|
||||
msgstr "%(date)s的傳票"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/expense/create.html:24
|
||||
msgid "Add a New Cash Expense Transaction"
|
||||
msgstr "新增現金支出傳票"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/expense/detail.html:30
|
||||
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:45
|
||||
#: src/accounting/templates/accounting/transaction/include/form.html:52
|
||||
#: src/accounting/templates/accounting/transaction/income/detail.html:30
|
||||
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:45
|
||||
msgid "Content"
|
||||
msgstr "內容"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/expense/detail.html:46
|
||||
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:68
|
||||
#: src/accounting/templates/accounting/transaction/income/detail.html:46
|
||||
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:68
|
||||
#: src/accounting/templates/accounting/transaction/transfer/detail.html:49
|
||||
#: src/accounting/templates/accounting/transaction/transfer/detail.html:75
|
||||
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:70
|
||||
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:110
|
||||
msgid "Total"
|
||||
msgstr "合計"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/expense/edit.html:24
|
||||
#: src/accounting/templates/accounting/transaction/income/edit.html:24
|
||||
#: src/accounting/templates/accounting/transaction/transfer/edit.html:24
|
||||
#, python-format
|
||||
msgid "Editing %(txn)s"
|
||||
msgstr "編輯%(txn)s"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:32
|
||||
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:32
|
||||
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:32
|
||||
msgid "Currency"
|
||||
msgstr "貨幣"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:26
|
||||
msgid "Cash expense"
|
||||
msgstr "現金支出"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:29
|
||||
msgid "Cash income"
|
||||
msgstr "現金收入"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:26
|
||||
msgid "Select Credit Account"
|
||||
msgstr "選擇貸方科目科目"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:44
|
||||
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:44
|
||||
msgid "More…"
|
||||
msgstr "更多…"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:26
|
||||
msgid "Select Debit Account"
|
||||
msgstr "選擇借方科目"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/detail.html:69
|
||||
msgid "Delete Transaction Confirmation"
|
||||
msgstr "傳票刪除確認"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/detail.html:73
|
||||
msgid "Do you really want to delete this transaction?"
|
||||
msgstr "你確定要刪掉這張傳票嗎?"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:27
|
||||
msgid "Journal Entry Content"
|
||||
msgstr "分錄內容"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:33
|
||||
msgid "Account"
|
||||
msgstr "科目"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:41
|
||||
msgid "Summary"
|
||||
msgstr "摘要"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:47
|
||||
msgid "Amount"
|
||||
msgstr "金額"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/form.html:46
|
||||
msgid "Date"
|
||||
msgstr "日期"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/include/form.html:69
|
||||
msgid "Note"
|
||||
msgstr "備註"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/income/create.html:24
|
||||
msgid "Add a New Cash Income Transaction"
|
||||
msgstr "新增現金收入傳票"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/transfer/create.html:24
|
||||
msgid "Add a New Transfer Transaction"
|
||||
msgstr "新增轉帳傳票"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/transfer/detail.html:33
|
||||
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:47
|
||||
msgid "Debit"
|
||||
msgstr "借方"
|
||||
|
||||
#: src/accounting/templates/accounting/transaction/transfer/detail.html:59
|
||||
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:87
|
||||
msgid "Credit"
|
||||
msgstr "貸方"
|
||||
|
||||
#: src/accounting/transaction/forms.py:44
|
||||
msgid "Please select the currency."
|
||||
msgstr "請選擇貨幣。"
|
||||
|
||||
#: src/accounting/transaction/forms.py:67
|
||||
msgid "The currency does not exist."
|
||||
msgstr "沒有這個貨幣。"
|
||||
|
||||
#: src/accounting/transaction/forms.py:88
|
||||
msgid "The account does not exist."
|
||||
msgstr "沒有這個科目。"
|
||||
|
||||
#: src/accounting/transaction/forms.py:99
|
||||
msgid "Please fill in a positive amount."
|
||||
msgstr "金額請填正數。"
|
||||
|
||||
#: src/accounting/transaction/forms.py:113
|
||||
msgid "This account is not for debit entries."
|
||||
msgstr "科目不是借方科目。"
|
||||
|
||||
#: src/accounting/transaction/forms.py:200
|
||||
msgid "This account is not for credit entries."
|
||||
msgstr "科目不是貸方科目。"
|
||||
|
||||
#: src/accounting/transaction/template.py:71
|
||||
msgid "Today"
|
||||
msgstr "今天"
|
||||
|
||||
#: src/accounting/transaction/template.py:73
|
||||
msgid "Yesterday"
|
||||
msgstr "昨天"
|
||||
|
||||
#: src/accounting/transaction/template.py:75
|
||||
msgid "Tomorrow"
|
||||
msgstr "明天"
|
||||
|
||||
#: src/accounting/transaction/template.py:79
|
||||
msgid "The day before yesterday"
|
||||
msgstr "前天"
|
||||
|
||||
#: src/accounting/transaction/template.py:81
|
||||
msgid "The day after tomorrow"
|
||||
msgstr "後天"
|
||||
|
||||
#: src/accounting/transaction/views.py:104
|
||||
msgid "The transaction is added successfully"
|
||||
msgstr "傳票加好了。"
|
||||
|
||||
#: src/accounting/transaction/views.py:158
|
||||
msgid "The transaction was not modified."
|
||||
msgstr "傳票未異動。"
|
||||
|
||||
#: src/accounting/transaction/views.py:163
|
||||
msgid "The transaction is updated successfully."
|
||||
msgstr "傳票存好了。"
|
||||
|
||||
#: src/accounting/transaction/views.py:179
|
||||
msgid "The transaction is deleted successfully."
|
||||
msgstr "傳票刪掉了"
|
||||
|
||||
#: src/accounting/utils/pagination.py:206
|
||||
msgctxt "Pagination|"
|
||||
msgid "Previous"
|
||||
msgstr "上一頁"
|
||||
|
||||
#: src/accounting/utils/pagination.py:255
|
||||
msgctxt "Pagination|"
|
||||
msgid "Next"
|
||||
msgstr "下一頁"
|
||||
|
||||
|
50
src/accounting/utils/flash_errors.py
Normal file
50
src/accounting/utils/flash_errors.py
Normal file
@ -0,0 +1,50 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The utility to flash all errors from the forms.
|
||||
|
||||
This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
|
||||
from flask import flash
|
||||
from flask_wtf import FlaskForm
|
||||
|
||||
|
||||
def flash_form_errors(form: FlaskForm) -> None:
|
||||
"""Flash all errors from a form recursively.
|
||||
|
||||
:param form: The form.
|
||||
:return: None.
|
||||
"""
|
||||
__flash_errors(form.errors)
|
||||
|
||||
|
||||
def __flash_errors(error: t.Any) -> None:
|
||||
"""Flash all errors recursively.
|
||||
|
||||
:param error: The errors.
|
||||
:return: None.
|
||||
"""
|
||||
if isinstance(error, dict):
|
||||
for key in error:
|
||||
__flash_errors(error[key])
|
||||
elif isinstance(error, list):
|
||||
for e in error:
|
||||
__flash_errors(e)
|
||||
else:
|
||||
flash(error, "error")
|
@ -14,7 +14,7 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The utilities to handle the next URL.
|
||||
"""The utilities to handle the next URI.
|
||||
|
||||
This module should not import any other module from the application.
|
||||
|
||||
@ -22,7 +22,7 @@ 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
|
||||
from flask import request, Blueprint
|
||||
|
||||
|
||||
def append_next(uri: str) -> str:
|
||||
@ -68,8 +68,19 @@ def __set_next(uri: str, next_uri: str) -> str:
|
||||
"""
|
||||
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 = [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),
|
||||
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),
|
||||
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),
|
||||
if not self.is_paged:
|
||||
return []
|
||||
return [Link(str(x), self.__uri_size(x),
|
||||
is_current=x == self.page_size)
|
||||
for x in self.AVAILABLE_PAGE_SIZES]
|
||||
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,5 +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
|
||||
app.jinja_env.globals["can_edit_accounting"] = __can_edit_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
|
||||
|
@ -22,7 +22,7 @@ This module should not import any other module from the application.
|
||||
import typing as t
|
||||
from secrets import randbelow
|
||||
|
||||
from accounting.database import db
|
||||
from accounting import db
|
||||
|
||||
|
||||
def new_id(cls: t.Type):
|
||||
|
@ -19,6 +19,7 @@
|
||||
This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
import re
|
||||
|
||||
|
||||
def strip_text(s: str | None) -> str | None:
|
||||
@ -29,4 +30,17 @@ def strip_text(s: str | None) -> str | None:
|
||||
"""
|
||||
if s is None:
|
||||
return None
|
||||
return s.strip()
|
||||
s = s.strip()
|
||||
return s if s != "" else None
|
||||
|
||||
|
||||
def strip_multiline_text(s: str | None) -> str | None:
|
||||
"""The filter to strip a piece of multi-line text.
|
||||
|
||||
:param s: The text input string.
|
||||
:return: The filtered string.
|
||||
"""
|
||||
if s is None:
|
||||
return None
|
||||
s = re.sub(r"^\s*\n", "", s.rstrip())
|
||||
return s if s != "" else None
|
||||
|
@ -23,6 +23,7 @@ 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)
|
||||
@ -49,10 +50,11 @@ class AbstractUserUtils(t.Generic[T], ABC):
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def current_user(self) -> T:
|
||||
"""Returns the current user.
|
||||
def current_user(self) -> T | None:
|
||||
"""Returns the currently logged-in user.
|
||||
|
||||
:return: The current user.
|
||||
:return: The currently logged-in user, or None if the user has not
|
||||
logged in
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@ -72,9 +74,9 @@ class AbstractUserUtils(t.Generic[T], ABC):
|
||||
|
||||
__user_utils: AbstractUserUtils
|
||||
"""The user utilities."""
|
||||
user_cls: t.Type[Model]
|
||||
user_cls: t.Type[Model] = Model
|
||||
"""The user class."""
|
||||
user_pk_column: sa.Column
|
||||
user_pk_column: sa.Column = sa.Column(sa.Integer)
|
||||
"""The primary key column of the user class."""
|
||||
|
||||
|
||||
@ -95,7 +97,7 @@ def get_current_user_pk() -> int:
|
||||
|
||||
:return: The primary key value of the currently logged-in user.
|
||||
"""
|
||||
return __user_utils.get_pk(__user_utils.current_user)
|
||||
return __user_utils.get_pk(get_current_user())
|
||||
|
||||
|
||||
def has_user(username: str) -> bool:
|
||||
@ -114,3 +116,14 @@ def get_user_pk(username: str) -> int:
|
||||
: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")
|
||||
|
@ -14,11 +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 account management.
|
||||
|
||||
"""
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
|
||||
import httpx
|
||||
import sqlalchemy as sa
|
||||
@ -26,8 +26,43 @@ from click.testing import Result
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from testlib import UserClient, get_user_client
|
||||
from test_site import create_app
|
||||
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):
|
||||
@ -43,7 +78,6 @@ class AccountCommandTestCase(unittest.TestCase):
|
||||
|
||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||
with self.app.app_context():
|
||||
from accounting.database import db
|
||||
from accounting.models import BaseAccount, Account, AccountL10n
|
||||
result: Result
|
||||
result = runner.invoke(args="init-db")
|
||||
@ -97,7 +131,6 @@ class AccountTestCase(unittest.TestCase):
|
||||
|
||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||
with self.app.app_context():
|
||||
from accounting.database import db
|
||||
from accounting.models import BaseAccount, Account, AccountL10n
|
||||
result: Result
|
||||
result = runner.invoke(args="init-db")
|
||||
@ -109,26 +142,24 @@ class AccountTestCase(unittest.TestCase):
|
||||
Account.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
editor: UserClient = get_user_client(self, self.app, "editor")
|
||||
self.client: httpx.Client = editor.client
|
||||
self.csrf_token: str = editor.csrf_token
|
||||
self.client, self.csrf_token = get_client(self.app, "editor")
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "1111 title"})
|
||||
"base_code": cash.base_code,
|
||||
"title": cash.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1111-001")
|
||||
f"{PREFIX}/{cash.code}")
|
||||
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1112",
|
||||
"title": "1112 title"})
|
||||
"base_code": bank.base_code,
|
||||
"title": bank.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1112-001")
|
||||
f"{PREFIX}/{bank.code}")
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
@ -136,47 +167,47 @@ class AccountTestCase(unittest.TestCase):
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account
|
||||
client, csrf_token = get_client(self.app, "nobody")
|
||||
response: httpx.Response
|
||||
nobody: UserClient = get_user_client(self, self.app, "nobody")
|
||||
|
||||
response = nobody.client.get("/accounting/accounts")
|
||||
response = client.get(PREFIX)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.get("/accounting/accounts/1111-001")
|
||||
response = client.get(f"{PREFIX}/{cash.code}")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.get("/accounting/accounts/create")
|
||||
response = client.get(f"{PREFIX}/create")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": nobody.csrf_token,
|
||||
"base_code": "1113",
|
||||
"title": "1113 title"})
|
||||
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 = nobody.client.get("/accounting/accounts/1111-001/edit")
|
||||
response = client.get(f"{PREFIX}/{cash.code}/edit")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.post("/accounting/accounts/1111-001/update",
|
||||
data={"csrf_token": nobody.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "1111 title #2"})
|
||||
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 = nobody.client.post("/accounting/accounts/1111-001/delete",
|
||||
data={"csrf_token": nobody.csrf_token})
|
||||
response = client.post(f"{PREFIX}/{cash.code}/delete",
|
||||
data={"csrf_token": csrf_token})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.get("/accounting/accounts/bases/1111")
|
||||
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
with self.app.app_context():
|
||||
account_id: int = Account.find_by_code("1112-001").id
|
||||
bank_id: int = Account.find_by_code(bank.code).id
|
||||
|
||||
response = nobody.client.post("/accounting/accounts/bases/1112",
|
||||
data={"csrf_token": nobody.csrf_token,
|
||||
"next": "/next",
|
||||
f"{account_id}-no": "5"})
|
||||
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:
|
||||
@ -185,47 +216,47 @@ class AccountTestCase(unittest.TestCase):
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account
|
||||
client, csrf_token = get_client(self.app, "viewer")
|
||||
response: httpx.Response
|
||||
viewer: UserClient = get_user_client(self, self.app, "viewer")
|
||||
|
||||
response = viewer.client.get("/accounting/accounts")
|
||||
response = client.get(PREFIX)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = viewer.client.get("/accounting/accounts/1111-001")
|
||||
response = client.get(f"{PREFIX}/{cash.code}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = viewer.client.get("/accounting/accounts/create")
|
||||
response = client.get(f"{PREFIX}/create")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = viewer.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": viewer.csrf_token,
|
||||
"base_code": "1113",
|
||||
"title": "1113 title"})
|
||||
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 = viewer.client.get("/accounting/accounts/1111-001/edit")
|
||||
response = client.get(f"{PREFIX}/{cash.code}/edit")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = viewer.client.post("/accounting/accounts/1111-001/update",
|
||||
data={"csrf_token": viewer.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "1111 title #2"})
|
||||
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 = viewer.client.post("/accounting/accounts/1111-001/delete",
|
||||
data={"csrf_token": viewer.csrf_token})
|
||||
response = client.post(f"{PREFIX}/{cash.code}/delete",
|
||||
data={"csrf_token": csrf_token})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = viewer.client.get("/accounting/accounts/bases/1111")
|
||||
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
with self.app.app_context():
|
||||
account_id: int = Account.find_by_code("1112-001").id
|
||||
bank_id: int = Account.find_by_code(bank.code).id
|
||||
|
||||
response = viewer.client.post("/accounting/accounts/bases/1112",
|
||||
data={"csrf_token": viewer.csrf_token,
|
||||
"next": "/next",
|
||||
f"{account_id}-no": "5"})
|
||||
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:
|
||||
@ -236,125 +267,452 @@ class AccountTestCase(unittest.TestCase):
|
||||
from accounting.models import Account
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.get("/accounting/accounts")
|
||||
response = self.client.get(PREFIX)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get("/accounting/accounts/1111-001")
|
||||
response = self.client.get(f"{PREFIX}/{cash.code}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get("/accounting/accounts/create")
|
||||
response = self.client.get(f"{PREFIX}/create")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1113",
|
||||
"title": "1113 title"})
|
||||
"base_code": stock.base_code,
|
||||
"title": stock.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1113-001")
|
||||
f"{PREFIX}/{stock.code}")
|
||||
|
||||
response = self.client.get("/accounting/accounts/1111-001/edit")
|
||||
response = self.client.get(f"{PREFIX}/{cash.code}/edit")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post("/accounting/accounts/1111-001/update",
|
||||
response = self.client.post(f"{PREFIX}/{cash.code}/update",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "1111 title #2"})
|
||||
"base_code": cash.base_code,
|
||||
"title": f"{cash.title}-2"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1111-001")
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}")
|
||||
|
||||
response = self.client.post("/accounting/accounts/1111-001/delete",
|
||||
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"],
|
||||
"/accounting/accounts")
|
||||
self.assertEqual(response.headers["Location"], PREFIX)
|
||||
|
||||
response = self.client.get("/accounting/accounts/bases/1111")
|
||||
response = self.client.get(f"{PREFIX}/bases/{cash.base_code}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
with self.app.app_context():
|
||||
account_id: int = Account.find_by_code("1112-001").id
|
||||
bank_id: int = Account.find_by_code(bank.code).id
|
||||
|
||||
response = self.client.post("/accounting/accounts/bases/1112",
|
||||
response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": "/next",
|
||||
f"{account_id}-no": "5"})
|
||||
"next": NEXT_URI,
|
||||
f"{bank_id}-no": "5"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], "/next")
|
||||
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||
|
||||
def test_change_base(self) -> None:
|
||||
"""Tests to change the base account.
|
||||
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.database import db
|
||||
from accounting.models import Account
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
for i in range(2, 6):
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "Title #1"})
|
||||
"title": "Title"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1111-002")
|
||||
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "Title #1"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1111-003")
|
||||
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1112",
|
||||
"title": "Title #1"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1112-002")
|
||||
f"{PREFIX}/1111-00{i}")
|
||||
|
||||
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("1112-001").id
|
||||
id_5: int = Account.find_by_code("1112-002").id
|
||||
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("/accounting/accounts/1111-002/update",
|
||||
response = self.client.post(f"{PREFIX}/1111-005/update",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1112",
|
||||
"title": "Account #1"})
|
||||
"title": "Title"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1112-003")
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/1112-003")
|
||||
|
||||
with self.app.app_context():
|
||||
self.assertEqual(db.session.get(Account, id_1).code, "1111-001")
|
||||
self.assertEqual(db.session.get(Account, id_2).code, "1112-003")
|
||||
self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
|
||||
self.assertEqual(db.session.get(Account, id_4).code, "1112-001")
|
||||
self.assertEqual(db.session.get(Account, id_5).code, "1112-002")
|
||||
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.database import db
|
||||
from accounting.models import Account
|
||||
response: httpx.Response
|
||||
|
||||
for i in range(2, 6):
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
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"/accounting/accounts/1111-00{i}")
|
||||
f"{PREFIX}/1111-00{i}")
|
||||
|
||||
# Normal reorder
|
||||
with self.app.app_context():
|
||||
@ -364,16 +722,16 @@ class AccountTestCase(unittest.TestCase):
|
||||
id_4: int = Account.find_by_code("1111-004").id
|
||||
id_5: int = Account.find_by_code("1111-005").id
|
||||
|
||||
response = self.client.post("/accounting/accounts/bases/1111",
|
||||
response = self.client.post(f"{PREFIX}/bases/1111",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": "/next",
|
||||
"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"], f"/next")
|
||||
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
self.assertEqual(db.session.get(Account, id_1).code, "1111-004")
|
||||
@ -391,14 +749,14 @@ class AccountTestCase(unittest.TestCase):
|
||||
db.session.get(Account, id_5).no = 9
|
||||
db.session.commit()
|
||||
|
||||
response = self.client.post("/accounting/accounts/bases/1111",
|
||||
response = self.client.post(f"{PREFIX}/bases/1111",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": "/next",
|
||||
"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"], f"/next")
|
||||
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
self.assertEqual(db.session.get(Account, id_1).code, "1111-003")
|
||||
|
@ -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,13 @@ from click.testing import Result
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from testlib import UserClient, get_user_client
|
||||
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):
|
||||
@ -53,19 +59,33 @@ class BaseAccountCommandTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import BaseAccount, BaseAccountL10n
|
||||
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()
|
||||
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}
|
||||
|
||||
self.assertEqual(len(accounts), len(data))
|
||||
for account in accounts:
|
||||
self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
|
||||
self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
|
||||
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):
|
||||
@ -88,22 +108,18 @@ class BaseAccountTestCase(unittest.TestCase):
|
||||
result = runner.invoke(args="accounting-init-base")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
self.viewer: UserClient = get_user_client(self, self.app, "viewer")
|
||||
self.editor: UserClient = get_user_client(self, self.app, "editor")
|
||||
self.nobody: UserClient = get_user_client(self, self.app, "nobody")
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
client, csrf_token = get_client(self.app, "nobody")
|
||||
response: httpx.Response
|
||||
nobody: UserClient = get_user_client(self, self.app, "nobody")
|
||||
|
||||
response = nobody.client.get("/accounting/base-accounts")
|
||||
response = client.get(LIST_URI)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.get("/accounting/base-accounts/1111")
|
||||
response = client.get(DETAIL_URI)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_viewer(self) -> None:
|
||||
@ -111,13 +127,13 @@ class BaseAccountTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
client, csrf_token = get_client(self.app, "viewer")
|
||||
response: httpx.Response
|
||||
viewer: UserClient = get_user_client(self, self.app, "viewer")
|
||||
|
||||
response = viewer.client.get("/accounting/base-accounts")
|
||||
response = client.get(LIST_URI)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = viewer.client.get("/accounting/base-accounts/1111")
|
||||
response = client.get(DETAIL_URI)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_editor(self) -> None:
|
||||
@ -125,11 +141,11 @@ class BaseAccountTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
client, csrf_token = get_client(self.app, "editor")
|
||||
response: httpx.Response
|
||||
editor: UserClient = get_user_client(self, self.app, "editor")
|
||||
|
||||
response = editor.client.get("/accounting/base-accounts")
|
||||
response = client.get(LIST_URI)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = editor.client.get("/accounting/base-accounts/1111")
|
||||
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)
|
@ -80,7 +80,7 @@ def create_app(is_testing: bool = False) -> Flask:
|
||||
return auth.User.id
|
||||
|
||||
@property
|
||||
def current_user(self) -> auth.User:
|
||||
def current_user(self) -> auth.User | None:
|
||||
return auth.current_user()
|
||||
|
||||
def get_by_username(self, username: str) -> auth.User | None:
|
||||
@ -91,9 +91,9 @@ def create_app(is_testing: bool = False) -> Flask:
|
||||
return user.id
|
||||
|
||||
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
|
||||
and auth.current_user().username in ["viewer", "editor"]
|
||||
and auth.current_user().username in ["viewer", "editor", "editor2"]
|
||||
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
|
||||
and auth.current_user().username == "editor"
|
||||
and auth.current_user().username in ["editor", "editor2"]
|
||||
accounting.init_app(app, user_utils=UserUtils(),
|
||||
can_view_func=can_view, can_edit_func=can_edit)
|
||||
|
||||
@ -106,7 +106,7 @@ def init_db_command() -> None:
|
||||
"""Initializes the database."""
|
||||
db.create_all()
|
||||
from .auth import User
|
||||
for username in ["viewer", "editor", "nobody"]:
|
||||
for username in ["viewer", "editor", "editor2", "nobody"]:
|
||||
if User.query.filter(User.username == username).first() is None:
|
||||
db.session.add(User(username=username))
|
||||
db.session.commit()
|
||||
|
@ -58,7 +58,8 @@ def login() -> redirect:
|
||||
|
||||
:return: The redirection to the home page.
|
||||
"""
|
||||
if request.form.get("username") not in ["viewer", "editor", "nobody"]:
|
||||
if request.form.get("username") not in ["viewer", "editor", "editor2",
|
||||
"nobody"]:
|
||||
return redirect(url_for("auth.login"))
|
||||
session["user"] = request.form.get("username")
|
||||
return redirect(url_for("home.home"))
|
||||
|
@ -29,8 +29,8 @@ First written: 2023/1/27
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css">
|
||||
{% block styles %}{% endblock %}
|
||||
<script src="{{ url_for("babel_catalog") }}"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/decimal.js/9.0.0/decimal.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/decimal.js-light@2.5.1/decimal.min.js" integrity="sha384-QdsxGEq4Y0erX8WUIsZJDtfoSSyBF6dmNCnzRNYCa2AOM/xzNsyhHu0RbdFBAm+l" crossorigin="anonymous"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
</head>
|
||||
|
@ -29,6 +29,7 @@ First written: 2023/1/27
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
|
||||
<button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
|
||||
<button class="btn btn-primary" type="submit" name="username" value="editor2">{{ _("Editor2") }}</button>
|
||||
<button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
|
||||
</form>
|
||||
|
||||
|
@ -9,8 +9,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
|
||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||
"POT-Creation-Date: 2023-01-28 13:42+0800\n"
|
||||
"PO-Revision-Date: 2023-01-28 13:42+0800\n"
|
||||
"POT-Creation-Date: 2023-02-27 10:07+0800\n"
|
||||
"PO-Revision-Date: 2023-02-27 10:08+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"
|
||||
@ -31,7 +31,7 @@ msgstr "首頁"
|
||||
|
||||
#: tests/test_site/templates/base.html:68
|
||||
msgid "Log Out"
|
||||
msgstr ""
|
||||
msgstr "登出"
|
||||
|
||||
#: tests/test_site/templates/base.html:78
|
||||
#: tests/test_site/templates/login.html:24
|
||||
@ -51,6 +51,10 @@ msgid "Editor"
|
||||
msgstr "記帳者"
|
||||
|
||||
#: tests/test_site/templates/login.html:32
|
||||
msgid "Editor2"
|
||||
msgstr "記帳者2"
|
||||
|
||||
#: tests/test_site/templates/login.html:33
|
||||
msgid "Nobody"
|
||||
msgstr "沒有權限者"
|
||||
|
||||
|
2233
tests/test_transaction.py
Normal file
2233
tests/test_transaction.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user