Compare commits
150 Commits
Author | SHA1 | Date | |
---|---|---|---|
2253ec7e6d | |||
32aa532548 | |||
56138f7de3 | |||
21ef944259 | |||
760f1c2877 | |||
e377eac407 | |||
77787eee9f | |||
03265a1232 | |||
079dc1ab6d | |||
d4fe91ec4a | |||
acc5b4d6ea | |||
19a93cb4c3 | |||
116089d1d2 | |||
50dd6078c7 | |||
9a4531b26c | |||
b1af1d7425 | |||
8f909965a9 | |||
e26af6f3fc | |||
02fffc3400 | |||
d7d6929bf2 | |||
e4cc61552e | |||
d18dd7d4d2 | |||
3251660092 | |||
c1235608d8 | |||
25c45b16ae | |||
78f570b81b | |||
5db13393cc | |||
1e286fbeba | |||
d4b3fe67b9 | |||
5d0757c845 | |||
b69a519904 | |||
122b7b059c | |||
4977847dd8 | |||
b9b197ea27 | |||
884e37fe1b | |||
cc6a73211e | |||
2299b86d0f | |||
6d293a1aac | |||
a2311aee24 | |||
5571c0d01f | |||
98e1bad413 | |||
7ff52d99e6 | |||
cc440a4110 | |||
f5149a0c37 | |||
ca928636fd | |||
4a8297d594 | |||
915e4408e1 | |||
fd9eac06f6 | |||
403942dfc0 | |||
35dc513760 | |||
01861f0b6a | |||
8c10f1e96a | |||
5f7fc0b8e8 | |||
700c179774 | |||
cabe02f7d0 | |||
5ceb9f2e83 | |||
fe1c7669b6 | |||
4eac10981f | |||
c869bccc04 | |||
61c111db69 | |||
34f63c1cdf | |||
a643d9e811 | |||
2239ddfad1 | |||
12fbe36b9a | |||
46e34bb89a | |||
c9453d3023 | |||
fc766724c4 | |||
38c394c0af | |||
67e2b06d37 | |||
be10a8d99e | |||
fbeec600b7 | |||
1a54592d4c | |||
94a527caf2 | |||
0a1bbbdd47 | |||
82b63e4bd4 | |||
e1d1aff0c1 | |||
2e5f9ee01f | |||
f901a0020f | |||
fc2be75c3b | |||
96c131940b | |||
b9435a255b | |||
56045f0faf | |||
08d1e60238 | |||
d88b3ac770 | |||
40e329d37f | |||
23a0721d8d | |||
2b2c665eb6 | |||
954173a2c2 | |||
91e6dc6668 | |||
e9d8a8fcd8 | |||
4c84686395 | |||
61fd1849ed | |||
a67158f8f6 | |||
5c6bfd8b49 | |||
d9ecf51c6d | |||
5d31eb9172 | |||
fadce244c5 | |||
cbe7c6ca6d | |||
b03938fb2e | |||
8061a23fdc | |||
cd8a480cd0 | |||
b8b87714eb | |||
bf2f96621d | |||
2d771f04be | |||
3a12472d4b | |||
d5a686a5d8 | |||
690f89e29a | |||
82a6a53dc4 | |||
cdd31b1047 | |||
5bad949cfa | |||
3826646d06 | |||
74071e8997 | |||
3ce34803f3 | |||
232f73172f | |||
ff1bb7142b | |||
7155bf635a | |||
c306ff8009 | |||
b344abce06 | |||
b3c666c872 | |||
6a671cac84 | |||
fe87c3a7de | |||
2013f8cbd9 | |||
2325842471 | |||
c80e58b049 | |||
be0ae5eba4 | |||
2b84f64554 | |||
0a658a76e8 | |||
50dc79d865 | |||
8e5377a416 | |||
4299fd6fbd | |||
1d6a53f7cd | |||
bb2993b0c0 | |||
f6946c1165 | |||
8e219d8006 | |||
53565eb9e6 | |||
965e78d8ad | |||
74b81d3e23 | |||
a0fba6387f | |||
d28bdf2064 | |||
edf0c00e34 | |||
107d161379 | |||
f2c184f769 | |||
b45986ecfc | |||
a2c2452ec5 | |||
5194258b48 | |||
3fe7eb41ac | |||
7fb9e2f0a1 | |||
1d443f7b76 | |||
6ad4fba9cd | |||
3dda6531b5 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -38,3 +38,4 @@ excludes
|
|||||||
*.mo
|
*.mo
|
||||||
zh_Hans
|
zh_Hans
|
||||||
test_temp.py
|
test_temp.py
|
||||||
|
dummy.js
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
exclude src/accounting/static/js/dummy.js
|
||||||
include src/accounting/translations/*
|
include src/accounting/translations/*
|
||||||
include src/accounting/translations/*/LC_MESSAGES/*
|
include src/accounting/translations/*/LC_MESSAGES/*
|
||||||
include docs/*
|
include docs/*
|
||||||
@ -22,7 +23,9 @@ include docs/source/*
|
|||||||
include docs/source/_static/*
|
include docs/source/_static/*
|
||||||
include docs/source/_templates/*
|
include docs/source/_templates/*
|
||||||
include tests/*
|
include tests/*
|
||||||
|
exclude tests/test_temp.py
|
||||||
include tests/test_site/*
|
include tests/test_site/*
|
||||||
|
include tests/test_site/static/*
|
||||||
include tests/test_site/templates/*
|
include tests/test_site/templates/*
|
||||||
include tests/test_site/translations/*
|
include tests/test_site/translations/*
|
||||||
include tests/test_site/translations/*/LC_MESSAGES/*
|
include tests/test_site/translations/*/LC_MESSAGES/*
|
||||||
|
45
docs/source/accounting.journal_entry.forms.rst
Normal file
45
docs/source/accounting.journal_entry.forms.rst
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
accounting.journal\_entry.forms package
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.journal\_entry.forms.currency module
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.forms.currency
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.forms.journal\_entry module
|
||||||
|
-----------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.forms.journal_entry
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.forms.line\_item module
|
||||||
|
-------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.forms.line_item
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.forms.reorder module
|
||||||
|
----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.forms.reorder
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.forms
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
46
docs/source/accounting.journal_entry.rst
Normal file
46
docs/source/accounting.journal_entry.rst
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
accounting.journal\_entry package
|
||||||
|
=================================
|
||||||
|
|
||||||
|
Subpackages
|
||||||
|
-----------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 4
|
||||||
|
|
||||||
|
accounting.journal_entry.forms
|
||||||
|
accounting.journal_entry.utils
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.journal\_entry.converters module
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.converters
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.template\_filters module
|
||||||
|
--------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.template_filters
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.views module
|
||||||
|
--------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.views
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
53
docs/source/accounting.journal_entry.utils.rst
Normal file
53
docs/source/accounting.journal_entry.utils.rst
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
accounting.journal\_entry.utils package
|
||||||
|
=======================================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.journal\_entry.utils.account\_option module
|
||||||
|
------------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.utils.account_option
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.utils.description\_editor module
|
||||||
|
----------------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.utils.description_editor
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.utils.offset\_alias module
|
||||||
|
----------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.utils.offset_alias
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.utils.operators module
|
||||||
|
------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.utils.operators
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.journal\_entry.utils.original\_line\_items module
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.utils.original_line_items
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.journal_entry.utils
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
@ -10,8 +10,8 @@ Subpackages
|
|||||||
accounting.account
|
accounting.account
|
||||||
accounting.base_account
|
accounting.base_account
|
||||||
accounting.currency
|
accounting.currency
|
||||||
|
accounting.journal_entry
|
||||||
accounting.report
|
accounting.report
|
||||||
accounting.transaction
|
|
||||||
accounting.utils
|
accounting.utils
|
||||||
|
|
||||||
Submodules
|
Submodules
|
||||||
|
@ -1,61 +0,0 @@
|
|||||||
accounting.transaction package
|
|
||||||
==============================
|
|
||||||
|
|
||||||
Submodules
|
|
||||||
----------
|
|
||||||
|
|
||||||
accounting.transaction.converters module
|
|
||||||
----------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.transaction.converters
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.transaction.forms module
|
|
||||||
-----------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.transaction.forms
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.transaction.operators module
|
|
||||||
---------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.transaction.operators
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.transaction.summary\_editor module
|
|
||||||
---------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.transaction.summary_editor
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.transaction.template\_filters module
|
|
||||||
-----------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.transaction.template_filters
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.transaction.views module
|
|
||||||
-----------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.transaction.views
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
Module contents
|
|
||||||
---------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.transaction
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
@ -4,6 +4,14 @@ accounting.utils package
|
|||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
accounting.utils.cast module
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.cast
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.utils.flash\_errors module
|
accounting.utils.flash\_errors module
|
||||||
-------------------------------------
|
-------------------------------------
|
||||||
|
|
||||||
@ -12,6 +20,14 @@ accounting.utils.flash\_errors module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.journal\_entry\_types module
|
||||||
|
---------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.journal_entry_types
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.utils.next\_uri module
|
accounting.utils.next\_uri module
|
||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
@ -60,14 +76,6 @@ accounting.utils.strip\_text module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.utils.txn\_types module
|
|
||||||
----------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.utils.txn_types
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.utils.user module
|
accounting.utils.user module
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
|
|||||||
project = 'Mia! Accounting Flask'
|
project = 'Mia! Accounting Flask'
|
||||||
copyright = '2023, imacat'
|
copyright = '2023, imacat'
|
||||||
author = 'imacat'
|
author = 'imacat'
|
||||||
release = '0.4.0'
|
release = '0.7.0'
|
||||||
|
|
||||||
# -- General configuration ---------------------------------------------------
|
# -- General configuration ---------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
name = mia-accounting-flask
|
name = mia-accounting-flask
|
||||||
version = 0.5.0
|
version = 0.7.0
|
||||||
author = imacat
|
author = imacat
|
||||||
author_email = imacat@mail.imacat.idv.tw
|
author_email = imacat@mail.imacat.idv.tw
|
||||||
description = The Mia! Accounting Flask project.
|
description = The Mia! Accounting Flask project.
|
||||||
|
@ -17,13 +17,12 @@
|
|||||||
"""The accounting application.
|
"""The accounting application.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, Blueprint
|
from flask import Flask, Blueprint
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
from accounting.utils.user import AbstractUserUtils
|
from accounting.utils.user import UserUtilityInterface
|
||||||
|
|
||||||
db: SQLAlchemy = SQLAlchemy()
|
db: SQLAlchemy = SQLAlchemy()
|
||||||
"""The database instance."""
|
"""The database instance."""
|
||||||
@ -31,19 +30,13 @@ data_dir: Path = Path(__file__).parent / "data"
|
|||||||
"""The data directory."""
|
"""The data directory."""
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask, user_utils: AbstractUserUtils,
|
def init_app(app: Flask, user_utils: UserUtilityInterface,
|
||||||
url_prefix: str = "/accounting",
|
url_prefix: str = "/accounting") -> None:
|
||||||
can_view_func: t.Callable[[], bool] | None = None,
|
|
||||||
can_edit_func: t.Callable[[], bool] | None = None) -> None:
|
|
||||||
"""Initialize the application.
|
"""Initialize the application.
|
||||||
|
|
||||||
:param app: The Flask application.
|
:param app: The Flask application.
|
||||||
:param user_utils: The user utilities.
|
:param user_utils: The user utilities.
|
||||||
:param url_prefix: The URL prefix of the accounting application.
|
:param url_prefix: The URL prefix of the accounting application.
|
||||||
:param can_view_func: A callback that returns whether the current user can
|
|
||||||
view the accounting data.
|
|
||||||
:param can_edit_func: A callback that returns whether the current user can
|
|
||||||
edit the accounting data.
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
# The database instance must be set before loading everything
|
# The database instance must be set before loading everything
|
||||||
@ -73,7 +66,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
|||||||
locale.init_app(app, bp)
|
locale.init_app(app, bp)
|
||||||
|
|
||||||
from .utils import permission
|
from .utils import permission
|
||||||
permission.init_app(bp, can_view_func, can_edit_func)
|
permission.init_app(bp, user_utils)
|
||||||
|
|
||||||
from .utils import next_uri
|
from .utils import next_uri
|
||||||
next_uri.init_app(bp)
|
next_uri.init_app(bp)
|
||||||
@ -87,8 +80,8 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
|||||||
from . import currency
|
from . import currency
|
||||||
currency.init_app(app, bp)
|
currency.init_app(app, bp)
|
||||||
|
|
||||||
from . import transaction
|
from . import journal_entry
|
||||||
transaction.init_app(app, bp)
|
journal_entry.init_app(app, bp)
|
||||||
|
|
||||||
from . import report
|
from . import report
|
||||||
report.init_app(app, bp)
|
report.init_app(app, bp)
|
||||||
|
@ -18,7 +18,6 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
from secrets import randbelow
|
from secrets import randbelow
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@ -30,7 +29,7 @@ from accounting.utils.user import has_user, get_user_pk
|
|||||||
|
|
||||||
AccountData = tuple[int, str, int, str, str, str, bool]
|
AccountData = tuple[int, str, int, str, str, str, bool]
|
||||||
"""The format of the account data, as a list of (ID, base account code, number,
|
"""The format of the account data, as a list of (ID, base account code, number,
|
||||||
English, Traditional Chinese, Simplified Chinese, is-offset-needed) tuples."""
|
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
|
||||||
|
|
||||||
|
|
||||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||||
@ -93,14 +92,37 @@ def init_accounts_command(username: str) -> None:
|
|||||||
data: list[AccountData] = []
|
data: list[AccountData] = []
|
||||||
for base in bases_to_add:
|
for base in bases_to_add:
|
||||||
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
||||||
is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \
|
is_need_offset: bool = __is_need_offset(base.code)
|
||||||
else False
|
|
||||||
data.append((get_new_id(), base.code, 1, base.title_l10n,
|
data.append((get_new_id(), base.code, 1, base.title_l10n,
|
||||||
l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed))
|
l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset))
|
||||||
__add_accounting_accounts(data, creator_pk)
|
__add_accounting_accounts(data, creator_pk)
|
||||||
click.echo(F"{len(data)} added. Accounting accounts initialized.")
|
click.echo(F"{len(data)} added. Accounting accounts initialized.")
|
||||||
|
|
||||||
|
|
||||||
|
def __is_need_offset(base_code: str) -> bool:
|
||||||
|
"""Checks that whether journal entry line items in the account need offset.
|
||||||
|
|
||||||
|
:param base_code: The code of the base account.
|
||||||
|
:return: True if journal entry line items in the account need offset, or
|
||||||
|
False otherwise.
|
||||||
|
"""
|
||||||
|
# Assets
|
||||||
|
if base_code[0] == "1":
|
||||||
|
if base_code[:3] in {"113", "114", "118", "184"}:
|
||||||
|
return True
|
||||||
|
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
|
||||||
|
"1581", "1611", "1851", ""}:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
# Liabilities
|
||||||
|
if base_code[0] == "2":
|
||||||
|
if base_code in {"2111", "2114", "2284", "2293"}:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
# Only assets and liabilities need offset
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
|
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
|
||||||
-> None:
|
-> None:
|
||||||
"""Adds the accounts.
|
"""Adds the accounts.
|
||||||
@ -113,7 +135,7 @@ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
|
|||||||
base_code=x[1],
|
base_code=x[1],
|
||||||
no=x[2],
|
no=x[2],
|
||||||
title_l10n=x[3],
|
title_l10n=x[3],
|
||||||
is_offset_needed=x[6],
|
is_need_offset=x[6],
|
||||||
created_by_id=creator_pk,
|
created_by_id=creator_pk,
|
||||||
updated_by_id=creator_pk)
|
updated_by_id=creator_pk)
|
||||||
for x in data]
|
for x in data]
|
||||||
|
@ -53,6 +53,20 @@ class BaseAccountAvailable:
|
|||||||
"The base account is not available."))
|
"The base account is not available."))
|
||||||
|
|
||||||
|
|
||||||
|
class NoOffsetNominalAccount:
|
||||||
|
"""The validator to check nominal account is not to be offset."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
|
||||||
|
assert isinstance(form, AccountForm)
|
||||||
|
if not field.data:
|
||||||
|
return
|
||||||
|
if form.base_code.data is None:
|
||||||
|
return
|
||||||
|
if form.base_code.data[0] not in {"1", "2", "3"}:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"A nominal account does not need offset."))
|
||||||
|
|
||||||
|
|
||||||
class AccountForm(FlaskForm):
|
class AccountForm(FlaskForm):
|
||||||
"""The form to create or edit an account."""
|
"""The form to create or edit an account."""
|
||||||
base_code = StringField(
|
base_code = StringField(
|
||||||
@ -66,8 +80,9 @@ class AccountForm(FlaskForm):
|
|||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
|
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
|
||||||
"""The title."""
|
"""The title."""
|
||||||
is_offset_needed = BooleanField()
|
is_need_offset = BooleanField(
|
||||||
"""Whether the the entries of this account need offset."""
|
validators=[NoOffsetNominalAccount()])
|
||||||
|
"""Whether the the journal entry line items of this account need offset."""
|
||||||
|
|
||||||
def populate_obj(self, obj: Account) -> None:
|
def populate_obj(self, obj: Account) -> None:
|
||||||
"""Populates the form data into an account object.
|
"""Populates the form data into an account object.
|
||||||
@ -87,7 +102,10 @@ class AccountForm(FlaskForm):
|
|||||||
obj.base_code = self.base_code.data
|
obj.base_code = self.base_code.data
|
||||||
obj.no = count + 1
|
obj.no = count + 1
|
||||||
obj.title = self.title.data
|
obj.title = self.title.data
|
||||||
obj.is_offset_needed = self.is_offset_needed.data
|
if self.base_code.data[0] in {"1", "2", "3"}:
|
||||||
|
obj.is_need_offset = self.is_need_offset.data
|
||||||
|
else:
|
||||||
|
obj.is_need_offset = False
|
||||||
if is_new:
|
if is_new:
|
||||||
current_user_pk: int = get_current_user_pk()
|
current_user_pk: int = get_current_user_pk()
|
||||||
obj.created_by_id = current_user_pk
|
obj.created_by_id = current_user_pk
|
||||||
|
@ -40,15 +40,15 @@ def get_account_query() -> list[Account]:
|
|||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
l10n: list[AccountL10n] = AccountL10n.query\
|
l10n: list[AccountL10n] = AccountL10n.query\
|
||||||
.filter(AccountL10n.title.contains(k)).all()
|
.filter(AccountL10n.title.icontains(k)).all()
|
||||||
l10n_matches: set[str] = {x.account_id for x in l10n}
|
l10n_matches: set[str] = {x.account_id for x in l10n}
|
||||||
sub_conditions: list[sa.BinaryExpression] \
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
= [Account.base_code.contains(k),
|
= [Account.base_code.contains(k),
|
||||||
Account.title_l10n.contains(k),
|
Account.title_l10n.icontains(k),
|
||||||
code.contains(k),
|
code.contains(k),
|
||||||
Account.id.in_(l10n_matches)]
|
Account.id.in_(l10n_matches)]
|
||||||
if k in gettext("Need offset"):
|
if k in gettext("Needs Offset"):
|
||||||
sub_conditions.append(Account.is_offset_needed)
|
sub_conditions.append(Account.is_need_offset)
|
||||||
conditions.append(sa.or_(*sub_conditions))
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
|
|
||||||
return Account.query.filter(*conditions)\
|
return Account.query.filter(*conditions)\
|
||||||
|
@ -27,6 +27,7 @@ from werkzeug.datastructures import ImmutableMultiDict
|
|||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import lazy_gettext
|
from accounting.locale import lazy_gettext
|
||||||
from accounting.models import Account, BaseAccount
|
from accounting.models import Account, BaseAccount
|
||||||
|
from accounting.utils.cast import s
|
||||||
from accounting.utils.flash_errors import flash_form_errors
|
from accounting.utils.flash_errors import flash_form_errors
|
||||||
from accounting.utils.next_uri import inherit_next, or_next
|
from accounting.utils.next_uri import inherit_next, or_next
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
@ -86,7 +87,7 @@ def add_account() -> redirect:
|
|||||||
form.populate_obj(account)
|
form.populate_obj(account)
|
||||||
db.session.add(account)
|
db.session.add(account)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The account is added successfully"), "success")
|
flash(s(lazy_gettext("The account is added successfully")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(account)))
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
|
|
||||||
|
|
||||||
@ -138,12 +139,12 @@ def update_account(account: Account) -> redirect:
|
|||||||
with db.session.no_autoflush:
|
with db.session.no_autoflush:
|
||||||
form.populate_obj(account)
|
form.populate_obj(account)
|
||||||
if not account.is_modified:
|
if not account.is_modified:
|
||||||
flash(lazy_gettext("The account was not modified."), "success")
|
flash(s(lazy_gettext("The account was not modified.")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(account)))
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
account.updated_by_id = get_current_user_pk()
|
account.updated_by_id = get_current_user_pk()
|
||||||
account.updated_at = sa.func.now()
|
account.updated_at = sa.func.now()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The account is updated successfully."), "success")
|
flash(s(lazy_gettext("The account is updated successfully.")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(account)))
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
|
|
||||||
|
|
||||||
@ -159,7 +160,7 @@ def delete_account(account: Account) -> redirect:
|
|||||||
account.delete()
|
account.delete()
|
||||||
sort_accounts_in(account.base_code, account.id)
|
sort_accounts_in(account.base_code, account.id)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The account is deleted successfully."), "success")
|
flash(s(lazy_gettext("The account is deleted successfully.")), "success")
|
||||||
return redirect(or_next(__get_list_uri()))
|
return redirect(or_next(__get_list_uri()))
|
||||||
|
|
||||||
|
|
||||||
@ -186,10 +187,10 @@ def sort_accounts(base: BaseAccount) -> redirect:
|
|||||||
form: AccountReorderForm = AccountReorderForm(base)
|
form: AccountReorderForm = AccountReorderForm(base)
|
||||||
form.save_order()
|
form.save_order()
|
||||||
if not form.is_modified:
|
if not form.is_modified:
|
||||||
flash(lazy_gettext("The order was not modified."), "success")
|
flash(s(lazy_gettext("The order was not modified.")), "success")
|
||||||
return redirect(or_next(__get_list_uri()))
|
return redirect(or_next(__get_list_uri()))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The order is updated successfully."), "success")
|
flash(s(lazy_gettext("The order is updated successfully.")), "success")
|
||||||
return redirect(or_next(__get_list_uri()))
|
return redirect(or_next(__get_list_uri()))
|
||||||
|
|
||||||
|
|
||||||
|
@ -35,10 +35,10 @@ def get_base_account_query() -> list[BaseAccount]:
|
|||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
|
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
|
||||||
.filter(BaseAccountL10n.title.contains(k)).all()
|
.filter(BaseAccountL10n.title.icontains(k)).all()
|
||||||
l10n_matches: set[str] = {x.account_code for x in l10n}
|
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||||
conditions.append(sa.or_(BaseAccount.code.contains(k),
|
conditions.append(sa.or_(BaseAccount.code.contains(k),
|
||||||
BaseAccount.title_l10n.contains(k),
|
BaseAccount.title_l10n.icontains(k),
|
||||||
BaseAccount.code.in_(l10n_matches)))
|
BaseAccount.code.in_(l10n_matches)))
|
||||||
return BaseAccount.query.filter(*conditions)\
|
return BaseAccount.query.filter(*conditions)\
|
||||||
.order_by(BaseAccount.code).all()
|
.order_by(BaseAccount.code).all()
|
||||||
|
@ -17,8 +17,6 @@
|
|||||||
"""The forms for the currency management.
|
"""The forms for the currency management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, ValidationError
|
from wtforms import StringField, ValidationError
|
||||||
from wtforms.validators import DataRequired, Regexp, NoneOf
|
from wtforms.validators import DataRequired, Regexp, NoneOf
|
||||||
@ -30,14 +28,11 @@ from accounting.utils.strip_text import strip_text
|
|||||||
from accounting.utils.user import get_current_user_pk
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
|
||||||
|
|
||||||
class CurrencyForm(FlaskForm):
|
class CodeUnique:
|
||||||
"""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."""
|
"""The validator to check if the code is unique."""
|
||||||
def __call__(self, form: CurrencyForm, field: StringField) -> None:
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, CurrencyForm)
|
||||||
if field.data == "":
|
if field.data == "":
|
||||||
return
|
return
|
||||||
if form.obj_code is not None and form.obj_code == field.data:
|
if form.obj_code is not None and form.obj_code == field.data:
|
||||||
@ -46,6 +41,11 @@ class CurrencyForm(FlaskForm):
|
|||||||
raise ValidationError(lazy_gettext(
|
raise ValidationError(lazy_gettext(
|
||||||
"Code conflicts with another currency."))
|
"Code conflicts with another currency."))
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyForm(FlaskForm):
|
||||||
|
"""The form to create or edit a currency."""
|
||||||
|
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
|
||||||
|
"""The reserved codes that are not available."""
|
||||||
code = StringField(
|
code = StringField(
|
||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
validators=[DataRequired(lazy_gettext("Please fill in the code.")),
|
validators=[DataRequired(lazy_gettext("Please fill in the code.")),
|
||||||
|
@ -35,10 +35,10 @@ def get_currency_query() -> list[Currency]:
|
|||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
||||||
.filter(CurrencyL10n.name.contains(k)).all()
|
.filter(CurrencyL10n.name.icontains(k)).all()
|
||||||
l10n_matches: set[str] = {x.account_code for x in l10n}
|
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||||
conditions.append(sa.or_(Currency.code.contains(k),
|
conditions.append(sa.or_(Currency.code.icontains(k),
|
||||||
Currency.name_l10n.contains(k),
|
Currency.name_l10n.icontains(k),
|
||||||
Currency.code.in_(l10n_matches)))
|
Currency.code.in_(l10n_matches)))
|
||||||
return Currency.query.filter(*conditions)\
|
return Currency.query.filter(*conditions)\
|
||||||
.order_by(Currency.code).all()
|
.order_by(Currency.code).all()
|
||||||
|
@ -27,6 +27,7 @@ from werkzeug.datastructures import ImmutableMultiDict
|
|||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import lazy_gettext
|
from accounting.locale import lazy_gettext
|
||||||
from accounting.models import Currency
|
from accounting.models import Currency
|
||||||
|
from accounting.utils.cast import s
|
||||||
from accounting.utils.flash_errors import flash_form_errors
|
from accounting.utils.flash_errors import flash_form_errors
|
||||||
from accounting.utils.next_uri import inherit_next, or_next
|
from accounting.utils.next_uri import inherit_next, or_next
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
@ -88,7 +89,7 @@ def add_currency() -> redirect:
|
|||||||
form.populate_obj(currency)
|
form.populate_obj(currency)
|
||||||
db.session.add(currency)
|
db.session.add(currency)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The currency is added successfully"), "success")
|
flash(s(lazy_gettext("The currency is added successfully.")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
|
|
||||||
|
|
||||||
@ -141,12 +142,12 @@ def update_currency(currency: Currency) -> redirect:
|
|||||||
with db.session.no_autoflush:
|
with db.session.no_autoflush:
|
||||||
form.populate_obj(currency)
|
form.populate_obj(currency)
|
||||||
if not currency.is_modified:
|
if not currency.is_modified:
|
||||||
flash(lazy_gettext("The currency was not modified."), "success")
|
flash(s(lazy_gettext("The currency was not modified.")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
currency.updated_by_id = get_current_user_pk()
|
currency.updated_by_id = get_current_user_pk()
|
||||||
currency.updated_at = sa.func.now()
|
currency.updated_at = sa.func.now()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The currency is updated successfully."), "success")
|
flash(s(lazy_gettext("The currency is updated successfully.")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
|
|
||||||
|
|
||||||
@ -161,7 +162,7 @@ def delete_currency(currency: Currency) -> redirect:
|
|||||||
"""
|
"""
|
||||||
currency.delete()
|
currency.delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The currency is deleted successfully."), "success")
|
flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
|
||||||
return redirect(or_next(url_for("accounting.currency.list")))
|
return redirect(or_next(url_for("accounting.currency.list")))
|
||||||
|
|
||||||
|
|
||||||
@ -182,4 +183,3 @@ def __get_detail_uri(currency: Currency) -> str:
|
|||||||
:return: The detail URI of the currency.
|
:return: The detail URI of the currency.
|
||||||
"""
|
"""
|
||||||
return url_for("accounting.currency.detail", currency=currency)
|
return url_for("accounting.currency.detail", currency=currency)
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
"""The transaction management.
|
"""The journal entry management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from flask import Flask, Blueprint
|
from flask import Flask, Blueprint
|
||||||
@ -27,11 +27,11 @@ def init_app(app: Flask, bp: Blueprint) -> None:
|
|||||||
:param bp: The blueprint of the accounting application.
|
:param bp: The blueprint of the accounting application.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from .converters import TransactionConverter, TransactionTypeConverter, \
|
from .converters import JournalEntryConverter, JournalEntryTypeConverter, \
|
||||||
DateConverter
|
DateConverter
|
||||||
app.url_map.converters["transaction"] = TransactionConverter
|
app.url_map.converters["journalEntry"] = JournalEntryConverter
|
||||||
app.url_map.converters["transactionType"] = TransactionTypeConverter
|
app.url_map.converters["journalEntryType"] = JournalEntryTypeConverter
|
||||||
app.url_map.converters["date"] = DateConverter
|
app.url_map.converters["date"] = DateConverter
|
||||||
|
|
||||||
from .views import bp as transaction_bp
|
from .views import bp as journal_entry_bp
|
||||||
bp.register_blueprint(transaction_bp, url_prefix="/transactions")
|
bp.register_blueprint(journal_entry_bp, url_prefix="/journal-entries")
|
107
src/accounting/journal_entry/converters.py
Normal file
107
src/accounting/journal_entry/converters.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The path converters for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from flask import abort
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
|
from accounting.models import JournalEntry, JournalEntryLineItem
|
||||||
|
from accounting.utils.journal_entry_types import JournalEntryType
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryConverter(BaseConverter):
|
||||||
|
"""The journal entry converter to convert the journal entry ID from and to
|
||||||
|
the corresponding journal entry in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> JournalEntry:
|
||||||
|
"""Converts a journal entry ID to a journal entry.
|
||||||
|
|
||||||
|
:param value: The journal entry ID.
|
||||||
|
:return: The corresponding journal entry.
|
||||||
|
"""
|
||||||
|
journal_entry: JournalEntry | None = JournalEntry.query\
|
||||||
|
.join(JournalEntryLineItem)\
|
||||||
|
.filter(JournalEntry.id == value)\
|
||||||
|
.options(selectinload(JournalEntry.line_items)
|
||||||
|
.selectinload(JournalEntryLineItem.offsets)
|
||||||
|
.selectinload(JournalEntryLineItem.journal_entry))\
|
||||||
|
.first()
|
||||||
|
if journal_entry is None:
|
||||||
|
abort(404)
|
||||||
|
return journal_entry
|
||||||
|
|
||||||
|
def to_url(self, value: JournalEntry) -> str:
|
||||||
|
"""Converts a journal entry to its ID.
|
||||||
|
|
||||||
|
:param value: The journal entry.
|
||||||
|
:return: The ID.
|
||||||
|
"""
|
||||||
|
return str(value.id)
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryTypeConverter(BaseConverter):
|
||||||
|
"""The journal entry converter to convert the journal entry type ID from
|
||||||
|
and to the corresponding journal entry type in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> JournalEntryType:
|
||||||
|
"""Converts a journal entry ID to a journal entry.
|
||||||
|
|
||||||
|
:param value: The journal entry ID.
|
||||||
|
:return: The corresponding journal entry type.
|
||||||
|
"""
|
||||||
|
type_dict: dict[str, JournalEntryType] \
|
||||||
|
= {x.value: x for x in JournalEntryType}
|
||||||
|
journal_entry_type: JournalEntryType | None = type_dict.get(value)
|
||||||
|
if journal_entry_type is None:
|
||||||
|
abort(404)
|
||||||
|
return journal_entry_type
|
||||||
|
|
||||||
|
def to_url(self, value: JournalEntryType) -> str:
|
||||||
|
"""Converts a journal entry type to its ID.
|
||||||
|
|
||||||
|
:param value: The journal entry type.
|
||||||
|
:return: The ID.
|
||||||
|
"""
|
||||||
|
return str(value.value)
|
||||||
|
|
||||||
|
|
||||||
|
class DateConverter(BaseConverter):
|
||||||
|
"""The date converter to convert the ISO date from and to the
|
||||||
|
corresponding date in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> date:
|
||||||
|
"""Converts an ISO date to a date.
|
||||||
|
|
||||||
|
:param value: The ISO date.
|
||||||
|
:return: The corresponding date.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
def to_url(self, value: date) -> str:
|
||||||
|
"""Converts a date to its ISO date.
|
||||||
|
|
||||||
|
:param value: The date.
|
||||||
|
:return: The ISO date.
|
||||||
|
"""
|
||||||
|
return value.isoformat()
|
22
src/accounting/journal_entry/forms/__init__.py
Normal file
22
src/accounting/journal_entry/forms/__init__.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The forms for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from .reorder import sort_journal_entries_in, JournalEntryReorderForm
|
||||||
|
from .journal_entry import JournalEntryForm, CashReceiptJournalEntryForm, \
|
||||||
|
CashDisbursementJournalEntryForm, TransferJournalEntryForm
|
303
src/accounting/journal_entry/forms/currency.py
Normal file
303
src/accounting/journal_entry/forms/currency.py
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The currency sub-forms for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask_babel import LazyString
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, ValidationError, FieldList, IntegerField, \
|
||||||
|
BooleanField, FormField
|
||||||
|
from wtforms.validators import DataRequired
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Currency, JournalEntryLineItem
|
||||||
|
from accounting.journal_entry.utils.offset_alias import offset_alias
|
||||||
|
from accounting.utils.cast import be
|
||||||
|
from accounting.utils.strip_text import strip_text
|
||||||
|
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
|
||||||
|
|
||||||
|
CURRENCY_REQUIRED: DataRequired = DataRequired(
|
||||||
|
lazy_gettext("Please select the currency."))
|
||||||
|
"""The validator to check if the currency code is empty."""
|
||||||
|
|
||||||
|
|
||||||
|
class 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 SameCurrencyAsOriginalLineItems:
|
||||||
|
"""The validator to check if the currency is the same as the
|
||||||
|
original line items."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, CurrencyForm)
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
original_line_item_id: set[int] \
|
||||||
|
= {x.original_line_item_id.data
|
||||||
|
for x in form.line_items
|
||||||
|
if x.original_line_item_id.data is not None}
|
||||||
|
if len(original_line_item_id) == 0:
|
||||||
|
return
|
||||||
|
original_line_item_currency_codes: set[str] = set(db.session.scalars(
|
||||||
|
sa.select(JournalEntryLineItem.currency_code)
|
||||||
|
.filter(JournalEntryLineItem.id.in_(original_line_item_id))).all())
|
||||||
|
for currency_code in original_line_item_currency_codes:
|
||||||
|
if field.data != currency_code:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The currency must be the same as the"
|
||||||
|
" original line item."))
|
||||||
|
|
||||||
|
|
||||||
|
class KeepCurrencyWhenHavingOffset:
|
||||||
|
"""The validator to check if the currency is the same when there is
|
||||||
|
offset."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, CurrencyForm)
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
offset: sa.Alias = offset_alias()
|
||||||
|
original_line_items: list[JournalEntryLineItem]\
|
||||||
|
= JournalEntryLineItem.query\
|
||||||
|
.join(offset, be(JournalEntryLineItem.id
|
||||||
|
== offset.c.original_line_item_id),
|
||||||
|
isouter=True)\
|
||||||
|
.filter(JournalEntryLineItem.id
|
||||||
|
.in_({x.id.data for x in form.line_items
|
||||||
|
if x.id.data is not None}))\
|
||||||
|
.group_by(JournalEntryLineItem.id,
|
||||||
|
JournalEntryLineItem.currency_code)\
|
||||||
|
.having(sa.func.count(offset.c.id) > 0).all()
|
||||||
|
for original_line_item in original_line_items:
|
||||||
|
if original_line_item.currency_code != field.data:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The currency must not be changed when there is offset."))
|
||||||
|
|
||||||
|
|
||||||
|
class NeedSomeLineItems:
|
||||||
|
"""The validator to check if there is any line item sub-form."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: FieldList) -> None:
|
||||||
|
if len(field) == 0:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"Please add some line items."))
|
||||||
|
|
||||||
|
|
||||||
|
class IsBalanced:
|
||||||
|
"""The validator to check that the total amount of the debit and credit
|
||||||
|
line items are equal."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
|
||||||
|
assert isinstance(form, TransferCurrencyForm)
|
||||||
|
if len(form.debit) == 0 or len(form.credit) == 0:
|
||||||
|
return
|
||||||
|
if form.debit_total != form.credit_total:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The totals of the debit and credit amounts do not match."))
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyForm(FlaskForm):
|
||||||
|
"""The form to create or edit a currency in a journal entry."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the journal entry."""
|
||||||
|
code = StringField()
|
||||||
|
"""The currency code."""
|
||||||
|
whole_form = BooleanField()
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def line_items(self) -> list[LineItemForm]:
|
||||||
|
"""Returns the line item sub-forms.
|
||||||
|
|
||||||
|
:return: The line item sub-forms.
|
||||||
|
"""
|
||||||
|
line_item_forms: list[LineItemForm] = []
|
||||||
|
if isinstance(self, CashReceiptCurrencyForm):
|
||||||
|
line_item_forms.extend([x.form for x in self.credit])
|
||||||
|
elif isinstance(self, CashDisbursementCurrencyForm):
|
||||||
|
line_item_forms.extend([x.form for x in self.debit])
|
||||||
|
elif isinstance(self, TransferCurrencyForm):
|
||||||
|
line_item_forms.extend([x.form for x in self.debit])
|
||||||
|
line_item_forms.extend([x.form for x in self.credit])
|
||||||
|
return line_item_forms
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_code_locked(self) -> bool:
|
||||||
|
"""Returns whether the currency code should not be changed.
|
||||||
|
|
||||||
|
:return: True if the currency code should not be changed, or False
|
||||||
|
otherwise
|
||||||
|
"""
|
||||||
|
line_item_forms: list[LineItemForm] = self.line_items
|
||||||
|
original_line_item_id: set[int] \
|
||||||
|
= {x.original_line_item_id.data for x in line_item_forms
|
||||||
|
if x.original_line_item_id.data is not None}
|
||||||
|
if len(original_line_item_id) > 0:
|
||||||
|
return True
|
||||||
|
line_item_id: set[int] = {x.id.data for x in line_item_forms
|
||||||
|
if x.id.data is not None}
|
||||||
|
select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
|
||||||
|
.filter(JournalEntryLineItem.original_line_item_id
|
||||||
|
.in_(line_item_id))
|
||||||
|
return db.session.scalar(select) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class CashReceiptCurrencyForm(CurrencyForm):
|
||||||
|
"""The form to create or edit a currency in a
|
||||||
|
cash receipt journal entry."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the journal entry."""
|
||||||
|
code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[CURRENCY_REQUIRED,
|
||||||
|
CurrencyExists(),
|
||||||
|
SameCurrencyAsOriginalLineItems(),
|
||||||
|
KeepCurrencyWhenHavingOffset()])
|
||||||
|
"""The currency code."""
|
||||||
|
credit = FieldList(FormField(CreditLineItemForm),
|
||||||
|
validators=[NeedSomeLineItems()])
|
||||||
|
"""The credit line items."""
|
||||||
|
whole_form = BooleanField()
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the credit line items.
|
||||||
|
|
||||||
|
:return: The total amount of the credit line items.
|
||||||
|
"""
|
||||||
|
return sum([x.amount.data for x in self.credit
|
||||||
|
if x.amount.data is not None])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the credit line item errors, without the errors in their
|
||||||
|
sub-forms.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return [x for x in self.credit.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
|
||||||
|
class CashDisbursementCurrencyForm(CurrencyForm):
|
||||||
|
"""The form to create or edit a currency in a
|
||||||
|
cash disbursement journal entry."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the journal entry."""
|
||||||
|
code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[CURRENCY_REQUIRED,
|
||||||
|
CurrencyExists(),
|
||||||
|
SameCurrencyAsOriginalLineItems(),
|
||||||
|
KeepCurrencyWhenHavingOffset()])
|
||||||
|
"""The currency code."""
|
||||||
|
debit = FieldList(FormField(DebitLineItemForm),
|
||||||
|
validators=[NeedSomeLineItems()])
|
||||||
|
"""The debit line items."""
|
||||||
|
whole_form = BooleanField()
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the debit line items.
|
||||||
|
|
||||||
|
:return: The total amount of the debit line items.
|
||||||
|
"""
|
||||||
|
return sum([x.amount.data for x in self.debit
|
||||||
|
if x.amount.data is not None])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the debit line item errors, without the errors in their
|
||||||
|
sub-forms.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return [x for x in self.debit.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
|
||||||
|
class TransferCurrencyForm(CurrencyForm):
|
||||||
|
"""The form to create or edit a currency in a transfer journal entry."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the journal entry."""
|
||||||
|
code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[CURRENCY_REQUIRED,
|
||||||
|
CurrencyExists(),
|
||||||
|
SameCurrencyAsOriginalLineItems(),
|
||||||
|
KeepCurrencyWhenHavingOffset()])
|
||||||
|
"""The currency code."""
|
||||||
|
debit = FieldList(FormField(DebitLineItemForm),
|
||||||
|
validators=[NeedSomeLineItems()])
|
||||||
|
"""The debit line items."""
|
||||||
|
credit = FieldList(FormField(CreditLineItemForm),
|
||||||
|
validators=[NeedSomeLineItems()])
|
||||||
|
"""The credit line items."""
|
||||||
|
whole_form = BooleanField(validators=[IsBalanced()])
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the debit line items.
|
||||||
|
|
||||||
|
:return: The total amount of the debit line items.
|
||||||
|
"""
|
||||||
|
return sum([x.amount.data for x in self.debit
|
||||||
|
if x.amount.data is not None])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_total(self) -> Decimal:
|
||||||
|
"""Returns the total amount of the credit line items.
|
||||||
|
|
||||||
|
:return: The total amount of the credit line items.
|
||||||
|
"""
|
||||||
|
return sum([x.amount.data for x in self.credit
|
||||||
|
if x.amount.data is not None])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the debit line item errors, without the errors in their
|
||||||
|
sub-forms.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return [x for x in self.debit.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the credit line item errors, without the errors in their
|
||||||
|
sub-forms.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return [x for x in self.credit.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
593
src/accounting/journal_entry/forms/journal_entry.py
Normal file
593
src/accounting/journal_entry/forms/journal_entry.py
Normal file
@ -0,0 +1,593 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The journal entry forms for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
import typing as t
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask_babel import LazyString
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import DateField, FieldList, FormField, TextAreaField, \
|
||||||
|
BooleanField
|
||||||
|
from wtforms.validators import DataRequired, ValidationError
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
|
||||||
|
JournalEntryCurrency
|
||||||
|
from accounting.journal_entry.utils.account_option import AccountOption
|
||||||
|
from accounting.journal_entry.utils.original_line_items import \
|
||||||
|
get_selectable_original_line_items
|
||||||
|
from accounting.journal_entry.utils.description_editor import DescriptionEditor
|
||||||
|
from accounting.utils.random_id import new_id
|
||||||
|
from accounting.utils.strip_text import strip_multiline_text
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
from .currency import CurrencyForm, CashReceiptCurrencyForm, \
|
||||||
|
CashDisbursementCurrencyForm, TransferCurrencyForm
|
||||||
|
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
|
||||||
|
from .reorder import sort_journal_entries_in
|
||||||
|
|
||||||
|
DATE_REQUIRED: DataRequired = DataRequired(
|
||||||
|
lazy_gettext("Please fill in the date."))
|
||||||
|
"""The validator to check if the date is empty."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotBeforeOriginalLineItems:
|
||||||
|
"""The validator to check if the date is not before the
|
||||||
|
original line items."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: DateField) -> None:
|
||||||
|
assert isinstance(form, JournalEntryForm)
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
min_date: dt.date | None = form.min_date
|
||||||
|
if min_date is None:
|
||||||
|
return
|
||||||
|
if field.data < min_date:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The date cannot be earlier than the original line items."))
|
||||||
|
|
||||||
|
|
||||||
|
class NotAfterOffsetItems:
|
||||||
|
"""The validator to check if the date is not after the offset items."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: DateField) -> None:
|
||||||
|
assert isinstance(form, JournalEntryForm)
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
max_date: dt.date | None = form.max_date
|
||||||
|
if max_date is None:
|
||||||
|
return
|
||||||
|
if field.data > max_date:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The date cannot be later than the offset items."))
|
||||||
|
|
||||||
|
|
||||||
|
class NeedSomeCurrencies:
|
||||||
|
"""The validator to check if there is any currency sub-form."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: FieldList) -> None:
|
||||||
|
if len(field) == 0:
|
||||||
|
raise ValidationError(lazy_gettext("Please add some currencies."))
|
||||||
|
|
||||||
|
|
||||||
|
class CannotDeleteOriginalLineItemsWithOffset:
|
||||||
|
"""The validator to check the original line items with offset."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: FieldList) -> None:
|
||||||
|
assert isinstance(form, JournalEntryForm)
|
||||||
|
if form.obj is None:
|
||||||
|
return
|
||||||
|
existing_matched_original_line_item_id: set[int] \
|
||||||
|
= {x.id for x in form.obj.line_items if len(x.offsets) > 0}
|
||||||
|
line_item_id_in_form: set[int] \
|
||||||
|
= {x.id.data for x in form.line_items if x.id.data is not None}
|
||||||
|
for line_item_id in existing_matched_original_line_item_id:
|
||||||
|
if line_item_id not in line_item_id_in_form:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"Line items with offset cannot be deleted."))
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryForm(FlaskForm):
|
||||||
|
"""The base form to create or edit a journal entry."""
|
||||||
|
date = DateField()
|
||||||
|
"""The date."""
|
||||||
|
currencies = FieldList(FormField(CurrencyForm))
|
||||||
|
"""The line items categorized by their currencies."""
|
||||||
|
note = TextAreaField()
|
||||||
|
"""The note."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Constructs a base journal entry form.
|
||||||
|
|
||||||
|
:param args: The arguments.
|
||||||
|
:param kwargs: The keyword arguments.
|
||||||
|
"""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.is_modified: bool = False
|
||||||
|
"""Whether the journal entry is modified during populate_obj()."""
|
||||||
|
self.collector: t.Type[LineItemCollector] = LineItemCollector
|
||||||
|
"""The line item collector. The default is the base abstract
|
||||||
|
collector only to provide the correct type. The subclass forms should
|
||||||
|
provide their own collectors."""
|
||||||
|
self.obj: JournalEntry | None = kwargs.get("obj")
|
||||||
|
"""The journal entry, when editing an existing one."""
|
||||||
|
self._is_need_payable: bool = False
|
||||||
|
"""Whether we need the payable original line items."""
|
||||||
|
self._is_need_receivable: bool = False
|
||||||
|
"""Whether we need the receivable original line items."""
|
||||||
|
self.__original_line_item_options: list[JournalEntryLineItem] | None \
|
||||||
|
= None
|
||||||
|
"""The options of the original line items."""
|
||||||
|
self.__net_balance_exceeded: dict[int, LazyString] | None = None
|
||||||
|
"""The original line items whose net balances were exceeded by the
|
||||||
|
amounts in the line item sub-forms."""
|
||||||
|
for line_item in self.line_items:
|
||||||
|
line_item.journal_entry_form = self
|
||||||
|
|
||||||
|
def populate_obj(self, obj: JournalEntry) -> None:
|
||||||
|
"""Populates the form data into a journal entry object.
|
||||||
|
|
||||||
|
:param obj: The journal entry object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
is_new: bool = obj.id is None
|
||||||
|
if is_new:
|
||||||
|
obj.id = new_id(JournalEntry)
|
||||||
|
self.date: DateField
|
||||||
|
self.__set_date(obj, self.date.data)
|
||||||
|
obj.note = self.note.data
|
||||||
|
|
||||||
|
collector_cls: t.Type[LineItemCollector] = self.collector
|
||||||
|
collector: collector_cls = collector_cls(self, obj)
|
||||||
|
collector.collect()
|
||||||
|
|
||||||
|
to_delete: set[int] = {x.id for x in obj.line_items
|
||||||
|
if x.id not in collector.to_keep}
|
||||||
|
if len(to_delete) > 0:
|
||||||
|
JournalEntryLineItem.query\
|
||||||
|
.filter(JournalEntryLineItem.id.in_(to_delete)).delete()
|
||||||
|
self.is_modified = True
|
||||||
|
|
||||||
|
if is_new or db.session.is_modified(obj):
|
||||||
|
self.is_modified = True
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.created_by_id = current_user_pk
|
||||||
|
obj.updated_by_id = current_user_pk
|
||||||
|
|
||||||
|
@property
|
||||||
|
def line_items(self) -> list[LineItemForm]:
|
||||||
|
"""Collects and returns the line item sub-forms.
|
||||||
|
|
||||||
|
:return: The line item sub-forms.
|
||||||
|
"""
|
||||||
|
line_items: list[LineItemForm] = []
|
||||||
|
for currency in self.currencies:
|
||||||
|
line_items.extend(currency.line_items)
|
||||||
|
return line_items
|
||||||
|
|
||||||
|
def __set_date(self, obj: JournalEntry, new_date: dt.date) -> None:
|
||||||
|
"""Sets the journal entry date and number.
|
||||||
|
|
||||||
|
:param obj: The journal entry object.
|
||||||
|
:param new_date: The new date.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if obj.date is None or obj.date != new_date:
|
||||||
|
if obj.date is not None:
|
||||||
|
sort_journal_entries_in(obj.date, obj.id)
|
||||||
|
if self.max_date is not None and new_date == self.max_date:
|
||||||
|
db_min_no: int | None = db.session.scalar(
|
||||||
|
sa.select(sa.func.min(JournalEntry.no))
|
||||||
|
.filter(JournalEntry.date == new_date))
|
||||||
|
if db_min_no is None:
|
||||||
|
obj.date = new_date
|
||||||
|
obj.no = 1
|
||||||
|
else:
|
||||||
|
obj.date = new_date
|
||||||
|
obj.no = db_min_no - 1
|
||||||
|
sort_journal_entries_in(new_date)
|
||||||
|
else:
|
||||||
|
sort_journal_entries_in(new_date, obj.id)
|
||||||
|
count: int = JournalEntry.query\
|
||||||
|
.filter(JournalEntry.date == new_date).count()
|
||||||
|
obj.date = new_date
|
||||||
|
obj.no = count + 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit_account_options(self) -> list[AccountOption]:
|
||||||
|
"""The selectable debit accounts.
|
||||||
|
|
||||||
|
:return: The selectable debit accounts.
|
||||||
|
"""
|
||||||
|
accounts: list[AccountOption] \
|
||||||
|
= [AccountOption(x) for x in Account.debit()
|
||||||
|
if not (x.code[0] == "2" and x.is_need_offset)]
|
||||||
|
in_use: set[int] = set(db.session.scalars(
|
||||||
|
sa.select(JournalEntryLineItem.account_id)
|
||||||
|
.filter(JournalEntryLineItem.is_debit)
|
||||||
|
.group_by(JournalEntryLineItem.account_id)).all())
|
||||||
|
for account in accounts:
|
||||||
|
account.is_in_use = account.id in in_use
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def credit_account_options(self) -> list[AccountOption]:
|
||||||
|
"""The selectable credit accounts.
|
||||||
|
|
||||||
|
:return: The selectable credit accounts.
|
||||||
|
"""
|
||||||
|
accounts: list[AccountOption] \
|
||||||
|
= [AccountOption(x) for x in Account.credit()
|
||||||
|
if not (x.code[0] == "1" and x.is_need_offset)]
|
||||||
|
in_use: set[int] = set(db.session.scalars(
|
||||||
|
sa.select(JournalEntryLineItem.account_id)
|
||||||
|
.filter(sa.not_(JournalEntryLineItem.is_debit))
|
||||||
|
.group_by(JournalEntryLineItem.account_id)).all())
|
||||||
|
for account in accounts:
|
||||||
|
account.is_in_use = account.id in in_use
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currencies_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns the currency errors, without the errors in their sub-forms.
|
||||||
|
|
||||||
|
:return: The currency errors, without the errors in their sub-forms.
|
||||||
|
"""
|
||||||
|
return [x for x in self.currencies.errors
|
||||||
|
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description_editor(self) -> DescriptionEditor:
|
||||||
|
"""Returns the description editor.
|
||||||
|
|
||||||
|
:return: The description editor.
|
||||||
|
"""
|
||||||
|
return DescriptionEditor()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def original_line_item_options(self) -> list[JournalEntryLineItem]:
|
||||||
|
"""Returns the selectable original line items.
|
||||||
|
|
||||||
|
:return: The selectable original line items.
|
||||||
|
"""
|
||||||
|
if self.__original_line_item_options is None:
|
||||||
|
self.__original_line_item_options \
|
||||||
|
= get_selectable_original_line_items(
|
||||||
|
{x.id.data for x in self.line_items
|
||||||
|
if x.id.data is not None},
|
||||||
|
self._is_need_payable, self._is_need_receivable)
|
||||||
|
return self.__original_line_item_options
|
||||||
|
|
||||||
|
@property
|
||||||
|
def min_date(self) -> dt.date | None:
|
||||||
|
"""Returns the minimal available date.
|
||||||
|
|
||||||
|
:return: The minimal available date.
|
||||||
|
"""
|
||||||
|
original_line_item_id: set[int] \
|
||||||
|
= {x.original_line_item_id.data for x in self.line_items
|
||||||
|
if x.original_line_item_id.data is not None}
|
||||||
|
if len(original_line_item_id) == 0:
|
||||||
|
return None
|
||||||
|
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
|
||||||
|
.join(JournalEntryLineItem)\
|
||||||
|
.filter(JournalEntryLineItem.id.in_(original_line_item_id))
|
||||||
|
return db.session.scalar(select)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_date(self) -> dt.date | None:
|
||||||
|
"""Returns the maximum available date.
|
||||||
|
|
||||||
|
:return: The maximum available date.
|
||||||
|
"""
|
||||||
|
line_item_id: set[int] = {x.id.data for x in self.line_items
|
||||||
|
if x.id.data is not None}
|
||||||
|
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
|
||||||
|
.join(JournalEntryLineItem)\
|
||||||
|
.filter(JournalEntryLineItem.original_line_item_id
|
||||||
|
.in_(line_item_id))
|
||||||
|
return db.session.scalar(select)
|
||||||
|
|
||||||
|
|
||||||
|
T = t.TypeVar("T", bound=JournalEntryForm)
|
||||||
|
"""A journal entry form variant."""
|
||||||
|
|
||||||
|
|
||||||
|
class LineItemCollector(t.Generic[T], ABC):
|
||||||
|
"""The line item collector."""
|
||||||
|
|
||||||
|
def __init__(self, form: T, obj: JournalEntry):
|
||||||
|
"""Constructs the line item collector.
|
||||||
|
|
||||||
|
:param form: The journal entry form.
|
||||||
|
:param obj: The journal entry.
|
||||||
|
"""
|
||||||
|
self.form: T = form
|
||||||
|
"""The journal entry form."""
|
||||||
|
self.__obj: JournalEntry = obj
|
||||||
|
"""The journal entry object."""
|
||||||
|
self.__line_items: list[JournalEntryLineItem] = list(obj.line_items)
|
||||||
|
"""The existing line items."""
|
||||||
|
self.__line_items_by_id: dict[int, JournalEntryLineItem] \
|
||||||
|
= {x.id: x for x in self.__line_items}
|
||||||
|
"""A dictionary from the line item ID to their line items."""
|
||||||
|
self.__no_by_id: dict[int, int] \
|
||||||
|
= {x.id: x.no for x in self.__line_items}
|
||||||
|
"""A dictionary from the line item number to their line items."""
|
||||||
|
self.__currencies: list[JournalEntryCurrency] = obj.currencies
|
||||||
|
"""The currencies in the journal entry."""
|
||||||
|
self._debit_no: int = 1
|
||||||
|
"""The number index for the debit line items."""
|
||||||
|
self._credit_no: int = 1
|
||||||
|
"""The number index for the credit line items."""
|
||||||
|
self.to_keep: set[int] = set()
|
||||||
|
"""The ID of the existing line items to keep."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def collect(self) -> set[int]:
|
||||||
|
"""Collects the line items.
|
||||||
|
|
||||||
|
:return: The ID of the line items to keep.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _add_line_item(self, form: LineItemForm, currency_code: str, no: int) \
|
||||||
|
-> None:
|
||||||
|
"""Composes a line item from the form.
|
||||||
|
|
||||||
|
:param form: The line item form.
|
||||||
|
:param currency_code: The code of the currency.
|
||||||
|
:param no: The number of the line item.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
line_item: JournalEntryLineItem | None \
|
||||||
|
= self.__line_items_by_id.get(form.id.data)
|
||||||
|
if line_item is not None:
|
||||||
|
line_item.currency_code = currency_code
|
||||||
|
form.populate_obj(line_item)
|
||||||
|
line_item.no = no
|
||||||
|
if db.session.is_modified(line_item):
|
||||||
|
self.form.is_modified = True
|
||||||
|
else:
|
||||||
|
line_item = JournalEntryLineItem()
|
||||||
|
line_item.currency_code = currency_code
|
||||||
|
form.populate_obj(line_item)
|
||||||
|
line_item.no = no
|
||||||
|
self.__obj.line_items.append(line_item)
|
||||||
|
self.form.is_modified = True
|
||||||
|
self.to_keep.add(line_item.id)
|
||||||
|
|
||||||
|
def _make_cash_line_item(self, forms: list[LineItemForm], is_debit: bool,
|
||||||
|
currency_code: str, no: int) -> None:
|
||||||
|
"""Composes the cash line item at the other debit or credit of the
|
||||||
|
cash journal entry.
|
||||||
|
|
||||||
|
:param forms: The line item forms in the same currency.
|
||||||
|
:param is_debit: True for a cash receipt journal entry, or False for a
|
||||||
|
cash disbursement journal entry.
|
||||||
|
:param currency_code: The code of the currency.
|
||||||
|
:param no: The number of the line item.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
candidates: list[JournalEntryLineItem] \
|
||||||
|
= [x for x in self.__line_items
|
||||||
|
if x.is_debit == is_debit and x.currency_code == currency_code]
|
||||||
|
line_item: JournalEntryLineItem
|
||||||
|
if len(candidates) > 0:
|
||||||
|
candidates.sort(key=lambda x: x.no)
|
||||||
|
line_item = candidates[0]
|
||||||
|
line_item.account_id = Account.cash().id
|
||||||
|
line_item.description = None
|
||||||
|
line_item.amount = sum([x.amount.data for x in forms])
|
||||||
|
line_item.no = no
|
||||||
|
if db.session.is_modified(line_item):
|
||||||
|
self.form.is_modified = True
|
||||||
|
else:
|
||||||
|
line_item = JournalEntryLineItem()
|
||||||
|
line_item.id = new_id(JournalEntryLineItem)
|
||||||
|
line_item.is_debit = is_debit
|
||||||
|
line_item.currency_code = currency_code
|
||||||
|
line_item.account_id = Account.cash().id
|
||||||
|
line_item.description = None
|
||||||
|
line_item.amount = sum([x.amount.data for x in forms])
|
||||||
|
line_item.no = no
|
||||||
|
self.__obj.line_items.append(line_item)
|
||||||
|
self.form.is_modified = True
|
||||||
|
self.to_keep.add(line_item.id)
|
||||||
|
|
||||||
|
def _sort_line_item_forms(self, forms: list[LineItemForm]) -> None:
|
||||||
|
"""Sorts the line item sub-forms.
|
||||||
|
|
||||||
|
:param forms: The line item sub-forms.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
missing_no: int = 100 if len(self.__no_by_id) == 0 \
|
||||||
|
else max(self.__no_by_id.values()) + 100
|
||||||
|
ord_by_form: dict[LineItemForm, int] \
|
||||||
|
= {forms[i]: i for i in range(len(forms))}
|
||||||
|
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||||
|
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||||
|
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||||
|
missing_no if x.id.data is None else
|
||||||
|
self.__no_by_id.get(x.id.data, missing_no),
|
||||||
|
ord_by_form.get(x)))
|
||||||
|
|
||||||
|
def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None:
|
||||||
|
"""Sorts the currency forms.
|
||||||
|
|
||||||
|
:param forms: The currency forms.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
missing_no: int = len(self.__currencies) + 100
|
||||||
|
no_by_code: dict[str, int] = {self.__currencies[i].code: i
|
||||||
|
for i in range(len(self.__currencies))}
|
||||||
|
ord_by_form: dict[CurrencyForm, int] \
|
||||||
|
= {forms[i]: i for i in range(len(forms))}
|
||||||
|
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||||
|
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||||
|
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||||
|
no_by_code.get(x.code.data, missing_no),
|
||||||
|
ord_by_form.get(x)))
|
||||||
|
|
||||||
|
|
||||||
|
class CashReceiptJournalEntryForm(JournalEntryForm):
|
||||||
|
"""The form to create or edit a cash receipt journal entry."""
|
||||||
|
date = DateField(
|
||||||
|
validators=[DATE_REQUIRED,
|
||||||
|
NotBeforeOriginalLineItems(),
|
||||||
|
NotAfterOffsetItems()])
|
||||||
|
"""The date."""
|
||||||
|
currencies = FieldList(FormField(CashReceiptCurrencyForm), name="currency",
|
||||||
|
validators=[NeedSomeCurrencies()])
|
||||||
|
"""The line items categorized by their currencies."""
|
||||||
|
note = TextAreaField(filters=[strip_multiline_text])
|
||||||
|
"""The note."""
|
||||||
|
whole_form = BooleanField(
|
||||||
|
validators=[CannotDeleteOriginalLineItemsWithOffset()])
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._is_need_receivable = True
|
||||||
|
|
||||||
|
class Collector(LineItemCollector[CashReceiptJournalEntryForm]):
|
||||||
|
"""The line item collector for the cash receipt journal entries."""
|
||||||
|
|
||||||
|
def collect(self) -> None:
|
||||||
|
currencies: list[CashReceiptCurrencyForm] \
|
||||||
|
= [x.form for x in self.form.currencies]
|
||||||
|
self._sort_currency_forms(currencies)
|
||||||
|
for currency in currencies:
|
||||||
|
# The debit cash line item
|
||||||
|
self._make_cash_line_item(list(currency.credit), True,
|
||||||
|
currency.code.data,
|
||||||
|
self._debit_no)
|
||||||
|
self._debit_no = self._debit_no + 1
|
||||||
|
|
||||||
|
# The credit forms
|
||||||
|
credit_forms: list[CreditLineItemForm] \
|
||||||
|
= [x.form for x in currency.credit]
|
||||||
|
self._sort_line_item_forms(credit_forms)
|
||||||
|
for credit_form in credit_forms:
|
||||||
|
self._add_line_item(credit_form, currency.code.data,
|
||||||
|
self._credit_no)
|
||||||
|
self._credit_no = self._credit_no + 1
|
||||||
|
|
||||||
|
self.collector = Collector
|
||||||
|
|
||||||
|
|
||||||
|
class CashDisbursementJournalEntryForm(JournalEntryForm):
|
||||||
|
"""The form to create or edit a cash disbursement journal entry."""
|
||||||
|
date = DateField(
|
||||||
|
validators=[DATE_REQUIRED,
|
||||||
|
NotBeforeOriginalLineItems(),
|
||||||
|
NotAfterOffsetItems()])
|
||||||
|
"""The date."""
|
||||||
|
currencies = FieldList(FormField(CashDisbursementCurrencyForm),
|
||||||
|
name="currency",
|
||||||
|
validators=[NeedSomeCurrencies()])
|
||||||
|
"""The line items categorized by their currencies."""
|
||||||
|
note = TextAreaField(filters=[strip_multiline_text])
|
||||||
|
"""The note."""
|
||||||
|
whole_form = BooleanField(
|
||||||
|
validators=[CannotDeleteOriginalLineItemsWithOffset()])
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._is_need_payable = True
|
||||||
|
|
||||||
|
class Collector(LineItemCollector[CashDisbursementJournalEntryForm]):
|
||||||
|
"""The line item collector for the cash disbursement journal
|
||||||
|
entries."""
|
||||||
|
|
||||||
|
def collect(self) -> None:
|
||||||
|
currencies: list[CashDisbursementCurrencyForm] \
|
||||||
|
= [x.form for x in self.form.currencies]
|
||||||
|
self._sort_currency_forms(currencies)
|
||||||
|
for currency in currencies:
|
||||||
|
# The debit forms
|
||||||
|
debit_forms: list[DebitLineItemForm] \
|
||||||
|
= [x.form for x in currency.debit]
|
||||||
|
self._sort_line_item_forms(debit_forms)
|
||||||
|
for debit_form in debit_forms:
|
||||||
|
self._add_line_item(debit_form, currency.code.data,
|
||||||
|
self._debit_no)
|
||||||
|
self._debit_no = self._debit_no + 1
|
||||||
|
|
||||||
|
# The credit forms
|
||||||
|
self._make_cash_line_item(list(currency.debit), False,
|
||||||
|
currency.code.data,
|
||||||
|
self._credit_no)
|
||||||
|
self._credit_no = self._credit_no + 1
|
||||||
|
|
||||||
|
self.collector = Collector
|
||||||
|
|
||||||
|
|
||||||
|
class TransferJournalEntryForm(JournalEntryForm):
|
||||||
|
"""The form to create or edit a transfer journal entry."""
|
||||||
|
date = DateField(
|
||||||
|
validators=[DATE_REQUIRED,
|
||||||
|
NotBeforeOriginalLineItems(),
|
||||||
|
NotAfterOffsetItems()])
|
||||||
|
"""The date."""
|
||||||
|
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
|
||||||
|
validators=[NeedSomeCurrencies()])
|
||||||
|
"""The line items categorized by their currencies."""
|
||||||
|
note = TextAreaField(filters=[strip_multiline_text])
|
||||||
|
"""The note."""
|
||||||
|
whole_form = BooleanField(
|
||||||
|
validators=[CannotDeleteOriginalLineItemsWithOffset()])
|
||||||
|
"""The pseudo field for the whole form validators."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._is_need_payable = True
|
||||||
|
self._is_need_receivable = True
|
||||||
|
|
||||||
|
class Collector(LineItemCollector[TransferJournalEntryForm]):
|
||||||
|
"""The line item collector for the transfer journal entries."""
|
||||||
|
|
||||||
|
def collect(self) -> None:
|
||||||
|
currencies: list[TransferCurrencyForm] \
|
||||||
|
= [x.form for x in self.form.currencies]
|
||||||
|
self._sort_currency_forms(currencies)
|
||||||
|
for currency in currencies:
|
||||||
|
# The debit forms
|
||||||
|
debit_forms: list[DebitLineItemForm] \
|
||||||
|
= [x.form for x in currency.debit]
|
||||||
|
self._sort_line_item_forms(debit_forms)
|
||||||
|
for debit_form in debit_forms:
|
||||||
|
self._add_line_item(debit_form, currency.code.data,
|
||||||
|
self._debit_no)
|
||||||
|
self._debit_no = self._debit_no + 1
|
||||||
|
|
||||||
|
# The credit forms
|
||||||
|
credit_forms: list[CreditLineItemForm] \
|
||||||
|
= [x.form for x in currency.credit]
|
||||||
|
self._sort_line_item_forms(credit_forms)
|
||||||
|
for credit_form in credit_forms:
|
||||||
|
self._add_line_item(credit_form, currency.code.data,
|
||||||
|
self._credit_no)
|
||||||
|
self._credit_no = self._credit_no + 1
|
||||||
|
|
||||||
|
self.collector = Collector
|
537
src/accounting/journal_entry/forms/line_item.py
Normal file
537
src/accounting/journal_entry/forms/line_item.py
Normal file
@ -0,0 +1,537 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The line item sub-forms for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask_babel import LazyString
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from wtforms import StringField, ValidationError, DecimalField, IntegerField
|
||||||
|
from wtforms.validators import DataRequired, Optional
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Account, JournalEntryLineItem
|
||||||
|
from accounting.template_filters import format_amount
|
||||||
|
from accounting.utils.cast import be
|
||||||
|
from accounting.utils.random_id import new_id
|
||||||
|
from accounting.utils.strip_text import strip_text
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
|
||||||
|
ACCOUNT_REQUIRED: DataRequired = DataRequired(
|
||||||
|
lazy_gettext("Please select the account."))
|
||||||
|
"""The validator to check if the account code is empty."""
|
||||||
|
|
||||||
|
|
||||||
|
class OriginalLineItemExists:
|
||||||
|
"""The validator to check if the original line item exists."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if db.session.get(JournalEntryLineItem, field.data) is None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The original line item does not exist."))
|
||||||
|
|
||||||
|
|
||||||
|
class OriginalLineItemOppositeDebitCredit:
|
||||||
|
"""The validator to check if the original line item is on the opposite
|
||||||
|
debit or credit."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
original_line_item: JournalEntryLineItem | None \
|
||||||
|
= db.session.get(JournalEntryLineItem, field.data)
|
||||||
|
if original_line_item is None:
|
||||||
|
return
|
||||||
|
if isinstance(form, CreditLineItemForm) \
|
||||||
|
and original_line_item.is_debit:
|
||||||
|
return
|
||||||
|
if isinstance(form, DebitLineItemForm) \
|
||||||
|
and not original_line_item.is_debit:
|
||||||
|
return
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The original line item is on the same debit or credit."))
|
||||||
|
|
||||||
|
|
||||||
|
class OriginalLineItemNeedOffset:
|
||||||
|
"""The validator to check if the original line item needs offset."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
original_line_item: JournalEntryLineItem | None \
|
||||||
|
= db.session.get(JournalEntryLineItem, field.data)
|
||||||
|
if original_line_item is None:
|
||||||
|
return
|
||||||
|
if not original_line_item.account.is_need_offset:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The original line item does not need offset."))
|
||||||
|
|
||||||
|
|
||||||
|
class OriginalLineItemNotOffset:
|
||||||
|
"""The validator to check if the original line item is not itself an
|
||||||
|
offset item."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
original_line_item: JournalEntryLineItem | None \
|
||||||
|
= db.session.get(JournalEntryLineItem, field.data)
|
||||||
|
if original_line_item is None:
|
||||||
|
return
|
||||||
|
if original_line_item.original_line_item_id is not None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The original line item cannot be an offset item."))
|
||||||
|
|
||||||
|
|
||||||
|
class AccountExists:
|
||||||
|
"""The validator to check if the account exists."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if Account.find_by_code(field.data) is None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The account does not exist."))
|
||||||
|
|
||||||
|
|
||||||
|
class IsDebitAccount:
|
||||||
|
"""The validator to check if the account is for debit line items."""
|
||||||
|
|
||||||
|
def __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 line items."))
|
||||||
|
|
||||||
|
|
||||||
|
class IsCreditAccount:
|
||||||
|
"""The validator to check if the account is for credit line items."""
|
||||||
|
|
||||||
|
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 line items."))
|
||||||
|
|
||||||
|
|
||||||
|
class SameAccountAsOriginalLineItem:
|
||||||
|
"""The validator to check if the account is the same as the
|
||||||
|
original line item."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, LineItemForm)
|
||||||
|
if field.data is None or form.original_line_item_id.data is None:
|
||||||
|
return
|
||||||
|
original_line_item: JournalEntryLineItem | None \
|
||||||
|
= db.session.get(JournalEntryLineItem,
|
||||||
|
form.original_line_item_id.data)
|
||||||
|
if original_line_item is None:
|
||||||
|
return
|
||||||
|
if field.data != original_line_item.account_code:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The account must be the same as the original line item."))
|
||||||
|
|
||||||
|
|
||||||
|
class KeepAccountWhenHavingOffset:
|
||||||
|
"""The validator to check if the account is the same when having offset."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, LineItemForm)
|
||||||
|
if field.data is None or form.id.data is None:
|
||||||
|
return
|
||||||
|
line_item: JournalEntryLineItem | None = db.session\
|
||||||
|
.query(JournalEntryLineItem)\
|
||||||
|
.filter(JournalEntryLineItem.id == form.id.data)\
|
||||||
|
.options(selectinload(JournalEntryLineItem.offsets)).first()
|
||||||
|
if line_item is None or len(line_item.offsets) == 0:
|
||||||
|
return
|
||||||
|
if field.data != line_item.account_code:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The account must not be changed when there is offset."))
|
||||||
|
|
||||||
|
|
||||||
|
class NotStartPayableFromDebit:
|
||||||
|
"""The validator to check that a payable line item does not start from
|
||||||
|
debit."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, DebitLineItemForm)
|
||||||
|
if field.data is None \
|
||||||
|
or field.data[0] != "2" \
|
||||||
|
or form.original_line_item_id.data is not None:
|
||||||
|
return
|
||||||
|
account: Account | None = Account.find_by_code(field.data)
|
||||||
|
if account is not None and account.is_need_offset:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"A payable line item cannot start from debit."))
|
||||||
|
|
||||||
|
|
||||||
|
class NotStartReceivableFromCredit:
|
||||||
|
"""The validator to check that a receivable line item does not start
|
||||||
|
from credit."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
assert isinstance(form, CreditLineItemForm)
|
||||||
|
if field.data is None \
|
||||||
|
or field.data[0] != "1" \
|
||||||
|
or form.original_line_item_id.data is not None:
|
||||||
|
return
|
||||||
|
account: Account | None = Account.find_by_code(field.data)
|
||||||
|
if account is not None and account.is_need_offset:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"A receivable line item cannot start from credit."))
|
||||||
|
|
||||||
|
|
||||||
|
class PositiveAmount:
|
||||||
|
"""The validator to check if the amount is positive."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
||||||
|
if field.data is None:
|
||||||
|
return
|
||||||
|
if field.data <= 0:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"Please fill in a positive amount."))
|
||||||
|
|
||||||
|
|
||||||
|
class NotExceedingOriginalLineItemNetBalance:
|
||||||
|
"""The validator to check if the amount exceeds the net balance of the
|
||||||
|
original line item."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
||||||
|
assert isinstance(form, LineItemForm)
|
||||||
|
if field.data is None or form.original_line_item_id.data is None:
|
||||||
|
return
|
||||||
|
original_line_item: JournalEntryLineItem | None \
|
||||||
|
= db.session.get(JournalEntryLineItem,
|
||||||
|
form.original_line_item_id.data)
|
||||||
|
if original_line_item is None:
|
||||||
|
return
|
||||||
|
is_debit: bool = isinstance(form, DebitLineItemForm)
|
||||||
|
existing_line_item_id: set[int] = set()
|
||||||
|
if form.journal_entry_form.obj is not None:
|
||||||
|
existing_line_item_id \
|
||||||
|
= {x.id for x in form.journal_entry_form.obj.line_items}
|
||||||
|
offset_total_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(be(JournalEntryLineItem.is_debit == is_debit),
|
||||||
|
JournalEntryLineItem.amount),
|
||||||
|
else_=-JournalEntryLineItem.amount))
|
||||||
|
offset_total_but_form: Decimal | None = db.session.scalar(
|
||||||
|
sa.select(offset_total_func)
|
||||||
|
.filter(be(JournalEntryLineItem.original_line_item_id
|
||||||
|
== original_line_item.id),
|
||||||
|
JournalEntryLineItem.id.not_in(existing_line_item_id)))
|
||||||
|
if offset_total_but_form is None:
|
||||||
|
offset_total_but_form = Decimal("0")
|
||||||
|
offset_total_on_form: Decimal = sum(
|
||||||
|
[x.amount.data for x in form.journal_entry_form.line_items
|
||||||
|
if x.original_line_item_id.data == original_line_item.id
|
||||||
|
and x.amount != field and x.amount.data is not None])
|
||||||
|
net_balance: Decimal = original_line_item.amount \
|
||||||
|
- offset_total_but_form - offset_total_on_form
|
||||||
|
if field.data > net_balance:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The amount must not exceed the net balance %(balance)s of the"
|
||||||
|
" original line item.", balance=format_amount(net_balance)))
|
||||||
|
|
||||||
|
|
||||||
|
class NotLessThanOffsetTotal:
|
||||||
|
"""The validator to check if the amount is less than the offset total."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
||||||
|
assert isinstance(form, LineItemForm)
|
||||||
|
if field.data is None or form.id.data is None:
|
||||||
|
return
|
||||||
|
is_debit: bool = isinstance(form, DebitLineItemForm)
|
||||||
|
select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
|
||||||
|
(JournalEntryLineItem.is_debit != is_debit,
|
||||||
|
JournalEntryLineItem.amount),
|
||||||
|
else_=-JournalEntryLineItem.amount)))\
|
||||||
|
.filter(be(JournalEntryLineItem.original_line_item_id
|
||||||
|
== form.id.data))
|
||||||
|
offset_total: Decimal | None = db.session.scalar(select_offset_total)
|
||||||
|
if offset_total is not None and field.data < offset_total:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The amount must not be less than the offset total %(total)s.",
|
||||||
|
total=format_amount(offset_total)))
|
||||||
|
|
||||||
|
|
||||||
|
class LineItemForm(FlaskForm):
|
||||||
|
"""The base form to create or edit a line item."""
|
||||||
|
id = IntegerField()
|
||||||
|
"""The existing line item ID."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the currency."""
|
||||||
|
original_line_item_id = IntegerField()
|
||||||
|
"""The Id of the original line item."""
|
||||||
|
account_code = StringField()
|
||||||
|
"""The account code."""
|
||||||
|
description = StringField()
|
||||||
|
"""The description."""
|
||||||
|
amount = DecimalField()
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Constructs a base line item form.
|
||||||
|
|
||||||
|
:param args: The arguments.
|
||||||
|
:param kwargs: The keyword arguments.
|
||||||
|
"""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
from .journal_entry import JournalEntryForm
|
||||||
|
self.journal_entry_form: JournalEntryForm | None = None
|
||||||
|
"""The source journal entry form."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_text(self) -> str:
|
||||||
|
"""Returns the text representation of the account.
|
||||||
|
|
||||||
|
:return: The text representation of the account.
|
||||||
|
"""
|
||||||
|
if self.account_code.data is None:
|
||||||
|
return ""
|
||||||
|
account: Account | None = Account.find_by_code(self.account_code.data)
|
||||||
|
if account is None:
|
||||||
|
return ""
|
||||||
|
return str(account)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __original_line_item(self) -> JournalEntryLineItem | None:
|
||||||
|
"""Returns the original line item.
|
||||||
|
|
||||||
|
:return: The original line item.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "____original_line_item"):
|
||||||
|
def get_line_item() -> JournalEntryLineItem | None:
|
||||||
|
if self.original_line_item_id.data is None:
|
||||||
|
return None
|
||||||
|
return db.session.get(JournalEntryLineItem,
|
||||||
|
self.original_line_item_id.data)
|
||||||
|
setattr(self, "____original_line_item", get_line_item())
|
||||||
|
return getattr(self, "____original_line_item")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def original_line_item_date(self) -> date | None:
|
||||||
|
"""Returns the text representation of the original line item.
|
||||||
|
|
||||||
|
:return: The text representation of the original line item.
|
||||||
|
"""
|
||||||
|
return None if self.__original_line_item is None \
|
||||||
|
else self.__original_line_item.journal_entry.date
|
||||||
|
|
||||||
|
@property
|
||||||
|
def original_line_item_text(self) -> str | None:
|
||||||
|
"""Returns the text representation of the original line item.
|
||||||
|
|
||||||
|
:return: The text representation of the original line item.
|
||||||
|
"""
|
||||||
|
return None if self.__original_line_item is None \
|
||||||
|
else str(self.__original_line_item)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_need_offset(self) -> bool:
|
||||||
|
"""Returns whether the line item needs offset.
|
||||||
|
|
||||||
|
:return: True if the line item needs offset, or False otherwise.
|
||||||
|
"""
|
||||||
|
if self.account_code.data is None:
|
||||||
|
return False
|
||||||
|
if self.account_code.data[0] == "1":
|
||||||
|
if isinstance(self, CreditLineItemForm):
|
||||||
|
return False
|
||||||
|
elif self.account_code.data[0] == "2":
|
||||||
|
if isinstance(self, DebitLineItemForm):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
account: Account | None = Account.find_by_code(self.account_code.data)
|
||||||
|
return account is not None and account.is_need_offset
|
||||||
|
|
||||||
|
@property
|
||||||
|
def offsets(self) -> list[JournalEntryLineItem]:
|
||||||
|
"""Returns the offsets.
|
||||||
|
|
||||||
|
:return: The offsets.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__offsets"):
|
||||||
|
def get_offsets() -> list[JournalEntryLineItem]:
|
||||||
|
if not self.is_need_offset or self.id.data is None:
|
||||||
|
return []
|
||||||
|
return JournalEntryLineItem.query\
|
||||||
|
.filter(JournalEntryLineItem.original_line_item_id
|
||||||
|
== self.id.data)\
|
||||||
|
.options(selectinload(JournalEntryLineItem.journal_entry),
|
||||||
|
selectinload(JournalEntryLineItem.account),
|
||||||
|
selectinload(JournalEntryLineItem.offsets)
|
||||||
|
.selectinload(
|
||||||
|
JournalEntryLineItem.journal_entry)).all()
|
||||||
|
setattr(self, "__offsets", get_offsets())
|
||||||
|
return getattr(self, "__offsets")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def offset_total(self) -> Decimal | None:
|
||||||
|
"""Returns the total amount of the offsets.
|
||||||
|
|
||||||
|
:return: The total amount of the offsets.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__offset_total"):
|
||||||
|
def get_offset_total():
|
||||||
|
if not self.is_need_offset or self.id.data is None:
|
||||||
|
return None
|
||||||
|
is_debit: bool = isinstance(self, DebitLineItemForm)
|
||||||
|
return sum([x.amount if x.is_debit != is_debit else -x.amount
|
||||||
|
for x in self.offsets])
|
||||||
|
setattr(self, "__offset_total", get_offset_total())
|
||||||
|
return getattr(self, "__offset_total")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def net_balance(self) -> Decimal | None:
|
||||||
|
"""Returns the net balance.
|
||||||
|
|
||||||
|
:return: The net balance.
|
||||||
|
"""
|
||||||
|
if not self.is_need_offset or self.id.data is None \
|
||||||
|
or self.amount.data is None:
|
||||||
|
return None
|
||||||
|
return self.amount.data - self.offset_total
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_errors(self) -> list[str | LazyString]:
|
||||||
|
"""Returns all the errors of the form.
|
||||||
|
|
||||||
|
:return: All the errors of the form.
|
||||||
|
"""
|
||||||
|
all_errors: list[str | LazyString] = []
|
||||||
|
for key in self.errors:
|
||||||
|
if key != "csrf_token":
|
||||||
|
all_errors.extend(self.errors[key])
|
||||||
|
return all_errors
|
||||||
|
|
||||||
|
|
||||||
|
class DebitLineItemForm(LineItemForm):
|
||||||
|
"""The form to create or edit a debit line item."""
|
||||||
|
id = IntegerField()
|
||||||
|
"""The existing line item ID."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the currency."""
|
||||||
|
original_line_item_id = IntegerField(
|
||||||
|
validators=[Optional(),
|
||||||
|
OriginalLineItemExists(),
|
||||||
|
OriginalLineItemOppositeDebitCredit(),
|
||||||
|
OriginalLineItemNeedOffset(),
|
||||||
|
OriginalLineItemNotOffset()])
|
||||||
|
"""The ID of the original line item."""
|
||||||
|
account_code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[ACCOUNT_REQUIRED,
|
||||||
|
AccountExists(),
|
||||||
|
IsDebitAccount(),
|
||||||
|
SameAccountAsOriginalLineItem(),
|
||||||
|
KeepAccountWhenHavingOffset(),
|
||||||
|
NotStartPayableFromDebit()])
|
||||||
|
"""The account code."""
|
||||||
|
description = StringField(filters=[strip_text])
|
||||||
|
"""The description."""
|
||||||
|
amount = DecimalField(
|
||||||
|
validators=[PositiveAmount(),
|
||||||
|
NotExceedingOriginalLineItemNetBalance(),
|
||||||
|
NotLessThanOffsetTotal()])
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
def populate_obj(self, obj: JournalEntryLineItem) -> None:
|
||||||
|
"""Populates the form data into a line item object.
|
||||||
|
|
||||||
|
:param obj: The line item object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
is_new: bool = obj.id is None
|
||||||
|
if is_new:
|
||||||
|
obj.id = new_id(JournalEntryLineItem)
|
||||||
|
obj.original_line_item_id = self.original_line_item_id.data
|
||||||
|
obj.account_id = Account.find_by_code(self.account_code.data).id
|
||||||
|
obj.description = self.description.data
|
||||||
|
obj.is_debit = True
|
||||||
|
obj.amount = self.amount.data
|
||||||
|
if is_new:
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.created_by_id = current_user_pk
|
||||||
|
obj.updated_by_id = current_user_pk
|
||||||
|
|
||||||
|
|
||||||
|
class CreditLineItemForm(LineItemForm):
|
||||||
|
"""The form to create or edit a credit line item."""
|
||||||
|
id = IntegerField()
|
||||||
|
"""The existing line item ID."""
|
||||||
|
no = IntegerField()
|
||||||
|
"""The order in the currency."""
|
||||||
|
original_line_item_id = IntegerField(
|
||||||
|
validators=[Optional(),
|
||||||
|
OriginalLineItemExists(),
|
||||||
|
OriginalLineItemOppositeDebitCredit(),
|
||||||
|
OriginalLineItemNeedOffset(),
|
||||||
|
OriginalLineItemNotOffset()])
|
||||||
|
"""The ID of the original line item."""
|
||||||
|
account_code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[ACCOUNT_REQUIRED,
|
||||||
|
AccountExists(),
|
||||||
|
IsCreditAccount(),
|
||||||
|
SameAccountAsOriginalLineItem(),
|
||||||
|
KeepAccountWhenHavingOffset(),
|
||||||
|
NotStartReceivableFromCredit()])
|
||||||
|
"""The account code."""
|
||||||
|
description = StringField(filters=[strip_text])
|
||||||
|
"""The description."""
|
||||||
|
amount = DecimalField(
|
||||||
|
validators=[PositiveAmount(),
|
||||||
|
NotExceedingOriginalLineItemNetBalance(),
|
||||||
|
NotLessThanOffsetTotal()])
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
def populate_obj(self, obj: JournalEntryLineItem) -> None:
|
||||||
|
"""Populates the form data into a line item object.
|
||||||
|
|
||||||
|
:param obj: The line item object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
is_new: bool = obj.id is None
|
||||||
|
if is_new:
|
||||||
|
obj.id = new_id(JournalEntryLineItem)
|
||||||
|
obj.original_line_item_id = self.original_line_item_id.data
|
||||||
|
obj.account_id = Account.find_by_code(self.account_code.data).id
|
||||||
|
obj.description = self.description.data
|
||||||
|
obj.is_debit = False
|
||||||
|
obj.amount = self.amount.data
|
||||||
|
if is_new:
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.created_by_id = current_user_pk
|
||||||
|
obj.updated_by_id = current_user_pk
|
95
src/accounting/journal_entry/forms/reorder.py
Normal file
95
src/accounting/journal_entry/forms/reorder.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The reorder forms for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import JournalEntry
|
||||||
|
|
||||||
|
|
||||||
|
def sort_journal_entries_in(journal_entry_date: date,
|
||||||
|
exclude: int | None = None) -> None:
|
||||||
|
"""Sorts the journal entries under a date after changing the date or
|
||||||
|
deleting a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry_date: The date of the journal entry.
|
||||||
|
:param exclude: The journal entry ID to exclude.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.date == journal_entry_date]
|
||||||
|
if exclude is not None:
|
||||||
|
conditions.append(JournalEntry.id != exclude)
|
||||||
|
journal_entries: list[JournalEntry] = JournalEntry.query\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.order_by(JournalEntry.no).all()
|
||||||
|
for i in range(len(journal_entries)):
|
||||||
|
if journal_entries[i].no != i + 1:
|
||||||
|
journal_entries[i].no = i + 1
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryReorderForm:
|
||||||
|
"""The form to reorder the journal entries."""
|
||||||
|
|
||||||
|
def __init__(self, journal_entry_date: date):
|
||||||
|
"""Constructs the form to reorder the journal entries in a day.
|
||||||
|
|
||||||
|
:param journal_entry_date: The date.
|
||||||
|
"""
|
||||||
|
self.date: date = journal_entry_date
|
||||||
|
self.is_modified: bool = False
|
||||||
|
|
||||||
|
def save_order(self) -> None:
|
||||||
|
"""Saves the order of the account.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
journal_entries: list[JournalEntry] = JournalEntry.query\
|
||||||
|
.filter(JournalEntry.date == self.date).all()
|
||||||
|
|
||||||
|
# Collects the specified order.
|
||||||
|
orders: dict[JournalEntry, int] = {}
|
||||||
|
for journal_entry in journal_entries:
|
||||||
|
if f"{journal_entry.id}-no" in request.form:
|
||||||
|
try:
|
||||||
|
orders[journal_entry] \
|
||||||
|
= int(request.form[f"{journal_entry.id}-no"])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Missing and invalid orders are appended to the end.
|
||||||
|
missing: list[JournalEntry] \
|
||||||
|
= [x for x in journal_entries if x not in orders]
|
||||||
|
if len(missing) > 0:
|
||||||
|
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
|
||||||
|
for journal_entry in missing:
|
||||||
|
orders[journal_entry] = next_no
|
||||||
|
|
||||||
|
# Sort by the specified order first, and their original order.
|
||||||
|
journal_entries.sort(key=lambda x: (orders[x], x.no))
|
||||||
|
|
||||||
|
# Update the orders.
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
for i in range(len(journal_entries)):
|
||||||
|
if journal_entries[i].no != i + 1:
|
||||||
|
journal_entries[i].no = i + 1
|
||||||
|
self.is_modified = True
|
@ -14,7 +14,7 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
"""The template filters for the transaction management.
|
"""The template filters for the journal entry management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@ -26,10 +26,10 @@ from flask import request
|
|||||||
|
|
||||||
|
|
||||||
def with_type(uri: str) -> str:
|
def with_type(uri: str) -> str:
|
||||||
"""Adds the transaction type to the URI, if it is specified.
|
"""Adds the journal entry type to the URI, if it is specified.
|
||||||
|
|
||||||
:param uri: The URI.
|
:param uri: The URI.
|
||||||
:return: The result URL, optionally with the transaction type added.
|
:return: The result URL, optionally with the journal entry type added.
|
||||||
"""
|
"""
|
||||||
if "as" not in request.args:
|
if "as" not in request.args:
|
||||||
return uri
|
return uri
|
||||||
@ -43,10 +43,10 @@ def with_type(uri: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def to_transfer(uri: str) -> str:
|
def to_transfer(uri: str) -> str:
|
||||||
"""Adds the transfer transaction type to the URI.
|
"""Adds the transfer journal entry type to the URI.
|
||||||
|
|
||||||
:param uri: The URI.
|
:param uri: The URI.
|
||||||
:return: The result URL, with the transfer transaction type added.
|
:return: The result URL, with the transfer journal entry type added.
|
||||||
"""
|
"""
|
||||||
uri_p: ParseResult = urlparse(uri)
|
uri_p: ParseResult = urlparse(uri)
|
||||||
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
19
src/accounting/journal_entry/utils/__init__.py
Normal file
19
src/accounting/journal_entry/utils/__init__.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The utilities for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
49
src/accounting/journal_entry/utils/account_option.py
Normal file
49
src/accounting/journal_entry/utils/account_option.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The account option for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from accounting.models import Account
|
||||||
|
|
||||||
|
|
||||||
|
class AccountOption:
|
||||||
|
"""An account option."""
|
||||||
|
|
||||||
|
def __init__(self, account: Account):
|
||||||
|
"""Constructs an account option.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
"""
|
||||||
|
self.id: str = account.id
|
||||||
|
"""The account ID."""
|
||||||
|
self.code: str = account.code
|
||||||
|
"""The account code."""
|
||||||
|
self.query_values: list[str] = account.query_values
|
||||||
|
"""The values to be queried."""
|
||||||
|
self.__str: str = str(account)
|
||||||
|
"""The string representation of the account option."""
|
||||||
|
self.is_in_use: bool = False
|
||||||
|
"""True if this account is in use, or False otherwise."""
|
||||||
|
self.is_need_offset: bool = account.is_need_offset
|
||||||
|
"""True if this account needs offset, or False otherwise."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the account option.
|
||||||
|
|
||||||
|
:return: The string representation of the account option.
|
||||||
|
"""
|
||||||
|
return self.__str
|
@ -14,7 +14,7 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
"""The summary editor.
|
"""The description editor.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
import typing as t
|
||||||
@ -22,14 +22,14 @@ import typing as t
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.models import Account, JournalEntry
|
from accounting.models import Account, JournalEntryLineItem
|
||||||
|
|
||||||
|
|
||||||
class SummaryAccount:
|
class DescriptionAccount:
|
||||||
"""An account for a summary tag."""
|
"""An account for a description tag."""
|
||||||
|
|
||||||
def __init__(self, account: Account, freq: int):
|
def __init__(self, account: Account, freq: int):
|
||||||
"""Constructs an account for a summary tag.
|
"""Constructs an account for a description tag.
|
||||||
|
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
:param freq: The frequency of the tag with the account.
|
:param freq: The frequency of the tag with the account.
|
||||||
@ -59,17 +59,17 @@ class SummaryAccount:
|
|||||||
self.freq = self.freq + freq
|
self.freq = self.freq + freq
|
||||||
|
|
||||||
|
|
||||||
class SummaryTag:
|
class DescriptionTag:
|
||||||
"""A summary tag."""
|
"""A description tag."""
|
||||||
|
|
||||||
def __init__(self, name: str):
|
def __init__(self, name: str):
|
||||||
"""Constructs a summary tag.
|
"""Constructs a description tag.
|
||||||
|
|
||||||
:param name: The tag name.
|
:param name: The tag name.
|
||||||
"""
|
"""
|
||||||
self.name: str = name
|
self.name: str = name
|
||||||
"""The tag name."""
|
"""The tag name."""
|
||||||
self.__account_dict: dict[int, SummaryAccount] = {}
|
self.__account_dict: dict[int, DescriptionAccount] = {}
|
||||||
"""The accounts that come with the tag, in the order of their
|
"""The accounts that come with the tag, in the order of their
|
||||||
frequency."""
|
frequency."""
|
||||||
self.freq: int = 0
|
self.freq: int = 0
|
||||||
@ -89,11 +89,11 @@ class SummaryTag:
|
|||||||
:param freq: The frequency of the tag name with the account.
|
:param freq: The frequency of the tag name with the account.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
self.__account_dict[account.id] = SummaryAccount(account, freq)
|
self.__account_dict[account.id] = DescriptionAccount(account, freq)
|
||||||
self.freq = self.freq + freq
|
self.freq = self.freq + freq
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def accounts(self) -> list[SummaryAccount]:
|
def accounts(self) -> list[DescriptionAccount]:
|
||||||
"""Returns the accounts by the order of their frequencies.
|
"""Returns the accounts by the order of their frequencies.
|
||||||
|
|
||||||
:return: The accounts by the order of their frequencies.
|
:return: The accounts by the order of their frequencies.
|
||||||
@ -109,17 +109,17 @@ class SummaryTag:
|
|||||||
return [x.code for x in self.accounts]
|
return [x.code for x in self.accounts]
|
||||||
|
|
||||||
|
|
||||||
class SummaryType:
|
class DescriptionType:
|
||||||
"""A summary type"""
|
"""A description type"""
|
||||||
|
|
||||||
def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
|
def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
|
||||||
"""Constructs a summary type.
|
"""Constructs a description type.
|
||||||
|
|
||||||
:param type_id: The type ID, either "general", "travel", or "bus".
|
:param type_id: The type ID, either "general", "travel", or "bus".
|
||||||
"""
|
"""
|
||||||
self.id: t.Literal["general", "travel", "bus"] = type_id
|
self.id: t.Literal["general", "travel", "bus"] = type_id
|
||||||
"""The type ID."""
|
"""The type ID."""
|
||||||
self.__tag_dict: dict[str, SummaryTag] = {}
|
self.__tag_dict: dict[str, DescriptionTag] = {}
|
||||||
"""A dictionary from the tag name to their corresponding tag."""
|
"""A dictionary from the tag name to their corresponding tag."""
|
||||||
|
|
||||||
def add_tag(self, name: str, account: Account, freq: int) -> None:
|
def add_tag(self, name: str, account: Account, freq: int) -> None:
|
||||||
@ -131,11 +131,11 @@ class SummaryType:
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
if name not in self.__tag_dict:
|
if name not in self.__tag_dict:
|
||||||
self.__tag_dict[name] = SummaryTag(name)
|
self.__tag_dict[name] = DescriptionTag(name)
|
||||||
self.__tag_dict[name].add_account(account, freq)
|
self.__tag_dict[name].add_account(account, freq)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tags(self) -> list[SummaryTag]:
|
def tags(self) -> list[DescriptionTag]:
|
||||||
"""Returns the tags by the order of their frequencies.
|
"""Returns the tags by the order of their frequencies.
|
||||||
|
|
||||||
:return: The tags by the order of their frequencies.
|
:return: The tags by the order of their frequencies.
|
||||||
@ -143,24 +143,24 @@ class SummaryType:
|
|||||||
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
|
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
|
||||||
|
|
||||||
|
|
||||||
class SummaryEntryType:
|
class DescriptionDebitCredit:
|
||||||
"""A summary type"""
|
"""The description on debit or credit."""
|
||||||
|
|
||||||
def __init__(self, entry_type_id: t.Literal["debit", "credit"]):
|
def __init__(self, debit_credit: t.Literal["debit", "credit"]):
|
||||||
"""Constructs a summary entry type.
|
"""Constructs the description on debit or credit.
|
||||||
|
|
||||||
:param entry_type_id: The entry type ID, either "debit" or "credit".
|
:param debit_credit: Either "debit" or "credit".
|
||||||
"""
|
"""
|
||||||
self.type: t.Literal["debit", "credit"] = entry_type_id
|
self.debit_credit: t.Literal["debit", "credit"] = debit_credit
|
||||||
"""The entry type."""
|
"""Either debit or credit."""
|
||||||
self.general: SummaryType = SummaryType("general")
|
self.general: DescriptionType = DescriptionType("general")
|
||||||
"""The general tags."""
|
"""The general tags."""
|
||||||
self.travel: SummaryType = SummaryType("travel")
|
self.travel: DescriptionType = DescriptionType("travel")
|
||||||
"""The travel tags."""
|
"""The travel tags."""
|
||||||
self.bus: SummaryType = SummaryType("bus")
|
self.bus: DescriptionType = DescriptionType("bus")
|
||||||
"""The bus tags."""
|
"""The bus tags."""
|
||||||
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
|
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
|
||||||
SummaryType] \
|
DescriptionType] \
|
||||||
= {x.id: x for x in {self.general, self.travel, self.bus}}
|
= {x.id: x for x in {self.general, self.travel, self.bus}}
|
||||||
"""A dictionary from the type ID to the corresponding tags."""
|
"""A dictionary from the type ID to the corresponding tags."""
|
||||||
|
|
||||||
@ -177,13 +177,13 @@ class SummaryEntryType:
|
|||||||
self.__type_dict[tag_type].add_tag(name, account, freq)
|
self.__type_dict[tag_type].add_tag(name, account, freq)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def accounts(self) -> list[SummaryAccount]:
|
def accounts(self) -> list[DescriptionAccount]:
|
||||||
"""Returns the suggested accounts of all tags in the summary editor in
|
"""Returns the suggested accounts of all tags in the description editor
|
||||||
the entry type, in their frequency order.
|
in debit or credit, in their frequency order.
|
||||||
|
|
||||||
:return: The suggested accounts of all tags, in their frequency order.
|
:return: The suggested accounts of all tags, in their frequency order.
|
||||||
"""
|
"""
|
||||||
accounts: dict[int, SummaryAccount] = {}
|
accounts: dict[int, DescriptionAccount] = {}
|
||||||
freq: dict[int, int] = {}
|
freq: dict[int, int] = {}
|
||||||
for tag_type in self.__type_dict.values():
|
for tag_type in self.__type_dict.values():
|
||||||
for tag in tag_type.tags:
|
for tag in tag_type.tags:
|
||||||
@ -197,37 +197,43 @@ class SummaryEntryType:
|
|||||||
key=lambda x: -freq[x])]
|
key=lambda x: -freq[x])]
|
||||||
|
|
||||||
|
|
||||||
class SummaryEditor:
|
class DescriptionEditor:
|
||||||
"""The summary editor."""
|
"""The description editor."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Constructs the summary editor."""
|
"""Constructs the description editor."""
|
||||||
self.debit: SummaryEntryType = SummaryEntryType("debit")
|
self.debit: DescriptionDebitCredit = DescriptionDebitCredit("debit")
|
||||||
"""The debit tags."""
|
"""The debit tags."""
|
||||||
self.credit: SummaryEntryType = SummaryEntryType("credit")
|
self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit")
|
||||||
"""The credit tags."""
|
"""The credit tags."""
|
||||||
entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"),
|
debit_credit: sa.Label = sa.case(
|
||||||
else_="credit").label("entry_type")
|
(JournalEntryLineItem.is_debit, "debit"),
|
||||||
|
else_="credit").label("debit_credit")
|
||||||
tag_type: sa.Label = sa.case(
|
tag_type: sa.Label = sa.case(
|
||||||
(JournalEntry.summary.like("_%—_%—_%→_%"), "bus"),
|
(JournalEntryLineItem.description.like("_%—_%—_%→_%"), "bus"),
|
||||||
(sa.or_(JournalEntry.summary.like("_%—_%→_%"),
|
(sa.or_(JournalEntryLineItem.description.like("_%—_%→_%"),
|
||||||
JournalEntry.summary.like("_%—_%↔_%")), "travel"),
|
JournalEntryLineItem.description.like("_%—_%↔_%")),
|
||||||
|
"travel"),
|
||||||
else_="general").label("tag_type")
|
else_="general").label("tag_type")
|
||||||
tag: sa.Label = get_prefix(JournalEntry.summary, "—").label("tag")
|
tag: sa.Label = get_prefix(JournalEntryLineItem.description, "—")\
|
||||||
select: sa.Select = sa.Select(entry_type, tag_type, tag,
|
.label("tag")
|
||||||
JournalEntry.account_id,
|
select: sa.Select = sa.Select(debit_credit, tag_type, tag,
|
||||||
|
JournalEntryLineItem.account_id,
|
||||||
sa.func.count().label("freq"))\
|
sa.func.count().label("freq"))\
|
||||||
.filter(JournalEntry.summary.is_not(None),
|
.filter(JournalEntryLineItem.description.is_not(None),
|
||||||
JournalEntry.summary.like("_%—_%"))\
|
JournalEntryLineItem.description.like("_%—_%"),
|
||||||
.group_by(entry_type, tag_type, tag, JournalEntry.account_id)
|
JournalEntryLineItem.original_line_item_id.is_(None))\
|
||||||
|
.group_by(debit_credit, tag_type, tag,
|
||||||
|
JournalEntryLineItem.account_id)
|
||||||
result: list[sa.Row] = db.session.execute(select).all()
|
result: list[sa.Row] = db.session.execute(select).all()
|
||||||
accounts: dict[int, Account] \
|
accounts: dict[int, Account] \
|
||||||
= {x.id: x for x in Account.query
|
= {x.id: x for x in Account.query
|
||||||
.filter(Account.id.in_({x.account_id for x in result})).all()}
|
.filter(Account.id.in_({x.account_id for x in result})).all()}
|
||||||
entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \
|
debit_credit_dict: dict[t.Literal["debit", "credit"],
|
||||||
= {x.type: x for x in {self.debit, self.credit}}
|
DescriptionDebitCredit] \
|
||||||
|
= {x.debit_credit: x for x in {self.debit, self.credit}}
|
||||||
for row in result:
|
for row in result:
|
||||||
entry_type_dict[row.entry_type].add_tag(
|
debit_credit_dict[row.debit_credit].add_tag(
|
||||||
row.tag_type, row.tag, accounts[row.account_id], row.freq)
|
row.tag_type, row.tag, accounts[row.account_id], row.freq)
|
||||||
|
|
||||||
|
|
39
src/accounting/journal_entry/utils/offset_alias.py
Normal file
39
src/accounting/journal_entry/utils/offset_alias.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/15
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The SQLAlchemy alias for the offset items.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from accounting.models import JournalEntryLineItem
|
||||||
|
|
||||||
|
|
||||||
|
def offset_alias() -> sa.Alias:
|
||||||
|
"""Returns the SQLAlchemy alias for the offset items.
|
||||||
|
|
||||||
|
:return: The SQLAlchemy alias for the offset items.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def as_from(model_cls: t.Any) -> sa.FromClause:
|
||||||
|
return model_cls
|
||||||
|
|
||||||
|
def as_alias(alias: t.Any) -> sa.Alias:
|
||||||
|
return alias
|
||||||
|
|
||||||
|
return as_alias(sa.alias(as_from(JournalEntryLineItem), name="offset"))
|
336
src/accounting/journal_entry/utils/operators.py
Normal file
336
src/accounting/journal_entry/utils/operators.py
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The operators for different journal entry types.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from flask import render_template, request, abort
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
|
||||||
|
from accounting.models import JournalEntry
|
||||||
|
from accounting.template_globals import default_currency_code
|
||||||
|
from accounting.utils.journal_entry_types import JournalEntryType
|
||||||
|
from accounting.journal_entry.forms import JournalEntryForm, \
|
||||||
|
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
|
||||||
|
TransferJournalEntryForm
|
||||||
|
from accounting.journal_entry.forms.line_item import LineItemForm
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryOperator(ABC):
|
||||||
|
"""The base journal entry operator."""
|
||||||
|
CHECK_ORDER: int = -1
|
||||||
|
"""The order when checking the journal entry operator."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def form(self) -> t.Type[JournalEntryForm]:
|
||||||
|
"""Returns the form class.
|
||||||
|
|
||||||
|
:return: The form class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render_create_template(self, form: FlaskForm) -> str:
|
||||||
|
"""Renders the template for the form to create a journal entry.
|
||||||
|
|
||||||
|
:param form: The journal entry form.
|
||||||
|
:return: the form to create a journal entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render_detail_template(self, journal_entry: JournalEntry) -> str:
|
||||||
|
"""Renders the template for the detail page.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: the detail page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render_edit_template(self, journal_entry: JournalEntry,
|
||||||
|
form: FlaskForm) -> str:
|
||||||
|
"""Renders the template for the form to edit a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:param form: The form.
|
||||||
|
:return: the form to edit a journal entry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def is_my_type(self, journal_entry: JournalEntry) -> bool:
|
||||||
|
"""Checks and returns whether the journal entry belongs to the type.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: True if the journal entry belongs to the type, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _line_item_template(self) -> str:
|
||||||
|
"""Renders and returns the template for the line item sub-form.
|
||||||
|
|
||||||
|
:return: The template for the line item sub-form.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/include/form-line-item.html",
|
||||||
|
currency_index="CURRENCY_INDEX",
|
||||||
|
debit_credit="DEBIT_CREDIT",
|
||||||
|
line_item_index="LINE_ITEM_INDEX",
|
||||||
|
form=LineItemForm())
|
||||||
|
|
||||||
|
|
||||||
|
class CashReceiptJournalEntry(JournalEntryOperator):
|
||||||
|
"""A cash receipt journal entry."""
|
||||||
|
CHECK_ORDER: int = 2
|
||||||
|
"""The order when checking the journal entry operator."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def form(self) -> t.Type[JournalEntryForm]:
|
||||||
|
"""Returns the form class.
|
||||||
|
|
||||||
|
:return: The form class.
|
||||||
|
"""
|
||||||
|
return CashReceiptJournalEntryForm
|
||||||
|
|
||||||
|
def render_create_template(self, form: CashReceiptJournalEntryForm) -> str:
|
||||||
|
"""Renders the template for the form to create a journal entry.
|
||||||
|
|
||||||
|
:param form: The journal entry form.
|
||||||
|
:return: the form to create a journal entry.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/receipt/create.html",
|
||||||
|
form=form,
|
||||||
|
journal_entry_type=JournalEntryType.CASH_RECEIPT,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
line_item_template=self._line_item_template)
|
||||||
|
|
||||||
|
def render_detail_template(self, journal_entry: JournalEntry) -> str:
|
||||||
|
"""Renders the template for the detail page.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: the detail page.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/journal-entry/receipt/detail.html",
|
||||||
|
obj=journal_entry)
|
||||||
|
|
||||||
|
def render_edit_template(self, journal_entry: JournalEntry,
|
||||||
|
form: CashReceiptJournalEntryForm) -> str:
|
||||||
|
"""Renders the template for the form to edit a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:param form: The form.
|
||||||
|
:return: the form to edit a journal entry.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/journal-entry/receipt/edit.html",
|
||||||
|
journal_entry=journal_entry, form=form,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
line_item_template=self._line_item_template)
|
||||||
|
|
||||||
|
def is_my_type(self, journal_entry: JournalEntry) -> bool:
|
||||||
|
"""Checks and returns whether the journal entry belongs to the type.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: True if the journal entry belongs to the type, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
return journal_entry.is_cash_receipt
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __currency_template(self) -> str:
|
||||||
|
"""Renders and returns the template for the currency sub-form.
|
||||||
|
|
||||||
|
:return: The template for the currency sub-form.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/receipt/include/form-currency.html",
|
||||||
|
currency_index="CURRENCY_INDEX",
|
||||||
|
currency_code_data=default_currency_code(),
|
||||||
|
credit_total="-")
|
||||||
|
|
||||||
|
|
||||||
|
class CashDisbursementJournalEntry(JournalEntryOperator):
|
||||||
|
"""A cash disbursement journal entry."""
|
||||||
|
CHECK_ORDER: int = 1
|
||||||
|
"""The order when checking the journal entry operator."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def form(self) -> t.Type[JournalEntryForm]:
|
||||||
|
"""Returns the form class.
|
||||||
|
|
||||||
|
:return: The form class.
|
||||||
|
"""
|
||||||
|
return CashDisbursementJournalEntryForm
|
||||||
|
|
||||||
|
def render_create_template(self, form: CashDisbursementJournalEntryForm) \
|
||||||
|
-> str:
|
||||||
|
"""Renders the template for the form to create a journal entry.
|
||||||
|
|
||||||
|
:param form: The journal entry form.
|
||||||
|
:return: the form to create a journal entry.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/disbursement/create.html",
|
||||||
|
form=form,
|
||||||
|
journal_entry_type=JournalEntryType.CASH_DISBURSEMENT,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
line_item_template=self._line_item_template)
|
||||||
|
|
||||||
|
def render_detail_template(self, journal_entry: JournalEntry) -> str:
|
||||||
|
"""Renders the template for the detail page.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: the detail page.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/disbursement/detail.html",
|
||||||
|
obj=journal_entry)
|
||||||
|
|
||||||
|
def render_edit_template(self, journal_entry: JournalEntry,
|
||||||
|
form: CashDisbursementJournalEntryForm) -> str:
|
||||||
|
"""Renders the template for the form to edit a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:param form: The form.
|
||||||
|
:return: the form to edit a journal entry.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/disbursement/edit.html",
|
||||||
|
journal_entry=journal_entry, form=form,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
line_item_template=self._line_item_template)
|
||||||
|
|
||||||
|
def is_my_type(self, journal_entry: JournalEntry) -> bool:
|
||||||
|
"""Checks and returns whether the journal entry belongs to the type.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: True if the journal entry belongs to the type, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
return journal_entry.is_cash_disbursement
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __currency_template(self) -> str:
|
||||||
|
"""Renders and returns the template for the currency sub-form.
|
||||||
|
|
||||||
|
:return: The template for the currency sub-form.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/disbursement/include/form-currency.html",
|
||||||
|
currency_index="CURRENCY_INDEX",
|
||||||
|
currency_code_data=default_currency_code(),
|
||||||
|
debit_total="-")
|
||||||
|
|
||||||
|
|
||||||
|
class TransferJournalEntry(JournalEntryOperator):
|
||||||
|
"""A transfer journal entry."""
|
||||||
|
CHECK_ORDER: int = 3
|
||||||
|
"""The order when checking the journal entry operator."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def form(self) -> t.Type[JournalEntryForm]:
|
||||||
|
"""Returns the form class.
|
||||||
|
|
||||||
|
:return: The form class.
|
||||||
|
"""
|
||||||
|
return TransferJournalEntryForm
|
||||||
|
|
||||||
|
def render_create_template(self, form: TransferJournalEntryForm) -> str:
|
||||||
|
"""Renders the template for the form to create a journal entry.
|
||||||
|
|
||||||
|
:param form: The journal entry form.
|
||||||
|
:return: the form to create a journal entry.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/transfer/create.html",
|
||||||
|
form=form,
|
||||||
|
journal_entry_type=JournalEntryType.TRANSFER,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
line_item_template=self._line_item_template)
|
||||||
|
|
||||||
|
def render_detail_template(self, journal_entry: JournalEntry) -> str:
|
||||||
|
"""Renders the template for the detail page.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: the detail page.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/journal-entry/transfer/detail.html",
|
||||||
|
obj=journal_entry)
|
||||||
|
|
||||||
|
def render_edit_template(self, journal_entry: JournalEntry,
|
||||||
|
form: TransferJournalEntryForm) -> str:
|
||||||
|
"""Renders the template for the form to edit a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:param form: The form.
|
||||||
|
:return: the form to edit a journal entry.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/journal-entry/transfer/edit.html",
|
||||||
|
journal_entry=journal_entry, form=form,
|
||||||
|
currency_template=self.__currency_template,
|
||||||
|
line_item_template=self._line_item_template)
|
||||||
|
|
||||||
|
def is_my_type(self, journal_entry: JournalEntry) -> bool:
|
||||||
|
"""Checks and returns whether the journal entry belongs to the type.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: True if the journal entry belongs to the type, or False
|
||||||
|
otherwise.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __currency_template(self) -> str:
|
||||||
|
"""Renders and returns the template for the currency sub-form.
|
||||||
|
|
||||||
|
:return: The template for the currency sub-form.
|
||||||
|
"""
|
||||||
|
return render_template(
|
||||||
|
"accounting/journal-entry/transfer/include/form-currency.html",
|
||||||
|
currency_index="CURRENCY_INDEX",
|
||||||
|
currency_code_data=default_currency_code(),
|
||||||
|
debit_total="-", credit_total="-")
|
||||||
|
|
||||||
|
|
||||||
|
JOURNAL_ENTRY_TYPE_TO_OP: dict[JournalEntryType, JournalEntryOperator] \
|
||||||
|
= {JournalEntryType.CASH_RECEIPT: CashReceiptJournalEntry(),
|
||||||
|
JournalEntryType.CASH_DISBURSEMENT: CashDisbursementJournalEntry(),
|
||||||
|
JournalEntryType.TRANSFER: TransferJournalEntry()}
|
||||||
|
"""The map from the journal entry types to their operators."""
|
||||||
|
|
||||||
|
|
||||||
|
def get_journal_entry_op(journal_entry: JournalEntry,
|
||||||
|
is_check_as: bool = False) -> JournalEntryOperator:
|
||||||
|
"""Returns the journal entry operator that may be specified in the "as"
|
||||||
|
query parameter. If it is not specified, check the journal entry type from
|
||||||
|
the journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:param is_check_as: True to check the "as" parameter, or False otherwise.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if is_check_as and "as" in request.args:
|
||||||
|
type_dict: dict[str, JournalEntryType] \
|
||||||
|
= {x.value: x for x in JournalEntryType}
|
||||||
|
if request.args["as"] not in type_dict:
|
||||||
|
abort(404)
|
||||||
|
return JOURNAL_ENTRY_TYPE_TO_OP[type_dict[request.args["as"]]]
|
||||||
|
for journal_entry_type in sorted(JOURNAL_ENTRY_TYPE_TO_OP.values(),
|
||||||
|
key=lambda x: x.CHECK_ORDER):
|
||||||
|
if journal_entry_type.is_my_type(journal_entry):
|
||||||
|
return journal_entry_type
|
84
src/accounting/journal_entry/utils/original_line_items.py
Normal file
84
src/accounting/journal_entry/utils/original_line_items.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The selectable original line items.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import Account, JournalEntry, JournalEntryLineItem
|
||||||
|
from accounting.utils.cast import be
|
||||||
|
from .offset_alias import offset_alias
|
||||||
|
|
||||||
|
|
||||||
|
def get_selectable_original_line_items(
|
||||||
|
line_item_id_on_form: set[int], is_payable: bool,
|
||||||
|
is_receivable: bool) -> list[JournalEntryLineItem]:
|
||||||
|
"""Queries and returns the selectable original line items, with their net
|
||||||
|
balances. The offset amounts of the form is excluded.
|
||||||
|
|
||||||
|
:param line_item_id_on_form: The ID of the line items on the form.
|
||||||
|
:param is_payable: True to check the payable original line items, or False
|
||||||
|
otherwise.
|
||||||
|
:param is_receivable: True to check the receivable original line items, or
|
||||||
|
False otherwise.
|
||||||
|
:return: The selectable original line items, with their net balances.
|
||||||
|
"""
|
||||||
|
assert is_payable or is_receivable
|
||||||
|
offset: sa.Alias = offset_alias()
|
||||||
|
net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case(
|
||||||
|
(offset.c.id.in_(line_item_id_on_form), 0),
|
||||||
|
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
|
||||||
|
offset.c.amount),
|
||||||
|
else_=-offset.c.amount))).label("net_balance")
|
||||||
|
conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
|
||||||
|
sub_conditions: list[sa.BinaryExpression] = []
|
||||||
|
if is_payable:
|
||||||
|
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
|
||||||
|
sa.not_(JournalEntryLineItem.is_debit)))
|
||||||
|
if is_receivable:
|
||||||
|
sub_conditions.append(sa.and_(Account.base_code.startswith("1"),
|
||||||
|
JournalEntryLineItem.is_debit))
|
||||||
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
|
select_net_balances: sa.Select \
|
||||||
|
= sa.select(JournalEntryLineItem.id, net_balance)\
|
||||||
|
.join(Account)\
|
||||||
|
.join(offset, be(JournalEntryLineItem.id
|
||||||
|
== offset.c.original_line_item_id),
|
||||||
|
isouter=True)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.group_by(JournalEntryLineItem.id)\
|
||||||
|
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
|
||||||
|
net_balances: dict[int, Decimal] \
|
||||||
|
= {x.id: x.net_balance
|
||||||
|
for x in db.session.execute(select_net_balances).all()}
|
||||||
|
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\
|
||||||
|
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\
|
||||||
|
.join(JournalEntry)\
|
||||||
|
.order_by(JournalEntry.date, JournalEntryLineItem.is_debit,
|
||||||
|
JournalEntryLineItem.no)\
|
||||||
|
.options(selectinload(JournalEntryLineItem.currency),
|
||||||
|
selectinload(JournalEntryLineItem.account),
|
||||||
|
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||||
|
for line_item in line_items:
|
||||||
|
line_item.net_balance = line_item.amount \
|
||||||
|
if net_balances[line_item.id] is None \
|
||||||
|
else net_balances[line_item.id]
|
||||||
|
return line_items
|
235
src/accounting/journal_entry/views.py
Normal file
235
src/accounting/journal_entry/views.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The views for the journal entry management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import Blueprint, render_template, session, redirect, request, \
|
||||||
|
flash, url_for
|
||||||
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import JournalEntry
|
||||||
|
from accounting.utils.cast import s
|
||||||
|
from accounting.utils.flash_errors import flash_form_errors
|
||||||
|
from accounting.utils.next_uri import inherit_next, or_next
|
||||||
|
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||||
|
from accounting.utils.journal_entry_types import JournalEntryType
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
from .forms import sort_journal_entries_in, JournalEntryReorderForm
|
||||||
|
from .template_filters import with_type, to_transfer, format_amount_input, \
|
||||||
|
text2html
|
||||||
|
from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
|
||||||
|
get_journal_entry_op
|
||||||
|
|
||||||
|
bp: Blueprint = Blueprint("journal-entry", __name__)
|
||||||
|
"""The view blueprint for the journal entry management."""
|
||||||
|
bp.add_app_template_filter(with_type, "accounting_journal_entry_with_type")
|
||||||
|
bp.add_app_template_filter(to_transfer, "accounting_journal_entry_to_transfer")
|
||||||
|
bp.add_app_template_filter(format_amount_input,
|
||||||
|
"accounting_journal_entry_format_amount_input")
|
||||||
|
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/create/<journalEntryType:journal_entry_type>", endpoint="create")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
|
||||||
|
"""Shows the form to add a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry_type: The journal entry type.
|
||||||
|
:return: The form to add a journal entry.
|
||||||
|
"""
|
||||||
|
journal_entry_op: JournalEntryOperator \
|
||||||
|
= JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
|
||||||
|
form: journal_entry_op.form
|
||||||
|
if "form" in session:
|
||||||
|
form = journal_entry_op.form(
|
||||||
|
ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = journal_entry_op.form()
|
||||||
|
form.date.data = date.today()
|
||||||
|
return journal_entry_op.render_create_template(form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/store/<journalEntryType:journal_entry_type>", endpoint="store")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
|
||||||
|
"""Adds a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry_type: The journal entry type.
|
||||||
|
:return: The redirection to the journal entry detail on success, or the
|
||||||
|
journal entry creation form on error.
|
||||||
|
"""
|
||||||
|
journal_entry_op: JournalEntryOperator \
|
||||||
|
= JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
|
||||||
|
form: journal_entry_op.form = journal_entry_op.form(request.form)
|
||||||
|
if not form.validate():
|
||||||
|
flash_form_errors(form)
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(with_type(
|
||||||
|
url_for("accounting.journal-entry.create",
|
||||||
|
journal_entry_type=journal_entry_type))))
|
||||||
|
journal_entry: JournalEntry = JournalEntry()
|
||||||
|
form.populate_obj(journal_entry)
|
||||||
|
db.session.add(journal_entry)
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The journal entry is added successfully.")),
|
||||||
|
"success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<journalEntry:journal_entry>", endpoint="detail")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
|
||||||
|
"""Shows the journal entry detail.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: The detail.
|
||||||
|
"""
|
||||||
|
journal_entry_op: JournalEntryOperator \
|
||||||
|
= get_journal_entry_op(journal_entry)
|
||||||
|
return journal_entry_op.render_detail_template(journal_entry)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<journalEntry:journal_entry>/edit", endpoint="edit")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
|
||||||
|
"""Shows the form to edit a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: The form to edit the journal entry.
|
||||||
|
"""
|
||||||
|
journal_entry_op: JournalEntryOperator \
|
||||||
|
= get_journal_entry_op(journal_entry, is_check_as=True)
|
||||||
|
form: journal_entry_op.form
|
||||||
|
if "form" in session:
|
||||||
|
form = journal_entry_op.form(
|
||||||
|
ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.obj = journal_entry
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = journal_entry_op.form(obj=journal_entry)
|
||||||
|
return journal_entry_op.render_edit_template(journal_entry, form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<journalEntry:journal_entry>/update", endpoint="update")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
|
||||||
|
"""Updates a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: The redirection to the journal entry detail on success, or the
|
||||||
|
journal entry edit form on error.
|
||||||
|
"""
|
||||||
|
journal_entry_op: JournalEntryOperator \
|
||||||
|
= get_journal_entry_op(journal_entry, is_check_as=True)
|
||||||
|
form: journal_entry_op.form = journal_entry_op.form(request.form)
|
||||||
|
form.obj = journal_entry
|
||||||
|
if not form.validate():
|
||||||
|
flash_form_errors(form)
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(with_type(
|
||||||
|
url_for("accounting.journal-entry.edit",
|
||||||
|
journal_entry=journal_entry))))
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
form.populate_obj(journal_entry)
|
||||||
|
if not form.is_modified:
|
||||||
|
flash(s(lazy_gettext("The journal entry was not modified.")),
|
||||||
|
"success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||||
|
journal_entry.updated_by_id = get_current_user_pk()
|
||||||
|
journal_entry.updated_at = sa.func.now()
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The journal entry is updated successfully.")),
|
||||||
|
"success")
|
||||||
|
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<journalEntry:journal_entry>/delete", endpoint="delete")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
|
||||||
|
"""Deletes a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: The redirection to the journal entry list on success, or the
|
||||||
|
journal entry detail on error.
|
||||||
|
"""
|
||||||
|
journal_entry.delete()
|
||||||
|
sort_journal_entries_in(journal_entry.date, journal_entry.id)
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The journal entry is deleted successfully.")),
|
||||||
|
"success")
|
||||||
|
return redirect(or_next(__get_default_page_uri()))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/dates/<date:journal_entry_date>", endpoint="order")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_journal_entry_order(journal_entry_date: date) -> str:
|
||||||
|
"""Shows the order of the journal entries in a same date.
|
||||||
|
|
||||||
|
:param journal_entry_date: The date.
|
||||||
|
:return: The order of the journal entries in the date.
|
||||||
|
"""
|
||||||
|
journal_entries: list[JournalEntry] = JournalEntry.query \
|
||||||
|
.filter(JournalEntry.date == journal_entry_date) \
|
||||||
|
.order_by(JournalEntry.no).all()
|
||||||
|
return render_template("accounting/journal-entry/order.html",
|
||||||
|
date=journal_entry_date, list=journal_entries)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/dates/<date:journal_entry_date>", endpoint="sort")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def sort_journal_entries(journal_entry_date: date) -> redirect:
|
||||||
|
"""Reorders the journal entries in a date.
|
||||||
|
|
||||||
|
:param journal_entry_date: The date.
|
||||||
|
:return: The redirection to the incoming account or the account list. The
|
||||||
|
reordering operation does not fail.
|
||||||
|
"""
|
||||||
|
form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date)
|
||||||
|
form.save_order()
|
||||||
|
if not form.is_modified:
|
||||||
|
flash(s(lazy_gettext("The order was not modified.")), "success")
|
||||||
|
return redirect(or_next(__get_default_page_uri()))
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("The order is updated successfully.")), "success")
|
||||||
|
return redirect(or_next(__get_default_page_uri()))
|
||||||
|
|
||||||
|
|
||||||
|
def __get_detail_uri(journal_entry: JournalEntry) -> str:
|
||||||
|
"""Returns the detail URI of a journal entry.
|
||||||
|
|
||||||
|
:param journal_entry: The journal entry.
|
||||||
|
:return: The detail URI of the journal entry.
|
||||||
|
"""
|
||||||
|
return url_for("accounting.journal-entry.detail",
|
||||||
|
journal_entry=journal_entry)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_default_page_uri() -> str:
|
||||||
|
"""Returns the URI for the default page.
|
||||||
|
|
||||||
|
:return: The URI for the default page.
|
||||||
|
"""
|
||||||
|
return url_for("accounting.report.default")
|
@ -21,6 +21,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import typing as t
|
import typing as t
|
||||||
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@ -52,7 +53,7 @@ class BaseAccount(db.Model):
|
|||||||
|
|
||||||
:return: The string representation of the base account.
|
:return: The string representation of the base account.
|
||||||
"""
|
"""
|
||||||
return f"{self.code} {self.title}"
|
return f"{self.code} {self.title.title()}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
@ -113,8 +114,8 @@ class Account(db.Model):
|
|||||||
"""The account number under the base account."""
|
"""The account number under the base account."""
|
||||||
title_l10n = db.Column("title", db.String, nullable=False)
|
title_l10n = db.Column("title", db.String, nullable=False)
|
||||||
"""The title."""
|
"""The title."""
|
||||||
is_offset_needed = db.Column(db.Boolean, nullable=False, default=False)
|
is_need_offset = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
"""Whether the entries of this account need offset."""
|
"""Whether the journal entry line items of this account need offset."""
|
||||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
server_default=db.func.now())
|
server_default=db.func.now())
|
||||||
"""The time of creation."""
|
"""The time of creation."""
|
||||||
@ -138,8 +139,9 @@ class Account(db.Model):
|
|||||||
l10n = db.relationship("AccountL10n", back_populates="account",
|
l10n = db.relationship("AccountL10n", back_populates="account",
|
||||||
lazy=False)
|
lazy=False)
|
||||||
"""The localized titles."""
|
"""The localized titles."""
|
||||||
entries = db.relationship("JournalEntry", back_populates="account")
|
line_items = db.relationship("JournalEntryLineItem",
|
||||||
"""The journal entries."""
|
back_populates="account")
|
||||||
|
"""The journal entry line items."""
|
||||||
|
|
||||||
CASH_CODE: str = "1111-001"
|
CASH_CODE: str = "1111-001"
|
||||||
"""The code of the cash account,"""
|
"""The code of the cash account,"""
|
||||||
@ -153,7 +155,7 @@ class Account(db.Model):
|
|||||||
|
|
||||||
:return: The string representation of this account.
|
:return: The string representation of this account.
|
||||||
"""
|
"""
|
||||||
return f"{self.base_code}-{self.no:03d} {self.title}"
|
return f"{self.base_code}-{self.no:03d} {self.title.title()}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def code(self) -> str:
|
def code(self) -> str:
|
||||||
@ -197,6 +199,52 @@ class Account(db.Model):
|
|||||||
return
|
return
|
||||||
self.l10n.append(AccountL10n(locale=current_locale, title=value))
|
self.l10n.append(AccountL10n(locale=current_locale, title=value))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_real(self) -> bool:
|
||||||
|
"""Returns whether the account is a real account.
|
||||||
|
|
||||||
|
:return: True if the account is a real account, or False otherwise.
|
||||||
|
"""
|
||||||
|
return self.base_code[0] in {"1", "2", "3"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_nominal(self) -> bool:
|
||||||
|
"""Returns whether the account is a nominal account.
|
||||||
|
|
||||||
|
:return: True if the account is a nominal account, or False otherwise.
|
||||||
|
"""
|
||||||
|
return not self.is_real
|
||||||
|
|
||||||
|
@property
|
||||||
|
def query_values(self) -> list[str]:
|
||||||
|
"""Returns the values to be queried.
|
||||||
|
|
||||||
|
:return: The values to be queried.
|
||||||
|
"""
|
||||||
|
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_modified(self) -> bool:
|
||||||
|
"""Returns whether a product account was modified.
|
||||||
|
|
||||||
|
:return: True if modified, or False otherwise.
|
||||||
|
"""
|
||||||
|
if db.session.is_modified(self):
|
||||||
|
return True
|
||||||
|
for l10n in self.l10n:
|
||||||
|
if db.session.is_modified(l10n):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Deletes this account.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
AccountL10n.query.filter(AccountL10n.account == self).delete()
|
||||||
|
cls: t.Type[t.Self] = self.__class__
|
||||||
|
cls.query.filter(cls.id == self.id).delete()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_by_code(cls, code: str) -> t.Self | None:
|
def find_by_code(cls, code: str) -> t.Self | None:
|
||||||
"""Finds an account by its code.
|
"""Finds an account by its code.
|
||||||
@ -251,14 +299,6 @@ class Account(db.Model):
|
|||||||
cls.base_code != "3353")\
|
cls.base_code != "3353")\
|
||||||
.order_by(cls.base_code, cls.no).all()
|
.order_by(cls.base_code, cls.no).all()
|
||||||
|
|
||||||
@property
|
|
||||||
def query_values(self) -> list[str]:
|
|
||||||
"""Returns the values to be queried.
|
|
||||||
|
|
||||||
:return: The values to be queried.
|
|
||||||
"""
|
|
||||||
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def cash(cls) -> t.Self:
|
def cash(cls) -> t.Self:
|
||||||
"""Returns the cash account.
|
"""Returns the cash account.
|
||||||
@ -275,28 +315,6 @@ class Account(db.Model):
|
|||||||
"""
|
"""
|
||||||
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
|
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
|
||||||
|
|
||||||
@property
|
|
||||||
def is_modified(self) -> bool:
|
|
||||||
"""Returns whether a product account was modified.
|
|
||||||
|
|
||||||
:return: True if modified, or False otherwise.
|
|
||||||
"""
|
|
||||||
if db.session.is_modified(self):
|
|
||||||
return True
|
|
||||||
for l10n in self.l10n:
|
|
||||||
if db.session.is_modified(l10n):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
|
||||||
"""Deletes this account.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
AccountL10n.query.filter(AccountL10n.account == self).delete()
|
|
||||||
cls: t.Type[t.Self] = self.__class__
|
|
||||||
cls.query.filter(cls.id == self.id).delete()
|
|
||||||
|
|
||||||
|
|
||||||
class AccountL10n(db.Model):
|
class AccountL10n(db.Model):
|
||||||
"""A localized account title."""
|
"""A localized account title."""
|
||||||
@ -346,15 +364,16 @@ class Currency(db.Model):
|
|||||||
l10n = db.relationship("CurrencyL10n", back_populates="currency",
|
l10n = db.relationship("CurrencyL10n", back_populates="currency",
|
||||||
lazy=False)
|
lazy=False)
|
||||||
"""The localized names."""
|
"""The localized names."""
|
||||||
entries = db.relationship("JournalEntry", back_populates="currency")
|
line_items = db.relationship("JournalEntryLineItem",
|
||||||
"""The journal entries."""
|
back_populates="currency")
|
||||||
|
"""The journal entry line items."""
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""Returns the string representation of the currency.
|
"""Returns the string representation of the currency.
|
||||||
|
|
||||||
:return: The string representation of the currency.
|
:return: The string representation of the currency.
|
||||||
"""
|
"""
|
||||||
return f"{self.name} ({self.code})"
|
return f"{self.name.title()} ({self.code})"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -430,23 +449,23 @@ class CurrencyL10n(db.Model):
|
|||||||
"""The localized name."""
|
"""The localized name."""
|
||||||
|
|
||||||
|
|
||||||
class TransactionCurrency:
|
class JournalEntryCurrency:
|
||||||
"""A currency in a transaction."""
|
"""A currency in a journal entry."""
|
||||||
|
|
||||||
def __init__(self, code: str, debit: list[JournalEntry],
|
def __init__(self, code: str, debit: list[JournalEntryLineItem],
|
||||||
credit: list[JournalEntry]):
|
credit: list[JournalEntryLineItem]):
|
||||||
"""Constructs the currency in the transaction.
|
"""Constructs the currency in the journal entry.
|
||||||
|
|
||||||
:param code: The currency code.
|
:param code: The currency code.
|
||||||
:param debit: The debit entries.
|
:param debit: The debit line items.
|
||||||
:param credit: The credit entries.
|
:param credit: The credit line items.
|
||||||
"""
|
"""
|
||||||
self.code: str = code
|
self.code: str = code
|
||||||
"""The currency code."""
|
"""The currency code."""
|
||||||
self.debit: list[JournalEntry] = debit
|
self.debit: list[JournalEntryLineItem] = debit
|
||||||
"""The debit entries."""
|
"""The debit line items."""
|
||||||
self.credit: list[JournalEntry] = credit
|
self.credit: list[JournalEntryLineItem] = credit
|
||||||
"""The credit entries."""
|
"""The credit line items."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@ -458,28 +477,28 @@ class TransactionCurrency:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def debit_total(self) -> Decimal:
|
def debit_total(self) -> Decimal:
|
||||||
"""Returns the total amount of the debit journal entries.
|
"""Returns the total amount of the debit line items.
|
||||||
|
|
||||||
:return: The total amount of the debit journal entries.
|
:return: The total amount of the debit line items.
|
||||||
"""
|
"""
|
||||||
return sum([x.amount for x in self.debit])
|
return sum([x.amount for x in self.debit])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def credit_total(self) -> str:
|
def credit_total(self) -> str:
|
||||||
"""Returns the total amount of the credit journal entries.
|
"""Returns the total amount of the credit line items.
|
||||||
|
|
||||||
:return: The total amount of the credit journal entries.
|
:return: The total amount of the credit line items.
|
||||||
"""
|
"""
|
||||||
return sum([x.amount for x in self.credit])
|
return sum([x.amount for x in self.credit])
|
||||||
|
|
||||||
|
|
||||||
class Transaction(db.Model):
|
class JournalEntry(db.Model):
|
||||||
"""A transaction."""
|
"""A journal entry."""
|
||||||
__tablename__ = "accounting_transactions"
|
__tablename__ = "accounting_journal_entries"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||||
autoincrement=False)
|
autoincrement=False)
|
||||||
"""The transaction ID."""
|
"""The journal entry ID."""
|
||||||
date = db.Column(db.Date, nullable=False)
|
date = db.Column(db.Date, nullable=False)
|
||||||
"""The date."""
|
"""The date."""
|
||||||
no = db.Column(db.Integer, nullable=False, default=text("1"))
|
no = db.Column(db.Integer, nullable=False, default=text("1"))
|
||||||
@ -506,35 +525,38 @@ class Transaction(db.Model):
|
|||||||
"""The ID of the updator."""
|
"""The ID of the updator."""
|
||||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||||
"""The updator."""
|
"""The updator."""
|
||||||
entries = db.relationship("JournalEntry", back_populates="transaction")
|
line_items = db.relationship("JournalEntryLineItem",
|
||||||
"""The journal entries."""
|
back_populates="journal_entry")
|
||||||
|
"""The line items."""
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""Returns the string representation of this transaction.
|
"""Returns the string representation of this journal entry.
|
||||||
|
|
||||||
:return: The string representation of this transaction.
|
:return: The string representation of this journal entry.
|
||||||
"""
|
"""
|
||||||
if self.is_cash_expense:
|
if self.is_cash_disbursement:
|
||||||
return gettext("Cash Expense Transaction#%(id)s", id=self.id)
|
return gettext("Cash Disbursement Journal Entry#%(id)s",
|
||||||
if self.is_cash_income:
|
id=self.id)
|
||||||
return gettext("Cash Income Transaction#%(id)s", id=self.id)
|
if self.is_cash_receipt:
|
||||||
return gettext("Transfer Transaction#%(id)s", id=self.id)
|
return gettext("Cash Receipt Journal Entry#%(id)s", id=self.id)
|
||||||
|
return gettext("Transfer Journal Entry#%(id)s", id=self.id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def currencies(self) -> list[TransactionCurrency]:
|
def currencies(self) -> list[JournalEntryCurrency]:
|
||||||
"""Returns the journal entries categorized by their currencies.
|
"""Returns the line items categorized by their currencies.
|
||||||
|
|
||||||
:return: The currency categories.
|
:return: The currency categories.
|
||||||
"""
|
"""
|
||||||
entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no)
|
line_items: list[JournalEntryLineItem] = sorted(self.line_items,
|
||||||
|
key=lambda x: x.no)
|
||||||
codes: list[str] = []
|
codes: list[str] = []
|
||||||
by_currency: dict[str, list[JournalEntry]] = {}
|
by_currency: dict[str, list[JournalEntryLineItem]] = {}
|
||||||
for entry in entries:
|
for line_item in line_items:
|
||||||
if entry.currency_code not in by_currency:
|
if line_item.currency_code not in by_currency:
|
||||||
codes.append(entry.currency_code)
|
codes.append(line_item.currency_code)
|
||||||
by_currency[entry.currency_code] = []
|
by_currency[line_item.currency_code] = []
|
||||||
by_currency[entry.currency_code].append(entry)
|
by_currency[line_item.currency_code].append(line_item)
|
||||||
return [TransactionCurrency(code=x,
|
return [JournalEntryCurrency(code=x,
|
||||||
debit=[y for y in by_currency[x]
|
debit=[y for y in by_currency[x]
|
||||||
if y.is_debit],
|
if y.is_debit],
|
||||||
credit=[y for y in by_currency[x]
|
credit=[y for y in by_currency[x]
|
||||||
@ -542,10 +564,11 @@ class Transaction(db.Model):
|
|||||||
for x in codes]
|
for x in codes]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_cash_income(self) -> bool:
|
def is_cash_receipt(self) -> bool:
|
||||||
"""Returns whether this is a cash income transaction.
|
"""Returns whether this is a cash receipt journal entry.
|
||||||
|
|
||||||
:return: True if this is a cash income transaction, or False otherwise.
|
:return: True if this is a cash receipt journal entry, or False
|
||||||
|
otherwise.
|
||||||
"""
|
"""
|
||||||
for currency in self.currencies:
|
for currency in self.currencies:
|
||||||
if len(currency.debit) > 1:
|
if len(currency.debit) > 1:
|
||||||
@ -555,10 +578,10 @@ class Transaction(db.Model):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_cash_expense(self) -> bool:
|
def is_cash_disbursement(self) -> bool:
|
||||||
"""Returns whether this is a cash expense transaction.
|
"""Returns whether this is a cash disbursement journal entry.
|
||||||
|
|
||||||
:return: True if this is a cash expense transaction, or False
|
:return: True if this is a cash disbursement journal entry, or False
|
||||||
otherwise.
|
otherwise.
|
||||||
"""
|
"""
|
||||||
for currency in self.currencies:
|
for currency in self.currencies:
|
||||||
@ -568,70 +591,93 @@ class Transaction(db.Model):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_delete(self) -> bool:
|
||||||
|
"""Returns whether the journal entry can be deleted.
|
||||||
|
|
||||||
|
:return: True if the journal entry can be deleted, or False otherwise.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__can_delete"):
|
||||||
|
def has_offset() -> bool:
|
||||||
|
for line_item in self.line_items:
|
||||||
|
if len(line_item.offsets) > 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
setattr(self, "__can_delete", not has_offset())
|
||||||
|
return getattr(self, "__can_delete")
|
||||||
|
|
||||||
def delete(self) -> None:
|
def delete(self) -> None:
|
||||||
"""Deletes the transaction.
|
"""Deletes the journal entry.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
JournalEntry.query\
|
JournalEntryLineItem.query\
|
||||||
.filter(JournalEntry.transaction_id == self.id).delete()
|
.filter(JournalEntryLineItem.journal_entry_id == self.id).delete()
|
||||||
db.session.delete(self)
|
db.session.delete(self)
|
||||||
|
|
||||||
|
|
||||||
class JournalEntry(db.Model):
|
class JournalEntryLineItem(db.Model):
|
||||||
"""An accounting journal entry."""
|
"""A line item in the journal entry."""
|
||||||
__tablename__ = "accounting_journal_entries"
|
__tablename__ = "accounting_journal_entry_line_items"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||||
autoincrement=False)
|
autoincrement=False)
|
||||||
"""The entry ID."""
|
"""The line item ID."""
|
||||||
transaction_id = db.Column(db.Integer,
|
journal_entry_id = db.Column(db.Integer,
|
||||||
db.ForeignKey(Transaction.id,
|
db.ForeignKey(JournalEntry.id,
|
||||||
onupdate="CASCADE",
|
onupdate="CASCADE",
|
||||||
ondelete="CASCADE"),
|
ondelete="CASCADE"),
|
||||||
nullable=False)
|
nullable=False)
|
||||||
"""The transaction ID."""
|
"""The journal entry ID."""
|
||||||
transaction = db.relationship(Transaction, back_populates="entries")
|
journal_entry = db.relationship(JournalEntry, back_populates="line_items")
|
||||||
"""The transaction."""
|
"""The journal entry."""
|
||||||
is_debit = db.Column(db.Boolean, nullable=False)
|
is_debit = db.Column(db.Boolean, nullable=False)
|
||||||
"""True for a debit entry, or False for a credit entry."""
|
"""True for a debit line item, or False for a credit line item."""
|
||||||
no = db.Column(db.Integer, nullable=False)
|
no = db.Column(db.Integer, nullable=False)
|
||||||
"""The entry number under the transaction and debit or credit."""
|
"""The line item number under the journal entry and debit or credit."""
|
||||||
offset_original_id = db.Column(db.Integer,
|
original_line_item_id = db.Column(db.Integer,
|
||||||
db.ForeignKey(id, onupdate="CASCADE"),
|
db.ForeignKey(id, onupdate="CASCADE"),
|
||||||
nullable=True)
|
nullable=True)
|
||||||
"""The ID of the original entry to offset."""
|
"""The ID of the original line item."""
|
||||||
offset_original = db.relationship("JournalEntry", back_populates="offsets",
|
original_line_item = db.relationship("JournalEntryLineItem",
|
||||||
|
back_populates="offsets",
|
||||||
remote_side=id, passive_deletes=True)
|
remote_side=id, passive_deletes=True)
|
||||||
"""The original entry to offset."""
|
"""The original line item."""
|
||||||
offsets = db.relationship("JournalEntry", back_populates="offset_original")
|
offsets = db.relationship("JournalEntryLineItem",
|
||||||
"""The offset entries."""
|
back_populates="original_line_item")
|
||||||
|
"""The offset items."""
|
||||||
currency_code = db.Column(db.String,
|
currency_code = db.Column(db.String,
|
||||||
db.ForeignKey(Currency.code, onupdate="CASCADE"),
|
db.ForeignKey(Currency.code, onupdate="CASCADE"),
|
||||||
nullable=False)
|
nullable=False)
|
||||||
"""The currency code."""
|
"""The currency code."""
|
||||||
currency = db.relationship(Currency, back_populates="entries")
|
currency = db.relationship(Currency, back_populates="line_items")
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
account_id = db.Column(db.Integer,
|
account_id = db.Column(db.Integer,
|
||||||
db.ForeignKey(Account.id,
|
db.ForeignKey(Account.id,
|
||||||
onupdate="CASCADE"),
|
onupdate="CASCADE"),
|
||||||
nullable=False)
|
nullable=False)
|
||||||
"""The account ID."""
|
"""The account ID."""
|
||||||
account = db.relationship(Account, back_populates="entries", lazy=False)
|
account = db.relationship(Account, back_populates="line_items", lazy=False)
|
||||||
"""The account."""
|
"""The account."""
|
||||||
summary = db.Column(db.String, nullable=True)
|
description = db.Column(db.String, nullable=True)
|
||||||
"""The summary."""
|
"""The description."""
|
||||||
amount = db.Column(db.Numeric(14, 2), nullable=False)
|
amount = db.Column(db.Numeric(14, 2), nullable=False)
|
||||||
"""The amount."""
|
"""The amount."""
|
||||||
|
|
||||||
@property
|
def __str__(self) -> str:
|
||||||
def eid(self) -> int | None:
|
"""Returns the string representation of the line item.
|
||||||
"""Returns the journal entry ID. This is the alternative name of the
|
|
||||||
ID field, to work with WTForms.
|
|
||||||
|
|
||||||
:return: The journal entry ID.
|
:return: The string representation of the line item.
|
||||||
"""
|
"""
|
||||||
return self.id
|
if not hasattr(self, "__str"):
|
||||||
|
from accounting.template_filters import format_date, format_amount
|
||||||
|
setattr(self, "__str",
|
||||||
|
gettext("%(date)s %(description)s %(amount)s",
|
||||||
|
date=format_date(self.journal_entry.date),
|
||||||
|
description="" if self.description is None
|
||||||
|
else self.description,
|
||||||
|
amount=format_amount(self.amount)))
|
||||||
|
return getattr(self, "__str")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def account_code(self) -> str:
|
def account_code(self) -> str:
|
||||||
@ -645,14 +691,74 @@ class JournalEntry(db.Model):
|
|||||||
def debit(self) -> Decimal | None:
|
def debit(self) -> Decimal | None:
|
||||||
"""Returns the debit amount.
|
"""Returns the debit amount.
|
||||||
|
|
||||||
:return: The debit amount, or None if this is not a debit entry.
|
:return: The debit amount, or None if this is not a debit line item.
|
||||||
"""
|
"""
|
||||||
return self.amount if self.is_debit else None
|
return self.amount if self.is_debit else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_need_offset(self) -> bool:
|
||||||
|
"""Returns whether the line item needs offset.
|
||||||
|
|
||||||
|
:return: True if the line item needs offset, or False otherwise.
|
||||||
|
"""
|
||||||
|
if not self.account.is_need_offset:
|
||||||
|
return False
|
||||||
|
if self.account.base_code[0] == "1" and not self.is_debit:
|
||||||
|
return False
|
||||||
|
if self.account.base_code[0] == "2" and self.is_debit:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def credit(self) -> Decimal | None:
|
def credit(self) -> Decimal | None:
|
||||||
"""Returns the credit amount.
|
"""Returns the credit amount.
|
||||||
|
|
||||||
:return: The credit amount, or None if this is not a credit entry.
|
:return: The credit amount, or None if this is not a credit line item.
|
||||||
"""
|
"""
|
||||||
return None if self.is_debit else self.amount
|
return None if self.is_debit else self.amount
|
||||||
|
|
||||||
|
@property
|
||||||
|
def net_balance(self) -> Decimal:
|
||||||
|
"""Returns the net balance.
|
||||||
|
|
||||||
|
:return: The net balance.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__net_balance"):
|
||||||
|
setattr(self, "__net_balance", self.amount + sum(
|
||||||
|
[x.amount if x.is_debit == self.is_debit else -x.amount
|
||||||
|
for x in self.offsets]))
|
||||||
|
return getattr(self, "__net_balance")
|
||||||
|
|
||||||
|
@net_balance.setter
|
||||||
|
def net_balance(self, net_balance: Decimal) -> None:
|
||||||
|
"""Sets the net balance.
|
||||||
|
|
||||||
|
:param net_balance: The net balance.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__net_balance", net_balance)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def query_values(self) -> tuple[list[str], list[str]]:
|
||||||
|
"""Returns the values to be queried.
|
||||||
|
|
||||||
|
:return: The values to be queried.
|
||||||
|
"""
|
||||||
|
def format_amount(value: Decimal) -> str:
|
||||||
|
whole: int = int(value)
|
||||||
|
frac: Decimal = (value - whole).normalize()
|
||||||
|
return str(whole) + str(abs(frac))[1:]
|
||||||
|
|
||||||
|
journal_entry_day: date = self.journal_entry.date
|
||||||
|
description: str = "" if self.description is None else self.description
|
||||||
|
return ([description],
|
||||||
|
[str(journal_entry_day.year),
|
||||||
|
"{}/{}".format(journal_entry_day.year,
|
||||||
|
journal_entry_day.month),
|
||||||
|
"{}/{}".format(journal_entry_day.month,
|
||||||
|
journal_entry_day.day),
|
||||||
|
"{}/{}/{}".format(journal_entry_day.year,
|
||||||
|
journal_entry_day.month,
|
||||||
|
journal_entry_day.day),
|
||||||
|
format_amount(self.amount),
|
||||||
|
format_amount(self.net_balance)])
|
||||||
|
@ -23,7 +23,7 @@ This file is largely taken from the NanoParma ERP project, first written in
|
|||||||
import typing as t
|
import typing as t
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from accounting.models import Transaction
|
from accounting.models import JournalEntry
|
||||||
from .period import Period
|
from .period import Period
|
||||||
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
||||||
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
|
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
|
||||||
@ -61,8 +61,8 @@ class PeriodChooser:
|
|||||||
self.url_template: str = get_url(TemplatePeriod())
|
self.url_template: str = get_url(TemplatePeriod())
|
||||||
"""The URL template."""
|
"""The URL template."""
|
||||||
|
|
||||||
first: Transaction | None \
|
first: JournalEntry | None \
|
||||||
= Transaction.query.order_by(Transaction.date).first()
|
= JournalEntry.query.order_by(JournalEntry.date).first()
|
||||||
start: date | None = None if first is None else first.date
|
start: date | None = None if first is None else first.date
|
||||||
|
|
||||||
# Attributes
|
# Attributes
|
||||||
|
@ -35,7 +35,7 @@ class ThisMonth(Period):
|
|||||||
|
|
||||||
def _set_properties(self) -> None:
|
def _set_properties(self) -> None:
|
||||||
self.spec = "this-month"
|
self.spec = "this-month"
|
||||||
self.desc = gettext("This month")
|
self.desc = gettext("This Month")
|
||||||
self.is_a_month = True
|
self.is_a_month = True
|
||||||
self.is_type_month = True
|
self.is_type_month = True
|
||||||
|
|
||||||
@ -55,7 +55,7 @@ class LastMonth(Period):
|
|||||||
|
|
||||||
def _set_properties(self) -> None:
|
def _set_properties(self) -> None:
|
||||||
self.spec = "last-month"
|
self.spec = "last-month"
|
||||||
self.desc = gettext("Last month")
|
self.desc = gettext("Last Month")
|
||||||
self.is_a_month = True
|
self.is_a_month = True
|
||||||
self.is_type_month = True
|
self.is_type_month = True
|
||||||
|
|
||||||
@ -75,7 +75,7 @@ class SinceLastMonth(Period):
|
|||||||
|
|
||||||
def _set_properties(self) -> None:
|
def _set_properties(self) -> None:
|
||||||
self.spec = "since-last-month"
|
self.spec = "since-last-month"
|
||||||
self.desc = gettext("Since last month")
|
self.desc = gettext("Since Last Month")
|
||||||
self.is_type_month = True
|
self.is_type_month = True
|
||||||
|
|
||||||
|
|
||||||
@ -90,7 +90,7 @@ class ThisYear(Period):
|
|||||||
|
|
||||||
def _set_properties(self) -> None:
|
def _set_properties(self) -> None:
|
||||||
self.spec = "this-year"
|
self.spec = "this-year"
|
||||||
self.desc = gettext("This year")
|
self.desc = gettext("This Year")
|
||||||
self.is_a_year = True
|
self.is_a_year = True
|
||||||
|
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ class LastYear(Period):
|
|||||||
|
|
||||||
def _set_properties(self) -> None:
|
def _set_properties(self) -> None:
|
||||||
self.spec = "last-year"
|
self.spec = "last-year"
|
||||||
self.desc = gettext("Last year")
|
self.desc = gettext("Last Year")
|
||||||
self.is_a_year = True
|
self.is_a_year = True
|
||||||
|
|
||||||
|
|
||||||
|
@ -24,8 +24,8 @@ from flask import render_template, Response
|
|||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
|
||||||
JournalEntry
|
JournalEntryLineItem
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from accounting.report.period import Period, PeriodChooser
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
@ -124,17 +124,17 @@ class AccountCollector:
|
|||||||
sub_conditions: list[sa.BinaryExpression] \
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
|
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntry.currency_code == self.__currency.code,
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
sa.or_(*sub_conditions)]
|
sa.or_(*sub_conditions)]
|
||||||
if self.__period.end is not None:
|
if self.__period.end is not None:
|
||||||
conditions.append(Transaction.date <= self.__period.end)
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntry.is_debit, JournalEntry.amount),
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
else_=-JournalEntry.amount)).label("balance")
|
else_=-JournalEntryLineItem.amount)).label("balance")
|
||||||
select_balance: sa.Select \
|
select_balance: sa.Select \
|
||||||
= sa.select(Account.id, Account.base_code, Account.no,
|
= sa.select(Account.id, Account.base_code, Account.no,
|
||||||
balance_func)\
|
balance_func)\
|
||||||
.join(Transaction).join(Account)\
|
.join(JournalEntry).join(Account)\
|
||||||
.filter(*conditions)\
|
.filter(*conditions)\
|
||||||
.group_by(Account.id, Account.base_code, Account.no)\
|
.group_by(Account.id, Account.base_code, Account.no)\
|
||||||
.order_by(Account.base_code, Account.no)
|
.order_by(Account.base_code, Account.no)
|
||||||
@ -178,8 +178,8 @@ class AccountCollector:
|
|||||||
if self.__period.start is None:
|
if self.__period.start is None:
|
||||||
return None
|
return None
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntry.currency_code == self.__currency.code,
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
Transaction.date < self.__period.start]
|
JournalEntry.date < self.__period.start]
|
||||||
return self.__query_balance(conditions)
|
return self.__query_balance(conditions)
|
||||||
|
|
||||||
def __add_current_period(self) -> None:
|
def __add_current_period(self) -> None:
|
||||||
@ -188,20 +188,20 @@ class AccountCollector:
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
self.__add_owner_s_equity(Account.NET_CHANGE_CODE,
|
self.__add_owner_s_equity(Account.NET_CHANGE_CODE,
|
||||||
self.__query_currency_period(),
|
self.__query_current_period(),
|
||||||
self.__period)
|
self.__period)
|
||||||
|
|
||||||
def __query_currency_period(self) -> Decimal | None:
|
def __query_current_period(self) -> Decimal | None:
|
||||||
"""Queries and returns the net income or loss for current period.
|
"""Queries and returns the net income or loss for current period.
|
||||||
|
|
||||||
:return: The net income or loss for current period.
|
:return: The net income or loss for current period.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntry.currency_code == self.__currency.code]
|
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(Transaction.date >= self.__period.start)
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
if self.__period.end is not None:
|
if self.__period.end is not None:
|
||||||
conditions.append(Transaction.date <= self.__period.end)
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
return self.__query_balance(conditions)
|
return self.__query_balance(conditions)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -213,12 +213,12 @@ class AccountCollector:
|
|||||||
:return: The balance.
|
:return: The balance.
|
||||||
"""
|
"""
|
||||||
conditions.extend([sa.not_(Account.base_code.startswith(x))
|
conditions.extend([sa.not_(Account.base_code.startswith(x))
|
||||||
for x in {"1", "2"}])
|
for x in {"1", "2", "3"}])
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntry.is_debit, JournalEntry.amount),
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
else_=-JournalEntry.amount))
|
else_=-JournalEntryLineItem.amount))
|
||||||
select_balance: sa.Select = sa.select(balance_func)\
|
select_balance: sa.Select = sa.select(balance_func)\
|
||||||
.join(Transaction).join(Account).filter(*conditions)
|
.join(JournalEntry).join(Account).filter(*conditions)
|
||||||
return db.session.scalar(select_balance)
|
return db.session.scalar(select_balance)
|
||||||
|
|
||||||
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
|
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
|
||||||
|
@ -26,7 +26,8 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, Account, Transaction, JournalEntry
|
from accounting.models import Currency, Account, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from accounting.report.period import Period, PeriodChooser
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
@ -37,27 +38,28 @@ from accounting.report.utils.option_link import OptionLink
|
|||||||
from accounting.report.utils.report_chooser import ReportChooser
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
from accounting.report.utils.report_type import ReportType
|
from accounting.report.utils.report_type import ReportType
|
||||||
from accounting.report.utils.urls import income_expenses_url
|
from accounting.report.utils.urls import income_expenses_url
|
||||||
|
from accounting.utils.cast import be
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
class ReportEntry:
|
class ReportLineItem:
|
||||||
"""An entry in the report."""
|
"""A line item in the report."""
|
||||||
|
|
||||||
def __init__(self, entry: JournalEntry | None = None):
|
def __init__(self, line_item: JournalEntryLineItem | None = None):
|
||||||
"""Constructs the entry in the report.
|
"""Constructs the line item in the report.
|
||||||
|
|
||||||
:param entry: The journal entry.
|
:param line_item: The journal entry line item.
|
||||||
"""
|
"""
|
||||||
self.is_brought_forward: bool = False
|
self.is_brought_forward: bool = False
|
||||||
"""Whether this is the brought-forward entry."""
|
"""Whether this is the brought-forward line item."""
|
||||||
self.is_total: bool = False
|
self.is_total: bool = False
|
||||||
"""Whether this is the total entry."""
|
"""Whether this is the total line item."""
|
||||||
self.date: date | None = None
|
self.date: date | None = None
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.account: Account | None = None
|
self.account: Account | None = None
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.summary: str | None = None
|
self.description: str | None = None
|
||||||
"""The summary."""
|
"""The description."""
|
||||||
self.income: Decimal | None = None
|
self.income: Decimal | None = None
|
||||||
"""The income amount."""
|
"""The income amount."""
|
||||||
self.expense: Decimal | None = None
|
self.expense: Decimal | None = None
|
||||||
@ -67,24 +69,24 @@ class ReportEntry:
|
|||||||
self.note: str | None = None
|
self.note: str | None = None
|
||||||
"""The note."""
|
"""The note."""
|
||||||
self.url: str | None = None
|
self.url: str | None = None
|
||||||
"""The URL to the journal entry."""
|
"""The URL to the journal entry line item."""
|
||||||
if entry is not None:
|
if line_item is not None:
|
||||||
self.date = entry.transaction.date
|
self.date = line_item.journal_entry.date
|
||||||
self.account = entry.account
|
self.account = line_item.account
|
||||||
self.summary = entry.summary
|
self.description = line_item.description
|
||||||
self.income = None if entry.is_debit else entry.amount
|
self.income = None if line_item.is_debit else line_item.amount
|
||||||
self.expense = entry.amount if entry.is_debit else None
|
self.expense = line_item.amount if line_item.is_debit else None
|
||||||
self.note = entry.transaction.note
|
self.note = line_item.journal_entry.note
|
||||||
self.url = url_for("accounting.transaction.detail",
|
self.url = url_for("accounting.journal-entry.detail",
|
||||||
txn=entry.transaction)
|
journal_entry=line_item.journal_entry)
|
||||||
|
|
||||||
|
|
||||||
class EntryCollector:
|
class LineItemCollector:
|
||||||
"""The report entry collector."""
|
"""The line item collector."""
|
||||||
|
|
||||||
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
|
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
|
||||||
period: Period):
|
period: Period):
|
||||||
"""Constructs the report entry collector.
|
"""Constructs the line item collector.
|
||||||
|
|
||||||
:param currency: The currency.
|
:param currency: The currency.
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
@ -96,73 +98,78 @@ class EntryCollector:
|
|||||||
"""The account."""
|
"""The account."""
|
||||||
self.__period: Period = period
|
self.__period: Period = period
|
||||||
"""The period"""
|
"""The period"""
|
||||||
self.brought_forward: ReportEntry | None
|
self.brought_forward: ReportLineItem | None
|
||||||
"""The brought-forward entry."""
|
"""The brought-forward line item."""
|
||||||
self.entries: list[ReportEntry]
|
self.line_items: list[ReportLineItem]
|
||||||
"""The log entries."""
|
"""The line items."""
|
||||||
self.total: ReportEntry | None
|
self.total: ReportLineItem | None
|
||||||
"""The total entry."""
|
"""The total line item."""
|
||||||
self.brought_forward = self.__get_brought_forward_entry()
|
self.brought_forward = self.__get_brought_forward()
|
||||||
self.entries = self.__query_entries()
|
self.line_items = self.__query_line_items()
|
||||||
self.total = self.__get_total_entry()
|
self.total = self.__get_total()
|
||||||
self.__populate_balance()
|
self.__populate_balance()
|
||||||
|
|
||||||
def __get_brought_forward_entry(self) -> ReportEntry | None:
|
def __get_brought_forward(self) -> ReportLineItem | None:
|
||||||
"""Queries, composes and returns the brought-forward entry.
|
"""Queries, composes and returns the brought-forward line item.
|
||||||
|
|
||||||
:return: The brought-forward entry, or None if the period starts from
|
:return: The brought-forward line item, or None if the period starts
|
||||||
the beginning.
|
from the beginning.
|
||||||
"""
|
"""
|
||||||
if self.__period.start is None:
|
if self.__period.start is None:
|
||||||
return None
|
return None
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntry.is_debit, JournalEntry.amount),
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
else_=-JournalEntry.amount))
|
else_=-JournalEntryLineItem.amount))
|
||||||
select: sa.Select = sa.Select(balance_func)\
|
select: sa.Select = sa.Select(balance_func)\
|
||||||
.join(Transaction).join(Account)\
|
.join(JournalEntry).join(Account)\
|
||||||
.filter(JournalEntry.currency_code == self.__currency.code,
|
.filter(be(JournalEntryLineItem.currency_code
|
||||||
|
== self.__currency.code),
|
||||||
self.__account_condition,
|
self.__account_condition,
|
||||||
Transaction.date < self.__period.start)
|
JournalEntry.date < self.__period.start)
|
||||||
balance: int | None = db.session.scalar(select)
|
balance: int | None = db.session.scalar(select)
|
||||||
if balance is None:
|
if balance is None:
|
||||||
return None
|
return None
|
||||||
entry: ReportEntry = ReportEntry()
|
line_item: ReportLineItem = ReportLineItem()
|
||||||
entry.is_brought_forward = True
|
line_item.is_brought_forward = True
|
||||||
entry.date = self.__period.start
|
line_item.date = self.__period.start
|
||||||
entry.account = Account.accumulated_change()
|
line_item.account = Account.accumulated_change()
|
||||||
entry.summary = gettext("Brought forward")
|
line_item.description = gettext("Brought forward")
|
||||||
if balance > 0:
|
if balance > 0:
|
||||||
entry.income = balance
|
line_item.income = balance
|
||||||
elif balance < 0:
|
elif balance < 0:
|
||||||
entry.expense = -balance
|
line_item.expense = -balance
|
||||||
entry.balance = balance
|
line_item.balance = balance
|
||||||
return entry
|
return line_item
|
||||||
|
|
||||||
def __query_entries(self) -> list[ReportEntry]:
|
def __query_line_items(self) -> list[ReportLineItem]:
|
||||||
"""Queries and returns the log entries.
|
"""Queries and returns the line items.
|
||||||
|
|
||||||
:return: The log entries.
|
:return: The line items.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntry.currency_code == self.__currency.code,
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
self.__account_condition]
|
self.__account_condition]
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(Transaction.date >= self.__period.start)
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
if self.__period.end is not None:
|
if self.__period.end is not None:
|
||||||
conditions.append(Transaction.date <= self.__period.end)
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
txn_with_account: sa.Select = sa.Select(Transaction.id).\
|
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
|
||||||
join(JournalEntry).join(Account).filter(*conditions)
|
join(JournalEntryLineItem).join(Account).filter(*conditions)
|
||||||
|
|
||||||
return [ReportEntry(x)
|
return [ReportLineItem(x)
|
||||||
for x in JournalEntry.query.join(Transaction).join(Account)
|
for x in JournalEntryLineItem.query
|
||||||
.filter(JournalEntry.transaction_id.in_(txn_with_account),
|
.join(JournalEntry).join(Account)
|
||||||
JournalEntry.currency_code == self.__currency.code,
|
.filter(JournalEntryLineItem.journal_entry_id
|
||||||
|
.in_(journal_entry_with_account),
|
||||||
|
JournalEntryLineItem.currency_code
|
||||||
|
== self.__currency.code,
|
||||||
sa.not_(self.__account_condition))
|
sa.not_(self.__account_condition))
|
||||||
.order_by(Transaction.date,
|
.order_by(JournalEntry.date,
|
||||||
JournalEntry.is_debit,
|
JournalEntry.no,
|
||||||
JournalEntry.no)
|
JournalEntryLineItem.is_debit,
|
||||||
.options(selectinload(JournalEntry.account),
|
JournalEntryLineItem.no)
|
||||||
selectinload(JournalEntry.transaction))]
|
.options(selectinload(JournalEntryLineItem.account),
|
||||||
|
selectinload(JournalEntryLineItem.journal_entry))]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def __account_condition(self) -> sa.BinaryExpression:
|
def __account_condition(self) -> sa.BinaryExpression:
|
||||||
@ -173,66 +180,67 @@ class EntryCollector:
|
|||||||
Account.base_code.startswith("22"))
|
Account.base_code.startswith("22"))
|
||||||
return Account.id == self.__account.id
|
return Account.id == self.__account.id
|
||||||
|
|
||||||
def __get_total_entry(self) -> ReportEntry | None:
|
def __get_total(self) -> ReportLineItem | None:
|
||||||
"""Composes the total entry.
|
"""Composes the total line item.
|
||||||
|
|
||||||
:return: The total entry, or None if there is no data.
|
:return: The total line item, or None if there is no data.
|
||||||
"""
|
"""
|
||||||
if self.brought_forward is None and len(self.entries) == 0:
|
if self.brought_forward is None and len(self.line_items) == 0:
|
||||||
return None
|
return None
|
||||||
entry: ReportEntry = ReportEntry()
|
line_item: ReportLineItem = ReportLineItem()
|
||||||
entry.is_total = True
|
line_item.is_total = True
|
||||||
entry.summary = gettext("Total")
|
line_item.description = gettext("Total")
|
||||||
entry.income = sum([x.income for x in self.entries
|
line_item.income = sum([x.income for x in self.line_items
|
||||||
if x.income is not None])
|
if x.income is not None])
|
||||||
entry.expense = sum([x.expense for x in self.entries
|
line_item.expense = sum([x.expense for x in self.line_items
|
||||||
if x.expense is not None])
|
if x.expense is not None])
|
||||||
entry.balance = entry.income - entry.expense
|
line_item.balance = line_item.income - line_item.expense
|
||||||
if self.brought_forward is not None:
|
if self.brought_forward is not None:
|
||||||
entry.balance = self.brought_forward.balance + entry.balance
|
line_item.balance \
|
||||||
return entry
|
= self.brought_forward.balance + line_item.balance
|
||||||
|
return line_item
|
||||||
|
|
||||||
def __populate_balance(self) -> None:
|
def __populate_balance(self) -> None:
|
||||||
"""Populates the balance of the entries.
|
"""Populates the balance of the line items.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
balance: Decimal = 0 if self.brought_forward is None \
|
balance: Decimal = 0 if self.brought_forward is None \
|
||||||
else self.brought_forward.balance
|
else self.brought_forward.balance
|
||||||
for entry in self.entries:
|
for line_item in self.line_items:
|
||||||
if entry.income is not None:
|
if line_item.income is not None:
|
||||||
balance = balance + entry.income
|
balance = balance + line_item.income
|
||||||
if entry.expense is not None:
|
if line_item.expense is not None:
|
||||||
balance = balance - entry.expense
|
balance = balance - line_item.expense
|
||||||
entry.balance = balance
|
line_item.balance = balance
|
||||||
|
|
||||||
|
|
||||||
class CSVRow(BaseCSVRow):
|
class CSVRow(BaseCSVRow):
|
||||||
"""A row in the CSV."""
|
"""A row in the CSV."""
|
||||||
|
|
||||||
def __init__(self, txn_date: date | str | None,
|
def __init__(self, journal_entry_date: date | str | None,
|
||||||
account: str | None,
|
account: str | None,
|
||||||
summary: str | None,
|
description: str | None,
|
||||||
income: str | Decimal | None,
|
income: str | Decimal | None,
|
||||||
expense: str | Decimal | None,
|
expense: str | Decimal | None,
|
||||||
balance: str | Decimal | None,
|
balance: str | Decimal | None,
|
||||||
note: str | None):
|
note: str | None):
|
||||||
"""Constructs a row in the CSV.
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
:param txn_date: The transaction date.
|
:param journal_entry_date: The journal entry date.
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
:param summary: The summary.
|
:param description: The description.
|
||||||
:param income: The income.
|
:param income: The income.
|
||||||
:param expense: The expense.
|
:param expense: The expense.
|
||||||
:param balance: The balance.
|
:param balance: The balance.
|
||||||
:param note: The note.
|
:param note: The note.
|
||||||
"""
|
"""
|
||||||
self.date: date | str | None = txn_date
|
self.date: date | str | None = journal_entry_date
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.account: str | None = account
|
self.account: str | None = account
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.summary: str | None = summary
|
self.description: str | None = description
|
||||||
"""The summary."""
|
"""The description."""
|
||||||
self.income: str | Decimal | None = income
|
self.income: str | Decimal | None = income
|
||||||
"""The income."""
|
"""The income."""
|
||||||
self.expense: str | Decimal | None = expense
|
self.expense: str | Decimal | None = expense
|
||||||
@ -248,7 +256,7 @@ class CSVRow(BaseCSVRow):
|
|||||||
|
|
||||||
:return: The values of the row.
|
:return: The values of the row.
|
||||||
"""
|
"""
|
||||||
return [self.date, self.account, self.summary,
|
return [self.date, self.account, self.description,
|
||||||
self.income, self.expense, self.balance, self.note]
|
self.income, self.expense, self.balance, self.note]
|
||||||
|
|
||||||
|
|
||||||
@ -259,19 +267,19 @@ class PageParams(BasePageParams):
|
|||||||
account: IncomeExpensesAccount,
|
account: IncomeExpensesAccount,
|
||||||
period: Period,
|
period: Period,
|
||||||
has_data: bool,
|
has_data: bool,
|
||||||
pagination: Pagination[ReportEntry],
|
pagination: Pagination[ReportLineItem],
|
||||||
brought_forward: ReportEntry | None,
|
brought_forward: ReportLineItem | None,
|
||||||
entries: list[ReportEntry],
|
line_items: list[ReportLineItem],
|
||||||
total: ReportEntry | None):
|
total: ReportLineItem | None):
|
||||||
"""Constructs the HTML page parameters.
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
:param currency: The currency.
|
:param currency: The currency.
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
:param period: The period.
|
:param period: The period.
|
||||||
:param has_data: True if there is any data, or False otherwise.
|
:param has_data: True if there is any data, or False otherwise.
|
||||||
:param brought_forward: The brought-forward entry.
|
:param brought_forward: The brought-forward line item.
|
||||||
:param entries: The log entries.
|
:param line_items: The line items.
|
||||||
:param total: The total entry.
|
:param total: The total line item.
|
||||||
"""
|
"""
|
||||||
self.currency: Currency = currency
|
self.currency: Currency = currency
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
@ -281,14 +289,14 @@ class PageParams(BasePageParams):
|
|||||||
"""The period."""
|
"""The period."""
|
||||||
self.__has_data: bool = has_data
|
self.__has_data: bool = has_data
|
||||||
"""True if there is any data, or False otherwise."""
|
"""True if there is any data, or False otherwise."""
|
||||||
self.pagination: Pagination[ReportEntry] = pagination
|
self.pagination: Pagination[ReportLineItem] = pagination
|
||||||
"""The pagination."""
|
"""The pagination."""
|
||||||
self.brought_forward: ReportEntry | None = brought_forward
|
self.brought_forward: ReportLineItem | None = brought_forward
|
||||||
"""The brought-forward entry."""
|
"""The brought-forward line item."""
|
||||||
self.entries: list[ReportEntry] = entries
|
self.line_items: list[ReportLineItem] = line_items
|
||||||
"""The report entries."""
|
"""The line items."""
|
||||||
self.total: ReportEntry | None = total
|
self.total: ReportLineItem | None = total
|
||||||
"""The total entry."""
|
"""The total line item."""
|
||||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||||
lambda x: income_expenses_url(currency, account, x))
|
lambda x: income_expenses_url(currency, account, x))
|
||||||
"""The period chooser."""
|
"""The period chooser."""
|
||||||
@ -340,14 +348,15 @@ class PageParams(BasePageParams):
|
|||||||
income_expenses_url(self.currency, current_al,
|
income_expenses_url(self.currency, current_al,
|
||||||
self.period),
|
self.period),
|
||||||
self.account.id == 0)]
|
self.account.id == 0)]
|
||||||
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
|
||||||
.join(Account)\
|
.join(Account)\
|
||||||
.filter(JournalEntry.currency_code == self.currency.code,
|
.filter(be(JournalEntryLineItem.currency_code
|
||||||
|
== self.currency.code),
|
||||||
sa.or_(Account.base_code.startswith("11"),
|
sa.or_(Account.base_code.startswith("11"),
|
||||||
Account.base_code.startswith("12"),
|
Account.base_code.startswith("12"),
|
||||||
Account.base_code.startswith("21"),
|
Account.base_code.startswith("21"),
|
||||||
Account.base_code.startswith("22")))\
|
Account.base_code.startswith("22")))\
|
||||||
.group_by(JournalEntry.account_id)
|
.group_by(JournalEntryLineItem.account_id)
|
||||||
options.extend([OptionLink(str(x),
|
options.extend([OptionLink(str(x),
|
||||||
income_expenses_url(
|
income_expenses_url(
|
||||||
self.currency,
|
self.currency,
|
||||||
@ -376,14 +385,15 @@ class IncomeExpenses(BaseReport):
|
|||||||
"""The account."""
|
"""The account."""
|
||||||
self.__period: Period = period
|
self.__period: Period = period
|
||||||
"""The period."""
|
"""The period."""
|
||||||
collector: EntryCollector = EntryCollector(
|
collector: LineItemCollector = LineItemCollector(
|
||||||
self.__currency, self.__account, self.__period)
|
self.__currency, self.__account, self.__period)
|
||||||
self.__brought_forward: ReportEntry | None = collector.brought_forward
|
self.__brought_forward: ReportLineItem | None \
|
||||||
"""The brought-forward entry."""
|
= collector.brought_forward
|
||||||
self.__entries: list[ReportEntry] = collector.entries
|
"""The brought-forward line item."""
|
||||||
"""The report entries."""
|
self.__line_items: list[ReportLineItem] = collector.line_items
|
||||||
self.__total: ReportEntry | None = collector.total
|
"""The line items."""
|
||||||
"""The total entry."""
|
self.__total: ReportLineItem | None = collector.total
|
||||||
|
"""The total line item."""
|
||||||
|
|
||||||
def csv(self) -> Response:
|
def csv(self) -> Response:
|
||||||
"""Returns the report as CSV for download.
|
"""Returns the report as CSV for download.
|
||||||
@ -401,20 +411,20 @@ class IncomeExpenses(BaseReport):
|
|||||||
:return: The CSV rows.
|
:return: The CSV rows.
|
||||||
"""
|
"""
|
||||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
|
||||||
gettext("Summary"), gettext("Income"),
|
gettext("Description"), gettext("Income"),
|
||||||
gettext("Expense"), gettext("Balance"),
|
gettext("Expense"), gettext("Balance"),
|
||||||
gettext("Note"))]
|
gettext("Note"))]
|
||||||
if self.__brought_forward is not None:
|
if self.__brought_forward is not None:
|
||||||
rows.append(CSVRow(self.__brought_forward.date,
|
rows.append(CSVRow(self.__brought_forward.date,
|
||||||
str(self.__brought_forward.account).title(),
|
str(self.__brought_forward.account).title(),
|
||||||
self.__brought_forward.summary,
|
self.__brought_forward.description,
|
||||||
self.__brought_forward.income,
|
self.__brought_forward.income,
|
||||||
self.__brought_forward.expense,
|
self.__brought_forward.expense,
|
||||||
self.__brought_forward.balance,
|
self.__brought_forward.balance,
|
||||||
None))
|
None))
|
||||||
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
|
rows.extend([CSVRow(x.date, str(x.account).title(), x.description,
|
||||||
x.income, x.expense, x.balance, x.note)
|
x.income, x.expense, x.balance, x.note)
|
||||||
for x in self.__entries])
|
for x in self.__line_items])
|
||||||
if self.__total is not None:
|
if self.__total is not None:
|
||||||
rows.append(CSVRow(gettext("Total"), None, None,
|
rows.append(CSVRow(gettext("Total"), None, None,
|
||||||
self.__total.income, self.__total.expense,
|
self.__total.income, self.__total.expense,
|
||||||
@ -426,31 +436,31 @@ class IncomeExpenses(BaseReport):
|
|||||||
|
|
||||||
:return: The report as HTML.
|
:return: The report as HTML.
|
||||||
"""
|
"""
|
||||||
all_entries: list[ReportEntry] = []
|
all_line_items: list[ReportLineItem] = []
|
||||||
if self.__brought_forward is not None:
|
if self.__brought_forward is not None:
|
||||||
all_entries.append(self.__brought_forward)
|
all_line_items.append(self.__brought_forward)
|
||||||
all_entries.extend(self.__entries)
|
all_line_items.extend(self.__line_items)
|
||||||
if self.__total is not None:
|
if self.__total is not None:
|
||||||
all_entries.append(self.__total)
|
all_line_items.append(self.__total)
|
||||||
pagination: Pagination[ReportEntry] \
|
pagination: Pagination[ReportLineItem] \
|
||||||
= Pagination[ReportEntry](all_entries)
|
= Pagination[ReportLineItem](all_line_items, is_reversed=True)
|
||||||
page_entries: list[ReportEntry] = pagination.list
|
page_line_items: list[ReportLineItem] = pagination.list
|
||||||
has_data: bool = len(page_entries) > 0
|
has_data: bool = len(page_line_items) > 0
|
||||||
brought_forward: ReportEntry | None = None
|
brought_forward: ReportLineItem | None = None
|
||||||
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
|
if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
|
||||||
brought_forward = page_entries[0]
|
brought_forward = page_line_items[0]
|
||||||
page_entries = page_entries[1:]
|
page_line_items = page_line_items[1:]
|
||||||
total: ReportEntry | None = None
|
total: ReportLineItem | None = None
|
||||||
if len(page_entries) > 0 and page_entries[-1].is_total:
|
if len(page_line_items) > 0 and page_line_items[-1].is_total:
|
||||||
total = page_entries[-1]
|
total = page_line_items[-1]
|
||||||
page_entries = page_entries[:-1]
|
page_line_items = page_line_items[:-1]
|
||||||
params: PageParams = PageParams(currency=self.__currency,
|
params: PageParams = PageParams(currency=self.__currency,
|
||||||
account=self.__account,
|
account=self.__account,
|
||||||
period=self.__period,
|
period=self.__period,
|
||||||
has_data=has_data,
|
has_data=has_data,
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
brought_forward=brought_forward,
|
brought_forward=brought_forward,
|
||||||
entries=page_entries,
|
line_items=page_line_items,
|
||||||
total=total)
|
total=total)
|
||||||
return render_template("accounting/report/income-expenses.html",
|
return render_template("accounting/report/income-expenses.html",
|
||||||
report=params)
|
report=params)
|
||||||
|
@ -24,8 +24,8 @@ from flask import render_template, Response
|
|||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
|
||||||
JournalEntry
|
JournalEntryLineItem
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from accounting.report.period import Period, PeriodChooser
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
@ -256,17 +256,17 @@ class IncomeStatement(BaseReport):
|
|||||||
sub_conditions: list[sa.BinaryExpression] \
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
|
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntry.currency_code == self.__currency.code,
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
sa.or_(*sub_conditions)]
|
sa.or_(*sub_conditions)]
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(Transaction.date >= self.__period.start)
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
if self.__period.end is not None:
|
if self.__period.end is not None:
|
||||||
conditions.append(Transaction.date <= self.__period.end)
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntry.is_debit, -JournalEntry.amount),
|
(JournalEntryLineItem.is_debit, -JournalEntryLineItem.amount),
|
||||||
else_=JournalEntry.amount)).label("balance")
|
else_=JournalEntryLineItem.amount)).label("balance")
|
||||||
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||||
.join(Transaction).join(Account)\
|
.join(JournalEntry).join(Account)\
|
||||||
.filter(*conditions)\
|
.filter(*conditions)\
|
||||||
.group_by(Account.id)\
|
.group_by(Account.id)\
|
||||||
.order_by(Account.base_code, Account.no)
|
.order_by(Account.base_code, Account.no)
|
||||||
|
@ -25,7 +25,8 @@ from flask import render_template, Response
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, Account, Transaction, JournalEntry
|
from accounting.models import Currency, Account, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from accounting.report.period import Period, PeriodChooser
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
@ -37,58 +38,58 @@ from accounting.report.utils.urls import journal_url
|
|||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
class ReportEntry:
|
class ReportLineItem:
|
||||||
"""An entry in the report."""
|
"""A line item in the report."""
|
||||||
|
|
||||||
def __init__(self, entry: JournalEntry):
|
def __init__(self, line_item: JournalEntryLineItem):
|
||||||
"""Constructs the entry in the report.
|
"""Constructs the line item in the report.
|
||||||
|
|
||||||
:param entry: The journal entry.
|
:param line_item: The journal entry line item.
|
||||||
"""
|
"""
|
||||||
self.entry: JournalEntry = entry
|
self.line_item: JournalEntryLineItem = line_item
|
||||||
|
"""The journal entry line item."""
|
||||||
|
self.journal_entry: JournalEntry = line_item.journal_entry
|
||||||
"""The journal entry."""
|
"""The journal entry."""
|
||||||
self.transaction: Transaction = entry.transaction
|
self.currency: Currency = line_item.currency
|
||||||
"""The transaction."""
|
|
||||||
self.currency: Currency = entry.currency
|
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.account: Account = entry.account
|
self.account: Account = line_item.account
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.summary: str | None = entry.summary
|
self.description: str | None = line_item.description
|
||||||
"""The summary."""
|
"""The description."""
|
||||||
self.debit: Decimal | None = entry.debit
|
self.debit: Decimal | None = line_item.debit
|
||||||
"""The debit amount."""
|
"""The debit amount."""
|
||||||
self.credit: Decimal | None = entry.credit
|
self.credit: Decimal | None = line_item.credit
|
||||||
"""The credit amount."""
|
"""The credit amount."""
|
||||||
self.amount: Decimal = entry.amount
|
self.amount: Decimal = line_item.amount
|
||||||
"""The amount."""
|
"""The amount."""
|
||||||
|
|
||||||
|
|
||||||
class CSVRow(BaseCSVRow):
|
class CSVRow(BaseCSVRow):
|
||||||
"""A row in the CSV."""
|
"""A row in the CSV."""
|
||||||
|
|
||||||
def __init__(self, txn_date: str | date,
|
def __init__(self, journal_entry_date: str | date,
|
||||||
currency: str,
|
currency: str,
|
||||||
account: str,
|
account: str,
|
||||||
summary: str | None,
|
description: str | None,
|
||||||
debit: str | Decimal | None,
|
debit: str | Decimal | None,
|
||||||
credit: str | Decimal | None,
|
credit: str | Decimal | None,
|
||||||
note: str | None):
|
note: str | None):
|
||||||
"""Constructs a row in the CSV.
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
:param txn_date: The transaction date.
|
:param journal_entry_date: The journal entry date.
|
||||||
:param summary: The summary.
|
:param description: The description.
|
||||||
:param debit: The debit amount.
|
:param debit: The debit amount.
|
||||||
:param credit: The credit amount.
|
:param credit: The credit amount.
|
||||||
:param note: The note.
|
:param note: The note.
|
||||||
"""
|
"""
|
||||||
self.date: str | date = txn_date
|
self.date: str | date = journal_entry_date
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.currency: str = currency
|
self.currency: str = currency
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
self.account: str = account
|
self.account: str = account
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.summary: str | None = summary
|
self.description: str | None = description
|
||||||
"""The summary."""
|
"""The description."""
|
||||||
self.debit: str | Decimal | None = debit
|
self.debit: str | Decimal | None = debit
|
||||||
"""The debit amount."""
|
"""The debit amount."""
|
||||||
self.credit: str | Decimal | None = credit
|
self.credit: str | Decimal | None = credit
|
||||||
@ -102,7 +103,7 @@ class CSVRow(BaseCSVRow):
|
|||||||
|
|
||||||
:return: The values of the row.
|
:return: The values of the row.
|
||||||
"""
|
"""
|
||||||
return [self.date, self.currency, self.account, self.summary,
|
return [self.date, self.currency, self.account, self.description,
|
||||||
self.debit, self.credit, self.note]
|
self.debit, self.credit, self.note]
|
||||||
|
|
||||||
|
|
||||||
@ -110,19 +111,19 @@ class PageParams(BasePageParams):
|
|||||||
"""The HTML page parameters."""
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
def __init__(self, period: Period,
|
def __init__(self, period: Period,
|
||||||
pagination: Pagination[JournalEntry],
|
pagination: Pagination[JournalEntryLineItem],
|
||||||
entries: list[JournalEntry]):
|
line_items: list[JournalEntryLineItem]):
|
||||||
"""Constructs the HTML page parameters.
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
:param period: The period.
|
:param period: The period.
|
||||||
:param entries: The journal entries.
|
:param line_items: The line items.
|
||||||
"""
|
"""
|
||||||
self.period: Period = period
|
self.period: Period = period
|
||||||
"""The period."""
|
"""The period."""
|
||||||
self.pagination: Pagination[JournalEntry] = pagination
|
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
||||||
"""The pagination."""
|
"""The pagination."""
|
||||||
self.entries: list[JournalEntry] = entries
|
self.line_items: list[JournalEntryLineItem] = line_items
|
||||||
"""The entries."""
|
"""The line items."""
|
||||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||||
lambda x: journal_url(x))
|
lambda x: journal_url(x))
|
||||||
"""The period chooser."""
|
"""The period chooser."""
|
||||||
@ -133,7 +134,7 @@ class PageParams(BasePageParams):
|
|||||||
|
|
||||||
:return: True if there is any data, or False otherwise.
|
:return: True if there is any data, or False otherwise.
|
||||||
"""
|
"""
|
||||||
return len(self.entries) > 0
|
return len(self.line_items) > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def report_chooser(self) -> ReportChooser:
|
def report_chooser(self) -> ReportChooser:
|
||||||
@ -145,20 +146,20 @@ class PageParams(BasePageParams):
|
|||||||
period=self.period)
|
period=self.period)
|
||||||
|
|
||||||
|
|
||||||
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
|
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
|
||||||
"""Composes and returns the CSV rows from the report entries.
|
"""Composes and returns the CSV rows from the line items.
|
||||||
|
|
||||||
:param entries: The report entries.
|
:param line_items: The line items.
|
||||||
:return: The CSV rows.
|
:return: The CSV rows.
|
||||||
"""
|
"""
|
||||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
|
||||||
gettext("Account"), gettext("Summary"),
|
gettext("Account"), gettext("Description"),
|
||||||
gettext("Debit"), gettext("Credit"),
|
gettext("Debit"), gettext("Credit"),
|
||||||
gettext("Note"))]
|
gettext("Note"))]
|
||||||
rows.extend([CSVRow(x.transaction.date, x.currency.code,
|
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
|
||||||
str(x.account).title(), x.summary,
|
str(x.account).title(), x.description,
|
||||||
x.debit, x.credit, x.transaction.note)
|
x.debit, x.credit, x.journal_entry.note)
|
||||||
for x in entries])
|
for x in line_items])
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@ -172,27 +173,29 @@ class Journal(BaseReport):
|
|||||||
"""
|
"""
|
||||||
self.__period: Period = period
|
self.__period: Period = period
|
||||||
"""The period."""
|
"""The period."""
|
||||||
self.__entries: list[JournalEntry] = self.__query_entries()
|
self.__line_items: list[JournalEntryLineItem] \
|
||||||
"""The journal entries."""
|
= self.__query_line_items()
|
||||||
|
"""The line items."""
|
||||||
|
|
||||||
def __query_entries(self) -> list[JournalEntry]:
|
def __query_line_items(self) -> list[JournalEntryLineItem]:
|
||||||
"""Queries and returns the journal entries.
|
"""Queries and returns the line items.
|
||||||
|
|
||||||
:return: The journal entries.
|
:return: The line items.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(Transaction.date >= self.__period.start)
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
if self.__period.end is not None:
|
if self.__period.end is not None:
|
||||||
conditions.append(Transaction.date <= self.__period.end)
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
return JournalEntry.query.join(Transaction)\
|
return JournalEntryLineItem.query.join(JournalEntry)\
|
||||||
.filter(*conditions)\
|
.filter(*conditions)\
|
||||||
.order_by(Transaction.date,
|
.order_by(JournalEntry.date,
|
||||||
JournalEntry.is_debit.desc(),
|
JournalEntry.no,
|
||||||
JournalEntry.no)\
|
JournalEntryLineItem.is_debit.desc(),
|
||||||
.options(selectinload(JournalEntry.account),
|
JournalEntryLineItem.no)\
|
||||||
selectinload(JournalEntry.currency),
|
.options(selectinload(JournalEntryLineItem.account),
|
||||||
selectinload(JournalEntry.transaction)).all()
|
selectinload(JournalEntryLineItem.currency),
|
||||||
|
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||||
|
|
||||||
def csv(self) -> Response:
|
def csv(self) -> Response:
|
||||||
"""Returns the report as CSV for download.
|
"""Returns the report as CSV for download.
|
||||||
@ -200,17 +203,18 @@ class Journal(BaseReport):
|
|||||||
:return: The response of the report for download.
|
:return: The response of the report for download.
|
||||||
"""
|
"""
|
||||||
filename: str = f"journal-{period_spec(self.__period)}.csv"
|
filename: str = f"journal-{period_spec(self.__period)}.csv"
|
||||||
return csv_download(filename, get_csv_rows(self.__entries))
|
return csv_download(filename, get_csv_rows(self.__line_items))
|
||||||
|
|
||||||
def html(self) -> str:
|
def html(self) -> str:
|
||||||
"""Composes and returns the report as HTML.
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
:return: The report as HTML.
|
:return: The report as HTML.
|
||||||
"""
|
"""
|
||||||
pagination: Pagination[JournalEntry] \
|
pagination: Pagination[JournalEntryLineItem] \
|
||||||
= Pagination[JournalEntry](self.__entries)
|
= Pagination[JournalEntryLineItem](self.__line_items,
|
||||||
|
is_reversed=True)
|
||||||
params: PageParams = PageParams(period=self.__period,
|
params: PageParams = PageParams(period=self.__period,
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
entries=pagination.list)
|
line_items=pagination.list)
|
||||||
return render_template("accounting/report/journal.html",
|
return render_template("accounting/report/journal.html",
|
||||||
report=params)
|
report=params)
|
||||||
|
@ -26,7 +26,8 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, Account, Transaction, JournalEntry
|
from accounting.models import Currency, Account, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from accounting.report.period import Period, PeriodChooser
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
@ -36,25 +37,26 @@ from accounting.report.utils.option_link import OptionLink
|
|||||||
from accounting.report.utils.report_chooser import ReportChooser
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
from accounting.report.utils.report_type import ReportType
|
from accounting.report.utils.report_type import ReportType
|
||||||
from accounting.report.utils.urls import ledger_url
|
from accounting.report.utils.urls import ledger_url
|
||||||
|
from accounting.utils.cast import be
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
class ReportEntry:
|
class ReportLineItem:
|
||||||
"""An entry in the report."""
|
"""A line item in the report."""
|
||||||
|
|
||||||
def __init__(self, entry: JournalEntry | None = None):
|
def __init__(self, line_item: JournalEntryLineItem | None = None):
|
||||||
"""Constructs the entry in the report.
|
"""Constructs the line item in the report.
|
||||||
|
|
||||||
:param entry: The journal entry.
|
:param line_item: The journal entry line item.
|
||||||
"""
|
"""
|
||||||
self.is_brought_forward: bool = False
|
self.is_brought_forward: bool = False
|
||||||
"""Whether this is the brought-forward entry."""
|
"""Whether this is the brought-forward line item."""
|
||||||
self.is_total: bool = False
|
self.is_total: bool = False
|
||||||
"""Whether this is the total entry."""
|
"""Whether this is the total line item."""
|
||||||
self.date: date | None = None
|
self.date: date | None = None
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.summary: str | None = None
|
self.description: str | None = None
|
||||||
"""The summary."""
|
"""The description."""
|
||||||
self.debit: Decimal | None = None
|
self.debit: Decimal | None = None
|
||||||
"""The debit amount."""
|
"""The debit amount."""
|
||||||
self.credit: Decimal | None = None
|
self.credit: Decimal | None = None
|
||||||
@ -64,22 +66,22 @@ class ReportEntry:
|
|||||||
self.note: str | None = None
|
self.note: str | None = None
|
||||||
"""The note."""
|
"""The note."""
|
||||||
self.url: str | None = None
|
self.url: str | None = None
|
||||||
"""The URL to the journal entry."""
|
"""The URL to the journal entry line item."""
|
||||||
if entry is not None:
|
if line_item is not None:
|
||||||
self.date = entry.transaction.date
|
self.date = line_item.journal_entry.date
|
||||||
self.summary = entry.summary
|
self.description = line_item.description
|
||||||
self.debit = entry.amount if entry.is_debit else None
|
self.debit = line_item.amount if line_item.is_debit else None
|
||||||
self.credit = None if entry.is_debit else entry.amount
|
self.credit = None if line_item.is_debit else line_item.amount
|
||||||
self.note = entry.transaction.note
|
self.note = line_item.journal_entry.note
|
||||||
self.url = url_for("accounting.transaction.detail",
|
self.url = url_for("accounting.journal-entry.detail",
|
||||||
txn=entry.transaction)
|
journal_entry=line_item.journal_entry)
|
||||||
|
|
||||||
|
|
||||||
class EntryCollector:
|
class LineItemCollector:
|
||||||
"""The report entry collector."""
|
"""The line item collector."""
|
||||||
|
|
||||||
def __init__(self, currency: Currency, account: Account, period: Period):
|
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||||
"""Constructs the report entry collector.
|
"""Constructs the line item collector.
|
||||||
|
|
||||||
:param currency: The currency.
|
:param currency: The currency.
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
@ -91,123 +93,131 @@ class EntryCollector:
|
|||||||
"""The account."""
|
"""The account."""
|
||||||
self.__period: Period = period
|
self.__period: Period = period
|
||||||
"""The period"""
|
"""The period"""
|
||||||
self.brought_forward: ReportEntry | None
|
self.brought_forward: ReportLineItem | None
|
||||||
"""The brought-forward entry."""
|
"""The brought-forward line item."""
|
||||||
self.entries: list[ReportEntry]
|
self.line_items: list[ReportLineItem]
|
||||||
"""The report entries."""
|
"""The line items."""
|
||||||
self.total: ReportEntry | None
|
self.total: ReportLineItem | None
|
||||||
"""The total entry."""
|
"""The total line item."""
|
||||||
self.brought_forward = self.__get_brought_forward_entry()
|
self.brought_forward = self.__get_brought_forward()
|
||||||
self.entries = self.__query_entries()
|
self.line_items = self.__query_line_items()
|
||||||
self.total = self.__get_total_entry()
|
self.total = self.__get_total()
|
||||||
self.__populate_balance()
|
self.__populate_balance()
|
||||||
|
|
||||||
def __get_brought_forward_entry(self) -> ReportEntry | None:
|
def __get_brought_forward(self) -> ReportLineItem | None:
|
||||||
"""Queries, composes and returns the brought-forward entry.
|
"""Queries, composes and returns the brought-forward line item.
|
||||||
|
|
||||||
:return: The brought-forward entry, or None if the report starts from
|
:return: The brought-forward line item, or None if the report starts
|
||||||
the beginning.
|
from the beginning.
|
||||||
"""
|
"""
|
||||||
if self.__period.start is None:
|
if self.__period.start is None:
|
||||||
return None
|
return None
|
||||||
if self.__account.base_code[0] not in {"1", "2", "3"}:
|
if self.__account.is_nominal:
|
||||||
return None
|
return None
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntry.is_debit, JournalEntry.amount),
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
else_=-JournalEntry.amount))
|
else_=-JournalEntryLineItem.amount))
|
||||||
select: sa.Select = sa.Select(balance_func).join(Transaction)\
|
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
|
||||||
.filter(JournalEntry.currency_code == self.__currency.code,
|
.filter(be(JournalEntryLineItem.currency_code
|
||||||
JournalEntry.account_id == self.__account.id,
|
== self.__currency.code),
|
||||||
Transaction.date < self.__period.start)
|
be(JournalEntryLineItem.account_id
|
||||||
|
== self.__account.id),
|
||||||
|
JournalEntry.date < self.__period.start)
|
||||||
balance: int | None = db.session.scalar(select)
|
balance: int | None = db.session.scalar(select)
|
||||||
if balance is None:
|
if balance is None:
|
||||||
return None
|
return None
|
||||||
entry: ReportEntry = ReportEntry()
|
line_item: ReportLineItem = ReportLineItem()
|
||||||
entry.is_brought_forward = True
|
line_item.is_brought_forward = True
|
||||||
entry.date = self.__period.start
|
line_item.date = self.__period.start
|
||||||
entry.summary = gettext("Brought forward")
|
line_item.description = gettext("Brought forward")
|
||||||
if balance > 0:
|
if balance > 0:
|
||||||
entry.debit = balance
|
line_item.debit = balance
|
||||||
elif balance < 0:
|
elif balance < 0:
|
||||||
entry.credit = -balance
|
line_item.credit = -balance
|
||||||
entry.balance = balance
|
line_item.balance = balance
|
||||||
return entry
|
return line_item
|
||||||
|
|
||||||
def __query_entries(self) -> list[ReportEntry]:
|
def __query_line_items(self) -> list[ReportLineItem]:
|
||||||
"""Queries and returns the report entries.
|
"""Queries and returns the line items.
|
||||||
|
|
||||||
:return: The report entries.
|
:return: The line items.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntry.currency_code == self.__currency.code,
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
JournalEntry.account_id == self.__account.id]
|
JournalEntryLineItem.account_id == self.__account.id]
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(Transaction.date >= self.__period.start)
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
if self.__period.end is not None:
|
if self.__period.end is not None:
|
||||||
conditions.append(Transaction.date <= self.__period.end)
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
return [ReportEntry(x) for x in JournalEntry.query.join(Transaction)
|
return [ReportLineItem(x) for x in JournalEntryLineItem.query
|
||||||
|
.join(JournalEntry)
|
||||||
.filter(*conditions)
|
.filter(*conditions)
|
||||||
.order_by(Transaction.date,
|
.order_by(JournalEntry.date,
|
||||||
JournalEntry.is_debit.desc(),
|
JournalEntry.no,
|
||||||
JournalEntry.no)
|
JournalEntryLineItem.is_debit.desc(),
|
||||||
.options(selectinload(JournalEntry.transaction)).all()]
|
JournalEntryLineItem.no)
|
||||||
|
.options(selectinload(JournalEntryLineItem.journal_entry))
|
||||||
|
.all()]
|
||||||
|
|
||||||
def __get_total_entry(self) -> ReportEntry | None:
|
def __get_total(self) -> ReportLineItem | None:
|
||||||
"""Composes the total entry.
|
"""Composes the total line item.
|
||||||
|
|
||||||
:return: The total entry, or None if there is no data.
|
:return: The total line item, or None if there is no data.
|
||||||
"""
|
"""
|
||||||
if self.brought_forward is None and len(self.entries) == 0:
|
if self.brought_forward is None and len(self.line_items) == 0:
|
||||||
return None
|
return None
|
||||||
entry: ReportEntry = ReportEntry()
|
line_item: ReportLineItem = ReportLineItem()
|
||||||
entry.is_total = True
|
line_item.is_total = True
|
||||||
entry.summary = gettext("Total")
|
line_item.description = gettext("Total")
|
||||||
entry.debit = sum([x.debit for x in self.entries
|
line_item.debit = sum([x.debit for x in self.line_items
|
||||||
if x.debit is not None])
|
if x.debit is not None])
|
||||||
entry.credit = sum([x.credit for x in self.entries
|
line_item.credit = sum([x.credit for x in self.line_items
|
||||||
if x.credit is not None])
|
if x.credit is not None])
|
||||||
entry.balance = entry.debit - entry.credit
|
line_item.balance = line_item.debit - line_item.credit
|
||||||
if self.brought_forward is not None:
|
if self.brought_forward is not None:
|
||||||
entry.balance = self.brought_forward.balance + entry.balance
|
line_item.balance \
|
||||||
return entry
|
= self.brought_forward.balance + line_item.balance
|
||||||
|
return line_item
|
||||||
|
|
||||||
def __populate_balance(self) -> None:
|
def __populate_balance(self) -> None:
|
||||||
"""Populates the balance of the entries.
|
"""Populates the balance of the line items.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
|
if self.__account.is_nominal:
|
||||||
|
return None
|
||||||
balance: Decimal = 0 if self.brought_forward is None \
|
balance: Decimal = 0 if self.brought_forward is None \
|
||||||
else self.brought_forward.balance
|
else self.brought_forward.balance
|
||||||
for entry in self.entries:
|
for line_item in self.line_items:
|
||||||
if entry.debit is not None:
|
if line_item.debit is not None:
|
||||||
balance = balance + entry.debit
|
balance = balance + line_item.debit
|
||||||
if entry.credit is not None:
|
if line_item.credit is not None:
|
||||||
balance = balance - entry.credit
|
balance = balance - line_item.credit
|
||||||
entry.balance = balance
|
line_item.balance = balance
|
||||||
|
|
||||||
|
|
||||||
class CSVRow(BaseCSVRow):
|
class CSVRow(BaseCSVRow):
|
||||||
"""A row in the CSV."""
|
"""A row in the CSV."""
|
||||||
|
|
||||||
def __init__(self, txn_date: date | str | None,
|
def __init__(self, journal_entry_date: date | str | None,
|
||||||
summary: str | None,
|
description: str | None,
|
||||||
debit: str | Decimal | None,
|
debit: str | Decimal | None,
|
||||||
credit: str | Decimal | None,
|
credit: str | Decimal | None,
|
||||||
balance: str | Decimal | None,
|
balance: str | Decimal | None,
|
||||||
note: str | None):
|
note: str | None):
|
||||||
"""Constructs a row in the CSV.
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
:param txn_date: The transaction date.
|
:param journal_entry_date: The journal entry date.
|
||||||
:param summary: The summary.
|
:param description: The description.
|
||||||
:param debit: The debit amount.
|
:param debit: The debit amount.
|
||||||
:param credit: The credit amount.
|
:param credit: The credit amount.
|
||||||
:param balance: The balance.
|
:param balance: The balance.
|
||||||
:param note: The note.
|
:param note: The note.
|
||||||
"""
|
"""
|
||||||
self.date: date | str | None = txn_date
|
self.date: date | str | None = journal_entry_date
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.summary: str | None = summary
|
self.description: str | None = description
|
||||||
"""The summary."""
|
"""The description."""
|
||||||
self.debit: str | Decimal | None = debit
|
self.debit: str | Decimal | None = debit
|
||||||
"""The debit amount."""
|
"""The debit amount."""
|
||||||
self.credit: str | Decimal | None = credit
|
self.credit: str | Decimal | None = credit
|
||||||
@ -223,7 +233,7 @@ class CSVRow(BaseCSVRow):
|
|||||||
|
|
||||||
:return: The values of the row.
|
:return: The values of the row.
|
||||||
"""
|
"""
|
||||||
return [self.date, self.summary,
|
return [self.date, self.description,
|
||||||
self.debit, self.credit, self.balance, self.note]
|
self.debit, self.credit, self.balance, self.note]
|
||||||
|
|
||||||
|
|
||||||
@ -234,19 +244,19 @@ class PageParams(BasePageParams):
|
|||||||
account: Account,
|
account: Account,
|
||||||
period: Period,
|
period: Period,
|
||||||
has_data: bool,
|
has_data: bool,
|
||||||
pagination: Pagination[ReportEntry],
|
pagination: Pagination[ReportLineItem],
|
||||||
brought_forward: ReportEntry | None,
|
brought_forward: ReportLineItem | None,
|
||||||
entries: list[ReportEntry],
|
line_items: list[ReportLineItem],
|
||||||
total: ReportEntry | None):
|
total: ReportLineItem | None):
|
||||||
"""Constructs the HTML page parameters.
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
:param currency: The currency.
|
:param currency: The currency.
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
:param period: The period.
|
:param period: The period.
|
||||||
:param has_data: True if there is any data, or False otherwise.
|
:param has_data: True if there is any data, or False otherwise.
|
||||||
:param brought_forward: The brought-forward entry.
|
:param brought_forward: The brought-forward line item.
|
||||||
:param entries: The report entries.
|
:param line_items: The line items.
|
||||||
:param total: The total entry.
|
:param total: The total line item.
|
||||||
"""
|
"""
|
||||||
self.currency: Currency = currency
|
self.currency: Currency = currency
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
@ -256,14 +266,14 @@ class PageParams(BasePageParams):
|
|||||||
"""The period."""
|
"""The period."""
|
||||||
self.__has_data: bool = has_data
|
self.__has_data: bool = has_data
|
||||||
"""True if there is any data, or False otherwise."""
|
"""True if there is any data, or False otherwise."""
|
||||||
self.pagination: Pagination[ReportEntry] = pagination
|
self.pagination: Pagination[ReportLineItem] = pagination
|
||||||
"""The pagination."""
|
"""The pagination."""
|
||||||
self.brought_forward: ReportEntry | None = brought_forward
|
self.brought_forward: ReportLineItem | None = brought_forward
|
||||||
"""The brought-forward entry."""
|
"""The brought-forward line item."""
|
||||||
self.entries: list[ReportEntry] = entries
|
self.line_items: list[ReportLineItem] = line_items
|
||||||
"""The entries."""
|
"""The line items."""
|
||||||
self.total: ReportEntry | None = total
|
self.total: ReportLineItem | None = total
|
||||||
"""The total entry."""
|
"""The total line item."""
|
||||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||||
lambda x: ledger_url(currency, account, x))
|
lambda x: ledger_url(currency, account, x))
|
||||||
"""The period chooser."""
|
"""The period chooser."""
|
||||||
@ -302,9 +312,10 @@ class PageParams(BasePageParams):
|
|||||||
|
|
||||||
:return: The account options.
|
:return: The account options.
|
||||||
"""
|
"""
|
||||||
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
|
||||||
.filter(JournalEntry.currency_code == self.currency.code)\
|
.filter(be(JournalEntryLineItem.currency_code
|
||||||
.group_by(JournalEntry.account_id)
|
== self.currency.code))\
|
||||||
|
.group_by(JournalEntryLineItem.account_id)
|
||||||
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
|
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
|
||||||
x.id == self.account.id)
|
x.id == self.account.id)
|
||||||
for x in Account.query.filter(Account.id.in_(in_use))
|
for x in Account.query.filter(Account.id.in_(in_use))
|
||||||
@ -327,14 +338,15 @@ class Ledger(BaseReport):
|
|||||||
"""The account."""
|
"""The account."""
|
||||||
self.__period: Period = period
|
self.__period: Period = period
|
||||||
"""The period."""
|
"""The period."""
|
||||||
collector: EntryCollector = EntryCollector(
|
collector: LineItemCollector = LineItemCollector(
|
||||||
self.__currency, self.__account, self.__period)
|
self.__currency, self.__account, self.__period)
|
||||||
self.__brought_forward: ReportEntry | None = collector.brought_forward
|
self.__brought_forward: ReportLineItem | None \
|
||||||
"""The brought-forward entry."""
|
= collector.brought_forward
|
||||||
self.__entries: list[ReportEntry] = collector.entries
|
"""The brought-forward line item."""
|
||||||
"""The report entries."""
|
self.__line_items: list[ReportLineItem] = collector.line_items
|
||||||
self.__total: ReportEntry | None = collector.total
|
"""The line items."""
|
||||||
"""The total entry."""
|
self.__total: ReportLineItem | None = collector.total
|
||||||
|
"""The total line item."""
|
||||||
|
|
||||||
def csv(self) -> Response:
|
def csv(self) -> Response:
|
||||||
"""Returns the report as CSV for download.
|
"""Returns the report as CSV for download.
|
||||||
@ -351,19 +363,19 @@ class Ledger(BaseReport):
|
|||||||
|
|
||||||
:return: The CSV rows.
|
:return: The CSV rows.
|
||||||
"""
|
"""
|
||||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Description"),
|
||||||
gettext("Debit"), gettext("Credit"),
|
gettext("Debit"), gettext("Credit"),
|
||||||
gettext("Balance"), gettext("Note"))]
|
gettext("Balance"), gettext("Note"))]
|
||||||
if self.__brought_forward is not None:
|
if self.__brought_forward is not None:
|
||||||
rows.append(CSVRow(self.__brought_forward.date,
|
rows.append(CSVRow(self.__brought_forward.date,
|
||||||
self.__brought_forward.summary,
|
self.__brought_forward.description,
|
||||||
self.__brought_forward.debit,
|
self.__brought_forward.debit,
|
||||||
self.__brought_forward.credit,
|
self.__brought_forward.credit,
|
||||||
self.__brought_forward.balance,
|
self.__brought_forward.balance,
|
||||||
None))
|
None))
|
||||||
rows.extend([CSVRow(x.date, x.summary,
|
rows.extend([CSVRow(x.date, x.description,
|
||||||
x.debit, x.credit, x.balance, x.note)
|
x.debit, x.credit, x.balance, x.note)
|
||||||
for x in self.__entries])
|
for x in self.__line_items])
|
||||||
if self.__total is not None:
|
if self.__total is not None:
|
||||||
rows.append(CSVRow(gettext("Total"), None,
|
rows.append(CSVRow(gettext("Total"), None,
|
||||||
self.__total.debit, self.__total.credit,
|
self.__total.debit, self.__total.credit,
|
||||||
@ -375,31 +387,31 @@ class Ledger(BaseReport):
|
|||||||
|
|
||||||
:return: The report as HTML.
|
:return: The report as HTML.
|
||||||
"""
|
"""
|
||||||
all_entries: list[ReportEntry] = []
|
all_line_items: list[ReportLineItem] = []
|
||||||
if self.__brought_forward is not None:
|
if self.__brought_forward is not None:
|
||||||
all_entries.append(self.__brought_forward)
|
all_line_items.append(self.__brought_forward)
|
||||||
all_entries.extend(self.__entries)
|
all_line_items.extend(self.__line_items)
|
||||||
if self.__total is not None:
|
if self.__total is not None:
|
||||||
all_entries.append(self.__total)
|
all_line_items.append(self.__total)
|
||||||
pagination: Pagination[ReportEntry] \
|
pagination: Pagination[ReportLineItem] \
|
||||||
= Pagination[ReportEntry](all_entries)
|
= Pagination[ReportLineItem](all_line_items, is_reversed=True)
|
||||||
page_entries: list[ReportEntry] = pagination.list
|
page_line_items: list[ReportLineItem] = pagination.list
|
||||||
has_data: bool = len(page_entries) > 0
|
has_data: bool = len(page_line_items) > 0
|
||||||
brought_forward: ReportEntry | None = None
|
brought_forward: ReportLineItem | None = None
|
||||||
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
|
if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
|
||||||
brought_forward = page_entries[0]
|
brought_forward = page_line_items[0]
|
||||||
page_entries = page_entries[1:]
|
page_line_items = page_line_items[1:]
|
||||||
total: ReportEntry | None = None
|
total: ReportLineItem | None = None
|
||||||
if len(page_entries) > 0 and page_entries[-1].is_total:
|
if len(page_line_items) > 0 and page_line_items[-1].is_total:
|
||||||
total = page_entries[-1]
|
total = page_line_items[-1]
|
||||||
page_entries = page_entries[:-1]
|
page_line_items = page_line_items[:-1]
|
||||||
params: PageParams = PageParams(currency=self.__currency,
|
params: PageParams = PageParams(currency=self.__currency,
|
||||||
account=self.__account,
|
account=self.__account,
|
||||||
period=self.__period,
|
period=self.__period,
|
||||||
has_data=has_data,
|
has_data=has_data,
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
brought_forward=brought_forward,
|
brought_forward=brought_forward,
|
||||||
entries=page_entries,
|
line_items=page_line_items,
|
||||||
total=total)
|
total=total)
|
||||||
return render_template("accounting/report/ledger.html",
|
return render_template("accounting/report/ledger.html",
|
||||||
report=params)
|
report=params)
|
||||||
|
@ -26,29 +26,30 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
|
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
|
||||||
Transaction, JournalEntry
|
JournalEntry, JournalEntryLineItem
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
from accounting.report.utils.csv_export import csv_download
|
from accounting.report.utils.csv_export import csv_download
|
||||||
from accounting.report.utils.report_chooser import ReportChooser
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
from accounting.report.utils.report_type import ReportType
|
from accounting.report.utils.report_type import ReportType
|
||||||
|
from accounting.utils.cast import be
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
from accounting.utils.query import parse_query_keywords
|
from accounting.utils.query import parse_query_keywords
|
||||||
from .journal import get_csv_rows
|
from .journal import get_csv_rows
|
||||||
|
|
||||||
|
|
||||||
class EntryCollector:
|
class LineItemCollector:
|
||||||
"""The report entry collector."""
|
"""The line item collector."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Constructs the report entry collector."""
|
"""Constructs the line item collector."""
|
||||||
self.entries: list[JournalEntry] = self.__query_entries()
|
self.line_items: list[JournalEntryLineItem] = self.__query_line_items()
|
||||||
"""The report entries."""
|
"""The line items."""
|
||||||
|
|
||||||
def __query_entries(self) -> list[JournalEntry]:
|
def __query_line_items(self) -> list[JournalEntryLineItem]:
|
||||||
"""Queries and returns the journal entries.
|
"""Queries and returns the line items.
|
||||||
|
|
||||||
:return: The journal entries.
|
:return: The line items.
|
||||||
"""
|
"""
|
||||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||||
if len(keywords) == 0:
|
if len(keywords) == 0:
|
||||||
@ -56,22 +57,28 @@ class EntryCollector:
|
|||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
sub_conditions: list[sa.BinaryExpression] \
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntry.summary.contains(k),
|
= [JournalEntryLineItem.description.icontains(k),
|
||||||
JournalEntry.account_id.in_(
|
JournalEntryLineItem.account_id.in_(
|
||||||
self.__get_account_condition(k)),
|
self.__get_account_condition(k)),
|
||||||
JournalEntry.currency_code.in_(
|
JournalEntryLineItem.currency_code.in_(
|
||||||
self.__get_currency_condition(k)),
|
self.__get_currency_condition(k)),
|
||||||
JournalEntry.transaction_id.in_(
|
JournalEntryLineItem.journal_entry_id.in_(
|
||||||
self.__get_transaction_condition(k))]
|
self.__get_journal_entry_condition(k))]
|
||||||
try:
|
try:
|
||||||
sub_conditions.append(JournalEntry.amount == Decimal(k))
|
sub_conditions.append(
|
||||||
|
JournalEntryLineItem.amount == Decimal(k))
|
||||||
except ArithmeticError:
|
except ArithmeticError:
|
||||||
pass
|
pass
|
||||||
conditions.append(sa.or_(*sub_conditions))
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
return JournalEntry.query.filter(*conditions)\
|
return JournalEntryLineItem.query.join(JournalEntry)\
|
||||||
.options(selectinload(JournalEntry.account),
|
.filter(*conditions)\
|
||||||
selectinload(JournalEntry.currency),
|
.order_by(JournalEntry.date,
|
||||||
selectinload(JournalEntry.transaction)).all()
|
JournalEntry.no,
|
||||||
|
JournalEntryLineItem.is_debit,
|
||||||
|
JournalEntryLineItem.no)\
|
||||||
|
.options(selectinload(JournalEntryLineItem.account),
|
||||||
|
selectinload(JournalEntryLineItem.currency),
|
||||||
|
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_account_condition(k: str) -> sa.Select:
|
def __get_account_condition(k: str) -> sa.Select:
|
||||||
@ -85,14 +92,14 @@ class EntryCollector:
|
|||||||
sa.func.char_length(sa.cast(Account.no,
|
sa.func.char_length(sa.cast(Account.no,
|
||||||
sa.String)) + 1)
|
sa.String)) + 1)
|
||||||
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
|
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
|
||||||
.filter(AccountL10n.title.contains(k))
|
.filter(AccountL10n.title.icontains(k))
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [Account.base_code.contains(k),
|
= [Account.base_code.contains(k),
|
||||||
Account.title_l10n.contains(k),
|
Account.title_l10n.icontains(k),
|
||||||
code.contains(k),
|
code.contains(k),
|
||||||
Account.id.in_(select_l10n)]
|
Account.id.in_(select_l10n)]
|
||||||
if k in gettext("Need offset"):
|
if k in gettext("Needs Offset"):
|
||||||
conditions.append(Account.is_offset_needed)
|
conditions.append(Account.is_need_offset)
|
||||||
return sa.select(Account.id).filter(sa.or_(*conditions))
|
return sa.select(Account.id).filter(sa.or_(*conditions))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -103,57 +110,63 @@ class EntryCollector:
|
|||||||
:return: The condition to filter the currency.
|
:return: The condition to filter the currency.
|
||||||
"""
|
"""
|
||||||
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
|
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
|
||||||
.filter(CurrencyL10n.name.contains(k))
|
.filter(CurrencyL10n.name.icontains(k))
|
||||||
return sa.select(Currency.code).filter(
|
return sa.select(Currency.code).filter(
|
||||||
sa.or_(Currency.code.contains(k),
|
sa.or_(Currency.code.icontains(k),
|
||||||
Currency.name_l10n.contains(k),
|
Currency.name_l10n.icontains(k),
|
||||||
Currency.code.in_(select_l10n)))
|
Currency.code.in_(select_l10n)))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_transaction_condition(k: str) -> sa.Select:
|
def __get_journal_entry_condition(k: str) -> sa.Select:
|
||||||
"""Composes and returns the condition to filter the transaction.
|
"""Composes and returns the condition to filter the journal entry.
|
||||||
|
|
||||||
:param k: The keyword.
|
:param k: The keyword.
|
||||||
:return: The condition to filter the transaction.
|
:return: The condition to filter the journal entry.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)]
|
conditions: list[sa.BinaryExpression] \
|
||||||
txn_date: datetime
|
= [JournalEntry.note.icontains(k)]
|
||||||
|
journal_entry_date: datetime
|
||||||
try:
|
try:
|
||||||
txn_date = datetime.strptime(k, "%Y")
|
journal_entry_date = datetime.strptime(k, "%Y")
|
||||||
conditions.append(
|
conditions.append(
|
||||||
sa.extract("year", Transaction.date) == txn_date.year)
|
be(sa.extract("year", JournalEntry.date)
|
||||||
|
== journal_entry_date.year))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
txn_date = datetime.strptime(k, "%Y/%m")
|
journal_entry_date = datetime.strptime(k, "%Y/%m")
|
||||||
conditions.append(sa.and_(
|
conditions.append(sa.and_(
|
||||||
sa.extract("year", Transaction.date) == txn_date.year,
|
sa.extract("year", JournalEntry.date)
|
||||||
sa.extract("month", Transaction.date) == txn_date.month))
|
== journal_entry_date.year,
|
||||||
|
sa.extract("month", JournalEntry.date)
|
||||||
|
== journal_entry_date.month))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||||
conditions.append(sa.and_(
|
conditions.append(sa.and_(
|
||||||
sa.extract("month", Transaction.date) == txn_date.month,
|
sa.extract("month", JournalEntry.date)
|
||||||
sa.extract("day", Transaction.date) == txn_date.day))
|
== journal_entry_date.month,
|
||||||
|
sa.extract("day", JournalEntry.date)
|
||||||
|
== journal_entry_date.day))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
return sa.select(Transaction.id).filter(sa.or_(*conditions))
|
return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
|
||||||
|
|
||||||
|
|
||||||
class PageParams(BasePageParams):
|
class PageParams(BasePageParams):
|
||||||
"""The HTML page parameters."""
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
def __init__(self, pagination: Pagination[JournalEntry],
|
def __init__(self, pagination: Pagination[JournalEntryLineItem],
|
||||||
entries: list[JournalEntry]):
|
line_items: list[JournalEntryLineItem]):
|
||||||
"""Constructs the HTML page parameters.
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
:param entries: The search result entries.
|
:param line_items: The search result line items.
|
||||||
"""
|
"""
|
||||||
self.pagination: Pagination[JournalEntry] = pagination
|
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
||||||
"""The pagination."""
|
"""The pagination."""
|
||||||
self.entries: list[JournalEntry] = entries
|
self.line_items: list[JournalEntryLineItem] = line_items
|
||||||
"""The entries."""
|
"""The line items."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_data(self) -> bool:
|
def has_data(self) -> bool:
|
||||||
@ -161,7 +174,7 @@ class PageParams(BasePageParams):
|
|||||||
|
|
||||||
:return: True if there is any data, or False otherwise.
|
:return: True if there is any data, or False otherwise.
|
||||||
"""
|
"""
|
||||||
return len(self.entries) > 0
|
return len(self.line_items) > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def report_chooser(self) -> ReportChooser:
|
def report_chooser(self) -> ReportChooser:
|
||||||
@ -177,8 +190,9 @@ class Search(BaseReport):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Constructs a search."""
|
"""Constructs a search."""
|
||||||
self.__entries: list[JournalEntry] = EntryCollector().entries
|
self.__line_items: list[JournalEntryLineItem] \
|
||||||
"""The journal entries."""
|
= LineItemCollector().line_items
|
||||||
|
"""The line items."""
|
||||||
|
|
||||||
def csv(self) -> Response:
|
def csv(self) -> Response:
|
||||||
"""Returns the report as CSV for download.
|
"""Returns the report as CSV for download.
|
||||||
@ -186,16 +200,17 @@ class Search(BaseReport):
|
|||||||
:return: The response of the report for download.
|
:return: The response of the report for download.
|
||||||
"""
|
"""
|
||||||
filename: str = "search-{q}.csv".format(q=request.args["q"])
|
filename: str = "search-{q}.csv".format(q=request.args["q"])
|
||||||
return csv_download(filename, get_csv_rows(self.__entries))
|
return csv_download(filename, get_csv_rows(self.__line_items))
|
||||||
|
|
||||||
def html(self) -> str:
|
def html(self) -> str:
|
||||||
"""Composes and returns the report as HTML.
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
:return: The report as HTML.
|
:return: The report as HTML.
|
||||||
"""
|
"""
|
||||||
pagination: Pagination[JournalEntry] \
|
pagination: Pagination[JournalEntryLineItem] \
|
||||||
= Pagination[JournalEntry](self.__entries)
|
= Pagination[JournalEntryLineItem](self.__line_items,
|
||||||
|
is_reversed=True)
|
||||||
params: PageParams = PageParams(pagination=pagination,
|
params: PageParams = PageParams(pagination=pagination,
|
||||||
entries=pagination.list)
|
line_items=pagination.list)
|
||||||
return render_template("accounting/report/search.html",
|
return render_template("accounting/report/search.html",
|
||||||
report=params)
|
report=params)
|
||||||
|
@ -24,7 +24,8 @@ from flask import Response, render_template
|
|||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, Account, Transaction, JournalEntry
|
from accounting.models import Currency, Account, JournalEntry, \
|
||||||
|
JournalEntryLineItem
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from accounting.report.period import Period, PeriodChooser
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
@ -178,16 +179,16 @@ class TrialBalance(BaseReport):
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntry.currency_code == self.__currency.code]
|
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(Transaction.date >= self.__period.start)
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
if self.__period.end is not None:
|
if self.__period.end is not None:
|
||||||
conditions.append(Transaction.date <= self.__period.end)
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntry.is_debit, JournalEntry.amount),
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
else_=-JournalEntry.amount)).label("balance")
|
else_=-JournalEntryLineItem.amount)).label("balance")
|
||||||
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||||
.join(Transaction).join(Account)\
|
.join(JournalEntry).join(Account)\
|
||||||
.filter(*conditions)\
|
.filter(*conditions)\
|
||||||
.group_by(Account.id)\
|
.group_by(Account.id)\
|
||||||
.order_by(Account.base_code, Account.no)
|
.order_by(Account.base_code, Account.no)
|
||||||
|
@ -26,8 +26,8 @@ import sqlalchemy as sa
|
|||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.models import Currency, JournalEntry
|
from accounting.models import Currency, JournalEntryLineItem
|
||||||
from accounting.utils.txn_types import TransactionType
|
from accounting.utils.journal_entry_types import JournalEntryType
|
||||||
from .option_link import OptionLink
|
from .option_link import OptionLink
|
||||||
from .report_chooser import ReportChooser
|
from .report_chooser import ReportChooser
|
||||||
|
|
||||||
@ -52,12 +52,12 @@ class BasePageParams(ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def txn_types(self) -> t.Type[TransactionType]:
|
def journal_entry_types(self) -> t.Type[JournalEntryType]:
|
||||||
"""Returns the transaction types.
|
"""Returns the journal entry types.
|
||||||
|
|
||||||
:return: The transaction types.
|
:return: The journal entry types.
|
||||||
"""
|
"""
|
||||||
return TransactionType
|
return JournalEntryType
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def csv_uri(self) -> str:
|
def csv_uri(self) -> str:
|
||||||
@ -81,8 +81,8 @@ class BasePageParams(ABC):
|
|||||||
:return: The currency options.
|
:return: The currency options.
|
||||||
"""
|
"""
|
||||||
in_use: set[str] = set(db.session.scalars(
|
in_use: set[str] = set(db.session.scalars(
|
||||||
sa.select(JournalEntry.currency_code)
|
sa.select(JournalEntryLineItem.currency_code)
|
||||||
.group_by(JournalEntry.currency_code)).all())
|
.group_by(JournalEntryLineItem.currency_code)).all())
|
||||||
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
|
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
|
||||||
for x in Currency.query.filter(Currency.code.in_(in_use))
|
for x in Currency.query.filter(Currency.code.in_(in_use))
|
||||||
.order_by(Currency.code).all()]
|
.order_by(Currency.code).all()]
|
||||||
|
@ -27,10 +27,15 @@ class OptionLink:
|
|||||||
"""Constructs an option link.
|
"""Constructs an option link.
|
||||||
|
|
||||||
:param title: The title.
|
:param title: The title.
|
||||||
:param url: The URI.
|
:param url: The URL.
|
||||||
:param is_active: True if active, or False otherwise
|
:param is_active: True if active, or False otherwise
|
||||||
|
:param fa_icon: The font-awesome icon, if any.
|
||||||
"""
|
"""
|
||||||
self.title: str = title
|
self.title: str = title
|
||||||
|
"""The title."""
|
||||||
self.url: str = url
|
self.url: str = url
|
||||||
|
"""The URL."""
|
||||||
self.is_active: bool = is_active
|
self.is_active: bool = is_active
|
||||||
|
"""True if active, or False otherwise."""
|
||||||
self.fa_icon: str | None = fa_icon
|
self.fa_icon: str | None = fa_icon
|
||||||
|
"""The font-awesome icon, if any."""
|
||||||
|
@ -31,6 +31,9 @@
|
|||||||
color: #141619;
|
color: #141619;
|
||||||
background-color: #D3D3D4;
|
background-color: #D3D3D4;
|
||||||
}
|
}
|
||||||
|
.form-control.accounting-disabled {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
/** The toolbar */
|
/** The toolbar */
|
||||||
.accounting-toolbar {
|
.accounting-toolbar {
|
||||||
@ -113,46 +116,70 @@
|
|||||||
border-bottom: thick double slategray;
|
border-bottom: thick double slategray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Links between objects */
|
||||||
|
.accounting-original-line-item {
|
||||||
|
border-top: thin solid darkslategray;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
.accounting-original-line-item a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.accounting-original-line-item a:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.accounting-offset-line-items {
|
||||||
|
border-top: thin solid darkslategray;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
.accounting-offset-line-items ul li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.accounting-offset-line-items ul li a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.accounting-offset-line-items ul li a:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
/** The option selector */
|
/** The option selector */
|
||||||
.accounting-selector-list {
|
.accounting-selector-list {
|
||||||
height: 20rem;
|
height: 20rem;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The transaction management */
|
/** The journal entry management */
|
||||||
.accounting-currency-control {
|
.accounting-currency-control {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
.accounting-currency-content {
|
.accounting-currency-content {
|
||||||
width: calc(100% - 3rem);
|
width: calc(100% - 3rem);
|
||||||
}
|
}
|
||||||
.accounting-entry-content {
|
.accounting-line-item-content {
|
||||||
width: calc(100% - 3rem);
|
width: calc(100% - 3rem);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
.accounting-entry-control {
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
|
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
|
||||||
background-color: #f2f2f2;
|
background-color: #f2f2f2;
|
||||||
}
|
}
|
||||||
.accounting-list-group-stripped .list-group-item-success:nth-child(2n+1) {
|
|
||||||
background-color: #c7dbd2;
|
|
||||||
}
|
|
||||||
.accounting-list-group-hover .list-group-item:hover {
|
.accounting-list-group-hover .list-group-item:hover {
|
||||||
background-color: #ececec;
|
background-color: #ececec;
|
||||||
}
|
}
|
||||||
.accounting-transaction-entry {
|
.accounting-journal-entry-line-item {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.accounting-transaction-entry-header {
|
.accounting-journal-entry-line-item-header {
|
||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
border-bottom: thick double slategray;
|
border-bottom: thick double slategray;
|
||||||
}
|
}
|
||||||
.list-group-item.accounting-transaction-entry-total {
|
.list-group-item.accounting-journal-entry-line-item-total {
|
||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
border-top: thick double slategray;
|
border-top: thick double slategray;
|
||||||
}
|
}
|
||||||
|
.accounting-line-item-editor-original-line-item-content {
|
||||||
|
width: calc(100% - 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
/* The report table */
|
/* The report table */
|
||||||
.accounting-report-table-header, .accounting-report-table-footer {
|
.accounting-report-table-header, .accounting-report-table-footer {
|
||||||
@ -191,12 +218,18 @@ a.accounting-report-table-row {
|
|||||||
.accounting-journal-table .accounting-report-table-row {
|
.accounting-journal-table .accounting-report-table-row {
|
||||||
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
|
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
|
||||||
}
|
}
|
||||||
.accounting-ledger-table .accounting-report-table-row {
|
.accounting-ledger-real-table .accounting-report-table-row {
|
||||||
grid-template-columns: 1fr 4fr 1fr 1fr 1fr;
|
grid-template-columns: 1fr 4fr 1fr 1fr 1fr;
|
||||||
}
|
}
|
||||||
.accounting-ledger-table .accounting-report-table-footer .accounting-report-table-row {
|
.accounting-ledger-real-table .accounting-report-table-footer .accounting-report-table-row {
|
||||||
grid-template-columns: 5fr 1fr 1fr 1fr;
|
grid-template-columns: 5fr 1fr 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
.accounting-ledger-nominal-table .accounting-report-table-row {
|
||||||
|
grid-template-columns: 1fr 4fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
.accounting-ledger-nominal-table .accounting-report-table-footer .accounting-report-table-row {
|
||||||
|
grid-template-columns: 5fr 1fr 1fr;
|
||||||
|
}
|
||||||
.accounting-income-expenses-table .accounting-report-table-row {
|
.accounting-income-expenses-table .accounting-report-table-row {
|
||||||
grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr;
|
grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
@ -24,90 +24,293 @@
|
|||||||
|
|
||||||
// Initializes the page JavaScript.
|
// Initializes the page JavaScript.
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
initializeBaseAccountSelector();
|
AccountForm.initialize();
|
||||||
document.getElementById("accounting-base-code")
|
|
||||||
.onchange = validateBase;
|
|
||||||
document.getElementById("accounting-title")
|
|
||||||
.onchange = validateTitle;
|
|
||||||
document.getElementById("accounting-form")
|
|
||||||
.onsubmit = validateForm;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the base account selector.
|
* The account form.
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
function initializeBaseAccountSelector() {
|
class AccountForm {
|
||||||
const selector = document.getElementById("accounting-base-selector-modal");
|
|
||||||
const base = document.getElementById("accounting-base");
|
/**
|
||||||
const baseCode = document.getElementById("accounting-base-code");
|
* The base account selector
|
||||||
const baseContent = document.getElementById("accounting-base-content");
|
* @type {BaseAccountSelector}
|
||||||
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
*/
|
||||||
const btnClear = document.getElementById("accounting-btn-clear-base");
|
#baseAccountSelector;
|
||||||
selector.addEventListener("show.bs.modal", () => {
|
|
||||||
base.classList.add("accounting-not-empty");
|
/**
|
||||||
for (const option of options) {
|
* The form element
|
||||||
option.classList.remove("active");
|
* @type {HTMLFormElement}
|
||||||
}
|
*/
|
||||||
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
|
#formElement;
|
||||||
if (selected !== null) {
|
|
||||||
selected.classList.add("active");
|
/**
|
||||||
}
|
* The control of the base account
|
||||||
});
|
* @type {HTMLDivElement}
|
||||||
selector.addEventListener("hidden.bs.modal", () => {
|
*/
|
||||||
if (baseCode.value === "") {
|
#baseControl;
|
||||||
base.classList.remove("accounting-not-empty");
|
|
||||||
}
|
/**
|
||||||
});
|
* The input of the base account
|
||||||
for (const option of options) {
|
* @type {HTMLInputElement}
|
||||||
option.onclick = () => {
|
*/
|
||||||
baseCode.value = option.dataset.code;
|
#baseCode;
|
||||||
baseContent.innerText = option.dataset.content;
|
|
||||||
btnClear.classList.add("btn-danger");
|
/**
|
||||||
btnClear.classList.remove("btn-secondary")
|
* The base account
|
||||||
btnClear.disabled = false;
|
* @type {HTMLDivElement}
|
||||||
validateBase();
|
*/
|
||||||
bootstrap.Modal.getInstance(selector).hide();
|
#base;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message for the base account
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#baseError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#title;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message of the title
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#titleError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The control of the is-need-offset option
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#isNeedOffsetControl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The is-need-offset option
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#isNeedOffset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the account form.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
this.#baseAccountSelector = new BaseAccountSelector(this);
|
||||||
|
this.#formElement = document.getElementById("accounting-form");
|
||||||
|
this.#baseControl = document.getElementById("accounting-base-control");
|
||||||
|
this.#baseCode = document.getElementById("accounting-base-code");
|
||||||
|
this.#base = document.getElementById("accounting-base");
|
||||||
|
this.#baseError = document.getElementById("accounting-base-error");
|
||||||
|
this.#title = document.getElementById("accounting-title");
|
||||||
|
this.#titleError = document.getElementById("accounting-title-error");
|
||||||
|
this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
|
||||||
|
this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
|
||||||
|
this.#formElement.onsubmit = () => {
|
||||||
|
return this.#validateForm();
|
||||||
|
};
|
||||||
|
this.#baseControl.onclick = () => {
|
||||||
|
this.#baseControl.classList.add("accounting-not-empty");
|
||||||
|
this.#baseAccountSelector.onOpen(this.#baseCode.value);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
btnClear.onclick = () => {
|
|
||||||
baseCode.value = "";
|
/**
|
||||||
baseContent.innerText = "";
|
* The callback when the base account selector is closed.
|
||||||
btnClear.classList.add("btn-secondary")
|
*
|
||||||
btnClear.classList.remove("btn-danger");
|
*/
|
||||||
btnClear.disabled = true;
|
onBaseAccountSelectorClosed() {
|
||||||
validateBase();
|
if (this.#baseCode.value === "") {
|
||||||
bootstrap.Modal.getInstance(selector).hide();
|
this.#baseControl.classList.remove("accounting-not-empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the base account.
|
||||||
|
*
|
||||||
|
* @param code {string} the base account code
|
||||||
|
* @param text {string} the text for the base account
|
||||||
|
*/
|
||||||
|
setBaseAccount(code, text) {
|
||||||
|
this.#baseCode.value = code;
|
||||||
|
this.#base.innerText = text;
|
||||||
|
if (["1", "2", "3"].includes(code.substring(0, 1))) {
|
||||||
|
this.#isNeedOffsetControl.classList.remove("d-none");
|
||||||
|
this.#isNeedOffset.disabled = false;
|
||||||
|
} else {
|
||||||
|
this.#isNeedOffsetControl.classList.add("d-none");
|
||||||
|
this.#isNeedOffset.disabled = true;
|
||||||
|
this.#isNeedOffset.checked = false;
|
||||||
|
}
|
||||||
|
this.#validateBase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the base account.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
clearBaseAccount() {
|
||||||
|
this.#baseCode.value = "";
|
||||||
|
this.#base.innerText = "";
|
||||||
|
this.#validateBase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the form.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateForm() {
|
||||||
|
let isValid = true;
|
||||||
|
isValid = this.#validateBase() && isValid;
|
||||||
|
isValid = this.#validateTitle() && isValid;
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the base account.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateBase() {
|
||||||
|
if (this.#baseCode.value === "") {
|
||||||
|
this.#baseControl.classList.add("is-invalid");
|
||||||
|
this.#baseError.innerText = A_("Please select the base account.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#baseControl.classList.remove("is-invalid");
|
||||||
|
this.#baseError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the title.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateTitle() {
|
||||||
|
this.#title.value = this.#title.value.trim();
|
||||||
|
if (this.#title.value === "") {
|
||||||
|
this.#title.classList.add("is-invalid");
|
||||||
|
this.#titleError.innerText = A_("Please fill in the title.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#title.classList.remove("is-invalid");
|
||||||
|
this.#titleError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account form
|
||||||
|
* @type {AccountForm} the form
|
||||||
|
*/
|
||||||
|
static #form;
|
||||||
|
|
||||||
|
static initialize() {
|
||||||
|
this.#form = new AccountForm();
|
||||||
}
|
}
|
||||||
initializeBaseAccountQuery();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the query on the base account options.
|
* The base account selector.
|
||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
function initializeBaseAccountQuery() {
|
class BaseAccountSelector {
|
||||||
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"));
|
* The account form
|
||||||
const queryNoResult = document.getElementById("accounting-base-option-no-result");
|
* @type {AccountForm}
|
||||||
query.addEventListener("input", () => {
|
*/
|
||||||
if (query.value === "") {
|
#form;
|
||||||
for (const option of options) {
|
|
||||||
|
/**
|
||||||
|
* The selector modal
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#modal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query input
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message when the query has no result
|
||||||
|
* @type {HTMLParagraphElement}
|
||||||
|
*/
|
||||||
|
#queryNoResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The option list
|
||||||
|
* @type {HTMLUListElement}
|
||||||
|
*/
|
||||||
|
#optionList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options
|
||||||
|
* @type {HTMLLIElement[]}
|
||||||
|
*/
|
||||||
|
#options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The button to clear the base account value
|
||||||
|
* @type {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#clearButton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the base account selector.
|
||||||
|
*
|
||||||
|
* @param form {AccountForm} the form
|
||||||
|
*/
|
||||||
|
constructor(form) {
|
||||||
|
this.#form = form;
|
||||||
|
this.#modal = document.getElementById("accounting-base-selector-modal");
|
||||||
|
this.#query = document.getElementById("accounting-base-selector-query");
|
||||||
|
this.#optionList = document.getElementById("accounting-base-selector-option-list");
|
||||||
|
// noinspection JSValidateTypes
|
||||||
|
this.#options = Array.from(document.getElementsByClassName("accounting-base-selector-option"));
|
||||||
|
this.#clearButton = document.getElementById("accounting-base-selector-clear");
|
||||||
|
this.#queryNoResult = document.getElementById("accounting-base-selector-option-no-result");
|
||||||
|
this.#modal.addEventListener("hidden.bs.modal", () => {
|
||||||
|
this.#form.onBaseAccountSelectorClosed();
|
||||||
|
});
|
||||||
|
for (const option of this.#options) {
|
||||||
|
option.onclick = () => {
|
||||||
|
this.#form.setBaseAccount(option.dataset.code, option.dataset.content);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.#clearButton.onclick = () => {
|
||||||
|
this.#form.clearBaseAccount();
|
||||||
|
};
|
||||||
|
this.#initializeBaseAccountQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the query.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#initializeBaseAccountQuery() {
|
||||||
|
this.#query.addEventListener("input", () => {
|
||||||
|
if (this.#query.value === "") {
|
||||||
|
for (const option of this.#options) {
|
||||||
option.classList.remove("d-none");
|
option.classList.remove("d-none");
|
||||||
}
|
}
|
||||||
optionList.classList.remove("d-none");
|
this.#optionList.classList.remove("d-none");
|
||||||
queryNoResult.classList.add("d-none");
|
this.#queryNoResult.classList.add("d-none");
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let hasAnyMatched = false;
|
let hasAnyMatched = false;
|
||||||
for (const option of options) {
|
for (const option of this.#options) {
|
||||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||||
let isMatched = false;
|
let isMatched = false;
|
||||||
for (const queryValue of queryValues) {
|
for (const queryValue of queryValues) {
|
||||||
if (queryValue.includes(query.value)) {
|
if (queryValue.toLowerCase().includes(this.#query.value.toLowerCase())) {
|
||||||
isMatched = true;
|
isMatched = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -120,65 +323,36 @@ function initializeBaseAccountQuery() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!hasAnyMatched) {
|
if (!hasAnyMatched) {
|
||||||
optionList.classList.add("d-none");
|
this.#optionList.classList.add("d-none");
|
||||||
queryNoResult.classList.remove("d-none");
|
this.#queryNoResult.classList.remove("d-none");
|
||||||
} else {
|
} else {
|
||||||
optionList.classList.remove("d-none");
|
this.#optionList.classList.remove("d-none");
|
||||||
queryNoResult.classList.add("d-none");
|
this.#queryNoResult.classList.add("d-none");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the form.
|
|
||||||
*
|
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateForm() {
|
|
||||||
let isValid = true;
|
|
||||||
isValid = validateBase() && isValid;
|
|
||||||
isValid = validateTitle() && isValid;
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the base account.
|
|
||||||
*
|
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateBase() {
|
|
||||||
const field = document.getElementById("accounting-base-code");
|
|
||||||
const error = document.getElementById("accounting-base-code-error");
|
|
||||||
const displayField = document.getElementById("accounting-base");
|
|
||||||
field.value = field.value.trim();
|
|
||||||
if (field.value === "") {
|
|
||||||
displayField.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please select the base account.");
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
displayField.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the title.
|
* The callback when the base account selector is shown.
|
||||||
*
|
*
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
* @param baseCode {string} the active base code
|
||||||
* @private
|
|
||||||
*/
|
*/
|
||||||
function validateTitle() {
|
onOpen(baseCode) {
|
||||||
const field = document.getElementById("accounting-title");
|
for (const option of this.#options) {
|
||||||
const error = document.getElementById("accounting-title-error");
|
if (option.dataset.code === baseCode) {
|
||||||
field.value = field.value.trim();
|
option.classList.add("active");
|
||||||
if (field.value === "") {
|
} else {
|
||||||
field.classList.add("is-invalid");
|
option.classList.remove("active");
|
||||||
error.innerText = A_("Please fill in the title.");
|
}
|
||||||
return false;
|
}
|
||||||
|
if (baseCode === "") {
|
||||||
|
this.#clearButton.classList.add("btn-secondary")
|
||||||
|
this.#clearButton.classList.remove("btn-danger");
|
||||||
|
this.#clearButton.disabled = true;
|
||||||
|
} else {
|
||||||
|
this.#clearButton.classList.add("btn-danger");
|
||||||
|
this.#clearButton.classList.remove("btn-secondary")
|
||||||
|
this.#clearButton.disabled = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
/* The Mia! Accounting Flask Project
|
||||||
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
|
* account-selector.js: The JavaScript for the account selector
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Copyright (c) 2023 imacat.
|
/* Copyright (c) 2023 imacat.
|
||||||
@ -22,11 +22,6 @@
|
|||||||
*/
|
*/
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// Initializes the page JavaScript.
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
AccountSelector.initialize();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The account selector.
|
* The account selector.
|
||||||
*
|
*
|
||||||
@ -34,10 +29,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
class AccountSelector {
|
class AccountSelector {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The entry type
|
* The line item editor
|
||||||
|
* @type {JournalEntryLineItemEditor}
|
||||||
|
*/
|
||||||
|
#lineItemEditor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Either "debit" or "credit"
|
||||||
* @type {string}
|
* @type {string}
|
||||||
*/
|
*/
|
||||||
#entryType;
|
#debitCredit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The prefix of the HTML ID and class
|
* The prefix of the HTML ID and class
|
||||||
@ -45,78 +46,81 @@ class AccountSelector {
|
|||||||
*/
|
*/
|
||||||
#prefix;
|
#prefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The button to clear the account
|
||||||
|
* @type {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#clearButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query input
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message when the query has no result
|
||||||
|
* @type {HTMLParagraphElement}
|
||||||
|
*/
|
||||||
|
#queryNoResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The option list
|
||||||
|
* @type {HTMLUListElement}
|
||||||
|
*/
|
||||||
|
#optionList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options
|
||||||
|
* @type {HTMLLIElement[]}
|
||||||
|
*/
|
||||||
|
#options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The more item to show all accounts
|
||||||
|
* @type {HTMLLIElement}
|
||||||
|
*/
|
||||||
|
#more;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs an account selector.
|
* Constructs an account selector.
|
||||||
*
|
*
|
||||||
* @param modal {HTMLFormElement} the account selector modal
|
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||||
|
* @param debitCredit {string} either "debit" or "credit"
|
||||||
*/
|
*/
|
||||||
constructor(modal) {
|
constructor(lineItemEditor, debitCredit) {
|
||||||
this.#entryType = modal.dataset.entryType;
|
this.#lineItemEditor = lineItemEditor
|
||||||
this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
|
this.#debitCredit = debitCredit;
|
||||||
this.#init();
|
this.#prefix = "accounting-account-selector-" + debitCredit;
|
||||||
}
|
this.#query = document.getElementById(this.#prefix + "-query");
|
||||||
|
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
||||||
/**
|
this.#optionList = document.getElementById(this.#prefix + "-option-list");
|
||||||
* Initializes the account selector.
|
// noinspection JSValidateTypes
|
||||||
*
|
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||||
*/
|
this.#more = document.getElementById(this.#prefix + "-more");
|
||||||
#init() {
|
this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
|
||||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
this.#more.onclick = () => {
|
||||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
this.#more.classList.add("d-none");
|
||||||
const more = document.getElementById(this.#prefix + "-more");
|
this.#filterOptions();
|
||||||
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
|
|
||||||
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
|
||||||
more.onclick = () => {
|
|
||||||
more.classList.add("d-none");
|
|
||||||
this.#filterAccountOptions();
|
|
||||||
};
|
|
||||||
this.#initializeAccountQuery();
|
|
||||||
btnClear.onclick = () => {
|
|
||||||
formAccountControl.classList.remove("accounting-not-empty");
|
|
||||||
formAccount.innerText = "";
|
|
||||||
formAccount.dataset.code = "";
|
|
||||||
formAccount.dataset.text = "";
|
|
||||||
validateJournalEntryAccount();
|
|
||||||
};
|
|
||||||
for (const option of options) {
|
|
||||||
option.onclick = () => {
|
|
||||||
formAccountControl.classList.add("accounting-not-empty");
|
|
||||||
formAccount.innerText = option.dataset.content;
|
|
||||||
formAccount.dataset.code = option.dataset.code;
|
|
||||||
formAccount.dataset.text = option.dataset.content;
|
|
||||||
validateJournalEntryAccount();
|
|
||||||
};
|
};
|
||||||
|
this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
|
||||||
|
for (const option of this.#options) {
|
||||||
|
option.onclick = () => this.#lineItemEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
|
||||||
}
|
}
|
||||||
}
|
this.#query.addEventListener("input", () => {
|
||||||
|
this.#filterOptions();
|
||||||
/**
|
|
||||||
* Initializes the query on the account options.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
#initializeAccountQuery() {
|
|
||||||
const query = document.getElementById(this.#prefix + "-query");
|
|
||||||
query.addEventListener("input", () => {
|
|
||||||
this.#filterAccountOptions();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters the account options.
|
* Filters the options.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
#filterAccountOptions() {
|
#filterOptions() {
|
||||||
const query = document.getElementById(this.#prefix + "-query");
|
const codesInUse = this.#getCodesUsedInForm();
|
||||||
const optionList = document.getElementById(this.#prefix + "-option-list");
|
|
||||||
if (optionList === null) {
|
|
||||||
console.log(this.#prefix + "-option-list");
|
|
||||||
}
|
|
||||||
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
|
||||||
const more = document.getElementById(this.#prefix + "-more");
|
|
||||||
const queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
|
||||||
const codesInUse = this.#getAccountCodeUsedInForm();
|
|
||||||
let shouldAnyShow = false;
|
let shouldAnyShow = false;
|
||||||
for (const option of options) {
|
for (const option of this.#options) {
|
||||||
const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
|
const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
|
||||||
if (shouldShow) {
|
if (shouldShow) {
|
||||||
option.classList.remove("d-none");
|
option.classList.remove("d-none");
|
||||||
shouldAnyShow = true;
|
shouldAnyShow = true;
|
||||||
@ -124,12 +128,12 @@ class AccountSelector {
|
|||||||
option.classList.add("d-none");
|
option.classList.add("d-none");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!shouldAnyShow && more.classList.contains("d-none")) {
|
if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
|
||||||
optionList.classList.add("d-none");
|
this.#optionList.classList.add("d-none");
|
||||||
queryNoResult.classList.remove("d-none");
|
this.#queryNoResult.classList.remove("d-none");
|
||||||
} else {
|
} else {
|
||||||
optionList.classList.remove("d-none");
|
this.#optionList.classList.remove("d-none");
|
||||||
queryNoResult.classList.add("d-none");
|
this.#queryNoResult.classList.add("d-none");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,33 +142,31 @@ class AccountSelector {
|
|||||||
*
|
*
|
||||||
* @return {string[]} the account codes that are used in the form
|
* @return {string[]} the account codes that are used in the form
|
||||||
*/
|
*/
|
||||||
#getAccountCodeUsedInForm() {
|
#getCodesUsedInForm() {
|
||||||
const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code"));
|
const inUse = this.#lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
|
||||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
if (this.#lineItemEditor.accountCode !== null) {
|
||||||
const inUse = [formAccount.dataset.code];
|
inUse.push(this.#lineItemEditor.accountCode);
|
||||||
for (const accountCode of accountCodes) {
|
|
||||||
inUse.push(accountCode.value);
|
|
||||||
}
|
}
|
||||||
return inUse
|
return inUse
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether an account option should show.
|
* Returns whether an option should show.
|
||||||
*
|
*
|
||||||
* @param option {HTMLLIElement} the account option
|
* @param option {HTMLLIElement} the option
|
||||||
* @param more {HTMLLIElement} the more account element
|
* @param more {HTMLLIElement} the more element
|
||||||
* @param inUse {string[]} the account codes that are used in the form
|
* @param inUse {string[]} the account codes that are used in the form
|
||||||
* @param query {HTMLInputElement} the query element, if any
|
* @param query {HTMLInputElement} the query element, if any
|
||||||
* @return {boolean} true if the account option should show, or false otherwise
|
* @return {boolean} true if the option should show, or false otherwise
|
||||||
*/
|
*/
|
||||||
#shouldAccountOptionShow(option, more, inUse, query) {
|
#shouldOptionShow(option, more, inUse, query) {
|
||||||
const isQueryMatched = () => {
|
const isQueryMatched = () => {
|
||||||
if (query.value === "") {
|
if (query.value === "") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||||
for (const queryValue of queryValues) {
|
for (const queryValue of queryValues) {
|
||||||
if (queryValue.includes(query.value)) {
|
if (queryValue.toLowerCase().includes(query.value.toLowerCase())) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -180,71 +182,43 @@ class AccountSelector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the account selector when it is shown.
|
* The callback when the account selector is shown.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
initShow() {
|
onOpen() {
|
||||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
this.#query.value = "";
|
||||||
const query = document.getElementById(this.#prefix + "-query")
|
this.#more.classList.remove("d-none");
|
||||||
const more = document.getElementById(this.#prefix + "-more");
|
this.#filterOptions();
|
||||||
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
for (const option of this.#options) {
|
||||||
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
|
if (option.dataset.code === this.#lineItemEditor.accountCode) {
|
||||||
query.value = "";
|
|
||||||
more.classList.remove("d-none");
|
|
||||||
this.#filterAccountOptions();
|
|
||||||
for (const option of options) {
|
|
||||||
if (option.dataset.code === formAccount.dataset.code) {
|
|
||||||
option.classList.add("active");
|
option.classList.add("active");
|
||||||
} else {
|
} else {
|
||||||
option.classList.remove("active");
|
option.classList.remove("active");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (formAccount.dataset.code === "") {
|
if (this.#lineItemEditor.accountCode === null) {
|
||||||
btnClear.classList.add("btn-secondary");
|
this.#clearButton.classList.add("btn-secondary");
|
||||||
btnClear.classList.remove("btn-danger");
|
this.#clearButton.classList.remove("btn-danger");
|
||||||
btnClear.disabled = true;
|
this.#clearButton.disabled = true;
|
||||||
} else {
|
} else {
|
||||||
btnClear.classList.add("btn-danger");
|
this.#clearButton.classList.add("btn-danger");
|
||||||
btnClear.classList.remove("btn-secondary");
|
this.#clearButton.classList.remove("btn-secondary");
|
||||||
btnClear.disabled = false;
|
this.#clearButton.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The account selectors.
|
* Returns the account selector instances.
|
||||||
* @type {{debit: AccountSelector, credit: AccountSelector}}
|
|
||||||
*/
|
|
||||||
static #selectors = {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the account selectors.
|
|
||||||
*
|
*
|
||||||
|
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||||
|
* @return {{debit: AccountSelector, credit: AccountSelector}}
|
||||||
*/
|
*/
|
||||||
static initialize() {
|
static getInstances(lineItemEditor) {
|
||||||
const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal"));
|
const selectors = {}
|
||||||
|
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
|
||||||
for (const modal of modals) {
|
for (const modal of modals) {
|
||||||
const selector = new AccountSelector(modal);
|
selectors[modal.dataset.debitCredit] = new AccountSelector(lineItemEditor, modal.dataset.debitCredit);
|
||||||
this.#selectors[selector.#entryType] = selector;
|
|
||||||
}
|
}
|
||||||
this.#initializeTransactionForm();
|
return selectors;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the transaction form.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
static #initializeTransactionForm() {
|
|
||||||
const entryForm = document.getElementById("accounting-entry-form");
|
|
||||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
|
||||||
formAccountControl.onclick = () => this.#selectors[entryForm.dataset.entryType].initShow();
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Initializes the account selector for the journal entry form.
|
|
||||||
*x
|
|
||||||
*/
|
|
||||||
static initializeJournalEntryForm() {
|
|
||||||
const entryForm = document.getElementById("accounting-entry-form");
|
|
||||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
|
||||||
formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,152 +24,151 @@
|
|||||||
|
|
||||||
// Initializes the page JavaScript.
|
// Initializes the page JavaScript.
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
document.getElementById("accounting-code")
|
CurrencyForm.initialize();
|
||||||
.onchange = validateCode;
|
|
||||||
document.getElementById("accounting-name")
|
|
||||||
.onchange = validateName;
|
|
||||||
document.getElementById("accounting-form")
|
|
||||||
.onsubmit = validateForm;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The asynchronous validation result
|
* The currency form.
|
||||||
* @type {object}
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
let isAsyncValid = {};
|
class CurrencyForm {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* The form.
|
||||||
|
* @type {HTMLFormElement}
|
||||||
|
*/
|
||||||
|
#formElement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The code
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message of the code
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#codeError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message of the name
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#nameError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the currency form.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
this.#formElement = document.getElementById("accounting-form");
|
||||||
|
this.#code = document.getElementById("accounting-code");
|
||||||
|
this.#codeError = document.getElementById("accounting-code-error");
|
||||||
|
this.#name = document.getElementById("accounting-name");
|
||||||
|
this.#nameError = document.getElementById("accounting-name-error");
|
||||||
|
this.#code.onchange = () => {
|
||||||
|
this.#validateCode().then();
|
||||||
|
};
|
||||||
|
this.#name.onchange = () => {
|
||||||
|
this.#validateName();
|
||||||
|
};
|
||||||
|
this.#formElement.onsubmit = () => {
|
||||||
|
this.#validateForm().then((isValid) => {
|
||||||
|
if (isValid) {
|
||||||
|
this.#formElement.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* Validates the form.
|
* Validates the form.
|
||||||
*
|
*
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
* @returns {Promise<boolean>} true if valid, or false otherwise
|
||||||
* @private
|
|
||||||
*/
|
*/
|
||||||
function validateForm() {
|
async #validateForm() {
|
||||||
isAsyncValid = {
|
|
||||||
"code": false,
|
|
||||||
"_sync": false,
|
|
||||||
};
|
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
isValid = validateCode() && isValid;
|
isValid = await this.#validateCode() && isValid;
|
||||||
isValid = validateName() && isValid;
|
isValid = this.#validateName() && isValid;
|
||||||
isAsyncValid["_sync"] = isValid;
|
return isValid;
|
||||||
submitFormIfAllAsyncValid();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submits the form if the whole form passed the asynchronous
|
|
||||||
* validations.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function submitFormIfAllAsyncValid() {
|
|
||||||
let isValid = true;
|
|
||||||
for (const key of Object.keys(isAsyncValid)) {
|
|
||||||
isValid = isAsyncValid[key] && isValid;
|
|
||||||
}
|
}
|
||||||
if (isValid) {
|
|
||||||
document.getElementById("accounting-form").submit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the code.
|
* Validates the code.
|
||||||
*
|
*
|
||||||
* @param changeEvent {Event} the change event, if invoked from onchange
|
* @param changeEvent {Event} the change event, if invoked from onchange
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
* @returns {Promise<boolean>} true if valid, or false otherwise
|
||||||
* @private
|
|
||||||
*/
|
*/
|
||||||
function validateCode(changeEvent = null) {
|
async #validateCode(changeEvent = null) {
|
||||||
const key = "code";
|
this.#code.value = this.#code.value.trim();
|
||||||
const isSubmission = changeEvent === null;
|
if (this.#code.value === "") {
|
||||||
let hasAsyncValidation = false;
|
this.#code.classList.add("is-invalid");
|
||||||
const field = document.getElementById("accounting-code");
|
this.#codeError.innerText = A_("Please fill in the 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;
|
return false;
|
||||||
}
|
}
|
||||||
const blocklist = JSON.parse(field.dataset.blocklist);
|
const blocklist = JSON.parse(this.#code.dataset.blocklist);
|
||||||
if (blocklist.includes(field.value)) {
|
if (blocklist.includes(this.#code.value)) {
|
||||||
field.classList.add("is-invalid");
|
this.#code.classList.add("is-invalid");
|
||||||
error.innerText = A_("This code is not available.");
|
this.#codeError.innerText = A_("This code is not available.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!field.value.match(/^[A-Z]{3}$/)) {
|
if (!this.#code.value.match(/^[A-Z]{3}$/)) {
|
||||||
field.classList.add("is-invalid");
|
this.#code.classList.add("is-invalid");
|
||||||
error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
|
this.#codeError.innerText = A_("Code can only be composed of 3 upper-cased letters.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const original = field.dataset.original;
|
const original = this.#code.dataset.original;
|
||||||
if (original === "" || field.value !== original) {
|
if (original === "" || this.#code.value !== original) {
|
||||||
hasAsyncValidation = true;
|
const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value));
|
||||||
validateAsyncCodeIsDuplicated(isSubmission, key);
|
const data = await response.json();
|
||||||
|
if (data["exists"]) {
|
||||||
|
this.#code.classList.add("is-invalid");
|
||||||
|
this.#codeError.innerText = A_("Code conflicts with another currency.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
if (!hasAsyncValidation) {
|
|
||||||
isAsyncValid[key] = true;
|
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
}
|
}
|
||||||
|
this.#code.classList.remove("is-invalid");
|
||||||
|
this.#codeError.innerText = "";
|
||||||
return true;
|
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.
|
* Validates the name.
|
||||||
*
|
*
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
* @private
|
|
||||||
*/
|
*/
|
||||||
function validateName() {
|
#validateName() {
|
||||||
const field = document.getElementById("accounting-name");
|
this.#name.value = this.#name.value.trim();
|
||||||
const error = document.getElementById("accounting-name-error");
|
if (this.#name.value === "") {
|
||||||
field.value = field.value.trim();
|
this.#name.classList.add("is-invalid");
|
||||||
if (field.value === "") {
|
this.#nameError.innerText = A_("Please fill in the name.");
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please fill in the name.");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
field.classList.remove("is-invalid");
|
this.#name.classList.remove("is-invalid");
|
||||||
error.innerText = "";
|
this.#nameError.innerText = "";
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form
|
||||||
|
* @type {CurrencyForm}
|
||||||
|
*/
|
||||||
|
static #form;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the currency form.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
static initialize() {
|
||||||
|
this.#form = new CurrencyForm();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
/* The Mia! Accounting Flask Project
|
||||||
* summary-editor.js: The JavaScript for the summary editor
|
* description-editor.js: The JavaScript for the description editor
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Copyright (c) 2023 imacat.
|
/* Copyright (c) 2023 imacat.
|
||||||
@ -22,19 +22,20 @@
|
|||||||
*/
|
*/
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// Initializes the page JavaScript.
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
SummaryEditor.initialize();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A summary editor.
|
* A description editor.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
class SummaryEditor {
|
class DescriptionEditor {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The summary editor form
|
* The line item editor
|
||||||
|
* @type {JournalEntryLineItemEditor}
|
||||||
|
*/
|
||||||
|
#lineItemEditor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The description editor form
|
||||||
* @type {HTMLFormElement}
|
* @type {HTMLFormElement}
|
||||||
*/
|
*/
|
||||||
#form;
|
#form;
|
||||||
@ -46,37 +47,43 @@ class SummaryEditor {
|
|||||||
prefix;
|
prefix;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The modal of the summary editor
|
* The modal of the description editor
|
||||||
* @type {HTMLDivElement}
|
* @type {HTMLDivElement}
|
||||||
*/
|
*/
|
||||||
#modal;
|
#modal;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The entry type, either "debit" or "credit"
|
* Either "debit" or "credit"
|
||||||
* @type {string}
|
* @type {string}
|
||||||
*/
|
*/
|
||||||
#entryType;
|
debitCredit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current tab.
|
* The current tab
|
||||||
* @type {TabPlane}
|
* @type {TabPlane}
|
||||||
*/
|
*/
|
||||||
currentTab;
|
currentTab;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The summary input.
|
* The description input
|
||||||
* @type {HTMLInputElement}
|
* @type {HTMLInputElement}
|
||||||
*/
|
*/
|
||||||
summary;
|
description;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The number input.
|
* The button to the original line item selector
|
||||||
|
* @type {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#offsetButton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number input
|
||||||
* @type {HTMLInputElement}
|
* @type {HTMLInputElement}
|
||||||
*/
|
*/
|
||||||
number;
|
number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The note.
|
* The note
|
||||||
* @type {HTMLInputElement}
|
* @type {HTMLInputElement}
|
||||||
*/
|
*/
|
||||||
note;
|
note;
|
||||||
@ -93,36 +100,6 @@ class SummaryEditor {
|
|||||||
*/
|
*/
|
||||||
#selectedAccount = null;
|
#selectedAccount = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* The modal of the journal entry form
|
|
||||||
* @type {HTMLDivElement}
|
|
||||||
*/
|
|
||||||
#entryFormModal;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The control of the account on the journal entry form
|
|
||||||
* @type {HTMLDivElement}
|
|
||||||
*/
|
|
||||||
#formAccountControl;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The account on the journal entry form
|
|
||||||
* @type {HTMLDivElement}
|
|
||||||
*/
|
|
||||||
#formAccount;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The control of the summary on the journal entry form
|
|
||||||
* @type {HTMLDivElement}
|
|
||||||
*/
|
|
||||||
#formSummaryControl;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The summary on the journal entry form
|
|
||||||
* @type {HTMLDivElement}
|
|
||||||
*/
|
|
||||||
#formSummary;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tab planes
|
* The tab planes
|
||||||
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}}
|
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}}
|
||||||
@ -130,35 +107,32 @@ class SummaryEditor {
|
|||||||
tabPlanes = {};
|
tabPlanes = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a summary editor.
|
* Constructs a description editor.
|
||||||
*
|
*
|
||||||
* @param form {HTMLFormElement} the summary editor form
|
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||||
|
* @param debitCredit {string} either "debit" or "credit"
|
||||||
*/
|
*/
|
||||||
constructor(form) {
|
constructor(lineItemEditor, debitCredit) {
|
||||||
this.#form = form;
|
this.#lineItemEditor = lineItemEditor;
|
||||||
this.#entryType = form.dataset.entryType;
|
this.debitCredit = debitCredit;
|
||||||
this.prefix = "accounting-summary-editor-" + form.dataset.entryType;
|
this.prefix = "accounting-description-editor-" + debitCredit;
|
||||||
|
this.#form = document.getElementById(this.prefix);
|
||||||
this.#modal = document.getElementById(this.prefix + "-modal");
|
this.#modal = document.getElementById(this.prefix + "-modal");
|
||||||
this.summary = document.getElementById(this.prefix + "-summary");
|
this.description = document.getElementById(this.prefix + "-description");
|
||||||
|
this.#offsetButton = document.getElementById(this.prefix + "-offset");
|
||||||
this.number = document.getElementById(this.prefix + "-annotation-number");
|
this.number = document.getElementById(this.prefix + "-annotation-number");
|
||||||
this.note = document.getElementById(this.prefix + "-annotation-note");
|
this.note = document.getElementById(this.prefix + "-annotation-note");
|
||||||
// noinspection JSValidateTypes
|
// noinspection JSValidateTypes
|
||||||
this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account"));
|
this.#accountButtons = Array.from(document.getElementsByClassName(this.prefix + "-account"));
|
||||||
|
|
||||||
// Things from the entry form
|
|
||||||
this.#entryFormModal = document.getElementById("accounting-entry-form-modal");
|
|
||||||
this.#formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
|
||||||
this.#formAccount = document.getElementById("accounting-entry-form-account");
|
|
||||||
this.#formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
|
||||||
this.#formSummary = document.getElementById("accounting-entry-form-summary");
|
|
||||||
|
|
||||||
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) {
|
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) {
|
||||||
const tab = new cls(this);
|
const tab = new cls(this);
|
||||||
this.tabPlanes[tab.tabId()] = tab;
|
this.tabPlanes[tab.tabId()] = tab;
|
||||||
}
|
}
|
||||||
this.currentTab = this.tabPlanes.general;
|
this.currentTab = this.tabPlanes.general;
|
||||||
this.#initializeSuggestedAccounts();
|
this.#initializeSuggestedAccounts();
|
||||||
this.summary.onchange = () => this.#onSummaryChange();
|
this.description.onchange = () => this.#onDescriptionChange();
|
||||||
|
this.#offsetButton.onclick = () => this.#lineItemEditor.originalLineItemSelector.onOpen();
|
||||||
this.#form.onsubmit = () => {
|
this.#form.onsubmit = () => {
|
||||||
if (this.currentTab.validate()) {
|
if (this.currentTab.validate()) {
|
||||||
this.#submit();
|
this.#submit();
|
||||||
@ -168,11 +142,11 @@ class SummaryEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The callback when the summary input is changed.
|
* The callback when the description input is changed.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
#onSummaryChange() {
|
#onDescriptionChange() {
|
||||||
this.summary.value = this.summary.value.trim();
|
this.description.value = this.description.value.trim();
|
||||||
for (const tabPlane of [this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
|
for (const tabPlane of [this.tabPlanes.bus, this.tabPlanes.travel, this.tabPlanes.general]) {
|
||||||
if (tabPlane.populate()) {
|
if (tabPlane.populate()) {
|
||||||
break;
|
break;
|
||||||
@ -235,43 +209,34 @@ class SummaryEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submits the summary.
|
* Submits the description.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
#submit() {
|
#submit() {
|
||||||
if (this.summary.value === "") {
|
|
||||||
this.#formSummaryControl.classList.remove("accounting-not-empty");
|
|
||||||
} else {
|
|
||||||
this.#formSummaryControl.classList.add("accounting-not-empty");
|
|
||||||
}
|
|
||||||
if (this.#selectedAccount !== null) {
|
|
||||||
this.#formAccountControl.classList.add("accounting-not-empty");
|
|
||||||
this.#formAccount.dataset.code = this.#selectedAccount.dataset.code;
|
|
||||||
this.#formAccount.dataset.text = this.#selectedAccount.dataset.text;
|
|
||||||
this.#formAccount.innerText = this.#selectedAccount.dataset.text;
|
|
||||||
}
|
|
||||||
this.#formSummary.dataset.value = this.summary.value;
|
|
||||||
this.#formSummary.innerText = this.summary.value;
|
|
||||||
bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
|
bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
|
||||||
bootstrap.Modal.getOrCreateInstance(this.#entryFormModal).show();
|
if (this.#selectedAccount !== null) {
|
||||||
|
this.#lineItemEditor.saveDescriptionWithAccount(this.description.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
|
||||||
|
} else {
|
||||||
|
this.#lineItemEditor.saveDescription(this.description.value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The callback when the summary editor is shown.
|
* The callback when the description editor is shown.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
#onOpen() {
|
onOpen() {
|
||||||
this.#reset();
|
this.#reset();
|
||||||
this.summary.value = this.#formSummary.dataset.value;
|
this.description.value = this.#lineItemEditor.description === null? "": this.#lineItemEditor.description;
|
||||||
this.#onSummaryChange();
|
this.#onDescriptionChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets the summary editor.
|
* Resets the description editor.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
#reset() {
|
#reset() {
|
||||||
this.summary.value = "";
|
this.description.value = "";
|
||||||
for (const tabPlane of Object.values(this.tabPlanes)) {
|
for (const tabPlane of Object.values(this.tabPlanes)) {
|
||||||
tabPlane.reset();
|
tabPlane.reset();
|
||||||
}
|
}
|
||||||
@ -279,33 +244,18 @@ class SummaryEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The summary editors.
|
* Returns the description editor instances.
|
||||||
* @type {{debit: SummaryEditor, credit: SummaryEditor}}
|
|
||||||
*/
|
|
||||||
static #editors = {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the summary editors.
|
|
||||||
*
|
*
|
||||||
|
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||||
|
* @return {{debit: DescriptionEditor, credit: DescriptionEditor}}
|
||||||
*/
|
*/
|
||||||
static initialize() {
|
static getInstances(lineItemEditor) {
|
||||||
const forms = Array.from(document.getElementsByClassName("accounting-summary-editor"));
|
const editors = {}
|
||||||
const entryForm = document.getElementById("accounting-entry-form");
|
const forms = Array.from(document.getElementsByClassName("accounting-description-editor"));
|
||||||
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
|
||||||
for (const form of forms) {
|
for (const form of forms) {
|
||||||
const editor = new SummaryEditor(form);
|
editors[form.dataset.debitCredit] = new DescriptionEditor(lineItemEditor, form.dataset.debitCredit);
|
||||||
this.#editors[editor.#entryType] = editor;
|
|
||||||
}
|
}
|
||||||
formSummaryControl.onclick = () => this.#editors[entryForm.dataset.entryType].#onOpen()
|
return editors;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the summary editor for a new journal entry.
|
|
||||||
*
|
|
||||||
* @param entryType {string} the entry type, either "debit" or "credit"
|
|
||||||
*/
|
|
||||||
static initializeNewJournalEntry(entryType) {
|
|
||||||
this.#editors[entryType].#onOpen();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -318,8 +268,8 @@ class SummaryEditor {
|
|||||||
class TabPlane {
|
class TabPlane {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The parent summary editor
|
* The parent description editor
|
||||||
* @type {SummaryEditor}
|
* @type {DescriptionEditor}
|
||||||
*/
|
*/
|
||||||
editor;
|
editor;
|
||||||
|
|
||||||
@ -344,7 +294,7 @@ class TabPlane {
|
|||||||
/**
|
/**
|
||||||
* Constructs a tab plane.
|
* Constructs a tab plane.
|
||||||
*
|
*
|
||||||
* @param editor {SummaryEditor} the parent summary editor
|
* @param editor {DescriptionEditor} the parent description editor
|
||||||
*/
|
*/
|
||||||
constructor(editor) {
|
constructor(editor) {
|
||||||
this.editor = editor;
|
this.editor = editor;
|
||||||
@ -370,9 +320,9 @@ class TabPlane {
|
|||||||
reset() { throw new Error("Method not implemented."); }
|
reset() { throw new Error("Method not implemented."); }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populates the tab plane with the summary input.
|
* Populates the tab plane with the description input.
|
||||||
*
|
*
|
||||||
* @return {boolean} true if the summary input matches this tab, or false otherwise
|
* @return {boolean} true if the description input matches this tab, or false otherwise
|
||||||
* @abstract
|
* @abstract
|
||||||
*/
|
*/
|
||||||
populate() { throw new Error("Method not implemented."); }
|
populate() { throw new Error("Method not implemented."); }
|
||||||
@ -433,7 +383,7 @@ class TagTabPlane extends TabPlane {
|
|||||||
/**
|
/**
|
||||||
* Constructs a tab plane.
|
* Constructs a tab plane.
|
||||||
*
|
*
|
||||||
* @param editor {SummaryEditor} the parent summary editor
|
* @param editor {DescriptionEditor} the parent description editor
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
constructor(editor) {
|
constructor(editor) {
|
||||||
@ -445,7 +395,7 @@ class TagTabPlane extends TabPlane {
|
|||||||
this.initializeTagButtons();
|
this.initializeTagButtons();
|
||||||
this.tag.onchange = () => {
|
this.tag.onchange = () => {
|
||||||
this.onTagChange();
|
this.onTagChange();
|
||||||
this.updateSummary();
|
this.updateDescription();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -474,11 +424,11 @@ class TagTabPlane extends TabPlane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the summary according to the input in the tab plane.
|
* Updates the description according to the input in the tab plane.
|
||||||
*
|
*
|
||||||
* @abstract
|
* @abstract
|
||||||
*/
|
*/
|
||||||
updateSummary() { throw new Error("Method not implemented."); }
|
updateDescription() { throw new Error("Method not implemented."); }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switches to the tab plane.
|
* Switches to the tab plane.
|
||||||
@ -511,7 +461,7 @@ class TagTabPlane extends TabPlane {
|
|||||||
tagButton.classList.add("btn-primary");
|
tagButton.classList.add("btn-primary");
|
||||||
this.tag.value = tagButton.dataset.value;
|
this.tag.value = tagButton.dataset.value;
|
||||||
this.editor.filterSuggestedAccounts(tagButton);
|
this.editor.filterSuggestedAccounts(tagButton);
|
||||||
this.updateSummary();
|
this.updateDescription();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -582,28 +532,28 @@ class GeneralTagTab extends TagTabPlane {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the summary according to the input in the tab plane.
|
* Updates the description according to the input in the tab plane.
|
||||||
*
|
*
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
updateSummary() {
|
updateDescription() {
|
||||||
const pos = this.editor.summary.value.indexOf("—");
|
const pos = this.editor.description.value.indexOf("—");
|
||||||
const prefix = this.tag.value === ""? "": this.tag.value + "—";
|
const prefix = this.tag.value === ""? "": this.tag.value + "—";
|
||||||
if (pos === -1) {
|
if (pos === -1) {
|
||||||
this.editor.summary.value = prefix + this.editor.summary.value;
|
this.editor.description.value = prefix + this.editor.description.value;
|
||||||
} else {
|
} else {
|
||||||
this.editor.summary.value = prefix + this.editor.summary.value.substring(pos + 1);
|
this.editor.description.value = prefix + this.editor.description.value.substring(pos + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populates the tab plane with the summary input.
|
* Populates the tab plane with the description input.
|
||||||
*
|
*
|
||||||
* @return {boolean} true if the summary input matches this tab, or false otherwise
|
* @return {boolean} true if the description input matches this tab, or false otherwise
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
populate() {
|
populate() {
|
||||||
const found = this.editor.summary.value.match(/^([^—]+)—/);
|
const found = this.editor.description.value.match(/^([^—]+)—/);
|
||||||
if (found === null) {
|
if (found === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -672,7 +622,7 @@ class GeneralTripTab extends TagTabPlane {
|
|||||||
/**
|
/**
|
||||||
* Constructs a tab plane.
|
* Constructs a tab plane.
|
||||||
*
|
*
|
||||||
* @param editor {SummaryEditor} the parent summary editor
|
* @param editor {DescriptionEditor} the parent description editor
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
constructor(editor) {
|
constructor(editor) {
|
||||||
@ -685,7 +635,7 @@ class GeneralTripTab extends TagTabPlane {
|
|||||||
this.#directionButtons = Array.from(document.getElementsByClassName(this.prefix + "-direction"));
|
this.#directionButtons = Array.from(document.getElementsByClassName(this.prefix + "-direction"));
|
||||||
this.#from.onchange = () => {
|
this.#from.onchange = () => {
|
||||||
this.#from.value = this.#from.value.trim();
|
this.#from.value = this.#from.value.trim();
|
||||||
this.updateSummary();
|
this.updateDescription();
|
||||||
this.validateFrom();
|
this.validateFrom();
|
||||||
};
|
};
|
||||||
for (const directionButton of this.#directionButtons) {
|
for (const directionButton of this.#directionButtons) {
|
||||||
@ -696,12 +646,12 @@ class GeneralTripTab extends TagTabPlane {
|
|||||||
}
|
}
|
||||||
directionButton.classList.remove("btn-outline-primary");
|
directionButton.classList.remove("btn-outline-primary");
|
||||||
directionButton.classList.add("btn-primary");
|
directionButton.classList.add("btn-primary");
|
||||||
this.updateSummary();
|
this.updateDescription();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
this.#to.onchange = () => {
|
this.#to.onchange = () => {
|
||||||
this.#to.value = this.#to.value.trim();
|
this.#to.value = this.#to.value.trim();
|
||||||
this.updateSummary();
|
this.updateDescription();
|
||||||
this.validateTo();
|
this.validateTo();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -717,11 +667,11 @@ class GeneralTripTab extends TagTabPlane {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the summary according to the input in the tab plane.
|
* Updates the description according to the input in the tab plane.
|
||||||
*
|
*
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
updateSummary() {
|
updateDescription() {
|
||||||
let direction;
|
let direction;
|
||||||
for (const directionButton of this.#directionButtons) {
|
for (const directionButton of this.#directionButtons) {
|
||||||
if (directionButton.classList.contains("btn-primary")) {
|
if (directionButton.classList.contains("btn-primary")) {
|
||||||
@ -729,7 +679,7 @@ class GeneralTripTab extends TagTabPlane {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.editor.summary.value = this.tag.value + "—" + this.#from.value + direction + this.#to.value;
|
this.editor.description.value = this.tag.value + "—" + this.#from.value + direction + this.#to.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -757,13 +707,13 @@ class GeneralTripTab extends TagTabPlane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populates the tab plane with the summary input.
|
* Populates the tab plane with the description input.
|
||||||
*
|
*
|
||||||
* @return {boolean} true if the summary input matches this tab, or false otherwise
|
* @return {boolean} true if the description input matches this tab, or false otherwise
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
populate() {
|
populate() {
|
||||||
const found = this.editor.summary.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
const found = this.editor.description.value.match(/^([^—]+)—([^—→↔]+)([→↔])(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
||||||
if (found === null) {
|
if (found === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -884,7 +834,7 @@ class BusTripTab extends TagTabPlane {
|
|||||||
/**
|
/**
|
||||||
* Constructs a tab plane.
|
* Constructs a tab plane.
|
||||||
*
|
*
|
||||||
* @param editor {SummaryEditor} the parent summary editor
|
* @param editor {DescriptionEditor} the parent description editor
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
constructor(editor) {
|
constructor(editor) {
|
||||||
@ -897,17 +847,17 @@ class BusTripTab extends TagTabPlane {
|
|||||||
this.#toError = document.getElementById(this.prefix + "-to-error")
|
this.#toError = document.getElementById(this.prefix + "-to-error")
|
||||||
this.#route.onchange = () => {
|
this.#route.onchange = () => {
|
||||||
this.#route.value = this.#route.value.trim();
|
this.#route.value = this.#route.value.trim();
|
||||||
this.updateSummary();
|
this.updateDescription();
|
||||||
this.validateRoute();
|
this.validateRoute();
|
||||||
};
|
};
|
||||||
this.#from.onchange = () => {
|
this.#from.onchange = () => {
|
||||||
this.#from.value = this.#from.value.trim();
|
this.#from.value = this.#from.value.trim();
|
||||||
this.updateSummary();
|
this.updateDescription();
|
||||||
this.validateFrom();
|
this.validateFrom();
|
||||||
};
|
};
|
||||||
this.#to.onchange = () => {
|
this.#to.onchange = () => {
|
||||||
this.#to.value = this.#to.value.trim();
|
this.#to.value = this.#to.value.trim();
|
||||||
this.updateSummary();
|
this.updateDescription();
|
||||||
this.validateTo();
|
this.validateTo();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -923,12 +873,12 @@ class BusTripTab extends TagTabPlane {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the summary according to the input in the tab plane.
|
* Updates the description according to the input in the tab plane.
|
||||||
*
|
*
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
updateSummary() {
|
updateDescription() {
|
||||||
this.editor.summary.value = this.tag.value + "—" + this.#route.value + "—" + this.#from.value + "→" + this.#to.value;
|
this.editor.description.value = this.tag.value + "—" + this.#route.value + "—" + this.#from.value + "→" + this.#to.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -950,13 +900,13 @@ class BusTripTab extends TagTabPlane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populates the tab plane with the summary input.
|
* Populates the tab plane with the description input.
|
||||||
*
|
*
|
||||||
* @return {boolean} true if the summary input matches this tab, or false otherwise
|
* @return {boolean} true if the description input matches this tab, or false otherwise
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
populate() {
|
populate() {
|
||||||
const found = this.editor.summary.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
const found = this.editor.description.value.match(/^([^—]+)—([^—]+)—([^—→]+)→(.+?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
||||||
if (found === null) {
|
if (found === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -1051,7 +1001,7 @@ class RegularPaymentTab extends TabPlane {
|
|||||||
/**
|
/**
|
||||||
* Constructs a tab plane.
|
* Constructs a tab plane.
|
||||||
*
|
*
|
||||||
* @param editor {SummaryEditor} the parent summary editor
|
* @param editor {DescriptionEditor} the parent description editor
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
constructor(editor) {
|
constructor(editor) {
|
||||||
@ -1083,9 +1033,9 @@ class RegularPaymentTab extends TabPlane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populates the tab plane with the summary input.
|
* Populates the tab plane with the description input.
|
||||||
*
|
*
|
||||||
* @return {boolean} true if the summary input matches this tab, or false otherwise
|
* @return {boolean} true if the description input matches this tab, or false otherwise
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
populate() {
|
populate() {
|
||||||
@ -1113,15 +1063,15 @@ class AnnotationTab extends TabPlane {
|
|||||||
/**
|
/**
|
||||||
* Constructs a tab plane.
|
* Constructs a tab plane.
|
||||||
*
|
*
|
||||||
* @param editor {SummaryEditor} the parent summary editor
|
* @param editor {DescriptionEditor} the parent description editor
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
constructor(editor) {
|
constructor(editor) {
|
||||||
super(editor);
|
super(editor);
|
||||||
this.editor.number.onchange = () => this.updateSummary();
|
this.editor.number.onchange = () => this.updateDescription();
|
||||||
this.editor.note.onchange = () => {
|
this.editor.note.onchange = () => {
|
||||||
this.editor.note.value = this.editor.note.value.trim();
|
this.editor.note.value = this.editor.note.value.trim();
|
||||||
this.updateSummary();
|
this.updateDescription();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1136,20 +1086,20 @@ class AnnotationTab extends TabPlane {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the summary according to the input in the tab plane.
|
* Updates the description according to the input in the tab plane.
|
||||||
*
|
*
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
updateSummary() {
|
updateDescription() {
|
||||||
const found = this.editor.summary.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
const found = this.editor.description.value.match(/^(.*?)(?:[*×]\d+)?(?:\([^()]+\))?$/);
|
||||||
if (found !== null) {
|
if (found !== null) {
|
||||||
this.editor.summary.value = found[1];
|
this.editor.description.value = found[1];
|
||||||
}
|
}
|
||||||
if (parseInt(this.editor.number.value) > 1) {
|
if (parseInt(this.editor.number.value) > 1) {
|
||||||
this.editor.summary.value = this.editor.summary.value + "×" + this.editor.number.value;
|
this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value;
|
||||||
}
|
}
|
||||||
if (this.editor.note.value !== "") {
|
if (this.editor.note.value !== "") {
|
||||||
this.editor.summary.value = this.editor.summary.value + "(" + this.editor.note.value + ")";
|
this.editor.description.value = this.editor.description.value + "(" + this.editor.note.value + ")";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1164,25 +1114,25 @@ class AnnotationTab extends TabPlane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Populates the tab plane with the summary input.
|
* Populates the tab plane with the description input.
|
||||||
*
|
*
|
||||||
* @return {boolean} true if the summary input matches this tab, or false otherwise
|
* @return {boolean} true if the description input matches this tab, or false otherwise
|
||||||
* @override
|
* @override
|
||||||
*/
|
*/
|
||||||
populate() {
|
populate() {
|
||||||
const found = this.editor.summary.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/);
|
const found = this.editor.description.value.match(/^(.*?)(?:[*×](\d+))?(?:\(([^()]+)\))?$/);
|
||||||
this.editor.summary.value = found[1];
|
this.editor.description.value = found[1];
|
||||||
if (found[2] === undefined || parseInt(found[2]) === 1) {
|
if (found[2] === undefined || parseInt(found[2]) === 1) {
|
||||||
this.editor.number.value = "";
|
this.editor.number.value = "";
|
||||||
} else {
|
} else {
|
||||||
this.editor.number.value = found[2];
|
this.editor.number.value = found[2];
|
||||||
this.editor.summary.value = this.editor.summary.value + "×" + this.editor.number.value;
|
this.editor.description.value = this.editor.description.value + "×" + this.editor.number.value;
|
||||||
}
|
}
|
||||||
if (found[3] === undefined) {
|
if (found[3] === undefined) {
|
||||||
this.editor.note.value = "";
|
this.editor.note.value = "";
|
||||||
} else {
|
} else {
|
||||||
this.editor.note.value = found[3];
|
this.editor.note.value = found[3];
|
||||||
this.editor.summary.value = this.editor.summary.value + "(" + this.editor.note.value + ")";
|
this.editor.description.value = this.editor.description.value + "(" + this.editor.note.value + ")";
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
1056
src/accounting/static/js/journal-entry-form.js
Normal file
1056
src/accounting/static/js/journal-entry-form.js
Normal file
File diff suppressed because it is too large
Load Diff
596
src/accounting/static/js/journal-entry-line-item-editor.js
Normal file
596
src/accounting/static/js/journal-entry-line-item-editor.js
Normal file
@ -0,0 +1,596 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* journal-entry-line-item-editor.js: The JavaScript for the journal entry line item editor
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/2/25
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journal entry line item editor.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class JournalEntryLineItemEditor {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journal entry form
|
||||||
|
* @type {JournalEntryForm}
|
||||||
|
*/
|
||||||
|
form;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journal entry line item editor
|
||||||
|
* @type {HTMLFormElement}
|
||||||
|
*/
|
||||||
|
#element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bootstrap modal
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#modal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Either "debit" or "credit"
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
debitCredit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix of the HTML ID and class
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#prefix = "accounting-line-item-editor"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The container of the original line item
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#originalLineItemContainer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The control of the original line item
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#originalLineItemControl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The original line item
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#originalLineItemText;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message of the original line item
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#originalLineItemError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The delete button of the original line item
|
||||||
|
* @type {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#originalLineItemDelete;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The control of the description
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#descriptionControl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The description
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#descriptionText;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message of the description
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#descriptionError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The control of the account
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#accountControl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#accountText;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message of the account
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#accountError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#amountInput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message of the amount
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#amountError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journal entry line item to edit
|
||||||
|
* @type {LineItemSubForm|null}
|
||||||
|
*/
|
||||||
|
lineItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The debit or credit sub-form
|
||||||
|
* @type {DebitCreditSubForm}
|
||||||
|
*/
|
||||||
|
#debitCreditSubForm;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the journal entry line item needs offset
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
isNeedOffset = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the original line item
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
originalLineItemId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date of the original line item
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
originalLineItemDate = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text of the original line item
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
originalLineItemText = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account code
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
accountCode = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account text
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
accountText = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The description
|
||||||
|
* @type {string|null}
|
||||||
|
*/
|
||||||
|
description = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
amount = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The description editors
|
||||||
|
* @type {{debit: DescriptionEditor, credit: DescriptionEditor}}
|
||||||
|
*/
|
||||||
|
#descriptionEditors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account selectors
|
||||||
|
* @type {{debit: AccountSelector, credit: AccountSelector}}
|
||||||
|
*/
|
||||||
|
#accountSelectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The original line item selector
|
||||||
|
* @type {OriginalLineItemSelector}
|
||||||
|
*/
|
||||||
|
originalLineItemSelector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new journal entry line item editor.
|
||||||
|
*
|
||||||
|
* @param form {JournalEntryForm} the journal entry form
|
||||||
|
*/
|
||||||
|
constructor(form) {
|
||||||
|
this.form = form;
|
||||||
|
this.#element = document.getElementById(this.#prefix);
|
||||||
|
this.#modal = document.getElementById(this.#prefix + "-modal");
|
||||||
|
this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container");
|
||||||
|
this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control");
|
||||||
|
this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item");
|
||||||
|
this.#originalLineItemError = document.getElementById(this.#prefix + "-original-line-item-error");
|
||||||
|
this.#originalLineItemDelete = document.getElementById(this.#prefix + "-original-line-item-delete");
|
||||||
|
this.#descriptionControl = document.getElementById(this.#prefix + "-description-control");
|
||||||
|
this.#descriptionText = document.getElementById(this.#prefix + "-description");
|
||||||
|
this.#descriptionError = document.getElementById(this.#prefix + "-description-error");
|
||||||
|
this.#accountControl = document.getElementById(this.#prefix + "-account-control");
|
||||||
|
this.#accountText = document.getElementById(this.#prefix + "-account");
|
||||||
|
this.#accountError = document.getElementById(this.#prefix + "-account-error")
|
||||||
|
this.#amountInput = document.getElementById(this.#prefix + "-amount");
|
||||||
|
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
|
||||||
|
this.#descriptionEditors = DescriptionEditor.getInstances(this);
|
||||||
|
this.#accountSelectors = AccountSelector.getInstances(this);
|
||||||
|
this.originalLineItemSelector = new OriginalLineItemSelector(this);
|
||||||
|
this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen()
|
||||||
|
this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
|
||||||
|
this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen();
|
||||||
|
this.#accountControl.onclick = () => this.#accountSelectors[this.debitCredit].onOpen();
|
||||||
|
this.#amountInput.onchange = () => this.#validateAmount();
|
||||||
|
this.#element.onsubmit = () => {
|
||||||
|
if (this.#validate()) {
|
||||||
|
if (this.lineItem === null) {
|
||||||
|
this.lineItem = this.#debitCreditSubForm.addLineItem();
|
||||||
|
}
|
||||||
|
this.amount = this.#amountInput.value;
|
||||||
|
this.lineItem.save(this);
|
||||||
|
bootstrap.Modal.getInstance(this.#modal).hide();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the original line item from the original line item selector.
|
||||||
|
*
|
||||||
|
* @param originalLineItem {OriginalLineItem} the original line item
|
||||||
|
*/
|
||||||
|
saveOriginalLineItem(originalLineItem) {
|
||||||
|
this.isNeedOffset = false;
|
||||||
|
this.#originalLineItemContainer.classList.remove("d-none");
|
||||||
|
this.#originalLineItemControl.classList.add("accounting-not-empty");
|
||||||
|
this.originalLineItemId = originalLineItem.id;
|
||||||
|
this.originalLineItemDate = originalLineItem.date;
|
||||||
|
this.originalLineItemText = originalLineItem.text;
|
||||||
|
this.#originalLineItemText.innerText = originalLineItem.text;
|
||||||
|
this.#setEnableDescriptionAccount(false);
|
||||||
|
if (originalLineItem.description === "") {
|
||||||
|
this.#descriptionControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
this.#descriptionControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
this.description = originalLineItem.description === ""? null: originalLineItem.description;
|
||||||
|
this.#descriptionText.innerText = originalLineItem.description;
|
||||||
|
this.#accountControl.classList.add("accounting-not-empty");
|
||||||
|
this.accountCode = originalLineItem.accountCode;
|
||||||
|
this.accountText = originalLineItem.accountText;
|
||||||
|
this.#accountText.innerText = originalLineItem.accountText;
|
||||||
|
this.#amountInput.value = String(originalLineItem.netBalance);
|
||||||
|
this.#amountInput.max = String(originalLineItem.netBalance);
|
||||||
|
this.#amountInput.min = "0";
|
||||||
|
this.#validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the original line item.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
clearOriginalLineItem() {
|
||||||
|
this.isNeedOffset = false;
|
||||||
|
this.#originalLineItemContainer.classList.add("d-none");
|
||||||
|
this.#originalLineItemControl.classList.remove("accounting-not-empty");
|
||||||
|
this.originalLineItemId = null;
|
||||||
|
this.originalLineItemDate = null;
|
||||||
|
this.originalLineItemText = null;
|
||||||
|
this.#originalLineItemText.innerText = "";
|
||||||
|
this.#setEnableDescriptionAccount(true);
|
||||||
|
this.#accountControl.classList.remove("accounting-not-empty");
|
||||||
|
this.accountCode = null;
|
||||||
|
this.accountText = null;
|
||||||
|
this.#accountText.innerText = "";
|
||||||
|
this.#amountInput.max = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currency code.
|
||||||
|
*
|
||||||
|
* @return {string} the currency code
|
||||||
|
*/
|
||||||
|
getCurrencyCode() {
|
||||||
|
return this.#debitCreditSubForm.currency.getCurrencyCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the description from the description editor.
|
||||||
|
*
|
||||||
|
* @param description {string} the description
|
||||||
|
*/
|
||||||
|
saveDescription(description) {
|
||||||
|
if (description === "") {
|
||||||
|
this.#descriptionControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
this.#descriptionControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
this.description = description === ""? null: description;
|
||||||
|
this.#descriptionText.innerText = description;
|
||||||
|
this.#validateDescription();
|
||||||
|
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the description with the suggested account from the description editor.
|
||||||
|
*
|
||||||
|
* @param description {string} the description
|
||||||
|
* @param accountCode {string} the account code
|
||||||
|
* @param accountText {string} the account text
|
||||||
|
* @param isAccountNeedOffset {boolean} true if the line items in the account need offset, or false otherwise
|
||||||
|
*/
|
||||||
|
saveDescriptionWithAccount(description, accountCode, accountText, isAccountNeedOffset) {
|
||||||
|
this.isNeedOffset = isAccountNeedOffset;
|
||||||
|
this.#accountControl.classList.add("accounting-not-empty");
|
||||||
|
this.accountCode = accountCode;
|
||||||
|
this.accountText = accountText;
|
||||||
|
this.#accountText.innerText = accountText;
|
||||||
|
this.#validateAccount();
|
||||||
|
this.saveDescription(description)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the account.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
clearAccount() {
|
||||||
|
this.isNeedOffset = false;
|
||||||
|
this.#accountControl.classList.remove("accounting-not-empty");
|
||||||
|
this.accountCode = null;
|
||||||
|
this.accountText = null;
|
||||||
|
this.#accountText.innerText = "";
|
||||||
|
this.#validateAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the account.
|
||||||
|
*
|
||||||
|
* @param code {string} the account code
|
||||||
|
* @param text {string} the account text
|
||||||
|
* @param isNeedOffset {boolean} true if the line items in the account need offset or false otherwise
|
||||||
|
*/
|
||||||
|
saveAccount(code, text, isNeedOffset) {
|
||||||
|
this.isNeedOffset = isNeedOffset;
|
||||||
|
this.#accountControl.classList.add("accounting-not-empty");
|
||||||
|
this.accountCode = code;
|
||||||
|
this.accountText = text;
|
||||||
|
this.#accountText.innerText = text;
|
||||||
|
this.#validateAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the form.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validate() {
|
||||||
|
let isValid = true;
|
||||||
|
isValid = this.#validateOriginalLineItem() && isValid;
|
||||||
|
isValid = this.#validateDescription() && isValid;
|
||||||
|
isValid = this.#validateAccount() && isValid;
|
||||||
|
isValid = this.#validateAmount() && isValid
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the original line item.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#validateOriginalLineItem() {
|
||||||
|
this.#originalLineItemControl.classList.remove("is-invalid");
|
||||||
|
this.#originalLineItemError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the description.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#validateDescription() {
|
||||||
|
this.#descriptionText.classList.remove("is-invalid");
|
||||||
|
this.#descriptionError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the account.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateAccount() {
|
||||||
|
if (this.accountCode === null) {
|
||||||
|
this.#accountControl.classList.add("is-invalid");
|
||||||
|
this.#accountError.innerText = A_("Please select the account.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#accountControl.classList.remove("is-invalid");
|
||||||
|
this.#accountError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the amount.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#validateAmount() {
|
||||||
|
this.#amountInput.value = this.#amountInput.value.trim();
|
||||||
|
this.#amountInput.classList.remove("is-invalid");
|
||||||
|
if (this.#amountInput.value === "") {
|
||||||
|
this.#amountInput.classList.add("is-invalid");
|
||||||
|
this.#amountError.innerText = A_("Please fill in the amount.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const amount =new Decimal(this.#amountInput.value);
|
||||||
|
if (amount.lessThanOrEqualTo(0)) {
|
||||||
|
this.#amountInput.classList.add("is-invalid");
|
||||||
|
this.#amountError.innerText = A_("Please fill in a positive amount.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.#amountInput.max !== "") {
|
||||||
|
if (amount.greaterThan(new Decimal(this.#amountInput.max))) {
|
||||||
|
this.#amountInput.classList.add("is-invalid");
|
||||||
|
this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original line item.", {balance: new Decimal(this.#amountInput.max)});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.#amountInput.min !== "") {
|
||||||
|
const min = new Decimal(this.#amountInput.min);
|
||||||
|
if (amount.lessThan(min)) {
|
||||||
|
this.#amountInput.classList.add("is-invalid");
|
||||||
|
this.#amountError.innerText = A_("The amount must not be less than the offset total %(total)s.", {total: formatDecimal(min)});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#amountInput.classList.remove("is-invalid");
|
||||||
|
this.#amountError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback when adding a new journal entry line item.
|
||||||
|
*
|
||||||
|
* @param debitCredit {DebitCreditSubForm} the debit or credit sub-form
|
||||||
|
*/
|
||||||
|
onAddNew(debitCredit) {
|
||||||
|
this.lineItem = null;
|
||||||
|
this.#debitCreditSubForm = debitCredit;
|
||||||
|
this.debitCredit = this.#debitCreditSubForm.debitCredit;
|
||||||
|
this.isNeedOffset = false;
|
||||||
|
this.#originalLineItemContainer.classList.add("d-none");
|
||||||
|
this.#originalLineItemControl.classList.remove("accounting-not-empty");
|
||||||
|
this.#originalLineItemControl.classList.remove("is-invalid");
|
||||||
|
this.originalLineItemId = null;
|
||||||
|
this.originalLineItemDate = null;
|
||||||
|
this.originalLineItemText = null;
|
||||||
|
this.#originalLineItemText.innerText = "";
|
||||||
|
this.#setEnableDescriptionAccount(true);
|
||||||
|
this.#descriptionControl.classList.remove("accounting-not-empty");
|
||||||
|
this.#descriptionControl.classList.remove("is-invalid");
|
||||||
|
this.description = null;
|
||||||
|
this.#descriptionText.innerText = ""
|
||||||
|
this.#descriptionError.innerText = ""
|
||||||
|
this.#accountControl.classList.remove("accounting-not-empty");
|
||||||
|
this.#accountControl.classList.remove("is-invalid");
|
||||||
|
this.accountCode = null;
|
||||||
|
this.accountText = null;
|
||||||
|
this.#accountText.innerText = "";
|
||||||
|
this.#accountError.innerText = "";
|
||||||
|
this.#amountInput.value = "";
|
||||||
|
this.#amountInput.max = "";
|
||||||
|
this.#amountInput.min = "0";
|
||||||
|
this.#amountInput.classList.remove("is-invalid");
|
||||||
|
this.#amountError.innerText = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback when editing a journal entry line item.
|
||||||
|
*
|
||||||
|
* @param lineItem {LineItemSubForm} the journal entry line item sub-form
|
||||||
|
*/
|
||||||
|
onEdit(lineItem) {
|
||||||
|
this.lineItem = lineItem;
|
||||||
|
this.#debitCreditSubForm = lineItem.debitCreditSubForm;
|
||||||
|
this.debitCredit = this.#debitCreditSubForm.debitCredit;
|
||||||
|
this.isNeedOffset = lineItem.isNeedOffset();
|
||||||
|
this.originalLineItemId = lineItem.getOriginalLineItemId();
|
||||||
|
this.originalLineItemDate = lineItem.getOriginalLineItemDate();
|
||||||
|
this.originalLineItemText = lineItem.getOriginalLineItemText();
|
||||||
|
this.#originalLineItemText.innerText = this.originalLineItemText;
|
||||||
|
if (this.originalLineItemId === null) {
|
||||||
|
this.#originalLineItemContainer.classList.add("d-none");
|
||||||
|
this.#originalLineItemControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
this.#originalLineItemContainer.classList.remove("d-none");
|
||||||
|
this.#originalLineItemControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null);
|
||||||
|
this.description = lineItem.getDescription();
|
||||||
|
if (this.description === null) {
|
||||||
|
this.#descriptionControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
this.#descriptionControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
this.#descriptionText.innerText = this.description === null? "": this.description;
|
||||||
|
if (lineItem.getAccountCode() === null) {
|
||||||
|
this.#accountControl.classList.remove("accounting-not-empty");
|
||||||
|
} else {
|
||||||
|
this.#accountControl.classList.add("accounting-not-empty");
|
||||||
|
}
|
||||||
|
this.accountCode = lineItem.getAccountCode();
|
||||||
|
this.accountText = lineItem.getAccountText();
|
||||||
|
this.#accountText.innerText = this.accountText;
|
||||||
|
this.#amountInput.value = lineItem.getAmount() === null? "": String(lineItem.getAmount());
|
||||||
|
const maxAmount = this.#getMaxAmount();
|
||||||
|
this.#amountInput.max = maxAmount === null? "": maxAmount;
|
||||||
|
this.#amountInput.min = lineItem.getAmountMin() === null? "": String(lineItem.getAmountMin());
|
||||||
|
this.#validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds out the max amount.
|
||||||
|
*
|
||||||
|
* @return {Decimal|null} the max amount
|
||||||
|
*/
|
||||||
|
#getMaxAmount() {
|
||||||
|
if (this.originalLineItemId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.originalLineItemSelector.getNetBalance(this.lineItem, this.form, this.originalLineItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the enable status of the description and account.
|
||||||
|
*
|
||||||
|
* @param isEnabled {boolean} true to enable, or false otherwise
|
||||||
|
*/
|
||||||
|
#setEnableDescriptionAccount(isEnabled) {
|
||||||
|
if (isEnabled) {
|
||||||
|
this.#descriptionControl.dataset.bsToggle = "modal";
|
||||||
|
this.#descriptionControl.dataset.bsTarget = "#accounting-description-editor-" + this.#debitCreditSubForm.debitCredit + "-modal";
|
||||||
|
this.#descriptionControl.classList.remove("accounting-disabled");
|
||||||
|
this.#descriptionControl.classList.add("accounting-clickable");
|
||||||
|
this.#accountControl.dataset.bsToggle = "modal";
|
||||||
|
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#debitCreditSubForm.debitCredit + "-modal";
|
||||||
|
this.#accountControl.classList.remove("accounting-disabled");
|
||||||
|
this.#accountControl.classList.add("accounting-clickable");
|
||||||
|
} else {
|
||||||
|
this.#descriptionControl.dataset.bsToggle = "";
|
||||||
|
this.#descriptionControl.dataset.bsTarget = "";
|
||||||
|
this.#descriptionControl.classList.add("accounting-disabled");
|
||||||
|
this.#descriptionControl.classList.remove("accounting-clickable");
|
||||||
|
this.#accountControl.dataset.bsToggle = "";
|
||||||
|
this.#accountControl.dataset.bsTarget = "";
|
||||||
|
this.#accountControl.classList.add("accounting-disabled");
|
||||||
|
this.#accountControl.classList.remove("accounting-clickable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
/* The Mia! Accounting Flask Project
|
||||||
* transaction-order.js: The JavaScript for the transaction order
|
* journal-entry-order.js: The JavaScript for the journal entry order
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Copyright (c) 2023 imacat.
|
/* Copyright (c) 2023 imacat.
|
409
src/accounting/static/js/original-line-item-selector.js
Normal file
409
src/accounting/static/js/original-line-item-selector.js
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* original-line-item-selector.js: The JavaScript for the original line item selector
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/3/10
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The original line item selector.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class OriginalLineItemSelector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line item editor
|
||||||
|
* @type {JournalEntryLineItemEditor}
|
||||||
|
*/
|
||||||
|
lineItemEditor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix of the HTML ID and class
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#prefix = "accounting-original-line-item-selector";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query input
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message when the query has no result
|
||||||
|
* @type {HTMLParagraphElement}
|
||||||
|
*/
|
||||||
|
#queryNoResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The option list
|
||||||
|
* @type {HTMLUListElement}
|
||||||
|
*/
|
||||||
|
#optionList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options
|
||||||
|
* @type {OriginalLineItem[]}
|
||||||
|
*/
|
||||||
|
#options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options by their ID
|
||||||
|
* @type {Object.<string, OriginalLineItem>}
|
||||||
|
*/
|
||||||
|
#optionById;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currency code
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#currencyCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Either "credit" or "debit"
|
||||||
|
*/
|
||||||
|
#debitCredit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an original line item selector.
|
||||||
|
*
|
||||||
|
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||||
|
*/
|
||||||
|
constructor(lineItemEditor) {
|
||||||
|
this.lineItemEditor = lineItemEditor;
|
||||||
|
this.#query = document.getElementById(this.#prefix + "-query");
|
||||||
|
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
||||||
|
this.#optionList = document.getElementById(this.#prefix + "-option-list");
|
||||||
|
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalLineItem(this, element));
|
||||||
|
this.#optionById = {};
|
||||||
|
for (const option of this.#options) {
|
||||||
|
this.#optionById[option.id] = option;
|
||||||
|
}
|
||||||
|
this.#query.addEventListener("input", () => {
|
||||||
|
this.#filterOptions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the net balance for an original line item.
|
||||||
|
*
|
||||||
|
* @param currentLineItem {LineItemSubForm} the line item sub-form that is currently editing
|
||||||
|
* @param form {JournalEntryForm} the journal entry form
|
||||||
|
* @param originalLineItemId {string} the ID of the original line item
|
||||||
|
* @return {Decimal} the net balance of the original line item
|
||||||
|
*/
|
||||||
|
getNetBalance(currentLineItem, form, originalLineItemId) {
|
||||||
|
const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem);
|
||||||
|
let otherOffset = new Decimal(0);
|
||||||
|
for (const otherLineItem of otherLineItems) {
|
||||||
|
if (otherLineItem.getOriginalLineItemId() === originalLineItemId) {
|
||||||
|
const amount = otherLineItem.getAmount();
|
||||||
|
if (amount !== null) {
|
||||||
|
otherOffset = otherOffset.plus(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.#optionById[originalLineItemId].bareNetBalance.minus(otherOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the net balances, subtracting the offset amounts on the form but the currently editing line item
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#updateNetBalances() {
|
||||||
|
const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem);
|
||||||
|
const otherOffsets = {}
|
||||||
|
for (const otherLineItem of otherLineItems) {
|
||||||
|
const otherOriginalLineItemId = otherLineItem.getOriginalLineItemId();
|
||||||
|
const amount = otherLineItem.getAmount();
|
||||||
|
if (otherOriginalLineItemId === null || amount === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(otherOriginalLineItemId in otherOffsets)) {
|
||||||
|
otherOffsets[otherOriginalLineItemId] = new Decimal("0");
|
||||||
|
}
|
||||||
|
otherOffsets[otherOriginalLineItemId] = otherOffsets[otherOriginalLineItemId].plus(amount);
|
||||||
|
}
|
||||||
|
for (const option of this.#options) {
|
||||||
|
if (option.id in otherOffsets) {
|
||||||
|
option.updateNetBalance(otherOffsets[option.id]);
|
||||||
|
} else {
|
||||||
|
option.resetNetBalance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the options.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#filterOptions() {
|
||||||
|
let hasAnyMatched = false;
|
||||||
|
for (const option of this.#options) {
|
||||||
|
if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) {
|
||||||
|
option.setShown(true);
|
||||||
|
hasAnyMatched = true;
|
||||||
|
} else {
|
||||||
|
option.setShown(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasAnyMatched) {
|
||||||
|
this.#optionList.classList.add("d-none");
|
||||||
|
this.#queryNoResult.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
this.#optionList.classList.remove("d-none");
|
||||||
|
this.#queryNoResult.classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback when the original line item selector is shown.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
onOpen() {
|
||||||
|
this.#currencyCode = this.lineItemEditor.getCurrencyCode();
|
||||||
|
this.#debitCredit = this.lineItemEditor.debitCredit;
|
||||||
|
for (const option of this.#options) {
|
||||||
|
option.setActive(option.id === this.lineItemEditor.originalLineItemId);
|
||||||
|
}
|
||||||
|
this.#query.value = "";
|
||||||
|
this.#updateNetBalances();
|
||||||
|
this.#filterOptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An original line item.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class OriginalLineItem {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The original line item selector
|
||||||
|
* @type {OriginalLineItemSelector}
|
||||||
|
*/
|
||||||
|
#selector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The element
|
||||||
|
* @type {HTMLLIElement}
|
||||||
|
*/
|
||||||
|
#element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Either "debit" or "credit"
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#debitCredit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currency code
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#currencyCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account code
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
accountCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account text
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
accountText;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The description
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The net balance, without the offset amounts on the form
|
||||||
|
* @type {Decimal}
|
||||||
|
*/
|
||||||
|
bareNetBalance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The net balance
|
||||||
|
* @type {Decimal}
|
||||||
|
*/
|
||||||
|
netBalance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text of the net balance
|
||||||
|
* @type {HTMLSpanElement}
|
||||||
|
*/
|
||||||
|
netBalanceText;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text representation of the original line item
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
text;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The values to query against
|
||||||
|
* @type {string[][]}
|
||||||
|
*/
|
||||||
|
#queryValues;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an original line item.
|
||||||
|
*
|
||||||
|
* @param selector {OriginalLineItemSelector} the original line item selector
|
||||||
|
* @param element {HTMLLIElement} the element
|
||||||
|
*/
|
||||||
|
constructor(selector, element) {
|
||||||
|
this.#selector = selector;
|
||||||
|
this.#element = element;
|
||||||
|
this.id = element.dataset.id;
|
||||||
|
this.date = element.dataset.date;
|
||||||
|
this.#debitCredit = element.dataset.debitCredit;
|
||||||
|
this.#currencyCode = element.dataset.currencyCode;
|
||||||
|
this.accountCode = element.dataset.accountCode;
|
||||||
|
this.accountText = element.dataset.accountText;
|
||||||
|
this.description = element.dataset.description;
|
||||||
|
this.bareNetBalance = new Decimal(element.dataset.netBalance);
|
||||||
|
this.netBalance = this.bareNetBalance;
|
||||||
|
this.netBalanceText = document.getElementById("accounting-original-line-item-selector-option-" + this.id + "-net-balance");
|
||||||
|
this.text = element.dataset.text;
|
||||||
|
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
||||||
|
this.#element.onclick = () => this.#selector.lineItemEditor.saveOriginalLineItem(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the net balance to its initial value, without the offset amounts on the form.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
resetNetBalance() {
|
||||||
|
if (this.netBalance !== this.bareNetBalance) {
|
||||||
|
this.netBalance = this.bareNetBalance;
|
||||||
|
this.#updateNetBalanceText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the net balance with an offset.
|
||||||
|
*
|
||||||
|
* @param offset {Decimal} the offset to be added to the net balance
|
||||||
|
*/
|
||||||
|
updateNetBalance(offset) {
|
||||||
|
this.netBalance = this.bareNetBalance.minus(offset);
|
||||||
|
this.#updateNetBalanceText();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the text display of the net balance.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#updateNetBalanceText() {
|
||||||
|
this.netBalanceText.innerText = formatDecimal(this.netBalance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the original matches.
|
||||||
|
*
|
||||||
|
* @param debitCredit {string} either "debit" or "credit"
|
||||||
|
* @param currencyCode {string} the currency code
|
||||||
|
* @param query {string|null} the query term
|
||||||
|
*/
|
||||||
|
isMatched(debitCredit, currencyCode, query = null) {
|
||||||
|
return this.netBalance.greaterThan(0)
|
||||||
|
&& this.date <= this.#selector.lineItemEditor.form.getDate()
|
||||||
|
&& this.#isDebitCreditMatches(debitCredit)
|
||||||
|
&& this.#currencyCode === currencyCode
|
||||||
|
&& this.#isQueryMatches(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the original line item matches the debit or credit.
|
||||||
|
*
|
||||||
|
* @param debitCredit {string} either "debit" or credit
|
||||||
|
* @return {boolean} true if the option matches, or false otherwise
|
||||||
|
*/
|
||||||
|
#isDebitCreditMatches(debitCredit) {
|
||||||
|
return (debitCredit === "debit" && this.#debitCredit === "credit")
|
||||||
|
|| (debitCredit === "credit" && this.#debitCredit === "debit");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the original line item matches the query.
|
||||||
|
*
|
||||||
|
* @param query {string|null} the query term
|
||||||
|
* @return {boolean} true if the option matches, or false otherwise
|
||||||
|
*/
|
||||||
|
#isQueryMatches(query) {
|
||||||
|
if (query === "") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const queryValue of this.#queryValues[0]) {
|
||||||
|
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const queryValue of this.#queryValues[1]) {
|
||||||
|
if (queryValue === query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether the option is shown.
|
||||||
|
*
|
||||||
|
* @param isShown {boolean} true to show, or false otherwise
|
||||||
|
*/
|
||||||
|
setShown(isShown) {
|
||||||
|
if (isShown) {
|
||||||
|
this.#element.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
this.#element.classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether the option is active.
|
||||||
|
*
|
||||||
|
* @param isActive {boolean} true if active, or false otherwise
|
||||||
|
*/
|
||||||
|
setActive(isActive) {
|
||||||
|
if (isActive) {
|
||||||
|
this.#element.classList.add("active");
|
||||||
|
} else {
|
||||||
|
this.#element.classList.remove("active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -175,7 +175,7 @@ class MonthTab extends TabPlane {
|
|||||||
let start = monthChooser.dataset.start;
|
let start = monthChooser.dataset.start;
|
||||||
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
|
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
|
||||||
restrictions: {
|
restrictions: {
|
||||||
minDate: start,
|
minDate: new Date(start),
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
inline: true,
|
inline: true,
|
||||||
@ -184,7 +184,7 @@ class MonthTab extends TabPlane {
|
|||||||
clock: false,
|
clock: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultDate: monthChooser.dataset.default,
|
defaultDate: new Date(monthChooser.dataset.default),
|
||||||
});
|
});
|
||||||
monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
|
monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
|
||||||
const date = e.detail.date;
|
const date = e.detail.date;
|
||||||
|
@ -1,680 +0,0 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
|
||||||
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* Copyright (c) 2023 imacat.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
|
||||||
* First written: 2023/2/25
|
|
||||||
*/
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// Initializes the page JavaScript.
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
initializeCurrencyForms();
|
|
||||||
initializeJournalEntries();
|
|
||||||
initializeFormValidation();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escapes the HTML special characters and returns.
|
|
||||||
*
|
|
||||||
* @param s {string} the original string
|
|
||||||
* @returns {string} the string with HTML special character escaped
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function escapeHtml(s) {
|
|
||||||
return String(s)
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll("\"", """);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a Decimal number.
|
|
||||||
*
|
|
||||||
* @param number {Decimal} the Decimal number
|
|
||||||
* @returns {string} the formatted Decimal number
|
|
||||||
*/
|
|
||||||
function formatDecimal(number) {
|
|
||||||
if (number.equals(new Decimal("0"))) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
const frac = number.modulo(1);
|
|
||||||
const whole = Number(number.minus(frac)).toLocaleString();
|
|
||||||
return whole + String(frac).substring(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the currency forms.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function initializeCurrencyForms() {
|
|
||||||
const form = document.getElementById("accounting-form");
|
|
||||||
const btnNew = document.getElementById("accounting-btn-new-currency");
|
|
||||||
const currencyList = document.getElementById("accounting-currency-list");
|
|
||||||
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
|
|
||||||
const onReorder = () => {
|
|
||||||
const currencies = Array.from(currencyList.children);
|
|
||||||
for (let i = 0; i < currencies.length; i++) {
|
|
||||||
const no = document.getElementById(currencies[i].dataset.prefix + "-no");
|
|
||||||
no.value = String(i + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
btnNew.onclick = () => {
|
|
||||||
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
|
|
||||||
let maxIndex = 0;
|
|
||||||
for (const currency of currencies) {
|
|
||||||
const index = parseInt(currency.dataset.index);
|
|
||||||
if (maxIndex < index) {
|
|
||||||
maxIndex = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newIndex = String(maxIndex + 1);
|
|
||||||
const html = form.dataset.currencyTemplate
|
|
||||||
.replaceAll("CURRENCY_INDEX", escapeHtml(newIndex));
|
|
||||||
currencyList.insertAdjacentHTML("beforeend", html);
|
|
||||||
const newEntryButtons = Array.from(document.getElementsByClassName("accounting-currency-" + newIndex + "-btn-new-entry"));
|
|
||||||
const btnDelete = document.getElementById("accounting-btn-delete-currency-" + newIndex);
|
|
||||||
newEntryButtons.forEach(initializeNewEntryButton);
|
|
||||||
initializeBtnDeleteCurrency(btnDelete);
|
|
||||||
resetDeleteCurrencyButtons();
|
|
||||||
initializeDragAndDropReordering(currencyList, onReorder);
|
|
||||||
};
|
|
||||||
deleteButtons.forEach(initializeBtnDeleteCurrency);
|
|
||||||
initializeDragAndDropReordering(currencyList, onReorder);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the button to delete a currency.
|
|
||||||
*
|
|
||||||
* @param button {HTMLButtonElement} the button to delete a currency.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function initializeBtnDeleteCurrency(button) {
|
|
||||||
const target = document.getElementById(button.dataset.target);
|
|
||||||
button.onclick = () => {
|
|
||||||
target.parentElement.removeChild(target);
|
|
||||||
resetDeleteCurrencyButtons();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets the status of the delete currency buttons.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function resetDeleteCurrencyButtons() {
|
|
||||||
const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
|
|
||||||
if (buttons.length > 1) {
|
|
||||||
for (const button of buttons) {
|
|
||||||
button.classList.remove("d-none");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
buttons[0].classList.add("d-none");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the journal entry forms.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function initializeJournalEntries() {
|
|
||||||
const newButtons = Array.from(document.getElementsByClassName("accounting-btn-new-entry"));
|
|
||||||
const entryLists = Array.from(document.getElementsByClassName("accounting-entry-list"));
|
|
||||||
const entries = Array.from(document.getElementsByClassName("accounting-entry"))
|
|
||||||
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-entry"));
|
|
||||||
newButtons.forEach(initializeNewEntryButton);
|
|
||||||
entryLists.forEach(initializeJournalEntryListReorder);
|
|
||||||
entries.forEach(initializeJournalEntry);
|
|
||||||
deleteButtons.forEach(initializeDeleteJournalEntryButton);
|
|
||||||
initializeJournalEntryFormModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the button to add a new journal entry.
|
|
||||||
*
|
|
||||||
* @param button {HTMLButtonElement} the button to add a new journal entry
|
|
||||||
*/
|
|
||||||
function initializeNewEntryButton(button) {
|
|
||||||
const entryForm = document.getElementById("accounting-entry-form");
|
|
||||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
|
||||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
|
||||||
const formAccountError = document.getElementById("accounting-entry-form-account-error")
|
|
||||||
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
|
||||||
const formSummary = document.getElementById("accounting-entry-form-summary");
|
|
||||||
const formSummaryError = document.getElementById("accounting-entry-form-summary-error");
|
|
||||||
const formAmount = document.getElementById("accounting-entry-form-amount");
|
|
||||||
const formAmountError = document.getElementById("accounting-entry-form-amount-error");
|
|
||||||
button.onclick = () => {
|
|
||||||
entryForm.dataset.currencyIndex = button.dataset.currencyIndex;
|
|
||||||
entryForm.dataset.entryType = button.dataset.entryType;
|
|
||||||
entryForm.dataset.entryIndex = button.dataset.entryIndex;
|
|
||||||
formAccountControl.classList.remove("accounting-not-empty");
|
|
||||||
formAccountControl.classList.remove("is-invalid");
|
|
||||||
formAccount.innerText = "";
|
|
||||||
formAccount.dataset.code = "";
|
|
||||||
formAccount.dataset.text = "";
|
|
||||||
formAccountError.innerText = "";
|
|
||||||
formSummaryControl.dataset.bsTarget = "#accounting-summary-editor-" + button.dataset.entryType + "-modal";
|
|
||||||
formSummaryControl.classList.remove("accounting-not-empty");
|
|
||||||
formSummaryControl.classList.remove("is-invalid");
|
|
||||||
formSummary.dataset.value = "";
|
|
||||||
formSummary.innerText = ""
|
|
||||||
formSummaryError.innerText = ""
|
|
||||||
formAmount.value = "";
|
|
||||||
formAmount.classList.remove("is-invalid");
|
|
||||||
formAmountError.innerText = "";
|
|
||||||
AccountSelector.initializeJournalEntryForm();
|
|
||||||
SummaryEditor.initializeNewJournalEntry(button.dataset.entryType);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the reordering of a journal entry list.
|
|
||||||
*
|
|
||||||
* @param entryList {HTMLUListElement} the journal entry list.
|
|
||||||
*/
|
|
||||||
function initializeJournalEntryListReorder(entryList) {
|
|
||||||
initializeDragAndDropReordering(entryList, () => {
|
|
||||||
const entries = Array.from(entryList.children);
|
|
||||||
for (let i = 0; i < entries.length; i++) {
|
|
||||||
const no = document.getElementById(entries[i].dataset.prefix + "-no");
|
|
||||||
no.value = String(i + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the journal entry.
|
|
||||||
*
|
|
||||||
* @param entry {HTMLLIElement} the journal entry.
|
|
||||||
*/
|
|
||||||
function initializeJournalEntry(entry) {
|
|
||||||
const entryForm = document.getElementById("accounting-entry-form");
|
|
||||||
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
|
|
||||||
const summary = document.getElementById(entry.dataset.prefix + "-summary");
|
|
||||||
const amount = document.getElementById(entry.dataset.prefix + "-amount");
|
|
||||||
const control = document.getElementById(entry.dataset.prefix + "-control");
|
|
||||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
|
||||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
|
||||||
const formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
|
||||||
const formSummary = document.getElementById("accounting-entry-form-summary");
|
|
||||||
const formAmount = document.getElementById("accounting-entry-form-amount");
|
|
||||||
control.onclick = () => {
|
|
||||||
entryForm.dataset.currencyIndex = entry.dataset.currencyIndex;
|
|
||||||
entryForm.dataset.entryType = entry.dataset.entryType;
|
|
||||||
entryForm.dataset.entryIndex = entry.dataset.entryIndex;
|
|
||||||
if (accountCode.value === "") {
|
|
||||||
formAccountControl.classList.remove("accounting-not-empty");
|
|
||||||
} else {
|
|
||||||
formAccountControl.classList.add("accounting-not-empty");
|
|
||||||
}
|
|
||||||
formAccount.innerText = accountCode.dataset.text;
|
|
||||||
formAccount.dataset.code = accountCode.value;
|
|
||||||
formAccount.dataset.text = accountCode.dataset.text;
|
|
||||||
formSummaryControl.dataset.bsTarget = "#accounting-summary-editor-" + entry.dataset.entryType + "-modal";
|
|
||||||
if (summary.value === "") {
|
|
||||||
formSummaryControl.classList.remove("accounting-not-empty");
|
|
||||||
} else {
|
|
||||||
formSummaryControl.classList.add("accounting-not-empty");
|
|
||||||
}
|
|
||||||
formSummary.dataset.value = summary.value;
|
|
||||||
formSummary.innerText = summary.value;
|
|
||||||
formAmount.value = amount.value;
|
|
||||||
AccountSelector.initializeJournalEntryForm();
|
|
||||||
validateJournalEntryForm();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the journal entry form modal.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function initializeJournalEntryFormModal() {
|
|
||||||
const entryForm = document.getElementById("accounting-entry-form");
|
|
||||||
const formAmount = document.getElementById("accounting-entry-form-amount");
|
|
||||||
const modal = document.getElementById("accounting-entry-form-modal");
|
|
||||||
formAmount.onchange = validateJournalEntryAmount;
|
|
||||||
entryForm.onsubmit = () => {
|
|
||||||
if (validateJournalEntryForm()) {
|
|
||||||
saveJournalEntryForm();
|
|
||||||
bootstrap.Modal.getInstance(modal).hide();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the journal entry form modal.
|
|
||||||
*
|
|
||||||
* @return {boolean} true if the form is valid, or false otherwise.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateJournalEntryForm() {
|
|
||||||
let isValid = true;
|
|
||||||
isValid = validateJournalEntryAccount() && isValid;
|
|
||||||
isValid = validateJournalEntrySummary() && isValid;
|
|
||||||
isValid = validateJournalEntryAmount() && isValid
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the account in the journal entry form modal.
|
|
||||||
*
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
*/
|
|
||||||
function validateJournalEntryAccount() {
|
|
||||||
const field = document.getElementById("accounting-entry-form-account");
|
|
||||||
const error = document.getElementById("accounting-entry-form-account-error");
|
|
||||||
const control = document.getElementById("accounting-entry-form-account-control");
|
|
||||||
if (field.dataset.code === "") {
|
|
||||||
control.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please select the account.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
control.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the summary in the journal entry form modal.
|
|
||||||
*
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateJournalEntrySummary() {
|
|
||||||
const control = document.getElementById("accounting-entry-form-summary-control");
|
|
||||||
const error = document.getElementById("accounting-entry-form-summary-error");
|
|
||||||
control.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the amount in the journal entry form modal.
|
|
||||||
*
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateJournalEntryAmount() {
|
|
||||||
const field = document.getElementById("accounting-entry-form-amount");
|
|
||||||
const error = document.getElementById("accounting-entry-form-amount-error");
|
|
||||||
field.value = field.value.trim();
|
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
if (field.value === "") {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please fill in the amount.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the journal entry form modal to the form.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function saveJournalEntryForm() {
|
|
||||||
const form = document.getElementById("accounting-form");
|
|
||||||
const entryForm = document.getElementById("accounting-entry-form");
|
|
||||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
|
||||||
const formSummary = document.getElementById("accounting-entry-form-summary");
|
|
||||||
const formAmount = document.getElementById("accounting-entry-form-amount");
|
|
||||||
const currencyIndex = entryForm.dataset.currencyIndex;
|
|
||||||
const entryType = entryForm.dataset.entryType;
|
|
||||||
let entryIndex;
|
|
||||||
if (entryForm.dataset.entryIndex === "new") {
|
|
||||||
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
|
|
||||||
const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list")
|
|
||||||
let maxIndex = 0;
|
|
||||||
for (const entry of entries) {
|
|
||||||
const index = parseInt(entry.dataset.entryIndex);
|
|
||||||
if (maxIndex < index) {
|
|
||||||
maxIndex = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entryIndex = String(maxIndex + 1);
|
|
||||||
const html = form.dataset.entryTemplate
|
|
||||||
.replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex))
|
|
||||||
.replaceAll("ENTRY_TYPE", escapeHtml(entryType))
|
|
||||||
.replaceAll("ENTRY_INDEX", escapeHtml(entryIndex));
|
|
||||||
entryList.insertAdjacentHTML("beforeend", html);
|
|
||||||
initializeJournalEntryListReorder(entryList);
|
|
||||||
} else {
|
|
||||||
entryIndex = entryForm.dataset.entryIndex;
|
|
||||||
}
|
|
||||||
const currency = document.getElementById("accounting-currency-" + currencyIndex);
|
|
||||||
const entry = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-" + entryIndex);
|
|
||||||
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
|
|
||||||
const accountText = document.getElementById(entry.dataset.prefix + "-account-text");
|
|
||||||
const summary = document.getElementById(entry.dataset.prefix + "-summary");
|
|
||||||
const summaryText = document.getElementById(entry.dataset.prefix + "-summary-text");
|
|
||||||
const amount = document.getElementById(entry.dataset.prefix + "-amount");
|
|
||||||
const amountText = document.getElementById(entry.dataset.prefix + "-amount-text");
|
|
||||||
accountCode.value = formAccount.dataset.code;
|
|
||||||
accountCode.dataset.text = formAccount.dataset.text;
|
|
||||||
accountText.innerText = formAccount.dataset.text;
|
|
||||||
summary.value = formSummary.dataset.value;
|
|
||||||
summaryText.innerText = formSummary.dataset.value;
|
|
||||||
amount.value = formAmount.value;
|
|
||||||
amountText.innerText = formatDecimal(new Decimal(formAmount.value));
|
|
||||||
if (entryForm.dataset.entryIndex === "new") {
|
|
||||||
const btnDelete = document.getElementById(entry.dataset.prefix + "-btn-delete");
|
|
||||||
initializeJournalEntry(entry);
|
|
||||||
initializeDeleteJournalEntryButton(btnDelete);
|
|
||||||
resetDeleteJournalEntryButtons(btnDelete.dataset.sameClass);
|
|
||||||
}
|
|
||||||
updateBalance(currencyIndex, entryType);
|
|
||||||
validateJournalEntriesReal(currencyIndex, entryType);
|
|
||||||
validateBalance(currency);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the button to delete a journal entry.
|
|
||||||
*
|
|
||||||
* @param button {HTMLButtonElement} the button to delete a journal entry
|
|
||||||
*/
|
|
||||||
function initializeDeleteJournalEntryButton(button) {
|
|
||||||
const target = document.getElementById(button.dataset.target);
|
|
||||||
const currencyIndex = target.dataset.currencyIndex;
|
|
||||||
const entryType = target.dataset.entryType;
|
|
||||||
const currency = document.getElementById("accounting-currency-" + currencyIndex);
|
|
||||||
button.onclick = () => {
|
|
||||||
target.parentElement.removeChild(target);
|
|
||||||
resetDeleteJournalEntryButtons(button.dataset.sameClass);
|
|
||||||
updateBalance(currencyIndex, entryType);
|
|
||||||
validateJournalEntriesReal(currencyIndex, entryType);
|
|
||||||
validateBalance(currency);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets the status of the delete journal entry buttons.
|
|
||||||
*
|
|
||||||
* @param sameClass {string} the class of the buttons
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function resetDeleteJournalEntryButtons(sameClass) {
|
|
||||||
const buttons = Array.from(document.getElementsByClassName(sameClass));
|
|
||||||
if (buttons.length > 1) {
|
|
||||||
for (const button of buttons) {
|
|
||||||
button.classList.remove("d-none");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
buttons[0].classList.add("d-none");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the balance.
|
|
||||||
*
|
|
||||||
* @param currencyIndex {string} the currency index.
|
|
||||||
* @param entryType {string} the journal entry type, either "debit" or "credit"
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function updateBalance(currencyIndex, entryType) {
|
|
||||||
const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
|
|
||||||
const amounts = Array.from(document.getElementsByClassName(prefix + "-amount"));
|
|
||||||
const totalText = document.getElementById(prefix + "-total");
|
|
||||||
let total = new Decimal("0");
|
|
||||||
for (const amount of amounts) {
|
|
||||||
if (amount.value !== "") {
|
|
||||||
total = total.plus(new Decimal(amount.value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
totalText.innerText = formatDecimal(total);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the form validation.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function initializeFormValidation() {
|
|
||||||
const date = document.getElementById("accounting-date");
|
|
||||||
const note = document.getElementById("accounting-note");
|
|
||||||
const form = document.getElementById("accounting-form");
|
|
||||||
date.onchange = validateDate;
|
|
||||||
note.onchange = validateNote;
|
|
||||||
form.onsubmit = validateForm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the form.
|
|
||||||
*
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateForm() {
|
|
||||||
let isValid = true;
|
|
||||||
isValid = validateDate() && isValid;
|
|
||||||
isValid = validateCurrencies() && isValid;
|
|
||||||
isValid = validateNote() && isValid;
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the date.
|
|
||||||
*
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateDate() {
|
|
||||||
const field = document.getElementById("accounting-date");
|
|
||||||
const error = document.getElementById("accounting-date-error");
|
|
||||||
field.value = field.value.trim();
|
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
if (field.value === "") {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please fill in the date.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the currency sub-forms.
|
|
||||||
*
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateCurrencies() {
|
|
||||||
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
|
|
||||||
let isValid = true;
|
|
||||||
isValid = validateCurrenciesReal() && isValid;
|
|
||||||
for (const currency of currencies) {
|
|
||||||
isValid = validateCurrency(currency) && isValid;
|
|
||||||
}
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the currency sub-forms, the validator itself.
|
|
||||||
*
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateCurrenciesReal() {
|
|
||||||
const field = document.getElementById("accounting-currencies");
|
|
||||||
const error = document.getElementById("accounting-currencies-error");
|
|
||||||
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
|
|
||||||
if (currencies.length === 0) {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please add some currencies.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a currency sub-form.
|
|
||||||
*
|
|
||||||
* @param currency {HTMLDivElement} the currency sub-form
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateCurrency(currency) {
|
|
||||||
const prefix = "accounting-currency-" + currency.dataset.index;
|
|
||||||
const debit = document.getElementById(prefix + "-debit");
|
|
||||||
const credit = document.getElementById(prefix + "-credit");
|
|
||||||
let isValid = true;
|
|
||||||
if (debit !== null) {
|
|
||||||
isValid = validateJournalEntries(currency, "debit") && isValid;
|
|
||||||
}
|
|
||||||
if (credit !== null) {
|
|
||||||
isValid = validateJournalEntries(currency, "credit") && isValid;
|
|
||||||
}
|
|
||||||
if (debit !== null && credit !== null) {
|
|
||||||
isValid = validateBalance(currency) && isValid;
|
|
||||||
}
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the journal entries in a currency sub-form.
|
|
||||||
*
|
|
||||||
* @param currency {HTMLDivElement} the currency
|
|
||||||
* @param entryType {string} the journal entry type, either "debit" or "credit"
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateJournalEntries(currency, entryType) {
|
|
||||||
const currencyIndex = currency.dataset.index;
|
|
||||||
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
|
|
||||||
let isValid = true;
|
|
||||||
isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid;
|
|
||||||
for (const entry of entries) {
|
|
||||||
isValid = validateJournalEntry(entry) && isValid;
|
|
||||||
}
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the journal entries, the validator itself.
|
|
||||||
*
|
|
||||||
* @param currencyIndex {string} the currency index
|
|
||||||
* @param entryType {string} the journal entry type, either "debit" or "credit"
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateJournalEntriesReal(currencyIndex, entryType) {
|
|
||||||
const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
|
|
||||||
const field = document.getElementById(prefix);
|
|
||||||
const error = document.getElementById(prefix + "-error");
|
|
||||||
const entries = Array.from(document.getElementsByClassName(prefix));
|
|
||||||
if (entries.length === 0) {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please add some journal entries.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a journal entry sub-form in a currency sub-form.
|
|
||||||
*
|
|
||||||
* @param entry {HTMLLIElement} the journal entry
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateJournalEntry(entry) {
|
|
||||||
const control = document.getElementById(entry.dataset.prefix + "-control");
|
|
||||||
const error = document.getElementById(entry.dataset.prefix + "-error");
|
|
||||||
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
|
|
||||||
const amount = document.getElementById(entry.dataset.prefix + "-amount");
|
|
||||||
if (accountCode.value === "") {
|
|
||||||
control.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please select the account.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (amount.value === "") {
|
|
||||||
control.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please fill in the amount.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
control.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the balance of a currency sub-form.
|
|
||||||
*
|
|
||||||
* @param currency {HTMLDivElement} the currency sub-form
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateBalance(currency) {
|
|
||||||
const prefix = "accounting-currency-" + currency.dataset.index;
|
|
||||||
const control = document.getElementById(prefix + "-control");
|
|
||||||
const error = document.getElementById(prefix + "-error");
|
|
||||||
const debit = document.getElementById(prefix + "-debit");
|
|
||||||
const debitAmounts = Array.from(document.getElementsByClassName(prefix + "-debit-amount"));
|
|
||||||
const credit = document.getElementById(prefix + "-credit");
|
|
||||||
const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount"));
|
|
||||||
if (debit !== null && credit !== null) {
|
|
||||||
let debitTotal = new Decimal("0");
|
|
||||||
for (const amount of debitAmounts) {
|
|
||||||
if (amount.value !== "") {
|
|
||||||
debitTotal = debitTotal.plus(new Decimal(amount.value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let creditTotal = new Decimal("0");
|
|
||||||
for (const amount of creditAmounts) {
|
|
||||||
if (amount.value !== "") {
|
|
||||||
creditTotal = creditTotal.plus(new Decimal(amount.value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!debitTotal.equals(creditTotal)) {
|
|
||||||
control.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("The totals of the debit and credit amounts do not match.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
control.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the note.
|
|
||||||
*
|
|
||||||
* @return {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateNote() {
|
|
||||||
const field = document.getElementById("accounting-note");
|
|
||||||
const error = document.getElementById("accounting-note-error");
|
|
||||||
field.value = field.value
|
|
||||||
.replace(/^\s*\n/, "")
|
|
||||||
.trimEnd();
|
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
@ -14,7 +14,7 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
"""The template globals for the transaction management.
|
"""The template globals.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
@ -66,7 +66,7 @@ First written: 2023/1/31
|
|||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Account Confirmation") }}</h1>
|
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Account") }}</h1>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@ -83,11 +83,11 @@ First written: 2023/1/31
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="accounting-card col-sm-6">
|
<div class="accounting-card col-sm-6">
|
||||||
<div class="accounting-card-title">{{ obj.title }}</div>
|
<div class="accounting-card-title">{{ obj.title|title }}</div>
|
||||||
<div class="accounting-card-code">{{ obj.code }}</div>
|
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||||
{% if obj.is_offset_needed %}
|
{% if obj.is_need_offset %}
|
||||||
<div>
|
<div>
|
||||||
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
|
<span class="badge rounded-pill bg-info">{{ A_("Needs Offset") }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="small text-secondary fst-italic">
|
<div class="small text-secondary fst-italic">
|
||||||
|
@ -41,9 +41,9 @@ First written: 2023/2/1
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-floating mb-3">
|
<div class="form-floating mb-3">
|
||||||
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
|
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ form.base_code.data|accounting_default }}">
|
||||||
<div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
|
<div id="accounting-base-control" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
|
||||||
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
|
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
|
||||||
<div id="accounting-base-content">
|
<div id="accounting-base">
|
||||||
{% if form.base_code.data %}
|
{% if form.base_code.data %}
|
||||||
{% if form.base_code.errors %}
|
{% if form.base_code.errors %}
|
||||||
{{ A_("(Unknown)") }}
|
{{ A_("(Unknown)") }}
|
||||||
@ -53,7 +53,7 @@ First written: 2023/2/1
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
|
<div id="accounting-base-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-floating mb-3">
|
<div class="form-floating mb-3">
|
||||||
@ -62,10 +62,10 @@ First written: 2023/2/1
|
|||||||
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
|
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-check form-switch mb-3">
|
<div id="accounting-is-need-offset-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2", "3"] %} d-none {% endif %}">
|
||||||
<input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
|
<input id="accounting-is-need-offset" class="form-check-input" type="checkbox" name="is_need_offset" value="1" {% if form.is_need_offset.data %} checked="checked" {% endif %}>
|
||||||
<label class="form-check-label" for="accounting-is-offset-needed">
|
<label class="form-check-label" for="accounting-is-need-offset">
|
||||||
{{ A_("The entries in the account need offset.") }}
|
{{ A_("The line items in the account need offset.") }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -99,21 +99,21 @@ First written: 2023/2/1
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul id="accounting-base-option-list" class="list-group accounting-selector-list">
|
<ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list">
|
||||||
{% for base in form.base_options %}
|
{% for base in form.base_options %}
|
||||||
<li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
|
<li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
|
||||||
{{ base }}
|
{{ base }}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
|
<p id="accounting-base-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
||||||
{% if form.base_code.data %}
|
{% if form.base_code.data %}
|
||||||
<button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
|
<button id="accounting-base-selector-clear" type="button" class="btn btn-danger" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
|
<button id="accounting-base-selector-clear" type="button" class="btn btn-secondary" disabled="disabled" data-bs-dismiss="modal">{{ A_("Clear") }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -58,8 +58,8 @@ First written: 2023/1/30
|
|||||||
{% for item in list %}
|
{% for item in list %}
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
|
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
|
||||||
{{ item }}
|
{{ item }}
|
||||||
{% if item.is_offset_needed %}
|
{% if item.is_need_offset %}
|
||||||
<span class="badge rounded-pill bg-info">{{ A_("Need offset") }}</span>
|
<span class="badge rounded-pill bg-info">{{ A_("Needs Offset") }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -33,7 +33,7 @@ First written: 2023/2/1
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="accounting-card col-sm-6">
|
<div class="accounting-card col-sm-6">
|
||||||
<div class="accounting-card-title">{{ obj.title }}</div>
|
<div class="accounting-card-title">{{ obj.title|title }}</div>
|
||||||
<div class="accounting-card-code">{{ obj.code }}</div>
|
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||||
{% if obj.accounts %}
|
{% if obj.accounts %}
|
||||||
<div>
|
<div>
|
||||||
|
@ -62,7 +62,7 @@ First written: 2023/2/6
|
|||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Currency Confirmation") }}</h1>
|
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Currency") }}</h1>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@ -79,7 +79,7 @@ First written: 2023/2/6
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="accounting-card col-sm-6">
|
<div class="accounting-card col-sm-6">
|
||||||
<div class="accounting-card-title">{{ obj.name }}</div>
|
<div class="accounting-card-title">{{ obj.name|title }}</div>
|
||||||
<div class="accounting-card-code">{{ obj.code }}</div>
|
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||||
<div class="small text-secondary fst-italic">
|
<div class="small text-secondary fst-italic">
|
||||||
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
|
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
|
||||||
|
@ -28,25 +28,25 @@ First written: 2023/1/26
|
|||||||
</span>
|
</span>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}">
|
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.report.") %} active {% endif %}" href="{{ url_for("accounting.report.default") }}">
|
||||||
<i class="fa-solid fa-book"></i>
|
<i class="fa-solid fa-book"></i>
|
||||||
{{ A_("Reports") }}
|
{{ A_("Reports") }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
|
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
|
||||||
<i class="fa-solid fa-clipboard"></i>
|
<i class="fa-solid fa-clipboard"></i>
|
||||||
{{ A_("Accounts") }}
|
{{ A_("Accounts") }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
|
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
|
||||||
<i class="fa-solid fa-list"></i>
|
<i class="fa-solid fa-list"></i>
|
||||||
{{ A_("Base Accounts") }}
|
{{ A_("Base Accounts") }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
|
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
|
||||||
<i class="fa-solid fa-money-bill-wave"></i>
|
<i class="fa-solid fa-money-bill-wave"></i>
|
||||||
{{ A_("Currencies") }}
|
{{ A_("Currencies") }}
|
||||||
</a>
|
</a>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
create.html: The cash expense transaction creation form
|
create.html: The cash disbursement journal entry creation form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,10 +19,10 @@ create.html: The cash expense transaction creation form
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
First written: 2023/2/25
|
||||||
#}
|
#}
|
||||||
{% extends "accounting/transaction/expense/include/form.html" %}
|
{% extends "accounting/journal-entry/disbursement/include/form.html" %}
|
||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
|
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
|
||||||
|
|
||||||
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
|
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
detail.html: The account detail
|
detail.html: The cash disbursement journal entry detail
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,36 +19,26 @@ detail.html: The account detail
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/26
|
First written: 2023/2/26
|
||||||
#}
|
#}
|
||||||
{% extends "accounting/transaction/include/detail.html" %}
|
{% extends "accounting/journal-entry/include/detail.html" %}
|
||||||
|
|
||||||
{% block to_transfer %}
|
{% block to_transfer %}
|
||||||
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
|
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
|
||||||
<i class="fa-solid fa-bars-staggered"></i>
|
<i class="fa-solid fa-bars-staggered"></i>
|
||||||
{{ A_("To Transfer") }}
|
{{ A_("To Transfer") }}
|
||||||
</a>
|
</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block transaction_currencies %}
|
{% block journal_entry_currencies %}
|
||||||
{% for currency in obj.currencies %}
|
{% for currency in obj.currencies %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
|
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
|
||||||
|
|
||||||
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
|
<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>
|
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Content") }}</li>
|
||||||
{% for entry in currency.debit %}
|
{% with line_items = currency.debit %}
|
||||||
<li class="list-group-item accounting-transaction-entry">
|
{% include "accounting/journal-entry/include/detail-line-items.html" %}
|
||||||
<div class="d-flex justify-content-between">
|
{% endwith %}
|
||||||
<div>
|
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
|
||||||
<div class="small">{{ entry.account }}</div>
|
|
||||||
{% if entry.summary is not none %}
|
|
||||||
<div>{{ entry.summary }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div>{{ entry.amount|accounting_format_amount }}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>{{ A_("Total") }}</div>
|
<div>{{ A_("Total") }}</div>
|
||||||
<div>{{ currency.debit_total|accounting_format_amount }}</div>
|
<div>{{ currency.debit_total|accounting_format_amount }}</div>
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
edit.html: The cash income transaction edit form
|
edit.html: The cash disbursement journal entry edit form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,10 +19,10 @@ edit.html: The cash income transaction edit form
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
First written: 2023/2/25
|
||||||
#}
|
#}
|
||||||
{% extends "accounting/transaction/income/include/form.html" %}
|
{% extends "accounting/journal-entry/disbursement/include/form.html" %}
|
||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
|
{% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %}
|
||||||
|
|
||||||
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}
|
{% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %}
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
currency-sub-form.html: The currency sub-form in the cash expense transaction form
|
form-currency.html: The currency sub-form in the cash disbursement journal entry form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,22 +19,23 @@ currency-sub-form.html: The currency sub-form in the cash expense transaction fo
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
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 }}">
|
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
|
||||||
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
|
||||||
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
|
<div 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="d-flex justify-content-between mt-2 mb-3">
|
||||||
<div class="form-floating accounting-currency-content">
|
<div class="form-floating accounting-currency-content">
|
||||||
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
|
<select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
|
||||||
{% for currency in accounting_currency_options() %}
|
{% for currency in accounting_currency_options() %}
|
||||||
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
|
<label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
|
||||||
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</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 }}">
|
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
|
||||||
<i class="fas fa-minus"></i>
|
<i class="fas fa-minus"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -43,23 +44,14 @@ First written: 2023/2/25
|
|||||||
<div class="mb-3">
|
<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 %}">
|
<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>
|
<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">
|
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-line-item-list">
|
||||||
{% for entry_form in debit_forms %}
|
{% for line_item_form in debit_forms %}
|
||||||
{% with currency_index = currency_index,
|
{% with currency_index = currency_index,
|
||||||
entry_type = "debit",
|
debit_credit = "debit",
|
||||||
entry_index = loop.index,
|
line_item_index = loop.index,
|
||||||
entry_id = entry_form.eid.data,
|
only_one_line_item_form = debit_forms|length == 1,
|
||||||
only_one_entry_form = debit_forms|length == 1,
|
form = line_item_form.form %}
|
||||||
account_code_data = entry_form.account_code.data|accounting_default,
|
{% include "accounting/journal-entry/include/form-line-item.html" %}
|
||||||
account_code_error = entry_form.account_code.errors,
|
|
||||||
account_text = entry_form.account_text,
|
|
||||||
summary_data = entry_form.summary.data|accounting_default,
|
|
||||||
summary_errors = entry_form.summary.errors,
|
|
||||||
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
|
|
||||||
amount_errors = entry_form.amount.errors,
|
|
||||||
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
|
|
||||||
entry_errors = entry_form.all_errors %}
|
|
||||||
{% include "accounting/transaction/include/form-entry-item.html" %}
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@ -70,7 +62,7 @@ First written: 2023/2/25
|
|||||||
</div>
|
</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-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
|
<button id="accounting-currency-{{ currency_index }}-debit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="debit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
{{ A_("New") }}
|
{{ A_("New") }}
|
||||||
</button>
|
</button>
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
form.html: The cash expense transaction form
|
form.html: The cash disbursement journal entry form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ form.html: The cash expense transaction form
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
First written: 2023/2/25
|
||||||
#}
|
#}
|
||||||
{% extends "accounting/transaction/include/form.html" %}
|
{% extends "accounting/journal-entry/include/form.html" %}
|
||||||
|
|
||||||
{% block currency_sub_forms %}
|
{% block currency_sub_forms %}
|
||||||
{% if form.currencies %}
|
{% if form.currencies %}
|
||||||
@ -29,10 +29,11 @@ First written: 2023/2/25
|
|||||||
currency_errors = currency_form.whole_form.errors,
|
currency_errors = currency_form.whole_form.errors,
|
||||||
currency_code_data = currency_form.code.data,
|
currency_code_data = currency_form.code.data,
|
||||||
currency_code_errors = currency_form.code.errors,
|
currency_code_errors = currency_form.code.errors,
|
||||||
|
currency_code_is_locked = currency_form.is_code_locked,
|
||||||
debit_forms = currency_form.debit,
|
debit_forms = currency_form.debit,
|
||||||
debit_errors = currency_form.debit_errors,
|
debit_errors = currency_form.debit_errors,
|
||||||
debit_total = currency_form.form.debit_total|accounting_format_amount %}
|
debit_total = currency_form.form.debit_total|accounting_format_amount %}
|
||||||
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
|
{% include "accounting/journal-entry/disbursement/include/form-currency.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -40,17 +41,17 @@ First written: 2023/2/25
|
|||||||
only_one_currency_form = True,
|
only_one_currency_form = True,
|
||||||
currency_code_data = accounting_default_currency_code(),
|
currency_code_data = accounting_default_currency_code(),
|
||||||
debit_total = "-" %}
|
debit_total = "-" %}
|
||||||
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
|
{% include "accounting/journal-entry/disbursement/include/form-currency.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block form_modals %}
|
{% block form_modals %}
|
||||||
{% with summary_editor = form.summary_editor.debit %}
|
{% with description_editor = form.description_editor.debit %}
|
||||||
{% include "accounting/transaction/include/summary-editor-modal.html" %}
|
{% include "accounting/journal-entry/include/description-editor-modal.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% with entry_type = "debit",
|
{% with debit_credit = "debit",
|
||||||
account_options = form.debit_account_options %}
|
account_options = form.debit_account_options %}
|
||||||
{% include "accounting/transaction/include/account-selector-modal.html" %}
|
{% include "accounting/journal-entry/include/account-selector-modal.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -0,0 +1,54 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
account-selector-modal.html: The modal for the account selector
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
<div id="accounting-account-selector-{{ debit_credit }}-modal" class="modal fade accounting-account-selector" data-debit-credit="{{ debit_credit }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ debit_credit }}-modal-label" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ debit_credit }}-modal-label">{{ A_("Select Account") }}</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input id="accounting-account-selector-{{ debit_credit }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
|
||||||
|
<label class="input-group-text" for="accounting-account-selector-{{ debit_credit }}-query">
|
||||||
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
|
{{ A_("Search") }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list">
|
||||||
|
{% for account in account_options %}
|
||||||
|
<li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
|
||||||
|
{{ account }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li id="accounting-account-selector-{{ debit_credit }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
|
||||||
|
</ul>
|
||||||
|
<p id="accounting-account-selector-{{ debit_credit }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button>
|
||||||
|
<button id="accounting-account-selector-{{ debit_credit }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Clear") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,193 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
description-editor-modal.html: The modal of the description editor
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/28
|
||||||
|
#}
|
||||||
|
<form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}">
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label">
|
||||||
|
<label for="accounting-description-editor-{{ description_editor.debit_credit }}-description">{{ A_("Description") }}</label>
|
||||||
|
</h1>
|
||||||
|
<button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="d-flex justify-content-between mb-3">
|
||||||
|
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-description" class="form-control" type="text" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label">
|
||||||
|
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-offset" class="btn btn-primary text-nowrap ms-2" type="button" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal">
|
||||||
|
{{ A_("Offset...") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Tab navigation #}
|
||||||
|
<ul class="nav nav-tabs mb-2">
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab" class="nav-link active accounting-clickable" aria-current="page">
|
||||||
|
{{ A_("General") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab" class="nav-link accounting-clickable" aria-current="false">
|
||||||
|
{{ A_("Travel") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab" class="nav-link accounting-clickable" aria-current="false">
|
||||||
|
{{ A_("Bus") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-regular-tab" class="nav-link accounting-clickable" aria-current="false">
|
||||||
|
{{ A_("Regular") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab" class="nav-link accounting-clickable" aria-current="false">
|
||||||
|
{{ A_("Annotation") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{# A general description with a tag #}
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-page" aria-current="page" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-general-tab">
|
||||||
|
<div class="form-floating mb-2">
|
||||||
|
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag" class="form-control" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag">{{ A_("Tag") }}</label>
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-general-tag-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% for tag in description_editor.general.tags %}
|
||||||
|
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# A general trip with the origin and distination #}
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tab">
|
||||||
|
<div class="form-floating mb-2">
|
||||||
|
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag" class="form-control" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag">{{ A_("Tag") }}</label>
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-tag-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% for tag in description_editor.travel.tags %}
|
||||||
|
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-2">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from" class="form-control" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from">{{ A_("From") }}</label>
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-from-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group-vertical ms-1 me-1">
|
||||||
|
<button class="btn btn-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="→">→</button>
|
||||||
|
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-travel-direction" type="button" tabindex="-1" data-arrow="↔">↔</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating">
|
||||||
|
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to" class="form-control" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to">{{ A_("To") }}</label>
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-travel-to-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# A bus trip with the route name or route number, the origin and distination #}
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tab">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<div class="form-floating me-2">
|
||||||
|
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag" class="form-control" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag">{{ A_("Tag") }}</label>
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-tag-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating">
|
||||||
|
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route" class="form-control" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route">{{ A_("Route") }}</label>
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-route-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% for tag in description_editor.bus.tags %}
|
||||||
|
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
|
||||||
|
{{ tag }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-2">
|
||||||
|
<div class="form-floating me-2">
|
||||||
|
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from" class="form-control" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from">{{ A_("From") }}</label>
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-from-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-floating">
|
||||||
|
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to" class="form-control" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to">{{ A_("To") }}</label>
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-bus-to-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# A regular income or payment #}
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-regular-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-regular-tab">
|
||||||
|
{# TODO: To be done #}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The annotation #}
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-tab">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number">{{ A_("The Number of Items") }}</label>
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-number-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-floating mt-2">
|
||||||
|
<input id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note" class="form-control" type="text" value="" placeholder=" ">
|
||||||
|
<label class="form-label" for="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note">{{ A_("Note") }}</label>
|
||||||
|
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-annotation-note-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The suggested accounts #}
|
||||||
|
<div class="mt-3">
|
||||||
|
{% for account in description_editor.accounts %}
|
||||||
|
<button class="btn btn-outline-primary d-none accounting-description-editor-{{ description_editor.debit_credit }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
|
||||||
|
{{ account }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button>
|
||||||
|
<button id="accounting-description-editor-{{ description_editor.debit_credit }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,75 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
detail-line-items-item: The line items in the journal entry detail
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/3/14
|
||||||
|
#}
|
||||||
|
{# <ul> For SonarQube not to complain about incorrect HTML #}
|
||||||
|
{% for line_item in line_items %}
|
||||||
|
<li class="list-group-item accounting-journal-entry-line-item">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="small">{{ line_item.account }}</div>
|
||||||
|
{% if line_item.description is not none %}
|
||||||
|
<div>{{ line_item.description }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if line_item.original_line_item %}
|
||||||
|
<div class="fst-italic small accounting-original-line-item">
|
||||||
|
<a href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.original_line_item.journal_entry)|accounting_append_next }}">
|
||||||
|
{{ A_("Offset %(item)s", item=line_item.original_line_item) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if line_item.is_need_offset %}
|
||||||
|
<div class="fst-italic small accounting-offset-line-items">
|
||||||
|
{% if line_item.offsets %}
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>{{ A_("Offsets") }}</div>
|
||||||
|
<ul class="ms-2 ps-0">
|
||||||
|
{% for offset in line_item.offsets %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for("accounting.journal-entry.detail", journal_entry=offset.journal_entry)|accounting_append_next }}">
|
||||||
|
{{ offset.journal_entry.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% if line_item.balance %}
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>{{ A_("Net balance") }}</div>
|
||||||
|
<div>{{ line_item.balance|accounting_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>{{ A_("Fully offset") }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
{{ A_("Unmatched") }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>{{ line_item.amount|accounting_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{# </ul> For SonarQube not to complain about incorrect HTML #}
|
@ -31,34 +31,41 @@ First written: 2023/2/26
|
|||||||
{{ A_("Back") }}
|
{{ A_("Back") }}
|
||||||
</a>
|
</a>
|
||||||
{% if accounting_can_edit() %}
|
{% 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 }}">
|
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
|
||||||
<i class="fa-solid fa-gear"></i>
|
<i class="fa-solid fa-gear"></i>
|
||||||
{{ A_("Settings") }}
|
{{ A_("Settings") }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.order", txn_date=obj.date)|accounting_append_next }}">
|
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
|
||||||
<i class="fa-solid fa-bars-staggered"></i>
|
<i class="fa-solid fa-bars-staggered"></i>
|
||||||
{{ A_("Order") }}
|
{{ A_("Order") }}
|
||||||
</a>
|
</a>
|
||||||
{% if accounting_can_edit() %}
|
{% if accounting_can_edit() %}
|
||||||
{% block to_transfer %}{% endblock %}
|
{% block to_transfer %}{% endblock %}
|
||||||
|
{% if obj.can_delete %}
|
||||||
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
|
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
|
||||||
<i class="fa-solid fa-trash"></i>
|
<i class="fa-solid fa-trash"></i>
|
||||||
{{ A_("Delete") }}
|
{{ A_("Delete") }}
|
||||||
</button>
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-secondary" type="button" disabled="disabled">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
|
{{ A_("Delete") }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if accounting_can_edit() %}
|
{% if accounting_can_edit() %}
|
||||||
<div class="d-md-none accounting-material-fab">
|
<div class="d-md-none accounting-material-fab">
|
||||||
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
|
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
|
||||||
<i class="fa-solid fa-pen-to-square"></i>
|
<i class="fa-solid fa-pen-to-square"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if accounting_can_edit() %}
|
{% if accounting_can_edit() and obj.can_delete %}
|
||||||
<form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
|
<form action="{{ url_for("accounting.journal-entry.delete", journal_entry=obj) }}" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
{% if request.args.next %}
|
{% if request.args.next %}
|
||||||
<input type="hidden" name="next" value="{{ request.args.next }}">
|
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||||
@ -67,11 +74,11 @@ First written: 2023/2/26
|
|||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Transaction Confirmation") }}</h1>
|
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Confirm Delete Journal Entry") }}</h1>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
{{ A_("Do you really want to delete this transaction?") }}
|
{{ A_("Do you really want to delete this journal entry?") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
||||||
@ -92,13 +99,13 @@ First written: 2023/2/26
|
|||||||
{{ obj.date|accounting_format_date }}
|
{{ obj.date|accounting_format_date }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block transaction_currencies %}{% endblock %}
|
{% block journal_entry_currencies %}{% endblock %}
|
||||||
|
|
||||||
{% if obj.note %}
|
{% if obj.note %}
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<i class="far fa-comment-dots"></i>
|
<i class="far fa-comment-dots"></i>
|
||||||
{{ obj.note|accounting_txn_text2html|safe }}
|
{{ obj.note|accounting_journal_entry_text2html|safe }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
@ -0,0 +1,74 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
form-line-item.html: The line item sub-form in the journal entry form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
{# <ul> For SonarQube not to complain about incorrect HTML #}
|
||||||
|
<li id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ debit_credit }} {% if form.offsets %} accounting-matched-line-item {% endif %}" data-currency-index="{{ currency_index }}" data-debit-credit="{{ debit_credit }}" data-line-item-index="{{ line_item_index }}" {% if form.is_need_offset %} data-is-need-offset="true" {% endif %}>
|
||||||
|
{% if form.id.data %}
|
||||||
|
<input type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-id" value="{{ form.id.data }}">
|
||||||
|
{% endif %}
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-no" value="{{ line_item_index }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-id" class="accounting-original-line-item-id" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original_line_item_id" value="{{ form.original_line_item_id.data|accounting_default }}" data-date="{{ form.original_line_item_date|accounting_default }}" data-text="{{ form.original_line_item_text|accounting_default }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-text="{{ form.account_text }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description" value="{{ form.description.data|accounting_default }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount" value="{{ form.amount.data|accounting_journal_entry_format_amount_input }}" data-min="{{ form.offset_total|accounting_default("0") }}">
|
||||||
|
<div class="accounting-line-item-content">
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-control" class="form-control clickable d-flex justify-content-between {% if form.all_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
|
||||||
|
<div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-account-text" class="small">{{ form.account_text }}</div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-description-text">{{ form.description.data|accounting_default }}</div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-original-line-item-text" class="fst-italic small accounting-original-line-item {% if not form.original_line_item_id.data %} d-none {% endif %}">
|
||||||
|
{% if form.original_line_item_id.data %}{{ A_("Offset %(item)s", item=form.original_line_item_text|accounting_default) }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-offsets" class="fst-italic small accounting-offset-line-items {% if not form.is_need_offset %} d-none {% endif %}">
|
||||||
|
{% if form.offsets %}
|
||||||
|
<div class="d-flex justify-content-between {% if not form.offsets %} d-none {% endif %}">
|
||||||
|
<div>{{ A_("Offsets") }}</div>
|
||||||
|
<ul class="ms-2 ps-0">
|
||||||
|
{% for offset in form.offsets %}
|
||||||
|
<li>{{ offset.journal_entry.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% if form.net_balance == 0 %}
|
||||||
|
<div>{{ A_("Fully offset") }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>{{ A_("Net balance") }}</div>
|
||||||
|
<div>{{ form.net_balance|accounting_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ A_("Unmatched") }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><span id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-amount-text" class="badge rounded-pill bg-primary">{{ form.amount.data|accounting_format_amount }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-error" class="invalid-feedback">{% if form.all_errors %}{{ form.all_errors[0] }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button id="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_form or form.offsets %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ debit_credit }}-{{ line_item_index }}">
|
||||||
|
<i class="fas fa-minus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{# </ul> For SonarQube not to complain about incorrect HTML #}
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
form.html: The transfer transaction form
|
form.html: The base journal entry form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -23,9 +23,11 @@ First written: 2023/2/26
|
|||||||
|
|
||||||
{% block accounting_scripts %}
|
{% 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/drag-and-drop-reorder.js") }}"></script>
|
||||||
<script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script>
|
<script src="{{ url_for("accounting.static", filename="js/journal-entry-form.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/journal-entry-line-item-editor.js") }}"></script>
|
||||||
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
|
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
|
||||||
<script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script>
|
<script src="{{ url_for("accounting.static", filename="js/original-line-item-selector.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/description-editor.js") }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -37,14 +39,14 @@ First written: 2023/2/26
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-entry-template="{{ entry_template }}">
|
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-line-item-template="{{ line_item_template }}">
|
||||||
{{ form.csrf_token }}
|
{{ form.csrf_token }}
|
||||||
{% if request.args.next %}
|
{% if request.args.next %}
|
||||||
<input type="hidden" name="next" value="{{ request.args.next }}">
|
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-floating mb-3">
|
<div class="form-floating mb-3">
|
||||||
<input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ form.date.data|accounting_default }}" placeholder=" " required="required">
|
<input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ form.date.data|accounting_default }}" max="{{ form.max_date|accounting_default }}" min="{{ form.min_date|accounting_default }}" placeholder=" " required="required">
|
||||||
<label class="form-label" for="accounting-date">{{ A_("Date") }}</label>
|
<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 id="accounting-date-error" class="invalid-feedback">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -57,7 +59,7 @@ First written: 2023/2/26
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button id="accounting-btn-new-currency" class="btn btn-primary" type="button">
|
<button id="accounting-add-currency" class="btn btn-primary" type="button">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
{{ A_("New") }}
|
{{ A_("New") }}
|
||||||
</button>
|
</button>
|
||||||
@ -86,7 +88,8 @@ First written: 2023/2/26
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% include "accounting/transaction/include/entry-form-modal.html" %}
|
{% include "accounting/journal-entry/include/journal-entry-line-item-editor-modal.html" %}
|
||||||
{% block form_modals %}{% endblock %}
|
{% block form_modals %}{% endblock %}
|
||||||
|
{% include "accounting/journal-entry/include/original-line-item-selector-modal.html" %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -0,0 +1,76 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
journal-entry-line-item-editor-modal: The modal of the journal entry line item editor
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
<form id="accounting-line-item-editor">
|
||||||
|
<div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="accounting-line-item-editor-modal-label">{{ A_("Line Item Content") }}</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="accounting-line-item-editor-original-line-item-container" class="d-flex justify-content-between mb-3">
|
||||||
|
<div class="accounting-line-item-editor-original-line-item-content">
|
||||||
|
<div id="accounting-line-item-editor-original-line-item-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal">
|
||||||
|
<label class="form-label" for="accounting-line-item-editor-original-line-item">{{ A_("Original Line Item") }}</label>
|
||||||
|
<div id="accounting-line-item-editor-original-line-item"></div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-line-item-editor-original-line-item-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button id="accounting-line-item-editor-original-line-item-delete" class="btn btn-danger rounded-circle" type="button">
|
||||||
|
<i class="fas fa-minus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div id="accounting-line-item-editor-description-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
|
||||||
|
<label class="form-label" for="accounting-line-item-editor-description">{{ A_("Description") }}</label>
|
||||||
|
<div id="accounting-line-item-editor-description"></div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-line-item-editor-description-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div id="accounting-line-item-editor-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
|
||||||
|
<label class="form-label" for="accounting-line-item-editor-account">{{ A_("Account") }}</label>
|
||||||
|
<div id="accounting-line-item-editor-account"></div>
|
||||||
|
</div>
|
||||||
|
<div id="accounting-line-item-editor-account-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input id="accounting-line-item-editor-amount" class="form-control" type="number" value="" min="0" max="" step="0.01" placeholder=" " required="required">
|
||||||
|
<label for="accounting-line-item-editor-amount">{{ A_("Amount") }}</label>
|
||||||
|
<div id="accounting-line-item-editor-amount-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
||||||
|
<button type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,56 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
original-line-item-selector-modal.html: The modal of the original line item selector
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/25
|
||||||
|
#}
|
||||||
|
<div id="accounting-original-line-item-selector-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-original-line-item-selector-modal-label" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="accounting-original-line-item-selector-modal-label">{{ A_("Select Original Line Item") }}</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="input-group mb-2">
|
||||||
|
<input id="accounting-original-line-item-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
|
||||||
|
<label class="input-group-text" for="accounting-original-line-item-selector-query">
|
||||||
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
|
{{ A_("Search") }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul id="accounting-original-line-item-selector-option-list" class="list-group accounting-selector-list">
|
||||||
|
{% for line_item in form.original_line_item_options %}
|
||||||
|
<li id="accounting-original-line-item-selector-option-{{ line_item.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-line-item-selector-option" data-id="{{ line_item.id }}" data-date="{{ line_item.journal_entry.date }}" data-debit-credit="{{ "debit" if line_item.is_debit else "credit" }}" data-currency-code="{{ line_item.currency.code }}" data-account-code="{{ line_item.account_code }}" data-account-text="{{ line_item.account }}" data-description="{{ line_item.description|accounting_default }}" data-net-balance="{{ line_item.net_balance|accounting_journal_entry_format_amount_input }}" data-text="{{ line_item }}" data-query-values="{{ line_item.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
|
||||||
|
<div>{{ line_item.journal_entry.date|accounting_format_date }} {{ line_item.description|accounting_default }}</div>
|
||||||
|
<div>
|
||||||
|
<span class="badge bg-primary rounded-pill">
|
||||||
|
<span id="accounting-original-line-item-selector-option-{{ line_item.id }}-net-balance">{{ line_item.net_balance|accounting_format_amount }}</span>
|
||||||
|
/ {{ line_item.amount|accounting_format_amount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p id="accounting-original-line-item-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
order.html: The order of the transactions in a same day
|
order.html: The order of the journal entries in a same day
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -23,10 +23,10 @@ First written: 2023/2/26
|
|||||||
|
|
||||||
{% block accounting_scripts %}
|
{% 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/drag-and-drop-reorder.js") }}"></script>
|
||||||
<script src="{{ url_for("accounting.static", filename="js/transaction-order.js") }}"></script>
|
<script src="{{ url_for("accounting.static", filename="js/journal-entry-order.js") }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("Transactions on %(date)s", date=date) }}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{{ A_("Journal Entries on %(date)s", date=date) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ First written: 2023/2/26
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if list|length > 1 and accounting_can_edit() %}
|
{% if list|length > 1 and accounting_can_edit() %}
|
||||||
<form action="{{ url_for("accounting.transaction.sort", txn_date=date) }}" method="post">
|
<form action="{{ url_for("accounting.journal-entry.sort", journal_entry_date=date) }}" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
{% if request.args.next %}
|
{% if request.args.next %}
|
||||||
<input type="hidden" name="next" value="{{ request.args.next }}">
|
<input type="hidden" name="next" value="{{ request.args.next }}">
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
create.html: The transfer transaction creation form
|
create.html: The cash receipt journal entry creation form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,10 +19,10 @@ create.html: The transfer transaction creation form
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
First written: 2023/2/25
|
||||||
#}
|
#}
|
||||||
{% extends "accounting/transaction/transfer/include/form.html" %}
|
{% extends "accounting/journal-entry/receipt/include/form.html" %}
|
||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("Add a New Transfer Transaction") }}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{{ A_("Add a New Cash Receipt Journal Entry") }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
|
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
|
||||||
|
|
||||||
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
|
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
detail.html: The account detail
|
detail.html: The cash receipt journal entry detail
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,36 +19,26 @@ detail.html: The account detail
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/26
|
First written: 2023/2/26
|
||||||
#}
|
#}
|
||||||
{% extends "accounting/transaction/include/detail.html" %}
|
{% extends "accounting/journal-entry/include/detail.html" %}
|
||||||
|
|
||||||
{% block to_transfer %}
|
{% block to_transfer %}
|
||||||
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_txn_to_transfer|accounting_inherit_next }}">
|
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
|
||||||
<i class="fa-solid fa-bars-staggered"></i>
|
<i class="fa-solid fa-bars-staggered"></i>
|
||||||
{{ A_("To Transfer") }}
|
{{ A_("To Transfer") }}
|
||||||
</a>
|
</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block transaction_currencies %}
|
{% block journal_entry_currencies %}
|
||||||
{% for currency in obj.currencies %}
|
{% for currency in obj.currencies %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
|
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
|
||||||
|
|
||||||
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
|
<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>
|
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Content") }}</li>
|
||||||
{% for entry in currency.credit %}
|
{% with line_items = currency.credit %}
|
||||||
<li class="list-group-item accounting-transaction-entry">
|
{% include "accounting/journal-entry/include/detail-line-items.html" %}
|
||||||
<div class="d-flex justify-content-between">
|
{% endwith %}
|
||||||
<div>
|
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
|
||||||
<div class="small">{{ entry.account }}</div>
|
|
||||||
{% if entry.summary is not none %}
|
|
||||||
<div>{{ entry.summary }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div>{{ entry.amount|accounting_format_amount }}</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
|
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div>{{ A_("Total") }}</div>
|
<div>{{ A_("Total") }}</div>
|
||||||
<div>{{ currency.debit_total|accounting_format_amount }}</div>
|
<div>{{ currency.debit_total|accounting_format_amount }}</div>
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
edit.html: The cash expense transaction edit form
|
edit.html: The cash receipt journal entry edit form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,10 +19,10 @@ edit.html: The cash expense transaction edit form
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
First written: 2023/2/25
|
||||||
#}
|
#}
|
||||||
{% extends "accounting/transaction/expense/include/form.html" %}
|
{% extends "accounting/journal-entry/receipt/include/form.html" %}
|
||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
|
{% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %}
|
||||||
|
|
||||||
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}
|
{% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %}
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
currency-sub-form.html: The currency sub-form in the cash income transaction form
|
form-currency.html: The currency sub-form in the cash receipt journal entry form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,22 +19,23 @@ currency-sub-form.html: The currency sub-form in the cash income transaction for
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
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 }}">
|
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
|
||||||
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
|
||||||
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
|
<div 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="d-flex justify-content-between mt-2 mb-3">
|
||||||
<div class="form-floating accounting-currency-content">
|
<div class="form-floating accounting-currency-content">
|
||||||
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
|
<select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" name="currency-{{ currency_index }}-code" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
|
||||||
{% for currency in accounting_currency_options() %}
|
{% for currency in accounting_currency_options() %}
|
||||||
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
|
<label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
|
||||||
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</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 }}">
|
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
|
||||||
<i class="fas fa-minus"></i>
|
<i class="fas fa-minus"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -43,23 +44,14 @@ First written: 2023/2/25
|
|||||||
<div class="mb-3">
|
<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 %}">
|
<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>
|
<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">
|
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-line-item-list">
|
||||||
{% for entry_form in credit_forms %}
|
{% for line_item_form in credit_forms %}
|
||||||
{% with currency_index = currency_index,
|
{% with currency_index = currency_index,
|
||||||
entry_type = "credit",
|
debit_credit = "credit",
|
||||||
entry_index = loop.index,
|
line_item_index = loop.index,
|
||||||
only_one_entry_form = debit_forms|length == 1,
|
only_one_line_item_form = credit_forms|length == 1,
|
||||||
entry_id = entry_form.eid.data,
|
form = line_item_form.form %}
|
||||||
account_code_data = entry_form.account_code.data|accounting_default,
|
{% include "accounting/journal-entry/include/form-line-item.html" %}
|
||||||
account_code_error = entry_form.account_code.errors,
|
|
||||||
account_text = entry_form.account_text,
|
|
||||||
summary_data = entry_form.summary.data|accounting_default,
|
|
||||||
summary_errors = entry_form.summary.errors,
|
|
||||||
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
|
|
||||||
amount_errors = entry_form.amount.errors,
|
|
||||||
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
|
|
||||||
entry_errors = entry_form.all_errors %}
|
|
||||||
{% include "accounting/transaction/include/form-entry-item.html" %}
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@ -70,7 +62,7 @@ First written: 2023/2/25
|
|||||||
</div>
|
</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-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
|
<button id="accounting-currency-{{ currency_index }}-credit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="credit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
{{ A_("New") }}
|
{{ A_("New") }}
|
||||||
</button>
|
</button>
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
form.html: The cash income transaction form
|
form.html: The cash receipt journal entry form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ form.html: The cash income transaction form
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
First written: 2023/2/25
|
||||||
#}
|
#}
|
||||||
{% extends "accounting/transaction/include/form.html" %}
|
{% extends "accounting/journal-entry/include/form.html" %}
|
||||||
|
|
||||||
{% block currency_sub_forms %}
|
{% block currency_sub_forms %}
|
||||||
{% if form.currencies %}
|
{% if form.currencies %}
|
||||||
@ -29,10 +29,11 @@ First written: 2023/2/25
|
|||||||
currency_errors = currency_form.whole_form.errors,
|
currency_errors = currency_form.whole_form.errors,
|
||||||
currency_code_data = currency_form.code.data,
|
currency_code_data = currency_form.code.data,
|
||||||
currency_code_errors = currency_form.code.errors,
|
currency_code_errors = currency_form.code.errors,
|
||||||
|
currency_code_is_locked = currency_form.is_code_locked,
|
||||||
credit_forms = currency_form.credit,
|
credit_forms = currency_form.credit,
|
||||||
credit_errors = currency_form.credit_errors,
|
credit_errors = currency_form.credit_errors,
|
||||||
credit_total = currency_form.form.credit_total|accounting_format_amount %}
|
credit_total = currency_form.form.credit_total|accounting_format_amount %}
|
||||||
{% include "accounting/transaction/income/include/form-currency-item.html" %}
|
{% include "accounting/journal-entry/receipt/include/form-currency.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -40,17 +41,17 @@ First written: 2023/2/25
|
|||||||
only_one_currency_form = True,
|
only_one_currency_form = True,
|
||||||
currency_code_data = accounting_default_currency_code(),
|
currency_code_data = accounting_default_currency_code(),
|
||||||
credit_total = "-" %}
|
credit_total = "-" %}
|
||||||
{% include "accounting/transaction/income/include/form-currency-item.html" %}
|
{% include "accounting/journal-entry/receipt/include/form-currency.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block form_modals %}
|
{% block form_modals %}
|
||||||
{% with summary_editor = form.summary_editor.credit %}
|
{% with description_editor = form.description_editor.credit %}
|
||||||
{% include "accounting/transaction/include/summary-editor-modal.html" %}
|
{% include "accounting/journal-entry/include/description-editor-modal.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% with entry_type = "credit",
|
{% with debit_credit = "credit",
|
||||||
account_options = form.credit_account_options %}
|
account_options = form.credit_account_options %}
|
||||||
{% include "accounting/transaction/include/account-selector-modal.html" %}
|
{% include "accounting/journal-entry/include/account-selector-modal.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
create.html: The cash income transaction creation form
|
create.html: The transfer journal entry creation form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,10 +19,10 @@ create.html: The cash income transaction creation form
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
First written: 2023/2/25
|
||||||
#}
|
#}
|
||||||
{% extends "accounting/transaction/income/include/form.html" %}
|
{% extends "accounting/journal-entry/transfer/include/form.html" %}
|
||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("Add a New Cash Income Transaction") }}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{{ A_("Add a New Transfer Journal Entry") }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
|
{% block back_url %}{{ request.args.get("next") or url_for("accounting.report.default") }}{% endblock %}
|
||||||
|
|
||||||
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}
|
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
|
@ -0,0 +1,64 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
detail.html: The transfer journal entry detail
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/26
|
||||||
|
#}
|
||||||
|
{% extends "accounting/journal-entry/include/detail.html" %}
|
||||||
|
|
||||||
|
{% block journal_entry_currencies %}
|
||||||
|
{% for currency in obj.currencies %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{# The debit line items #}
|
||||||
|
<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-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Debit") }}</li>
|
||||||
|
{% with line_items = currency.debit %}
|
||||||
|
{% include "accounting/journal-entry/include/detail-line-items.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div>{{ currency.debit_total|accounting_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The credit line items #}
|
||||||
|
<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-journal-entry-line-item accounting-journal-entry-line-item-header">{{ A_("Credit") }}</li>
|
||||||
|
{% with line_items = currency.credit %}
|
||||||
|
{% include "accounting/journal-entry/include/detail-line-items.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>{{ A_("Total") }}</div>
|
||||||
|
<div>{{ currency.debit_total|accounting_format_amount }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
edit.html: The transfer transaction edit form
|
edit.html: The transfer journal entry edit form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,10 +19,10 @@ edit.html: The transfer transaction edit form
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
First written: 2023/2/25
|
||||||
#}
|
#}
|
||||||
{% extends "accounting/transaction/transfer/include/form.html" %}
|
{% extends "accounting/journal-entry/transfer/include/form.html" %}
|
||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{{ A_("Editing %(journal_entry)s", journal_entry=journal_entry) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
|
{% block back_url %}{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_inherit_next }}{% endblock %}
|
||||||
|
|
||||||
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}
|
{% block action_url %}{{ url_for("accounting.journal-entry.update", journal_entry=journal_entry)|accounting_journal_entry_with_type }}{% endblock %}
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
currency-sub-form.html: The currency sub-form in the transfer transaction form
|
form-currency.html: The currency sub-form in the transfer journal entry form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,49 +19,41 @@ currency-sub-form.html: The currency sub-form in the transfer transaction form
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
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 }}">
|
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}">
|
||||||
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
|
||||||
|
<input id="accounting-currency-{{ currency_index }}-code" type="hidden" name="currency-{{ currency_index }}-code" value="{{ currency_code_data }}">
|
||||||
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
|
<div 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="d-flex justify-content-between mt-2 mb-3">
|
||||||
<div class="form-floating accounting-currency-content">
|
<div class="form-floating accounting-currency-content">
|
||||||
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
|
<select id="accounting-currency-{{ currency_index }}-code-select" class="form-select {% if currency_code_errors %} is-invalid {% endif %}" {% if currency_code_is_locked %} disabled="disabled" {% endif %}>
|
||||||
{% for currency in accounting_currency_options() %}
|
{% for currency in accounting_currency_options() %}
|
||||||
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
|
<label class="form-label" for="accounting-currency-{{ currency_index }}-code-select">{{ A_("Currency") }}</label>
|
||||||
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</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 }}">
|
<button id="accounting-currency-{{ currency_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
|
||||||
<i class="fas fa-minus"></i>
|
<i class="fas fa-minus"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{# The debit entries #}
|
{# The debit line items #}
|
||||||
<div class="col-sm-6 mb-3">
|
<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 %}">
|
<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>
|
<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">
|
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-line-item-list">
|
||||||
{% for entry_form in debit_forms %}
|
{% for line_item_form in debit_forms %}
|
||||||
{% with currency_index = currency_index,
|
{% with currency_index = currency_index,
|
||||||
entry_type = "debit",
|
debit_credit = "debit",
|
||||||
entry_index = loop.index,
|
line_item_index = loop.index,
|
||||||
only_one_entry_form = debit_forms|length == 1,
|
only_one_line_item_form = debit_forms|length == 1,
|
||||||
entry_id = entry_form.eid.data,
|
form = line_item_form.form %}
|
||||||
account_code_data = entry_form.account_code.data|accounting_default,
|
{% include "accounting/journal-entry/include/form-line-item.html" %}
|
||||||
account_code_error = entry_form.account_code.errors,
|
|
||||||
account_text = entry_form.account_text,
|
|
||||||
summary_data = entry_form.summary.data|accounting_default,
|
|
||||||
summary_errors = entry_form.summary.errors,
|
|
||||||
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
|
|
||||||
amount_errors = entry_form.amount.errors,
|
|
||||||
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
|
|
||||||
entry_errors = entry_form.all_errors %}
|
|
||||||
{% include "accounting/transaction/include/form-entry-item.html" %}
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@ -72,7 +64,7 @@ First written: 2023/2/25
|
|||||||
</div>
|
</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-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
|
<button id="accounting-currency-{{ currency_index }}-debit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="debit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
{{ A_("New") }}
|
{{ A_("New") }}
|
||||||
</button>
|
</button>
|
||||||
@ -81,27 +73,18 @@ First written: 2023/2/25
|
|||||||
<div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
|
<div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# The credit entries #}
|
{# The credit line items #}
|
||||||
<div class="col-sm-6 mb-3">
|
<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 %}">
|
<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>
|
<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">
|
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-line-item-list">
|
||||||
{% for entry_form in credit_forms %}
|
{% for line_item_form in credit_forms %}
|
||||||
{% with currency_index = currency_index,
|
{% with currency_index = currency_index,
|
||||||
entry_id = entry_form.eid.data,
|
debit_credit = "credit",
|
||||||
entry_type = "credit",
|
line_item_index = loop.index,
|
||||||
entry_index = loop.index,
|
only_one_line_item_form = credit_forms|length == 1,
|
||||||
only_one_entry_form = debit_forms|length == 1,
|
form = line_item_form.form %}
|
||||||
account_code_data = entry_form.account_code.data|accounting_default,
|
{% include "accounting/journal-entry/include/form-line-item.html" %}
|
||||||
account_code_error = entry_form.account_code.errors,
|
|
||||||
account_text = entry_form.account_text,
|
|
||||||
summary_data = entry_form.summary.data|accounting_default,
|
|
||||||
summary_errors = entry_form.summary.errors,
|
|
||||||
amount_data = entry_form.amount.data|accounting_txn_format_amount_input,
|
|
||||||
amount_errors = entry_form.amount.errors,
|
|
||||||
amount_text = entry_form.amount.data|accounting_format_amount|accounting_default("-"),
|
|
||||||
entry_errors = entry_form.all_errors %}
|
|
||||||
{% include "accounting/transaction/include/form-entry-item.html" %}
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@ -112,7 +95,7 @@ First written: 2023/2/25
|
|||||||
</div>
|
</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-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
|
<button id="accounting-currency-{{ currency_index }}-credit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-debit-credit="credit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
|
||||||
<i class="fas fa-plus"></i>
|
<i class="fas fa-plus"></i>
|
||||||
{{ A_("New") }}
|
{{ A_("New") }}
|
||||||
</button>
|
</button>
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
form.html: The transfer transaction form
|
form.html: The transfer journal entry form
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ form.html: The transfer transaction form
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/2/25
|
First written: 2023/2/25
|
||||||
#}
|
#}
|
||||||
{% extends "accounting/transaction/include/form.html" %}
|
{% extends "accounting/journal-entry/include/form.html" %}
|
||||||
|
|
||||||
{% block currency_sub_forms %}
|
{% block currency_sub_forms %}
|
||||||
{% if form.currencies %}
|
{% if form.currencies %}
|
||||||
@ -29,13 +29,14 @@ First written: 2023/2/25
|
|||||||
currency_errors = currency_form.whole_form.errors,
|
currency_errors = currency_form.whole_form.errors,
|
||||||
currency_code_data = currency_form.code.data,
|
currency_code_data = currency_form.code.data,
|
||||||
currency_code_errors = currency_form.code.errors,
|
currency_code_errors = currency_form.code.errors,
|
||||||
|
currency_code_is_locked = currency_form.is_code_locked,
|
||||||
debit_forms = currency_form.debit,
|
debit_forms = currency_form.debit,
|
||||||
debit_errors = currency_form.debit_errors,
|
debit_errors = currency_form.debit_errors,
|
||||||
debit_total = currency_form.form.debit_total|accounting_format_amount,
|
debit_total = currency_form.form.debit_total|accounting_format_amount,
|
||||||
credit_forms = currency_form.credit,
|
credit_forms = currency_form.credit,
|
||||||
credit_errors = currency_form.credit_errors,
|
credit_errors = currency_form.credit_errors,
|
||||||
credit_total = currency_form.form.credit_total|accounting_format_amount %}
|
credit_total = currency_form.form.credit_total|accounting_format_amount %}
|
||||||
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
|
{% include "accounting/journal-entry/transfer/include/form-currency.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -44,24 +45,24 @@ First written: 2023/2/25
|
|||||||
currency_code_data = accounting_default_currency_code(),
|
currency_code_data = accounting_default_currency_code(),
|
||||||
debit_total = "-",
|
debit_total = "-",
|
||||||
credit_total = "-" %}
|
credit_total = "-" %}
|
||||||
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
|
{% include "accounting/journal-entry/transfer/include/form-currency.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block form_modals %}
|
{% block form_modals %}
|
||||||
{% with summary_editor = form.summary_editor.debit %}
|
{% with description_editor = form.description_editor.debit %}
|
||||||
{% include "accounting/transaction/include/summary-editor-modal.html" %}
|
{% include "accounting/journal-entry/include/description-editor-modal.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% with summary_editor = form.summary_editor.credit %}
|
{% with description_editor = form.description_editor.credit %}
|
||||||
{% include "accounting/transaction/include/summary-editor-modal.html" %}
|
{% include "accounting/journal-entry/include/description-editor-modal.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% with entry_type = "debit",
|
{% with debit_credit = "debit",
|
||||||
account_options = form.debit_account_options %}
|
account_options = form.debit_account_options %}
|
||||||
{% include "accounting/transaction/include/account-selector-modal.html" %}
|
{% include "accounting/journal-entry/include/account-selector-modal.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% with entry_type = "credit",
|
{% with debit_credit = "credit",
|
||||||
account_options = form.credit_account_options %}
|
account_options = form.credit_account_options %}
|
||||||
{% include "accounting/transaction/include/account-selector-modal.html" %}
|
{% include "accounting/journal-entry/include/account-selector-modal.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -37,7 +37,7 @@ First written: 2023/3/7
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
|
||||||
|
|
||||||
{% include "accounting/report/include/period-chooser.html" %}
|
{% include "accounting/report/include/period-chooser.html" %}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{#
|
{#
|
||||||
The Mia! Accounting Flask Project
|
The Mia! Accounting Flask Project
|
||||||
add-txn-material-fab.html: The material floating action buttons to add a new transaction
|
add-journal-entry-material-fab.html: The material floating action buttons to add a new journal entry
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
@ -22,13 +22,13 @@ First written: 2023/2/25
|
|||||||
{% if accounting_can_edit() %}
|
{% 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" 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">
|
<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=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
|
<a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
|
||||||
{{ A_("Cash expense") }}
|
{{ A_("Cash Disbursement") }}
|
||||||
</a>
|
</a>
|
||||||
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
|
<a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
|
||||||
{{ A_("Cash income") }}
|
{{ A_("Cash Receipt") }}
|
||||||
</a>
|
</a>
|
||||||
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
|
<a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
|
||||||
{{ A_("Transfer") }}
|
{{ A_("Transfer") }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
@ -19,9 +19,9 @@ income-expenses-row-desktop.html: The row in the income and expenses log for the
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/3/8
|
First written: 2023/3/8
|
||||||
#}
|
#}
|
||||||
<div>{{ entry.date|accounting_format_date }}</div>
|
<div>{{ line_item.date|accounting_format_date }}</div>
|
||||||
<div>{{ entry.account.title|title }}</div>
|
<div>{{ line_item.account.title|title }}</div>
|
||||||
<div>{{ entry.summary|accounting_default }}</div>
|
<div>{{ line_item.description|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ line_item.income|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ line_item.expense|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
|
<div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
|
||||||
|
@ -20,31 +20,31 @@ Author: imacat@mail.imacat.idv.tw (imacat)
|
|||||||
First written: 2023/3/5
|
First written: 2023/3/5
|
||||||
#}
|
#}
|
||||||
<div>
|
<div>
|
||||||
{% if entry.date or entry.account %}
|
{% if line_item.date or line_item.account %}
|
||||||
<div class="text-muted small">
|
<div class="text-muted small">
|
||||||
{% if entry.date %}
|
{% if line_item.date %}
|
||||||
{{ entry.date|accounting_format_date }}
|
{{ line_item.date|accounting_format_date }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.account %}
|
{% if line_item.account %}
|
||||||
{{ entry.account.title|title }}
|
{{ line_item.account.title|title }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.summary %}
|
{% if line_item.description %}
|
||||||
<div>{{ entry.summary }}</div>
|
<div>{{ line_item.description }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-nowrap">
|
<div class="text-nowrap">
|
||||||
{% if entry.income %}
|
{% if line_item.income %}
|
||||||
<span class="badge rounded-pill bg-success">+{{ entry.income|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-success">+{{ line_item.income|accounting_format_amount }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.expense %}
|
{% if line_item.expense %}
|
||||||
<span class="badge rounded-pill bg-warning">-{{ entry.expense|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-warning">-{{ line_item.expense|accounting_format_amount }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.balance < 0 %}
|
{% if line_item.balance < 0 %}
|
||||||
<span class="badge rounded-pill bg-danger">{{ entry.balance|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-danger">{{ line_item.balance|accounting_format_amount }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-primary">{{ line_item.balance|accounting_format_amount }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -19,8 +19,10 @@ ledger-row-desktop.html: The row in the ledger for the desktop computers
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/3/8
|
First written: 2023/3/8
|
||||||
#}
|
#}
|
||||||
<div>{{ entry.date|accounting_format_date }}</div>
|
<div>{{ line_item.date|accounting_format_date }}</div>
|
||||||
<div>{{ entry.summary|accounting_default }}</div>
|
<div>{{ line_item.description|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ line_item.credit|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
|
{% if report.account.is_real %}
|
||||||
|
<div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
|
||||||
|
{% endif %}
|
||||||
|
@ -20,22 +20,24 @@ Author: imacat@mail.imacat.idv.tw (imacat)
|
|||||||
First written: 2023/3/5
|
First written: 2023/3/5
|
||||||
#}
|
#}
|
||||||
<div>
|
<div>
|
||||||
{% if entry.date %}
|
{% if line_item.date %}
|
||||||
<div class="text-muted small">
|
<div class="text-muted small">
|
||||||
{{ entry.date|accounting_format_date }}
|
{{ line_item.date|accounting_format_date }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.summary %}
|
{% if line_item.description %}
|
||||||
<div>{{ entry.summary }}</div>
|
<div>{{ line_item.description }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{% if entry.debit %}
|
{% if line_item.debit %}
|
||||||
<span class="badge rounded-pill bg-success">+{{ entry.debit|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-success">+{{ line_item.debit|accounting_format_amount }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if entry.credit %}
|
{% if line_item.credit %}
|
||||||
<span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-warning">-{{ line_item.credit|accounting_format_amount }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if report.account.is_real %}
|
||||||
|
<span class="badge rounded-pill bg-primary">{{ line_item.balance|accounting_format_amount }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -55,14 +55,14 @@ First written: 2023/3/4
|
|||||||
<div id="accounting-period-chooser-month-page" {% if report.period.is_type_month %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-month-tab">
|
<div id="accounting-period-chooser-month-page" {% if report.period.is_type_month %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-month-tab">
|
||||||
<div>
|
<div>
|
||||||
<a class="btn {% if report.period.is_this_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_month_url }}">
|
<a class="btn {% if report.period.is_this_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_month_url }}">
|
||||||
{{ A_("This month") }}
|
{{ A_("This Month") }}
|
||||||
</a>
|
</a>
|
||||||
{% if report.period_chooser.has_last_month %}
|
{% if report.period_chooser.has_last_month %}
|
||||||
<a class="btn {% if report.period.is_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_month_url }}">
|
<a class="btn {% if report.period.is_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_month_url }}">
|
||||||
{{ A_("Last month") }}
|
{{ A_("Last Month") }}
|
||||||
</a>
|
</a>
|
||||||
<a class="btn {% if report.period.is_since_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.since_last_month_url }}">
|
<a class="btn {% if report.period.is_since_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.since_last_month_url }}">
|
||||||
{{ A_("Since last month") }}
|
{{ A_("Since Last Month") }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -74,11 +74,11 @@ First written: 2023/3/4
|
|||||||
{# The year periods #}
|
{# The year periods #}
|
||||||
<div id="accounting-period-chooser-year-page" {% if report.period.is_a_year %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-year-tab">
|
<div id="accounting-period-chooser-year-page" {% if report.period.is_a_year %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-year-tab">
|
||||||
<a class="btn {% if report.period.is_this_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_year_url }}">
|
<a class="btn {% if report.period.is_this_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.this_year_url }}">
|
||||||
{{ A_("This year") }}
|
{{ A_("This Year") }}
|
||||||
</a>
|
</a>
|
||||||
{% if report.period_chooser.has_last_year %}
|
{% if report.period_chooser.has_last_year %}
|
||||||
<a class="btn {% if report.period.is_last_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_year_url }}">
|
<a class="btn {% if report.period.is_last_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ report.period_chooser.last_year_url }}">
|
||||||
{{ A_("Last year") }}
|
{{ A_("Last Year") }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if report.period_chooser.available_years %}
|
{% if report.period_chooser.available_years %}
|
||||||
|
@ -27,17 +27,17 @@ First written: 2023/3/8
|
|||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
|
<a class="dropdown-item" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
|
||||||
{{ A_("Cash Expense") }}
|
{{ A_("Cash Disbursement") }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
|
<a class="dropdown-item" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
|
||||||
{{ A_("Cash Income") }}
|
{{ A_("Cash Receipt") }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
|
<a class="dropdown-item" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
|
||||||
{{ A_("Transfer") }}
|
{{ A_("Transfer") }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -38,7 +38,7 @@ First written: 2023/3/5
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
|
||||||
|
|
||||||
{% include "accounting/report/include/period-chooser.html" %}
|
{% include "accounting/report/include/period-chooser.html" %}
|
||||||
|
|
||||||
@ -54,7 +54,7 @@ First written: 2023/3/5
|
|||||||
<div class="accounting-report-table-row">
|
<div class="accounting-report-table-row">
|
||||||
<div>{{ A_("Date") }}</div>
|
<div>{{ A_("Date") }}</div>
|
||||||
<div>{{ A_("Account") }}</div>
|
<div>{{ A_("Account") }}</div>
|
||||||
<div>{{ A_("Summary") }}</div>
|
<div>{{ A_("Description") }}</div>
|
||||||
<div class="accounting-amount">{{ A_("Income") }}</div>
|
<div class="accounting-amount">{{ A_("Income") }}</div>
|
||||||
<div class="accounting-amount">{{ A_("Expense") }}</div>
|
<div class="accounting-amount">{{ A_("Expense") }}</div>
|
||||||
<div class="accounting-amount">{{ A_("Balance") }}</div>
|
<div class="accounting-amount">{{ A_("Balance") }}</div>
|
||||||
@ -62,26 +62,26 @@ First written: 2023/3/5
|
|||||||
</div>
|
</div>
|
||||||
<div class="accounting-report-table-body">
|
<div class="accounting-report-table-body">
|
||||||
{% if report.brought_forward %}
|
{% if report.brought_forward %}
|
||||||
{% with entry = report.brought_forward %}
|
{% with line_item = report.brought_forward %}
|
||||||
<div class="accounting-report-table-row">
|
<div class="accounting-report-table-row">
|
||||||
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
|
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for entry in report.entries %}
|
{% for line_item in report.line_items %}
|
||||||
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
|
<a class="accounting-report-table-row" href="{{ line_item.url|accounting_append_next }}">
|
||||||
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
|
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if report.total %}
|
{% if report.total %}
|
||||||
{% with entry = report.total %}
|
{% with line_item = report.total %}
|
||||||
<div class="accounting-report-table-footer">
|
<div class="accounting-report-table-footer">
|
||||||
<div class="accounting-report-table-row">
|
<div class="accounting-report-table-row">
|
||||||
<div>{{ A_("Total") }}</div>
|
<div>{{ A_("Total") }}</div>
|
||||||
<div class="accounting-amount">{{ entry.income|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ line_item.income|accounting_format_amount }}</div>
|
||||||
<div class="accounting-amount">{{ entry.expense|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ line_item.expense|accounting_format_amount }}</div>
|
||||||
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
|
<div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
@ -90,19 +90,19 @@ First written: 2023/3/5
|
|||||||
|
|
||||||
<div class="list-group d-md-none">
|
<div class="list-group d-md-none">
|
||||||
{% if report.brought_forward %}
|
{% if report.brought_forward %}
|
||||||
{% with entry = report.brought_forward %}
|
{% with line_item = report.brought_forward %}
|
||||||
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||||
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
|
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for entry in report.entries %}
|
{% for line_item in report.line_items %}
|
||||||
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}">
|
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ line_item.url|accounting_append_next }}">
|
||||||
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
|
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if report.total %}
|
{% if report.total %}
|
||||||
{% with entry = report.total %}
|
{% with line_item = report.total %}
|
||||||
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||||
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
|
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -37,7 +37,7 @@ First written: 2023/3/7
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
|
||||||
|
|
||||||
{% include "accounting/report/include/period-chooser.html" %}
|
{% include "accounting/report/include/period-chooser.html" %}
|
||||||
|
|
||||||
|
@ -36,7 +36,7 @@ First written: 2023/3/4
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
|
||||||
|
|
||||||
{% include "accounting/report/include/period-chooser.html" %}
|
{% include "accounting/report/include/period-chooser.html" %}
|
||||||
|
|
||||||
@ -53,47 +53,47 @@ First written: 2023/3/4
|
|||||||
<div>{{ A_("Date") }}</div>
|
<div>{{ A_("Date") }}</div>
|
||||||
<div>{{ A_("Currency") }}</div>
|
<div>{{ A_("Currency") }}</div>
|
||||||
<div>{{ A_("Account") }}</div>
|
<div>{{ A_("Account") }}</div>
|
||||||
<div>{{ A_("Summary") }}</div>
|
<div>{{ A_("Description") }}</div>
|
||||||
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
||||||
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="accounting-report-table-body">
|
<div class="accounting-report-table-body">
|
||||||
{% for entry in report.entries %}
|
{% for line_item in report.line_items %}
|
||||||
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
<a class="accounting-report-table-row" href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.journal_entry)|accounting_append_next }}">
|
||||||
<div>{{ entry.transaction.date|accounting_format_date }}</div>
|
<div>{{ line_item.journal_entry.date|accounting_format_date }}</div>
|
||||||
<div>{{ entry.currency.name }}</div>
|
<div>{{ line_item.currency.name }}</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="d-none d-md-inline">{{ entry.account.code }}</span>
|
<span class="d-none d-md-inline">{{ line_item.account.code }}</span>
|
||||||
{{ entry.account.title|title }}
|
{{ line_item.account.title|title }}
|
||||||
</div>
|
</div>
|
||||||
<div>{{ entry.summary|accounting_default }}</div>
|
<div>{{ line_item.description|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ line_item.credit|accounting_format_amount|accounting_default }}</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="list-group d-md-none">
|
<div class="list-group d-md-none">
|
||||||
{% for entry in report.entries %}
|
{% for line_item in report.line_items %}
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.journal_entry)|accounting_append_next }}">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
|
<div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
|
||||||
<div class="text-muted small">
|
<div class="text-muted small">
|
||||||
{{ entry.transaction.date|accounting_format_date }}
|
{{ line_item.journal_entry.date|accounting_format_date }}
|
||||||
{{ entry.account.title|title }}
|
{{ line_item.account.title|title }}
|
||||||
{% if entry.currency.code != accounting_default_currency_code() %}
|
{% if line_item.currency.code != accounting_default_currency_code() %}
|
||||||
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>
|
<span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if entry.summary is not none %}
|
{% if line_item.description is not none %}
|
||||||
<div>{{ entry.summary }}</div>
|
<div>{{ line_item.description }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-info">{{ line_item.amount|accounting_format_amount }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
@ -38,7 +38,7 @@ First written: 2023/3/5
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "accounting/report/include/add-txn-material-fab.html" %}
|
{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
|
||||||
|
|
||||||
{% include "accounting/report/include/period-chooser.html" %}
|
{% include "accounting/report/include/period-chooser.html" %}
|
||||||
|
|
||||||
@ -49,38 +49,42 @@ First written: 2023/3/5
|
|||||||
{% include "accounting/include/pagination.html" %}
|
{% include "accounting/include/pagination.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<div class="d-none d-md-block accounting-report-table accounting-ledger-table">
|
<div class="d-none d-md-block accounting-report-table {% if report.account.is_real %} accounting-ledger-real-table {% else %} accounting-ledger-nominal-table {% endif %}">
|
||||||
<div class="accounting-report-table-header">
|
<div class="accounting-report-table-header">
|
||||||
<div class="accounting-report-table-row">
|
<div class="accounting-report-table-row">
|
||||||
<div>{{ A_("Date") }}</div>
|
<div>{{ A_("Date") }}</div>
|
||||||
<div>{{ A_("Summary") }}</div>
|
<div>{{ A_("Description") }}</div>
|
||||||
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
||||||
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
||||||
|
{% if report.account.is_real %}
|
||||||
<div class="accounting-amount">{{ A_("Balance") }}</div>
|
<div class="accounting-amount">{{ A_("Balance") }}</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="accounting-report-table-body">
|
<div class="accounting-report-table-body">
|
||||||
{% if report.brought_forward %}
|
{% if report.brought_forward %}
|
||||||
{% with entry = report.brought_forward %}
|
{% with line_item = report.brought_forward %}
|
||||||
<div class="accounting-report-table-row">
|
<div class="accounting-report-table-row">
|
||||||
{% include "accounting/report/include/ledger-row-desktop.html" %}
|
{% include "accounting/report/include/ledger-row-desktop.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for entry in report.entries %}
|
{% for line_item in report.line_items %}
|
||||||
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
|
<a class="accounting-report-table-row" href="{{ line_item.url|accounting_append_next }}">
|
||||||
{% include "accounting/report/include/ledger-row-desktop.html" %}
|
{% include "accounting/report/include/ledger-row-desktop.html" %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if report.total %}
|
{% if report.total %}
|
||||||
{% with entry = report.total %}
|
{% with line_item = report.total %}
|
||||||
<div class="accounting-report-table-footer">
|
<div class="accounting-report-table-footer">
|
||||||
<div class="accounting-report-table-row">
|
<div class="accounting-report-table-row">
|
||||||
<div>{{ A_("Total") }}</div>
|
<div>{{ A_("Total") }}</div>
|
||||||
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ line_item.credit|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div>
|
{% if report.account.is_real %}
|
||||||
|
<div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
@ -89,19 +93,19 @@ First written: 2023/3/5
|
|||||||
|
|
||||||
<div class="list-group d-md-none">
|
<div class="list-group d-md-none">
|
||||||
{% if report.brought_forward %}
|
{% if report.brought_forward %}
|
||||||
{% with entry = report.brought_forward %}
|
{% with line_item = report.brought_forward %}
|
||||||
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||||
{% include "accounting/report/include/ledger-row-mobile.html" %}
|
{% include "accounting/report/include/ledger-row-mobile.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for entry in report.entries %}
|
{% for line_item in report.line_items %}
|
||||||
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}">
|
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ line_item.url|accounting_append_next }}">
|
||||||
{% include "accounting/report/include/ledger-row-mobile.html" %}
|
{% include "accounting/report/include/ledger-row-mobile.html" %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if report.total %}
|
{% if report.total %}
|
||||||
{% with entry = report.total %}
|
{% with line_item = report.total %}
|
||||||
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||||
{% include "accounting/report/include/ledger-row-mobile.html" %}
|
{% include "accounting/report/include/ledger-row-mobile.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user