Compare commits
203 Commits
Author | SHA1 | Date | |
---|---|---|---|
5ae1ab95ae | |||
7a5b3b78fc | |||
7df4051452 | |||
85084c68fd | |||
0185c16654 | |||
7dd007f3cf | |||
38b8a028d5 | |||
213981a8b2 | |||
a4d1789b58 | |||
91620d7db2 | |||
02fcabb0ce | |||
4c2dcc5070 | |||
c9166fda4d | |||
3a0f0873e2 | |||
a17395b43e | |||
17c8d9d1a9 | |||
fa94cd407e | |||
9a704c8185 | |||
8286c0c6d8 | |||
f7efacad75 | |||
9263ae0274 | |||
78a9d7794c | |||
f3ae37a409 | |||
ddc1081252 | |||
202d51a032 | |||
562bc47be7 | |||
f3d43a66cc | |||
c3fc6d9a87 | |||
e1a0380628 | |||
f2a2fcdd32 | |||
ab29166f1e | |||
8033921181 | |||
08732c1e66 | |||
4adc464d3d | |||
2f9d2e36cb | |||
5bb10bf6ba | |||
06e7b6ddff | |||
20e1982984 | |||
a70720be50 | |||
cb6de08152 | |||
211821b4d7 | |||
0faca49540 | |||
14e79df571 | |||
04fbb725d2 | |||
a1d6844e52 | |||
94391b02a6 | |||
1cb8a7563e | |||
63f0f28948 | |||
3431922f12 | |||
d5a9e1af18 | |||
73f5d63f44 | |||
bf2c7bb785 | |||
93ba086548 | |||
5c4f6017b8 | |||
cb16b2f0ff | |||
d2f11e8779 | |||
4ccaf01b3c | |||
7c512b1c15 | |||
dc432da398 | |||
c8504bcbf5 | |||
c865141583 | |||
8c1ecd6eac | |||
e8e4100677 | |||
6a8773c531 | |||
30e0c7682c | |||
eb5a7bef7e | |||
8a174d8847 | |||
7459afd63a | |||
a9afc385e9 | |||
a8be739ec7 | |||
0130bc58a9 | |||
821059fa80 | |||
5b4f57d0b3 | |||
4bfac2d545 | |||
f105f0cf7b | |||
5e320729d7 | |||
7515032082 | |||
361b18e411 | |||
7d084e570e | |||
cb397910f8 | |||
5f8b0dec98 | |||
8398d1e8bb | |||
562801692a | |||
faee1e61c6 | |||
57a4177037 | |||
fa1dedf207 | |||
7ed13dc0af | |||
52807c5322 | |||
231a71feea | |||
4902eecae0 | |||
889e4c058e | |||
7262a6cb42 | |||
c4ff4ecb3d | |||
2859f628ea | |||
e0355b2af1 | |||
b4d390c33a | |||
a4ab8a761c | |||
907ce6d06e | |||
7e1388735e | |||
6f773dd837 | |||
87fa5aa6bc | |||
35e05b3708 | |||
7ccc96bda0 | |||
283758ebe9 | |||
b673c7aeaf | |||
0ad2ac53dd | |||
7e90ec5a8f | |||
7755365467 | |||
979eea606a | |||
5a9e08f2c4 | |||
68c810d492 | |||
5f88260507 | |||
779d89f8c4 | |||
5d4bf4361b | |||
10170d613d | |||
c885c08c37 | |||
e2a4340f2a | |||
9728ff30e0 | |||
a4644ede5f | |||
8f477dd6f1 | |||
44ac53f15c | |||
5edb5465c5 | |||
067afdb165 | |||
37a4c26f86 | |||
89e43830b4 | |||
671dbfb692 | |||
2014344d25 | |||
f9c39709c8 | |||
b394c58ec6 | |||
0af3e2785b | |||
7066f75e72 | |||
619540da49 | |||
567004f7d9 | |||
761d5a5824 | |||
fa3cdace7f | |||
656762850c | |||
e2325f08d0 | |||
855356084e | |||
7aaeb32a3d | |||
b376cf1580 | |||
ccbdc779ac | |||
61ee08fda2 | |||
d8afadda02 | |||
c8e1270d8f | |||
2a78799404 | |||
863d7a9368 | |||
6fd37b21d9 | |||
bbf3ee3320 | |||
b60cc7902d | |||
623313b58a | |||
d0d2d77a2e | |||
494faeffea | |||
871a5fd1d8 | |||
e615ad2690 | |||
da92a0b42c | |||
678d0aa773 | |||
9248ba7e3b | |||
446087b212 | |||
a42e7d13a2 | |||
a82f5091f1 | |||
3455827c09 | |||
5dccf99a55 | |||
8818b46e01 | |||
2f3ad99467 | |||
592910187b | |||
cb7a0d377f | |||
79175285f8 | |||
fef474977c | |||
fa1a55cd3d | |||
2253ec7e6d | |||
32aa532548 | |||
56138f7de3 | |||
21ef944259 | |||
760f1c2877 | |||
e377eac407 | |||
77787eee9f | |||
03265a1232 | |||
079dc1ab6d | |||
d4fe91ec4a | |||
acc5b4d6ea | |||
19a93cb4c3 | |||
116089d1d2 | |||
50dd6078c7 | |||
9a4531b26c | |||
b1af1d7425 | |||
8f909965a9 | |||
e26af6f3fc | |||
02fffc3400 | |||
d7d6929bf2 | |||
e4cc61552e | |||
d18dd7d4d2 | |||
3251660092 | |||
c1235608d8 | |||
25c45b16ae | |||
78f570b81b | |||
5db13393cc | |||
1e286fbeba | |||
d4b3fe67b9 | |||
5d0757c845 | |||
b69a519904 | |||
122b7b059c | |||
4977847dd8 | |||
b9b197ea27 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
|
||||
|
||||
# Copyright (c) 2022 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
||||
|
||||
# Copyright (c) 2022-2023 imacat.
|
||||
@ -25,6 +25,7 @@ include docs/source/_templates/*
|
||||
include tests/*
|
||||
exclude tests/test_temp.py
|
||||
include tests/test_site/*
|
||||
include tests/test_site/static/*
|
||||
include tests/test_site/templates/*
|
||||
include tests/test_site/translations/*
|
||||
include tests/test_site/translations/*/LC_MESSAGES/*
|
||||
|
26
README.rst
26
README.rst
@ -1,24 +1,33 @@
|
||||
=====================
|
||||
Mia! Accounting Flask
|
||||
=====================
|
||||
===============
|
||||
Mia! Accounting
|
||||
===============
|
||||
|
||||
|
||||
Description
|
||||
===========
|
||||
|
||||
This is the Mia! Accounting Flask project. It is an accounting
|
||||
This is the Mia! Accounting project. It is an accounting
|
||||
module for the Flask_ applications.
|
||||
|
||||
|
||||
Install
|
||||
=======
|
||||
|
||||
Install the latest source from the
|
||||
`Mia! Accounting Flask repository`_.
|
||||
Install ``mia-accounting`` with ``pip``.
|
||||
|
||||
::
|
||||
|
||||
pip install git+https://gitea.imacat.idv.tw/imacat/mia-accounting-flask.git
|
||||
pip install mia-accounting
|
||||
|
||||
|
||||
Usage
|
||||
=====
|
||||
|
||||
This needs to be done. Currently, you can refer to the test site
|
||||
located in the test directory on the `Mia! Accounting repository`_.
|
||||
|
||||
The test site is running as the
|
||||
`live demonstration for Mia! Accounting`_.
|
||||
|
||||
|
||||
Copyright
|
||||
@ -47,4 +56,5 @@ Authors
|
||||
| 2023/1/27
|
||||
|
||||
.. _Flask: https://flask.palletsprojects.com
|
||||
.. _Mia! Accounting Flask repository: https://gitea.imacat.idv.tw/imacat/mia-accounting-flask
|
||||
.. _Mia! Accounting repository: https://github.com/imacat/mia-accounting
|
||||
.. _live demonstration for Mia! Accounting: https://accounting.imacat.idv.tw
|
||||
|
45
docs/source/accounting.journal_entry.forms.rst
Normal file
45
docs/source/accounting.journal_entry.forms.rst
Normal file
@ -0,0 +1,45 @@
|
||||
accounting.journal\_entry.forms package
|
||||
=======================================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.journal\_entry.forms.currency module
|
||||
-----------------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.forms.currency
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.journal\_entry.forms.journal\_entry module
|
||||
-----------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.forms.journal_entry
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.journal\_entry.forms.line\_item module
|
||||
-------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.forms.line_item
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.journal\_entry.forms.reorder module
|
||||
----------------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.forms.reorder
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.forms
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
46
docs/source/accounting.journal_entry.rst
Normal file
46
docs/source/accounting.journal_entry.rst
Normal file
@ -0,0 +1,46 @@
|
||||
accounting.journal\_entry package
|
||||
=================================
|
||||
|
||||
Subpackages
|
||||
-----------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 4
|
||||
|
||||
accounting.journal_entry.forms
|
||||
accounting.journal_entry.utils
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.journal\_entry.converters module
|
||||
-------------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.converters
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.journal\_entry.template\_filters module
|
||||
--------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.template_filters
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.journal\_entry.views module
|
||||
--------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.views
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.journal_entry
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
53
docs/source/accounting.journal_entry.utils.rst
Normal file
53
docs/source/accounting.journal_entry.utils.rst
Normal file
@ -0,0 +1,53 @@
|
||||
accounting.journal\_entry.utils package
|
||||
=======================================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.journal\_entry.utils.account\_option module
|
||||
------------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.utils.account_option
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.journal\_entry.utils.description\_editor module
|
||||
----------------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.utils.description_editor
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.journal\_entry.utils.offset\_alias module
|
||||
----------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.utils.offset_alias
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.journal\_entry.utils.operators module
|
||||
------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.utils.operators
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.journal\_entry.utils.original\_line\_items module
|
||||
------------------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.utils.original_line_items
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.utils
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
29
docs/source/accounting.option.rst
Normal file
29
docs/source/accounting.option.rst
Normal file
@ -0,0 +1,29 @@
|
||||
accounting.option package
|
||||
=========================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.option.forms module
|
||||
------------------------------
|
||||
|
||||
.. automodule:: accounting.option.forms
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.option.views module
|
||||
------------------------------
|
||||
|
||||
.. automodule:: accounting.option.views
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.option
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
@ -28,14 +28,6 @@ accounting.report.utils.csv\_export module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.ie\_account module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.utils.ie_account
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.option\_link module
|
||||
-------------------------------------------
|
||||
|
||||
|
@ -10,13 +10,22 @@ Subpackages
|
||||
accounting.account
|
||||
accounting.base_account
|
||||
accounting.currency
|
||||
accounting.journal_entry
|
||||
accounting.option
|
||||
accounting.report
|
||||
accounting.transaction
|
||||
accounting.utils
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.forms module
|
||||
-----------------------
|
||||
|
||||
.. automodule:: accounting.forms
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.locale module
|
||||
------------------------
|
||||
|
||||
|
@ -1,45 +0,0 @@
|
||||
accounting.transaction.forms package
|
||||
====================================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.transaction.forms.currency module
|
||||
--------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.forms.currency
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.forms.journal\_entry module
|
||||
--------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.forms.journal_entry
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.forms.reorder module
|
||||
-------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.forms.reorder
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.forms.transaction module
|
||||
-----------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.forms.transaction
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.transaction.forms
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
@ -1,46 +0,0 @@
|
||||
accounting.transaction package
|
||||
==============================
|
||||
|
||||
Subpackages
|
||||
-----------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 4
|
||||
|
||||
accounting.transaction.forms
|
||||
accounting.transaction.utils
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.transaction.converters module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.converters
|
||||
: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:
|
@ -1,53 +0,0 @@
|
||||
accounting.transaction.utils package
|
||||
====================================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.transaction.utils.account\_option module
|
||||
---------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.utils.account_option
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.utils.offset\_alias module
|
||||
-------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.utils.offset_alias
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.utils.operators module
|
||||
---------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.utils.operators
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.utils.original\_entries module
|
||||
-----------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.utils.original_entries
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.utils.summary\_editor module
|
||||
---------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.utils.summary_editor
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.transaction.utils
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
@ -12,6 +12,14 @@ accounting.utils.cast module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.current\_account module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.current_account
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.flash\_errors module
|
||||
-------------------------------------
|
||||
|
||||
@ -20,6 +28,14 @@ accounting.utils.flash\_errors module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.journal\_entry\_types module
|
||||
---------------------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.journal_entry_types
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.next\_uri module
|
||||
---------------------------------
|
||||
|
||||
@ -28,6 +44,14 @@ accounting.utils.next\_uri module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.options module
|
||||
-------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.options
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.pagination module
|
||||
----------------------------------
|
||||
|
||||
@ -68,14 +92,6 @@ accounting.utils.strip\_text module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.txn\_types module
|
||||
----------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.txn_types
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.user module
|
||||
----------------------------
|
||||
|
||||
|
@ -10,10 +10,10 @@ sys.path.insert(0, os.path.abspath('../../src/'))
|
||||
# -- Project information -----------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||
|
||||
project = 'Mia! Accounting Flask'
|
||||
project = 'Mia! Accounting'
|
||||
copyright = '2023, imacat'
|
||||
author = 'imacat'
|
||||
release = '0.4.0'
|
||||
release = '0.11.1'
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
@ -1,10 +1,10 @@
|
||||
.. Mia! Accounting Flask documentation master file, created by
|
||||
.. Mia! Accounting documentation master file, created by
|
||||
sphinx-quickstart on Fri Jan 27 12:20:04 2023.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Welcome to Mia! Accounting Flask's documentation!
|
||||
=================================================
|
||||
Welcome to Mia! Accounting's documentation!
|
||||
===========================================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
||||
|
||||
# Copyright (c) 2022 imacat.
|
||||
|
12
setup.cfg
12
setup.cfg
@ -1,4 +1,4 @@
|
||||
# The Mia! Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
||||
|
||||
# Copyright (c) 2022-2023 imacat.
|
||||
@ -16,16 +16,16 @@
|
||||
# limitations under the License.
|
||||
|
||||
[metadata]
|
||||
name = mia-accounting-flask
|
||||
version = 0.6.0
|
||||
name = mia-accounting
|
||||
version = 0.11.1
|
||||
author = imacat
|
||||
author_email = imacat@mail.imacat.idv.tw
|
||||
description = The Mia! Accounting Flask project.
|
||||
description = The Mia! Accounting project.
|
||||
long_description = file: README.rst
|
||||
long_description_content_type = text/x-rst
|
||||
url = https://github.com/imacat/mia-accounting-flask
|
||||
url = https://github.com/imacat/mia-accounting
|
||||
project_urls =
|
||||
Bug Tracker = https://github.com/imacat/mia-accounting-flask/issues
|
||||
Bug Tracker = https://github.com/imacat/mia-accounting/issues
|
||||
classifiers =
|
||||
Programming Language :: Python :: 3
|
||||
License :: OSI Approved :: Apache Software License
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -47,7 +47,6 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
|
||||
init_user_utils(user_utils)
|
||||
|
||||
bp: Blueprint = Blueprint("accounting", __name__,
|
||||
url_prefix=url_prefix,
|
||||
template_folder="templates",
|
||||
static_folder="static")
|
||||
|
||||
@ -80,10 +79,13 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
|
||||
from . import currency
|
||||
currency.init_app(app, bp)
|
||||
|
||||
from . import transaction
|
||||
transaction.init_app(app, bp)
|
||||
from . import journal_entry
|
||||
journal_entry.init_app(app, bp)
|
||||
|
||||
from . import report
|
||||
report.init_app(app, bp)
|
||||
report.init_app(app, url_prefix)
|
||||
|
||||
app.register_blueprint(bp)
|
||||
from . import option
|
||||
option.init_app(bp)
|
||||
|
||||
app.register_blueprint(bp, url_prefix=url_prefix)
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -100,10 +100,11 @@ def init_accounts_command(username: str) -> None:
|
||||
|
||||
|
||||
def __is_need_offset(base_code: str) -> bool:
|
||||
"""Checks that whether entries in the account need offset.
|
||||
"""Checks that whether journal entry line items in the account need offset.
|
||||
|
||||
:param base_code: The code of the base account.
|
||||
:return: True if entries in the account need offset, or False otherwise.
|
||||
:return: True if journal entry line items in the account need offset, or
|
||||
False otherwise.
|
||||
"""
|
||||
# Assets
|
||||
if base_code[0] == "1":
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -82,7 +82,7 @@ class AccountForm(FlaskForm):
|
||||
"""The title."""
|
||||
is_need_offset = BooleanField(
|
||||
validators=[NoOffsetNominalAccount()])
|
||||
"""Whether the the entries of this account need offset."""
|
||||
"""Whether the the journal entry line items of this account need offset."""
|
||||
|
||||
def populate_obj(self, obj: Account) -> None:
|
||||
"""Populates the form data into an account object.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -40,14 +40,14 @@ def get_account_query() -> list[Account]:
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
for k in keywords:
|
||||
l10n: list[AccountL10n] = AccountL10n.query\
|
||||
.filter(AccountL10n.title.contains(k)).all()
|
||||
.filter(AccountL10n.title.icontains(k)).all()
|
||||
l10n_matches: set[str] = {x.account_id for x in l10n}
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
= [Account.base_code.contains(k),
|
||||
Account.title_l10n.contains(k),
|
||||
Account.title_l10n.icontains(k),
|
||||
code.contains(k),
|
||||
Account.id.in_(l10n_matches)]
|
||||
if k in gettext("Need offset"):
|
||||
if k in gettext("Needs Offset"):
|
||||
sub_conditions.append(Account.is_need_offset)
|
||||
conditions.append(sa.or_(*sub_conditions))
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -157,6 +157,9 @@ def delete_account(account: Account) -> redirect:
|
||||
:return: The redirection to the account list on success, or the account
|
||||
detail on error.
|
||||
"""
|
||||
if not account.can_delete:
|
||||
flash(s(lazy_gettext("The account cannot be deleted.")), "error")
|
||||
return redirect(inherit_next(__get_detail_uri(account)))
|
||||
account.delete()
|
||||
sort_accounts_in(account.base_code, account.id)
|
||||
db.session.commit()
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -35,10 +35,10 @@ def get_base_account_query() -> list[BaseAccount]:
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
for k in keywords:
|
||||
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
|
||||
.filter(BaseAccountL10n.title.contains(k)).all()
|
||||
.filter(BaseAccountL10n.title.icontains(k)).all()
|
||||
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||
conditions.append(sa.or_(BaseAccount.code.contains(k),
|
||||
BaseAccount.title_l10n.contains(k),
|
||||
BaseAccount.title_l10n.icontains(k),
|
||||
BaseAccount.code.in_(l10n_matches)))
|
||||
return BaseAccount.query.filter(*conditions)\
|
||||
.order_by(BaseAccount.code).all()
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -35,10 +35,10 @@ def get_currency_query() -> list[Currency]:
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
for k in keywords:
|
||||
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
||||
.filter(CurrencyL10n.name.contains(k)).all()
|
||||
.filter(CurrencyL10n.name.icontains(k)).all()
|
||||
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||
conditions.append(sa.or_(Currency.code.contains(k),
|
||||
Currency.name_l10n.contains(k),
|
||||
conditions.append(sa.or_(Currency.code.icontains(k),
|
||||
Currency.name_l10n.icontains(k),
|
||||
Currency.code.in_(l10n_matches)))
|
||||
return Currency.query.filter(*conditions)\
|
||||
.order_by(Currency.code).all()
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -89,7 +89,7 @@ def add_currency() -> redirect:
|
||||
form.populate_obj(currency)
|
||||
db.session.add(currency)
|
||||
db.session.commit()
|
||||
flash(s(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)))
|
||||
|
||||
|
||||
@ -160,6 +160,9 @@ def delete_currency(currency: Currency) -> redirect:
|
||||
:return: The redirection to the currency list on success, or the currency
|
||||
detail on error.
|
||||
"""
|
||||
if not currency.can_delete:
|
||||
flash(s(lazy_gettext("The currency cannot be deleted.")), "error")
|
||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||
currency.delete()
|
||||
db.session.commit()
|
||||
flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
|
||||
|
96
src/accounting/forms.py
Normal file
96
src/accounting/forms.py
Normal file
@ -0,0 +1,96 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The forms.
|
||||
|
||||
"""
|
||||
import re
|
||||
|
||||
from flask_babel import LazyString
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, ValidationError
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency, Account
|
||||
|
||||
|
||||
ACCOUNT_REQUIRED: DataRequired = DataRequired(
|
||||
lazy_gettext("Please select the account."))
|
||||
"""The validator to check if the account code is empty."""
|
||||
|
||||
|
||||
class CurrencyExists:
|
||||
"""The validator to check if the account exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if db.session.get(Currency, field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The currency does not exist."))
|
||||
|
||||
|
||||
class AccountExists:
|
||||
"""The validator to check if the account exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if Account.find_by_code(field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The account does not exist."))
|
||||
|
||||
|
||||
class IsDebitAccount:
|
||||
"""The validator to check if the account is for debit line items."""
|
||||
|
||||
def __init__(self, message: str | LazyString):
|
||||
"""Constructs the validator.
|
||||
|
||||
:param message: The error message.
|
||||
"""
|
||||
self.__message: str | LazyString = message
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
|
||||
and not field.data.startswith("3351-") \
|
||||
and not field.data.startswith("3353-"):
|
||||
return
|
||||
raise ValidationError(self.__message)
|
||||
|
||||
|
||||
class IsCreditAccount:
|
||||
"""The validator to check if the account is for credit line items."""
|
||||
|
||||
def __init__(self, message: str | LazyString):
|
||||
"""Constructs the validator.
|
||||
|
||||
:param message: The error message.
|
||||
"""
|
||||
self.__message: str | LazyString = message
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if re.match(r"^(?:[123489]|7[1234])", field.data) \
|
||||
and not field.data.startswith("3351-") \
|
||||
and not field.data.startswith("3353-"):
|
||||
return
|
||||
raise ValidationError(self.__message)
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -14,7 +14,7 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The transaction management.
|
||||
"""The journal entry management.
|
||||
|
||||
"""
|
||||
from flask import Flask, Blueprint
|
||||
@ -27,11 +27,11 @@ def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
from .converters import TransactionConverter, TransactionTypeConverter, \
|
||||
from .converters import JournalEntryConverter, JournalEntryTypeConverter, \
|
||||
DateConverter
|
||||
app.url_map.converters["transaction"] = TransactionConverter
|
||||
app.url_map.converters["transactionType"] = TransactionTypeConverter
|
||||
app.url_map.converters["journalEntry"] = JournalEntryConverter
|
||||
app.url_map.converters["journalEntryType"] = JournalEntryTypeConverter
|
||||
app.url_map.converters["date"] = DateConverter
|
||||
|
||||
from .views import bp as transaction_bp
|
||||
bp.register_blueprint(transaction_bp, url_prefix="/transactions")
|
||||
from .views import bp as journal_entry_bp
|
||||
bp.register_blueprint(journal_entry_bp, url_prefix="/journal-entries")
|
107
src/accounting/journal_entry/converters.py
Normal file
107
src/accounting/journal_entry/converters.py
Normal file
@ -0,0 +1,107 @@
|
||||
# The Mia! Accounting 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()
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -14,9 +14,9 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The forms for the transaction management.
|
||||
"""The forms for the journal entry management.
|
||||
|
||||
"""
|
||||
from .reorder import sort_transactions_in, TransactionReorderForm
|
||||
from .transaction import TransactionForm, IncomeTransactionForm, \
|
||||
ExpenseTransactionForm, TransferTransactionForm
|
||||
from .reorder import sort_journal_entries_in, JournalEntryReorderForm
|
||||
from .journal_entry import JournalEntryForm, CashReceiptJournalEntryForm, \
|
||||
CashDisbursementJournalEntryForm, TransferJournalEntryForm
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -14,7 +14,7 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The currency sub-forms for the transaction management.
|
||||
"""The currency sub-forms for the journal entry management.
|
||||
|
||||
"""
|
||||
from decimal import Decimal
|
||||
@ -27,49 +27,42 @@ from wtforms import StringField, ValidationError, FieldList, IntegerField, \
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
from accounting import db
|
||||
from accounting.forms import CurrencyExists
|
||||
from accounting.journal_entry.utils.offset_alias import offset_alias
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency, JournalEntry
|
||||
from accounting.transaction.utils.offset_alias import offset_alias
|
||||
from accounting.models import JournalEntryLineItem
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from .journal_entry import JournalEntryForm, CreditEntryForm, DebitEntryForm
|
||||
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 SameCurrencyAsOriginalEntries:
|
||||
"""The validator to check if the currency is the same as the original
|
||||
entries."""
|
||||
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_entry_id: set[int] = {x.original_entry_id.data
|
||||
for x in form.entries
|
||||
if x.original_entry_id.data is not None}
|
||||
if len(original_entry_id) == 0:
|
||||
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_entry_currency_codes: set[str] = set(db.session.scalars(
|
||||
sa.select(JournalEntry.currency_code)
|
||||
.filter(JournalEntry.id.in_(original_entry_id))).all())
|
||||
for currency_code in original_entry_currency_codes:
|
||||
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 entry."))
|
||||
"The currency must be the same as the"
|
||||
" original line item."))
|
||||
|
||||
|
||||
class KeepCurrencyWhenHavingOffset:
|
||||
@ -81,31 +74,35 @@ class KeepCurrencyWhenHavingOffset:
|
||||
if field.data is None:
|
||||
return
|
||||
offset: sa.Alias = offset_alias()
|
||||
original_entries: list[JournalEntry] = JournalEntry.query\
|
||||
.join(offset, be(JournalEntry.id == offset.c.original_entry_id),
|
||||
original_line_items: list[JournalEntryLineItem]\
|
||||
= JournalEntryLineItem.query\
|
||||
.join(offset, be(JournalEntryLineItem.id
|
||||
== offset.c.original_line_item_id),
|
||||
isouter=True)\
|
||||
.filter(JournalEntry.id.in_({x.eid.data for x in form.entries
|
||||
if x.eid.data is not None}))\
|
||||
.group_by(JournalEntry.id, JournalEntry.currency_code)\
|
||||
.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_entry in original_entries:
|
||||
if original_entry.currency_code != field.data:
|
||||
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 NeedSomeJournalEntries:
|
||||
"""The validator to check if there is any journal entry sub-form."""
|
||||
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 journal entries."))
|
||||
"Please add some line items."))
|
||||
|
||||
|
||||
class IsBalanced:
|
||||
"""The validator to check that the total amount of the debit and credit
|
||||
entries are equal."""
|
||||
line items are equal."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
|
||||
assert isinstance(form, TransferCurrencyForm)
|
||||
@ -117,29 +114,29 @@ class IsBalanced:
|
||||
|
||||
|
||||
class CurrencyForm(FlaskForm):
|
||||
"""The form to create or edit a currency in a transaction."""
|
||||
"""The form to create or edit a currency in a journal entry."""
|
||||
no = IntegerField()
|
||||
"""The order in the transaction."""
|
||||
"""The order in the journal entry."""
|
||||
code = StringField()
|
||||
"""The currency code."""
|
||||
whole_form = BooleanField()
|
||||
"""The pseudo field for the whole form validators."""
|
||||
|
||||
@property
|
||||
def entries(self) -> list[JournalEntryForm]:
|
||||
"""Returns the journal entry sub-forms.
|
||||
def line_items(self) -> list[LineItemForm]:
|
||||
"""Returns the line item sub-forms.
|
||||
|
||||
:return: The journal entry sub-forms.
|
||||
:return: The line item sub-forms.
|
||||
"""
|
||||
entry_forms: list[JournalEntryForm] = []
|
||||
if isinstance(self, IncomeCurrencyForm):
|
||||
entry_forms.extend([x.form for x in self.credit])
|
||||
elif isinstance(self, ExpenseCurrencyForm):
|
||||
entry_forms.extend([x.form for x in self.debit])
|
||||
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):
|
||||
entry_forms.extend([x.form for x in self.debit])
|
||||
entry_forms.extend([x.form for x in self.credit])
|
||||
return entry_forms
|
||||
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:
|
||||
@ -148,48 +145,50 @@ class CurrencyForm(FlaskForm):
|
||||
:return: True if the currency code should not be changed, or False
|
||||
otherwise
|
||||
"""
|
||||
entry_forms: list[JournalEntryForm] = self.entries
|
||||
original_entry_id: set[int] \
|
||||
= {x.original_entry_id.data for x in entry_forms
|
||||
if x.original_entry_id.data is not None}
|
||||
if len(original_entry_id) > 0:
|
||||
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
|
||||
entry_id: set[int] = {x.eid.data for x in entry_forms
|
||||
if x.eid.data is not None}
|
||||
select: sa.Select = sa.select(sa.func.count(JournalEntry.id))\
|
||||
.filter(JournalEntry.original_entry_id.in_(entry_id))
|
||||
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 IncomeCurrencyForm(CurrencyForm):
|
||||
"""The form to create or edit a currency in a cash income transaction."""
|
||||
class CashReceiptCurrencyForm(CurrencyForm):
|
||||
"""The form to create or edit a currency in a
|
||||
cash receipt journal entry."""
|
||||
no = IntegerField()
|
||||
"""The order in the transaction."""
|
||||
"""The order in the journal entry."""
|
||||
code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[CURRENCY_REQUIRED,
|
||||
CurrencyExists(),
|
||||
SameCurrencyAsOriginalEntries(),
|
||||
SameCurrencyAsOriginalLineItems(),
|
||||
KeepCurrencyWhenHavingOffset()])
|
||||
"""The currency code."""
|
||||
credit = FieldList(FormField(CreditEntryForm),
|
||||
validators=[NeedSomeJournalEntries()])
|
||||
"""The credit entries."""
|
||||
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 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.data for x in self.credit
|
||||
if x.amount.data is not None])
|
||||
|
||||
@property
|
||||
def credit_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the credit journal entry errors, without the errors in their
|
||||
"""Returns the credit line item errors, without the errors in their
|
||||
sub-forms.
|
||||
|
||||
:return:
|
||||
@ -198,35 +197,36 @@ class IncomeCurrencyForm(CurrencyForm):
|
||||
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||
|
||||
|
||||
class ExpenseCurrencyForm(CurrencyForm):
|
||||
"""The form to create or edit a currency in a cash expense transaction."""
|
||||
class CashDisbursementCurrencyForm(CurrencyForm):
|
||||
"""The form to create or edit a currency in a
|
||||
cash disbursement journal entry."""
|
||||
no = IntegerField()
|
||||
"""The order in the transaction."""
|
||||
"""The order in the journal entry."""
|
||||
code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[CURRENCY_REQUIRED,
|
||||
CurrencyExists(),
|
||||
SameCurrencyAsOriginalEntries(),
|
||||
SameCurrencyAsOriginalLineItems(),
|
||||
KeepCurrencyWhenHavingOffset()])
|
||||
"""The currency code."""
|
||||
debit = FieldList(FormField(DebitEntryForm),
|
||||
validators=[NeedSomeJournalEntries()])
|
||||
"""The debit entries."""
|
||||
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 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.data for x in self.debit
|
||||
if x.amount.data is not None])
|
||||
|
||||
@property
|
||||
def debit_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the debit journal entry errors, without the errors in their
|
||||
"""Returns the debit line item errors, without the errors in their
|
||||
sub-forms.
|
||||
|
||||
:return:
|
||||
@ -236,46 +236,46 @@ class ExpenseCurrencyForm(CurrencyForm):
|
||||
|
||||
|
||||
class TransferCurrencyForm(CurrencyForm):
|
||||
"""The form to create or edit a currency in a transfer transaction."""
|
||||
"""The form to create or edit a currency in a transfer journal entry."""
|
||||
no = IntegerField()
|
||||
"""The order in the transaction."""
|
||||
"""The order in the journal entry."""
|
||||
code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[CURRENCY_REQUIRED,
|
||||
CurrencyExists(),
|
||||
SameCurrencyAsOriginalEntries(),
|
||||
SameCurrencyAsOriginalLineItems(),
|
||||
KeepCurrencyWhenHavingOffset()])
|
||||
"""The currency code."""
|
||||
debit = FieldList(FormField(DebitEntryForm),
|
||||
validators=[NeedSomeJournalEntries()])
|
||||
"""The debit entries."""
|
||||
credit = FieldList(FormField(CreditEntryForm),
|
||||
validators=[NeedSomeJournalEntries()])
|
||||
"""The credit entries."""
|
||||
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 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.data for x in self.debit
|
||||
if x.amount.data is not None])
|
||||
|
||||
@property
|
||||
def credit_total(self) -> Decimal:
|
||||
"""Returns the total amount of the credit journal entries.
|
||||
"""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.data for x in self.credit
|
||||
if x.amount.data is not None])
|
||||
|
||||
@property
|
||||
def debit_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the debit journal entry errors, without the errors in their
|
||||
"""Returns the debit line item errors, without the errors in their
|
||||
sub-forms.
|
||||
|
||||
:return:
|
||||
@ -285,7 +285,7 @@ class TransferCurrencyForm(CurrencyForm):
|
||||
|
||||
@property
|
||||
def credit_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the credit journal entry errors, without the errors in their
|
||||
"""Returns the credit line item errors, without the errors in their
|
||||
sub-forms.
|
||||
|
||||
:return:
|
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 Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The journal entry forms for the journal entry management.
|
||||
|
||||
"""
|
||||
import datetime as dt
|
||||
import typing as t
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask_babel import LazyString
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import DateField, FieldList, FormField, TextAreaField, \
|
||||
BooleanField
|
||||
from wtforms.validators import DataRequired, ValidationError
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
|
||||
JournalEntryCurrency
|
||||
from accounting.journal_entry.utils.account_option import AccountOption
|
||||
from accounting.journal_entry.utils.original_line_items import \
|
||||
get_selectable_original_line_items
|
||||
from accounting.journal_entry.utils.description_editor import DescriptionEditor
|
||||
from accounting.utils.random_id import new_id
|
||||
from accounting.utils.strip_text import strip_multiline_text
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .currency import CurrencyForm, CashReceiptCurrencyForm, \
|
||||
CashDisbursementCurrencyForm, TransferCurrencyForm
|
||||
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
|
||||
from .reorder import sort_journal_entries_in
|
||||
|
||||
DATE_REQUIRED: DataRequired = DataRequired(
|
||||
lazy_gettext("Please fill in the date."))
|
||||
"""The validator to check if the date is empty."""
|
||||
|
||||
|
||||
class NotBeforeOriginalLineItems:
|
||||
"""The validator to check if the date is not before the
|
||||
original line items."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: DateField) -> None:
|
||||
assert isinstance(form, JournalEntryForm)
|
||||
if field.data is None:
|
||||
return
|
||||
min_date: dt.date | None = form.min_date
|
||||
if min_date is None:
|
||||
return
|
||||
if field.data < min_date:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The date cannot be earlier than the original line items."))
|
||||
|
||||
|
||||
class NotAfterOffsetItems:
|
||||
"""The validator to check if the date is not after the offset items."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: DateField) -> None:
|
||||
assert isinstance(form, JournalEntryForm)
|
||||
if field.data is None:
|
||||
return
|
||||
max_date: dt.date | None = form.max_date
|
||||
if max_date is None:
|
||||
return
|
||||
if field.data > max_date:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The date cannot be later than the offset items."))
|
||||
|
||||
|
||||
class NeedSomeCurrencies:
|
||||
"""The validator to check if there is any currency sub-form."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: FieldList) -> None:
|
||||
if len(field) == 0:
|
||||
raise ValidationError(lazy_gettext("Please add some currencies."))
|
||||
|
||||
|
||||
class CannotDeleteOriginalLineItemsWithOffset:
|
||||
"""The validator to check the original line items with offset."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: FieldList) -> None:
|
||||
assert isinstance(form, JournalEntryForm)
|
||||
if form.obj is None:
|
||||
return
|
||||
existing_matched_original_line_item_id: set[int] \
|
||||
= {x.id for x in form.obj.line_items if len(x.offsets) > 0}
|
||||
line_item_id_in_form: set[int] \
|
||||
= {x.id.data for x in form.line_items if x.id.data is not None}
|
||||
for line_item_id in existing_matched_original_line_item_id:
|
||||
if line_item_id not in line_item_id_in_form:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"Line items with offset cannot be deleted."))
|
||||
|
||||
|
||||
class JournalEntryForm(FlaskForm):
|
||||
"""The base form to create or edit a journal entry."""
|
||||
date = DateField()
|
||||
"""The date."""
|
||||
currencies = FieldList(FormField(CurrencyForm))
|
||||
"""The line items categorized by their currencies."""
|
||||
note = TextAreaField()
|
||||
"""The note."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Constructs a base journal entry form.
|
||||
|
||||
:param args: The arguments.
|
||||
:param kwargs: The keyword arguments.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.is_modified: bool = False
|
||||
"""Whether the journal entry is modified during populate_obj()."""
|
||||
self.collector: t.Type[LineItemCollector] = LineItemCollector
|
||||
"""The line item collector. The default is the base abstract
|
||||
collector only to provide the correct type. The subclass forms should
|
||||
provide their own collectors."""
|
||||
self.obj: JournalEntry | None = kwargs.get("obj")
|
||||
"""The journal entry, when editing an existing one."""
|
||||
self._is_need_payable: bool = False
|
||||
"""Whether we need the payable original line items."""
|
||||
self._is_need_receivable: bool = False
|
||||
"""Whether we need the receivable original line items."""
|
||||
self.__original_line_item_options: list[JournalEntryLineItem] | None \
|
||||
= None
|
||||
"""The options of the original line items."""
|
||||
self.__net_balance_exceeded: dict[int, LazyString] | None = None
|
||||
"""The original line items whose net balances were exceeded by the
|
||||
amounts in the line item sub-forms."""
|
||||
for line_item in self.line_items:
|
||||
line_item.journal_entry_form = self
|
||||
|
||||
def populate_obj(self, obj: JournalEntry) -> None:
|
||||
"""Populates the form data into a journal entry object.
|
||||
|
||||
:param obj: The journal entry object.
|
||||
:return: None.
|
||||
"""
|
||||
is_new: bool = obj.id is None
|
||||
if is_new:
|
||||
obj.id = new_id(JournalEntry)
|
||||
self.date: DateField
|
||||
self.__set_date(obj, self.date.data)
|
||||
obj.note = self.note.data
|
||||
|
||||
collector_cls: t.Type[LineItemCollector] = self.collector
|
||||
collector: collector_cls = collector_cls(self, obj)
|
||||
collector.collect()
|
||||
|
||||
to_delete: set[int] = {x.id for x in obj.line_items
|
||||
if x.id not in collector.to_keep}
|
||||
if len(to_delete) > 0:
|
||||
JournalEntryLineItem.query\
|
||||
.filter(JournalEntryLineItem.id.in_(to_delete)).delete()
|
||||
self.is_modified = True
|
||||
|
||||
if is_new or db.session.is_modified(obj):
|
||||
self.is_modified = True
|
||||
|
||||
if is_new:
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.created_by_id = current_user_pk
|
||||
obj.updated_by_id = current_user_pk
|
||||
|
||||
@property
|
||||
def line_items(self) -> list[LineItemForm]:
|
||||
"""Collects and returns the line item sub-forms.
|
||||
|
||||
:return: The line item sub-forms.
|
||||
"""
|
||||
line_items: list[LineItemForm] = []
|
||||
for currency in self.currencies:
|
||||
line_items.extend(currency.line_items)
|
||||
return line_items
|
||||
|
||||
def __set_date(self, obj: JournalEntry, new_date: dt.date) -> None:
|
||||
"""Sets the journal entry date and number.
|
||||
|
||||
:param obj: The journal entry object.
|
||||
:param new_date: The new date.
|
||||
:return: None.
|
||||
"""
|
||||
if obj.date is None or obj.date != new_date:
|
||||
if obj.date is not None:
|
||||
sort_journal_entries_in(obj.date, obj.id)
|
||||
if self.max_date is not None and new_date == self.max_date:
|
||||
db_min_no: int | None = db.session.scalar(
|
||||
sa.select(sa.func.min(JournalEntry.no))
|
||||
.filter(JournalEntry.date == new_date))
|
||||
if db_min_no is None:
|
||||
obj.date = new_date
|
||||
obj.no = 1
|
||||
else:
|
||||
obj.date = new_date
|
||||
obj.no = db_min_no - 1
|
||||
sort_journal_entries_in(new_date)
|
||||
else:
|
||||
sort_journal_entries_in(new_date, obj.id)
|
||||
count: int = JournalEntry.query\
|
||||
.filter(JournalEntry.date == new_date).count()
|
||||
obj.date = new_date
|
||||
obj.no = count + 1
|
||||
|
||||
@property
|
||||
def debit_account_options(self) -> list[AccountOption]:
|
||||
"""The selectable debit accounts.
|
||||
|
||||
:return: The selectable debit accounts.
|
||||
"""
|
||||
accounts: list[AccountOption] \
|
||||
= [AccountOption(x) for x in Account.selectable_debit()
|
||||
if not (x.code[0] == "2" and x.is_need_offset)]
|
||||
in_use: set[int] = set(db.session.scalars(
|
||||
sa.select(JournalEntryLineItem.account_id)
|
||||
.filter(JournalEntryLineItem.is_debit)
|
||||
.group_by(JournalEntryLineItem.account_id)).all())
|
||||
for account in accounts:
|
||||
account.is_in_use = account.id in in_use
|
||||
return accounts
|
||||
|
||||
@property
|
||||
def credit_account_options(self) -> list[AccountOption]:
|
||||
"""The selectable credit accounts.
|
||||
|
||||
:return: The selectable credit accounts.
|
||||
"""
|
||||
accounts: list[AccountOption] \
|
||||
= [AccountOption(x) for x in Account.selectable_credit()
|
||||
if not (x.code[0] == "1" and x.is_need_offset)]
|
||||
in_use: set[int] = set(db.session.scalars(
|
||||
sa.select(JournalEntryLineItem.account_id)
|
||||
.filter(sa.not_(JournalEntryLineItem.is_debit))
|
||||
.group_by(JournalEntryLineItem.account_id)).all())
|
||||
for account in accounts:
|
||||
account.is_in_use = account.id in in_use
|
||||
return accounts
|
||||
|
||||
@property
|
||||
def currencies_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the currency errors, without the errors in their sub-forms.
|
||||
|
||||
:return: The currency errors, without the errors in their sub-forms.
|
||||
"""
|
||||
return [x for x in self.currencies.errors
|
||||
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||
|
||||
@property
|
||||
def description_editor(self) -> DescriptionEditor:
|
||||
"""Returns the description editor.
|
||||
|
||||
:return: The description editor.
|
||||
"""
|
||||
return DescriptionEditor()
|
||||
|
||||
@property
|
||||
def original_line_item_options(self) -> list[JournalEntryLineItem]:
|
||||
"""Returns the selectable original line items.
|
||||
|
||||
:return: The selectable original line items.
|
||||
"""
|
||||
if self.__original_line_item_options is None:
|
||||
self.__original_line_item_options \
|
||||
= get_selectable_original_line_items(
|
||||
{x.id.data for x in self.line_items
|
||||
if x.id.data is not None},
|
||||
self._is_need_payable, self._is_need_receivable)
|
||||
return self.__original_line_item_options
|
||||
|
||||
@property
|
||||
def min_date(self) -> dt.date | None:
|
||||
"""Returns the minimal available date.
|
||||
|
||||
:return: The minimal available date.
|
||||
"""
|
||||
original_line_item_id: set[int] \
|
||||
= {x.original_line_item_id.data for x in self.line_items
|
||||
if x.original_line_item_id.data is not None}
|
||||
if len(original_line_item_id) == 0:
|
||||
return None
|
||||
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
|
||||
.join(JournalEntryLineItem)\
|
||||
.filter(JournalEntryLineItem.id.in_(original_line_item_id))
|
||||
return db.session.scalar(select)
|
||||
|
||||
@property
|
||||
def max_date(self) -> dt.date | None:
|
||||
"""Returns the maximum available date.
|
||||
|
||||
:return: The maximum available date.
|
||||
"""
|
||||
line_item_id: set[int] = {x.id.data for x in self.line_items
|
||||
if x.id.data is not None}
|
||||
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
|
||||
.join(JournalEntryLineItem)\
|
||||
.filter(JournalEntryLineItem.original_line_item_id
|
||||
.in_(line_item_id))
|
||||
return db.session.scalar(select)
|
||||
|
||||
|
||||
T = t.TypeVar("T", bound=JournalEntryForm)
|
||||
"""A journal entry form variant."""
|
||||
|
||||
|
||||
class LineItemCollector(t.Generic[T], ABC):
|
||||
"""The line item collector."""
|
||||
|
||||
def __init__(self, form: T, obj: JournalEntry):
|
||||
"""Constructs the line item collector.
|
||||
|
||||
:param form: The journal entry form.
|
||||
:param obj: The journal entry.
|
||||
"""
|
||||
self.form: T = form
|
||||
"""The journal entry form."""
|
||||
self.__obj: JournalEntry = obj
|
||||
"""The journal entry object."""
|
||||
self.__line_items: list[JournalEntryLineItem] = list(obj.line_items)
|
||||
"""The existing line items."""
|
||||
self.__line_items_by_id: dict[int, JournalEntryLineItem] \
|
||||
= {x.id: x for x in self.__line_items}
|
||||
"""A dictionary from the line item ID to their line items."""
|
||||
self.__no_by_id: dict[int, int] \
|
||||
= {x.id: x.no for x in self.__line_items}
|
||||
"""A dictionary from the line item number to their line items."""
|
||||
self.__currencies: list[JournalEntryCurrency] = obj.currencies
|
||||
"""The currencies in the journal entry."""
|
||||
self._debit_no: int = 1
|
||||
"""The number index for the debit line items."""
|
||||
self._credit_no: int = 1
|
||||
"""The number index for the credit line items."""
|
||||
self.to_keep: set[int] = set()
|
||||
"""The ID of the existing line items to keep."""
|
||||
|
||||
@abstractmethod
|
||||
def collect(self) -> set[int]:
|
||||
"""Collects the line items.
|
||||
|
||||
:return: The ID of the line items to keep.
|
||||
"""
|
||||
|
||||
def _add_line_item(self, form: LineItemForm, currency_code: str, no: int) \
|
||||
-> None:
|
||||
"""Composes a line item from the form.
|
||||
|
||||
:param form: The line item form.
|
||||
:param currency_code: The code of the currency.
|
||||
:param no: The number of the line item.
|
||||
:return: None.
|
||||
"""
|
||||
line_item: JournalEntryLineItem | None \
|
||||
= self.__line_items_by_id.get(form.id.data)
|
||||
if line_item is not None:
|
||||
line_item.currency_code = currency_code
|
||||
form.populate_obj(line_item)
|
||||
line_item.no = no
|
||||
if db.session.is_modified(line_item):
|
||||
self.form.is_modified = True
|
||||
else:
|
||||
line_item = JournalEntryLineItem()
|
||||
line_item.currency_code = currency_code
|
||||
form.populate_obj(line_item)
|
||||
line_item.no = no
|
||||
self.__obj.line_items.append(line_item)
|
||||
self.form.is_modified = True
|
||||
self.to_keep.add(line_item.id)
|
||||
|
||||
def _make_cash_line_item(self, forms: list[LineItemForm], is_debit: bool,
|
||||
currency_code: str, no: int) -> None:
|
||||
"""Composes the cash line item at the other debit or credit of the
|
||||
cash journal entry.
|
||||
|
||||
:param forms: The line item forms in the same currency.
|
||||
:param is_debit: True for a cash receipt journal entry, or False for a
|
||||
cash disbursement journal entry.
|
||||
:param currency_code: The code of the currency.
|
||||
:param no: The number of the line item.
|
||||
:return: None.
|
||||
"""
|
||||
candidates: list[JournalEntryLineItem] \
|
||||
= [x for x in self.__line_items
|
||||
if x.is_debit == is_debit and x.currency_code == currency_code]
|
||||
line_item: JournalEntryLineItem
|
||||
if len(candidates) > 0:
|
||||
candidates.sort(key=lambda x: x.no)
|
||||
line_item = candidates[0]
|
||||
line_item.account_id = Account.cash().id
|
||||
line_item.description = None
|
||||
line_item.amount = sum([x.amount.data for x in forms])
|
||||
line_item.no = no
|
||||
if db.session.is_modified(line_item):
|
||||
self.form.is_modified = True
|
||||
else:
|
||||
line_item = JournalEntryLineItem()
|
||||
line_item.id = new_id(JournalEntryLineItem)
|
||||
line_item.is_debit = is_debit
|
||||
line_item.currency_code = currency_code
|
||||
line_item.account_id = Account.cash().id
|
||||
line_item.description = None
|
||||
line_item.amount = sum([x.amount.data for x in forms])
|
||||
line_item.no = no
|
||||
self.__obj.line_items.append(line_item)
|
||||
self.form.is_modified = True
|
||||
self.to_keep.add(line_item.id)
|
||||
|
||||
def _sort_line_item_forms(self, forms: list[LineItemForm]) -> None:
|
||||
"""Sorts the line item sub-forms.
|
||||
|
||||
:param forms: The line item sub-forms.
|
||||
:return: None.
|
||||
"""
|
||||
missing_no: int = 100 if len(self.__no_by_id) == 0 \
|
||||
else max(self.__no_by_id.values()) + 100
|
||||
ord_by_form: dict[LineItemForm, int] \
|
||||
= {forms[i]: i for i in range(len(forms))}
|
||||
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||
missing_no if x.id.data is None else
|
||||
self.__no_by_id.get(x.id.data, missing_no),
|
||||
ord_by_form.get(x)))
|
||||
|
||||
def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None:
|
||||
"""Sorts the currency forms.
|
||||
|
||||
:param forms: The currency forms.
|
||||
:return: None.
|
||||
"""
|
||||
missing_no: int = len(self.__currencies) + 100
|
||||
no_by_code: dict[str, int] = {self.__currencies[i].code: i
|
||||
for i in range(len(self.__currencies))}
|
||||
ord_by_form: dict[CurrencyForm, int] \
|
||||
= {forms[i]: i for i in range(len(forms))}
|
||||
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||
no_by_code.get(x.code.data, missing_no),
|
||||
ord_by_form.get(x)))
|
||||
|
||||
|
||||
class CashReceiptJournalEntryForm(JournalEntryForm):
|
||||
"""The form to create or edit a cash receipt journal entry."""
|
||||
date = DateField(
|
||||
validators=[DATE_REQUIRED,
|
||||
NotBeforeOriginalLineItems(),
|
||||
NotAfterOffsetItems()])
|
||||
"""The date."""
|
||||
currencies = FieldList(FormField(CashReceiptCurrencyForm), name="currency",
|
||||
validators=[NeedSomeCurrencies()])
|
||||
"""The line items categorized by their currencies."""
|
||||
note = TextAreaField(filters=[strip_multiline_text])
|
||||
"""The note."""
|
||||
whole_form = BooleanField(
|
||||
validators=[CannotDeleteOriginalLineItemsWithOffset()])
|
||||
"""The pseudo field for the whole form validators."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._is_need_receivable = True
|
||||
|
||||
class Collector(LineItemCollector[CashReceiptJournalEntryForm]):
|
||||
"""The line item collector for the cash receipt journal entries."""
|
||||
|
||||
def collect(self) -> None:
|
||||
currencies: list[CashReceiptCurrencyForm] \
|
||||
= [x.form for x in self.form.currencies]
|
||||
self._sort_currency_forms(currencies)
|
||||
for currency in currencies:
|
||||
# The debit cash line item
|
||||
self._make_cash_line_item(list(currency.credit), True,
|
||||
currency.code.data,
|
||||
self._debit_no)
|
||||
self._debit_no = self._debit_no + 1
|
||||
|
||||
# The credit forms
|
||||
credit_forms: list[CreditLineItemForm] \
|
||||
= [x.form for x in currency.credit]
|
||||
self._sort_line_item_forms(credit_forms)
|
||||
for credit_form in credit_forms:
|
||||
self._add_line_item(credit_form, currency.code.data,
|
||||
self._credit_no)
|
||||
self._credit_no = self._credit_no + 1
|
||||
|
||||
self.collector = Collector
|
||||
|
||||
|
||||
class CashDisbursementJournalEntryForm(JournalEntryForm):
|
||||
"""The form to create or edit a cash disbursement journal entry."""
|
||||
date = DateField(
|
||||
validators=[DATE_REQUIRED,
|
||||
NotBeforeOriginalLineItems(),
|
||||
NotAfterOffsetItems()])
|
||||
"""The date."""
|
||||
currencies = FieldList(FormField(CashDisbursementCurrencyForm),
|
||||
name="currency",
|
||||
validators=[NeedSomeCurrencies()])
|
||||
"""The line items categorized by their currencies."""
|
||||
note = TextAreaField(filters=[strip_multiline_text])
|
||||
"""The note."""
|
||||
whole_form = BooleanField(
|
||||
validators=[CannotDeleteOriginalLineItemsWithOffset()])
|
||||
"""The pseudo field for the whole form validators."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._is_need_payable = True
|
||||
|
||||
class Collector(LineItemCollector[CashDisbursementJournalEntryForm]):
|
||||
"""The line item collector for the cash disbursement journal
|
||||
entries."""
|
||||
|
||||
def collect(self) -> None:
|
||||
currencies: list[CashDisbursementCurrencyForm] \
|
||||
= [x.form for x in self.form.currencies]
|
||||
self._sort_currency_forms(currencies)
|
||||
for currency in currencies:
|
||||
# The debit forms
|
||||
debit_forms: list[DebitLineItemForm] \
|
||||
= [x.form for x in currency.debit]
|
||||
self._sort_line_item_forms(debit_forms)
|
||||
for debit_form in debit_forms:
|
||||
self._add_line_item(debit_form, currency.code.data,
|
||||
self._debit_no)
|
||||
self._debit_no = self._debit_no + 1
|
||||
|
||||
# The credit forms
|
||||
self._make_cash_line_item(list(currency.debit), False,
|
||||
currency.code.data,
|
||||
self._credit_no)
|
||||
self._credit_no = self._credit_no + 1
|
||||
|
||||
self.collector = Collector
|
||||
|
||||
|
||||
class TransferJournalEntryForm(JournalEntryForm):
|
||||
"""The form to create or edit a transfer journal entry."""
|
||||
date = DateField(
|
||||
validators=[DATE_REQUIRED,
|
||||
NotBeforeOriginalLineItems(),
|
||||
NotAfterOffsetItems()])
|
||||
"""The date."""
|
||||
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
|
||||
validators=[NeedSomeCurrencies()])
|
||||
"""The line items categorized by their currencies."""
|
||||
note = TextAreaField(filters=[strip_multiline_text])
|
||||
"""The note."""
|
||||
whole_form = BooleanField(
|
||||
validators=[CannotDeleteOriginalLineItemsWithOffset()])
|
||||
"""The pseudo field for the whole form validators."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._is_need_payable = True
|
||||
self._is_need_receivable = True
|
||||
|
||||
class Collector(LineItemCollector[TransferJournalEntryForm]):
|
||||
"""The line item collector for the transfer journal entries."""
|
||||
|
||||
def collect(self) -> None:
|
||||
currencies: list[TransferCurrencyForm] \
|
||||
= [x.form for x in self.form.currencies]
|
||||
self._sort_currency_forms(currencies)
|
||||
for currency in currencies:
|
||||
# The debit forms
|
||||
debit_forms: list[DebitLineItemForm] \
|
||||
= [x.form for x in currency.debit]
|
||||
self._sort_line_item_forms(debit_forms)
|
||||
for debit_form in debit_forms:
|
||||
self._add_line_item(debit_form, currency.code.data,
|
||||
self._debit_no)
|
||||
self._debit_no = self._debit_no + 1
|
||||
|
||||
# The credit forms
|
||||
credit_forms: list[CreditLineItemForm] \
|
||||
= [x.form for x in currency.credit]
|
||||
self._sort_line_item_forms(credit_forms)
|
||||
for credit_form in credit_forms:
|
||||
self._add_line_item(credit_form, currency.code.data,
|
||||
self._credit_no)
|
||||
self._credit_no = self._credit_no + 1
|
||||
|
||||
self.collector = Collector
|
499
src/accounting/journal_entry/forms/line_item.py
Normal file
499
src/accounting/journal_entry/forms/line_item.py
Normal file
@ -0,0 +1,499 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The line item sub-forms for the journal entry management.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask_babel import LazyString
|
||||
from flask_wtf import FlaskForm
|
||||
from sqlalchemy.orm import selectinload
|
||||
from wtforms import StringField, ValidationError, DecimalField, IntegerField
|
||||
from wtforms.validators import Optional
|
||||
|
||||
from accounting import db
|
||||
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
|
||||
IsCreditAccount
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.template_filters import format_amount
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.random_id import new_id
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
|
||||
|
||||
class OriginalLineItemExists:
|
||||
"""The validator to check if the original line item exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if db.session.get(JournalEntryLineItem, field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The original line item does not exist."))
|
||||
|
||||
|
||||
class OriginalLineItemOppositeDebitCredit:
|
||||
"""The validator to check if the original line item is on the opposite
|
||||
debit or credit."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
original_line_item: JournalEntryLineItem | None \
|
||||
= db.session.get(JournalEntryLineItem, field.data)
|
||||
if original_line_item is None:
|
||||
return
|
||||
if isinstance(form, CreditLineItemForm) \
|
||||
and original_line_item.is_debit:
|
||||
return
|
||||
if isinstance(form, DebitLineItemForm) \
|
||||
and not original_line_item.is_debit:
|
||||
return
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The original line item is on the same debit or credit."))
|
||||
|
||||
|
||||
class OriginalLineItemNeedOffset:
|
||||
"""The validator to check if the original line item needs offset."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
original_line_item: JournalEntryLineItem | None \
|
||||
= db.session.get(JournalEntryLineItem, field.data)
|
||||
if original_line_item is None:
|
||||
return
|
||||
if not original_line_item.account.is_need_offset:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The original line item does not need offset."))
|
||||
|
||||
|
||||
class OriginalLineItemNotOffset:
|
||||
"""The validator to check if the original line item is not itself an
|
||||
offset item."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
original_line_item: JournalEntryLineItem | None \
|
||||
= db.session.get(JournalEntryLineItem, field.data)
|
||||
if original_line_item is None:
|
||||
return
|
||||
if original_line_item.original_line_item_id is not None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The original line item cannot be an offset item."))
|
||||
|
||||
|
||||
class SameAccountAsOriginalLineItem:
|
||||
"""The validator to check if the account is the same as the
|
||||
original line item."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
assert isinstance(form, LineItemForm)
|
||||
if field.data is None or form.original_line_item_id.data is None:
|
||||
return
|
||||
original_line_item: JournalEntryLineItem | None \
|
||||
= db.session.get(JournalEntryLineItem,
|
||||
form.original_line_item_id.data)
|
||||
if original_line_item is None:
|
||||
return
|
||||
if field.data != original_line_item.account_code:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The account must be the same as the original line item."))
|
||||
|
||||
|
||||
class KeepAccountWhenHavingOffset:
|
||||
"""The validator to check if the account is the same when having offset."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
assert isinstance(form, LineItemForm)
|
||||
if field.data is None or form.id.data is None:
|
||||
return
|
||||
line_item: JournalEntryLineItem | None = db.session\
|
||||
.query(JournalEntryLineItem)\
|
||||
.filter(JournalEntryLineItem.id == form.id.data)\
|
||||
.options(selectinload(JournalEntryLineItem.offsets)).first()
|
||||
if line_item is None or len(line_item.offsets) == 0:
|
||||
return
|
||||
if field.data != line_item.account_code:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The account must not be changed when there is offset."))
|
||||
|
||||
|
||||
class NotStartPayableFromDebit:
|
||||
"""The validator to check that a payable line item does not start from
|
||||
debit."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
assert isinstance(form, DebitLineItemForm)
|
||||
if field.data is None \
|
||||
or field.data[0] != "2" \
|
||||
or form.original_line_item_id.data is not None:
|
||||
return
|
||||
account: Account | None = Account.find_by_code(field.data)
|
||||
if account is not None and account.is_need_offset:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"A payable line item cannot start from debit."))
|
||||
|
||||
|
||||
class NotStartReceivableFromCredit:
|
||||
"""The validator to check that a receivable line item does not start
|
||||
from credit."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
assert isinstance(form, CreditLineItemForm)
|
||||
if field.data is None \
|
||||
or field.data[0] != "1" \
|
||||
or form.original_line_item_id.data is not None:
|
||||
return
|
||||
account: Account | None = Account.find_by_code(field.data)
|
||||
if account is not None and account.is_need_offset:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"A receivable line item cannot start from credit."))
|
||||
|
||||
|
||||
class PositiveAmount:
|
||||
"""The validator to check if the amount is positive."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if field.data <= 0:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"Please fill in a positive amount."))
|
||||
|
||||
|
||||
class NotExceedingOriginalLineItemNetBalance:
|
||||
"""The validator to check if the amount exceeds the net balance of the
|
||||
original line item."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
||||
assert isinstance(form, LineItemForm)
|
||||
if field.data is None or form.original_line_item_id.data is None:
|
||||
return
|
||||
original_line_item: JournalEntryLineItem | None \
|
||||
= db.session.get(JournalEntryLineItem,
|
||||
form.original_line_item_id.data)
|
||||
if original_line_item is None:
|
||||
return
|
||||
is_debit: bool = isinstance(form, DebitLineItemForm)
|
||||
existing_line_item_id: set[int] = set()
|
||||
if form.journal_entry_form.obj is not None:
|
||||
existing_line_item_id \
|
||||
= {x.id for x in form.journal_entry_form.obj.line_items}
|
||||
offset_total_func: sa.Function = sa.func.sum(sa.case(
|
||||
(be(JournalEntryLineItem.is_debit == is_debit),
|
||||
JournalEntryLineItem.amount),
|
||||
else_=-JournalEntryLineItem.amount))
|
||||
offset_total_but_form: Decimal | None = db.session.scalar(
|
||||
sa.select(offset_total_func)
|
||||
.filter(be(JournalEntryLineItem.original_line_item_id
|
||||
== original_line_item.id),
|
||||
JournalEntryLineItem.id.not_in(existing_line_item_id)))
|
||||
if offset_total_but_form is None:
|
||||
offset_total_but_form = Decimal("0")
|
||||
offset_total_on_form: Decimal = sum(
|
||||
[x.amount.data for x in form.journal_entry_form.line_items
|
||||
if x.original_line_item_id.data == original_line_item.id
|
||||
and x.amount != field and x.amount.data is not None])
|
||||
net_balance: Decimal = original_line_item.amount \
|
||||
- offset_total_but_form - offset_total_on_form
|
||||
if field.data > net_balance:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The amount must not exceed the net balance %(balance)s of the"
|
||||
" original line item.", balance=format_amount(net_balance)))
|
||||
|
||||
|
||||
class NotLessThanOffsetTotal:
|
||||
"""The validator to check if the amount is less than the offset total."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
||||
assert isinstance(form, LineItemForm)
|
||||
if field.data is None or form.id.data is None:
|
||||
return
|
||||
is_debit: bool = isinstance(form, DebitLineItemForm)
|
||||
select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
|
||||
(JournalEntryLineItem.is_debit != is_debit,
|
||||
JournalEntryLineItem.amount),
|
||||
else_=-JournalEntryLineItem.amount)))\
|
||||
.filter(be(JournalEntryLineItem.original_line_item_id
|
||||
== form.id.data))
|
||||
offset_total: Decimal | None = db.session.scalar(select_offset_total)
|
||||
if offset_total is not None and field.data < offset_total:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The amount must not be less than the offset total %(total)s.",
|
||||
total=format_amount(offset_total)))
|
||||
|
||||
|
||||
class LineItemForm(FlaskForm):
|
||||
"""The base form to create or edit a line item."""
|
||||
id = IntegerField()
|
||||
"""The existing line item ID."""
|
||||
no = IntegerField()
|
||||
"""The order in the currency."""
|
||||
original_line_item_id = IntegerField()
|
||||
"""The Id of the original line item."""
|
||||
account_code = StringField()
|
||||
"""The account code."""
|
||||
description = StringField()
|
||||
"""The description."""
|
||||
amount = DecimalField()
|
||||
"""The amount."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Constructs a base line item form.
|
||||
|
||||
:param args: The arguments.
|
||||
:param kwargs: The keyword arguments.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
from .journal_entry import JournalEntryForm
|
||||
self.journal_entry_form: JournalEntryForm | None = None
|
||||
"""The source journal entry form."""
|
||||
|
||||
@property
|
||||
def account_text(self) -> str:
|
||||
"""Returns the text representation of the account.
|
||||
|
||||
:return: The text representation of the account.
|
||||
"""
|
||||
if self.account_code.data is None:
|
||||
return ""
|
||||
account: Account | None = Account.find_by_code(self.account_code.data)
|
||||
if account is None:
|
||||
return ""
|
||||
return str(account)
|
||||
|
||||
@property
|
||||
def __original_line_item(self) -> JournalEntryLineItem | None:
|
||||
"""Returns the original line item.
|
||||
|
||||
:return: The original line item.
|
||||
"""
|
||||
if not hasattr(self, "____original_line_item"):
|
||||
def get_line_item() -> JournalEntryLineItem | None:
|
||||
if self.original_line_item_id.data is None:
|
||||
return None
|
||||
return db.session.get(JournalEntryLineItem,
|
||||
self.original_line_item_id.data)
|
||||
setattr(self, "____original_line_item", get_line_item())
|
||||
return getattr(self, "____original_line_item")
|
||||
|
||||
@property
|
||||
def original_line_item_date(self) -> date | None:
|
||||
"""Returns the text representation of the original line item.
|
||||
|
||||
:return: The text representation of the original line item.
|
||||
"""
|
||||
return None if self.__original_line_item is None \
|
||||
else self.__original_line_item.journal_entry.date
|
||||
|
||||
@property
|
||||
def original_line_item_text(self) -> str | None:
|
||||
"""Returns the text representation of the original line item.
|
||||
|
||||
:return: The text representation of the original line item.
|
||||
"""
|
||||
return None if self.__original_line_item is None \
|
||||
else str(self.__original_line_item)
|
||||
|
||||
@property
|
||||
def is_need_offset(self) -> bool:
|
||||
"""Returns whether the line item needs offset.
|
||||
|
||||
:return: True if the line item needs offset, or False otherwise.
|
||||
"""
|
||||
if self.account_code.data is None:
|
||||
return False
|
||||
if self.account_code.data[0] == "1":
|
||||
if isinstance(self, CreditLineItemForm):
|
||||
return False
|
||||
elif self.account_code.data[0] == "2":
|
||||
if isinstance(self, DebitLineItemForm):
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
account: Account | None = Account.find_by_code(self.account_code.data)
|
||||
return account is not None and account.is_need_offset
|
||||
|
||||
@property
|
||||
def offsets(self) -> list[JournalEntryLineItem]:
|
||||
"""Returns the offsets.
|
||||
|
||||
:return: The offsets.
|
||||
"""
|
||||
if not hasattr(self, "__offsets"):
|
||||
def get_offsets() -> list[JournalEntryLineItem]:
|
||||
if not self.is_need_offset or self.id.data is None:
|
||||
return []
|
||||
return JournalEntryLineItem.query\
|
||||
.filter(JournalEntryLineItem.original_line_item_id
|
||||
== self.id.data)\
|
||||
.options(selectinload(JournalEntryLineItem.journal_entry),
|
||||
selectinload(JournalEntryLineItem.account),
|
||||
selectinload(JournalEntryLineItem.offsets)
|
||||
.selectinload(
|
||||
JournalEntryLineItem.journal_entry)).all()
|
||||
setattr(self, "__offsets", get_offsets())
|
||||
return getattr(self, "__offsets")
|
||||
|
||||
@property
|
||||
def offset_total(self) -> Decimal | None:
|
||||
"""Returns the total amount of the offsets.
|
||||
|
||||
:return: The total amount of the offsets.
|
||||
"""
|
||||
if not hasattr(self, "__offset_total"):
|
||||
def get_offset_total():
|
||||
if not self.is_need_offset or self.id.data is None:
|
||||
return None
|
||||
is_debit: bool = isinstance(self, DebitLineItemForm)
|
||||
return sum([x.amount if x.is_debit != is_debit else -x.amount
|
||||
for x in self.offsets])
|
||||
setattr(self, "__offset_total", get_offset_total())
|
||||
return getattr(self, "__offset_total")
|
||||
|
||||
@property
|
||||
def net_balance(self) -> Decimal | None:
|
||||
"""Returns the net balance.
|
||||
|
||||
:return: The net balance.
|
||||
"""
|
||||
if not self.is_need_offset or self.id.data is None \
|
||||
or self.amount.data is None:
|
||||
return None
|
||||
return self.amount.data - self.offset_total
|
||||
|
||||
@property
|
||||
def all_errors(self) -> list[str | LazyString]:
|
||||
"""Returns all the errors of the form.
|
||||
|
||||
:return: All the errors of the form.
|
||||
"""
|
||||
all_errors: list[str | LazyString] = []
|
||||
for key in self.errors:
|
||||
if key != "csrf_token":
|
||||
all_errors.extend(self.errors[key])
|
||||
return all_errors
|
||||
|
||||
|
||||
class DebitLineItemForm(LineItemForm):
|
||||
"""The form to create or edit a debit line item."""
|
||||
id = IntegerField()
|
||||
"""The existing line item ID."""
|
||||
no = IntegerField()
|
||||
"""The order in the currency."""
|
||||
original_line_item_id = IntegerField(
|
||||
validators=[Optional(),
|
||||
OriginalLineItemExists(),
|
||||
OriginalLineItemOppositeDebitCredit(),
|
||||
OriginalLineItemNeedOffset(),
|
||||
OriginalLineItemNotOffset()])
|
||||
"""The ID of the original line item."""
|
||||
account_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
ACCOUNT_REQUIRED,
|
||||
AccountExists(),
|
||||
IsDebitAccount(lazy_gettext(
|
||||
"This account is not for debit line items.")),
|
||||
SameAccountAsOriginalLineItem(),
|
||||
KeepAccountWhenHavingOffset(),
|
||||
NotStartPayableFromDebit()])
|
||||
"""The account code."""
|
||||
description = StringField(filters=[strip_text])
|
||||
"""The description."""
|
||||
amount = DecimalField(
|
||||
validators=[PositiveAmount(),
|
||||
NotExceedingOriginalLineItemNetBalance(),
|
||||
NotLessThanOffsetTotal()])
|
||||
"""The amount."""
|
||||
|
||||
def populate_obj(self, obj: JournalEntryLineItem) -> None:
|
||||
"""Populates the form data into a line item object.
|
||||
|
||||
:param obj: The line item object.
|
||||
:return: None.
|
||||
"""
|
||||
is_new: bool = obj.id is None
|
||||
if is_new:
|
||||
obj.id = new_id(JournalEntryLineItem)
|
||||
obj.original_line_item_id = self.original_line_item_id.data
|
||||
obj.account_id = Account.find_by_code(self.account_code.data).id
|
||||
obj.description = self.description.data
|
||||
obj.is_debit = True
|
||||
obj.amount = self.amount.data
|
||||
if is_new:
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.created_by_id = current_user_pk
|
||||
obj.updated_by_id = current_user_pk
|
||||
|
||||
|
||||
class CreditLineItemForm(LineItemForm):
|
||||
"""The form to create or edit a credit line item."""
|
||||
id = IntegerField()
|
||||
"""The existing line item ID."""
|
||||
no = IntegerField()
|
||||
"""The order in the currency."""
|
||||
original_line_item_id = IntegerField(
|
||||
validators=[Optional(),
|
||||
OriginalLineItemExists(),
|
||||
OriginalLineItemOppositeDebitCredit(),
|
||||
OriginalLineItemNeedOffset(),
|
||||
OriginalLineItemNotOffset()])
|
||||
"""The ID of the original line item."""
|
||||
account_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
ACCOUNT_REQUIRED,
|
||||
AccountExists(),
|
||||
IsCreditAccount(lazy_gettext(
|
||||
"This account is not for credit line items.")),
|
||||
SameAccountAsOriginalLineItem(),
|
||||
KeepAccountWhenHavingOffset(),
|
||||
NotStartReceivableFromCredit()])
|
||||
"""The account code."""
|
||||
description = StringField(filters=[strip_text])
|
||||
"""The description."""
|
||||
amount = DecimalField(
|
||||
validators=[PositiveAmount(),
|
||||
NotExceedingOriginalLineItemNetBalance(),
|
||||
NotLessThanOffsetTotal()])
|
||||
"""The amount."""
|
||||
|
||||
def populate_obj(self, obj: JournalEntryLineItem) -> None:
|
||||
"""Populates the form data into a line item object.
|
||||
|
||||
:param obj: The line item object.
|
||||
:return: None.
|
||||
"""
|
||||
is_new: bool = obj.id is None
|
||||
if is_new:
|
||||
obj.id = new_id(JournalEntryLineItem)
|
||||
obj.original_line_item_id = self.original_line_item_id.data
|
||||
obj.account_id = Account.find_by_code(self.account_code.data).id
|
||||
obj.description = self.description.data
|
||||
obj.is_debit = False
|
||||
obj.amount = self.amount.data
|
||||
if is_new:
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.created_by_id = current_user_pk
|
||||
obj.updated_by_id = current_user_pk
|
95
src/accounting/journal_entry/forms/reorder.py
Normal file
95
src/accounting/journal_entry/forms/reorder.py
Normal file
@ -0,0 +1,95 @@
|
||||
# The Mia! Accounting 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
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -14,7 +14,7 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The template filters for the transaction management.
|
||||
"""The template filters for the journal entry management.
|
||||
|
||||
"""
|
||||
from decimal import Decimal
|
||||
@ -26,10 +26,10 @@ from flask import request
|
||||
|
||||
|
||||
def with_type(uri: str) -> str:
|
||||
"""Adds the transaction type to the URI, if it is specified.
|
||||
"""Adds the journal entry type to the URI, if it is specified.
|
||||
|
||||
:param uri: The URI.
|
||||
:return: The result URL, optionally with the transaction type added.
|
||||
:return: The result URL, optionally with the journal entry type added.
|
||||
"""
|
||||
if "as" not in request.args:
|
||||
return uri
|
||||
@ -43,10 +43,10 @@ def with_type(uri: str) -> str:
|
||||
|
||||
|
||||
def to_transfer(uri: str) -> str:
|
||||
"""Adds the transfer transaction type to the URI.
|
||||
"""Adds the transfer journal entry type to the URI.
|
||||
|
||||
:param uri: The URI.
|
||||
:return: The result URL, with the transfer transaction type added.
|
||||
:return: The result URL, with the transfer journal entry type added.
|
||||
"""
|
||||
uri_p: ParseResult = urlparse(uri)
|
||||
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -14,6 +14,6 @@
|
||||
# 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 transaction management.
|
||||
"""The utilities for the journal entry management.
|
||||
|
||||
"""
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -14,7 +14,7 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The account option for the transaction management.
|
||||
"""The account option for the journal entry management.
|
||||
|
||||
"""
|
||||
from accounting.models import Account
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -14,32 +14,36 @@
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The summary editor.
|
||||
"""The description editor.
|
||||
|
||||
"""
|
||||
import re
|
||||
import typing as t
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Account, JournalEntry
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.utils.options import options, Recurring
|
||||
|
||||
|
||||
class SummaryAccount:
|
||||
"""An account for a summary tag."""
|
||||
class DescriptionAccount:
|
||||
"""An account for a description tag."""
|
||||
|
||||
def __init__(self, account: Account, freq: int):
|
||||
"""Constructs an account for a summary tag.
|
||||
"""Constructs an account for a description tag.
|
||||
|
||||
:param account: The account.
|
||||
:param freq: The frequency of the tag with the account.
|
||||
"""
|
||||
self.account: Account = account
|
||||
self.__account: Account = account
|
||||
"""The account."""
|
||||
self.id: int = account.id
|
||||
"""The account ID."""
|
||||
self.code: str = account.code
|
||||
"""The account code."""
|
||||
self.is_need_offset: bool = account.is_need_offset
|
||||
"""Whether the journal entry line items of this account need offset."""
|
||||
self.freq: int = freq
|
||||
"""The frequency of the tag with the account."""
|
||||
|
||||
@ -48,7 +52,7 @@ class SummaryAccount:
|
||||
|
||||
:return: The string representation of the account.
|
||||
"""
|
||||
return str(self.account)
|
||||
return str(self.__account)
|
||||
|
||||
def add_freq(self, freq: int) -> None:
|
||||
"""Adds the frequency of an account.
|
||||
@ -59,17 +63,17 @@ class SummaryAccount:
|
||||
self.freq = self.freq + freq
|
||||
|
||||
|
||||
class SummaryTag:
|
||||
"""A summary tag."""
|
||||
class DescriptionTag:
|
||||
"""A description tag."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""Constructs a summary tag.
|
||||
"""Constructs a description tag.
|
||||
|
||||
:param name: The tag name.
|
||||
"""
|
||||
self.name: str = name
|
||||
"""The tag name."""
|
||||
self.__account_dict: dict[int, SummaryAccount] = {}
|
||||
self.__account_dict: dict[int, DescriptionAccount] = {}
|
||||
"""The accounts that come with the tag, in the order of their
|
||||
frequency."""
|
||||
self.freq: int = 0
|
||||
@ -89,11 +93,11 @@ class SummaryTag:
|
||||
:param freq: The frequency of the tag name with the account.
|
||||
:return: None.
|
||||
"""
|
||||
self.__account_dict[account.id] = SummaryAccount(account, freq)
|
||||
self.__account_dict[account.id] = DescriptionAccount(account, freq)
|
||||
self.freq = self.freq + freq
|
||||
|
||||
@property
|
||||
def accounts(self) -> list[SummaryAccount]:
|
||||
def accounts(self) -> list[DescriptionAccount]:
|
||||
"""Returns the accounts by the order of their frequencies.
|
||||
|
||||
:return: The accounts by the order of their frequencies.
|
||||
@ -109,17 +113,17 @@ class SummaryTag:
|
||||
return [x.code for x in self.accounts]
|
||||
|
||||
|
||||
class SummaryType:
|
||||
"""A summary type"""
|
||||
class DescriptionType:
|
||||
"""A description type"""
|
||||
|
||||
def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
|
||||
"""Constructs a summary type.
|
||||
"""Constructs a description type.
|
||||
|
||||
:param type_id: The type ID, either "general", "travel", or "bus".
|
||||
"""
|
||||
self.id: t.Literal["general", "travel", "bus"] = type_id
|
||||
"""The type ID."""
|
||||
self.__tag_dict: dict[str, SummaryTag] = {}
|
||||
self.__tag_dict: dict[str, DescriptionTag] = {}
|
||||
"""A dictionary from the tag name to their corresponding tag."""
|
||||
|
||||
def add_tag(self, name: str, account: Account, freq: int) -> None:
|
||||
@ -131,11 +135,11 @@ class SummaryType:
|
||||
:return: None.
|
||||
"""
|
||||
if name not in self.__tag_dict:
|
||||
self.__tag_dict[name] = SummaryTag(name)
|
||||
self.__tag_dict[name] = DescriptionTag(name)
|
||||
self.__tag_dict[name].add_account(account, freq)
|
||||
|
||||
@property
|
||||
def tags(self) -> list[SummaryTag]:
|
||||
def tags(self) -> list[DescriptionTag]:
|
||||
"""Returns the tags by the order of their frequencies.
|
||||
|
||||
:return: The tags by the order of their frequencies.
|
||||
@ -143,26 +147,51 @@ class SummaryType:
|
||||
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
|
||||
|
||||
|
||||
class SummaryEntryType:
|
||||
"""A summary type"""
|
||||
class DescriptionRecurring:
|
||||
"""A recurring transaction."""
|
||||
|
||||
def __init__(self, entry_type_id: t.Literal["debit", "credit"]):
|
||||
"""Constructs a summary entry type.
|
||||
def __init__(self, name: str, account: Account, description_template: str):
|
||||
"""Constructs a recurring transaction.
|
||||
|
||||
:param entry_type_id: The entry type ID, either "debit" or "credit".
|
||||
:param name: The name.
|
||||
:param description_template: The description template.
|
||||
:param account: The account.
|
||||
"""
|
||||
self.type: t.Literal["debit", "credit"] = entry_type_id
|
||||
"""The entry type."""
|
||||
self.general: SummaryType = SummaryType("general")
|
||||
self.name: str = name
|
||||
self.account: DescriptionAccount = DescriptionAccount(account, 0)
|
||||
self.description_template: str = description_template
|
||||
|
||||
@property
|
||||
def account_codes(self) -> list[str]:
|
||||
"""Returns the account codes by the order of their frequencies.
|
||||
|
||||
:return: The account codes by the order of their frequencies.
|
||||
"""
|
||||
return [self.account.code]
|
||||
|
||||
|
||||
class DescriptionDebitCredit:
|
||||
"""The description on debit or credit."""
|
||||
|
||||
def __init__(self, debit_credit: t.Literal["debit", "credit"]):
|
||||
"""Constructs the description on debit or credit.
|
||||
|
||||
:param debit_credit: Either "debit" or "credit".
|
||||
"""
|
||||
self.debit_credit: t.Literal["debit", "credit"] = debit_credit
|
||||
"""Either debit or credit."""
|
||||
self.general: DescriptionType = DescriptionType("general")
|
||||
"""The general tags."""
|
||||
self.travel: SummaryType = SummaryType("travel")
|
||||
self.travel: DescriptionType = DescriptionType("travel")
|
||||
"""The travel tags."""
|
||||
self.bus: SummaryType = SummaryType("bus")
|
||||
self.bus: DescriptionType = DescriptionType("bus")
|
||||
"""The bus tags."""
|
||||
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
|
||||
SummaryType] \
|
||||
DescriptionType] \
|
||||
= {x.id: x for x in {self.general, self.travel, self.bus}}
|
||||
"""A dictionary from the type ID to the corresponding tags."""
|
||||
self.recurring: list[DescriptionRecurring] = []
|
||||
"""The recurring transactions."""
|
||||
|
||||
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
|
||||
name: str, account: Account, freq: int) -> None:
|
||||
@ -177,13 +206,13 @@ class SummaryEntryType:
|
||||
self.__type_dict[tag_type].add_tag(name, account, freq)
|
||||
|
||||
@property
|
||||
def accounts(self) -> list[SummaryAccount]:
|
||||
"""Returns the suggested accounts of all tags in the summary editor in
|
||||
the entry type, in their frequency order.
|
||||
def accounts(self) -> list[DescriptionAccount]:
|
||||
"""Returns the suggested accounts of all tags in the description editor
|
||||
in debit or credit, in their frequency order.
|
||||
|
||||
:return: The suggested accounts of all tags, in their frequency order.
|
||||
"""
|
||||
accounts: dict[int, SummaryAccount] = {}
|
||||
accounts: dict[int, DescriptionAccount] = {}
|
||||
freq: dict[int, int] = {}
|
||||
for tag_type in self.__type_dict.values():
|
||||
for tag in tag_type.tags:
|
||||
@ -193,44 +222,105 @@ class SummaryEntryType:
|
||||
freq[account.id] = 0
|
||||
freq[account.id] \
|
||||
= freq[account.id] + account.freq
|
||||
for recurring in self.recurring:
|
||||
accounts[recurring.account.id] = recurring.account
|
||||
if recurring.account.id not in freq:
|
||||
freq[recurring.account.id] = 0
|
||||
return [accounts[y] for y in sorted(freq.keys(),
|
||||
key=lambda x: -freq[x])]
|
||||
|
||||
|
||||
class SummaryEditor:
|
||||
"""The summary editor."""
|
||||
class DescriptionEditor:
|
||||
"""The description editor."""
|
||||
|
||||
def __init__(self):
|
||||
"""Constructs the summary editor."""
|
||||
self.debit: SummaryEntryType = SummaryEntryType("debit")
|
||||
"""Constructs the description editor."""
|
||||
self.debit: DescriptionDebitCredit = DescriptionDebitCredit("debit")
|
||||
"""The debit tags."""
|
||||
self.credit: SummaryEntryType = SummaryEntryType("credit")
|
||||
self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit")
|
||||
"""The credit tags."""
|
||||
entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"),
|
||||
else_="credit").label("entry_type")
|
||||
self.__init_tags()
|
||||
self.__init_recurring()
|
||||
|
||||
def __init_tags(self):
|
||||
"""Initializes the tags.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
debit_credit: sa.Label = sa.case(
|
||||
(JournalEntryLineItem.is_debit, "debit"),
|
||||
else_="credit").label("debit_credit")
|
||||
tag_type: sa.Label = sa.case(
|
||||
(JournalEntry.summary.like("_%—_%—_%→_%"), "bus"),
|
||||
(sa.or_(JournalEntry.summary.like("_%—_%→_%"),
|
||||
JournalEntry.summary.like("_%—_%↔_%")), "travel"),
|
||||
(JournalEntryLineItem.description.like("_%—_%—_%→_%"), "bus"),
|
||||
(sa.or_(JournalEntryLineItem.description.like("_%—_%→_%"),
|
||||
JournalEntryLineItem.description.like("_%—_%↔_%")),
|
||||
"travel"),
|
||||
else_="general").label("tag_type")
|
||||
tag: sa.Label = get_prefix(JournalEntry.summary, "—").label("tag")
|
||||
select: sa.Select = sa.Select(entry_type, tag_type, tag,
|
||||
JournalEntry.account_id,
|
||||
tag: sa.Label = get_prefix(JournalEntryLineItem.description, "—")\
|
||||
.label("tag")
|
||||
select: sa.Select = sa.Select(debit_credit, tag_type, tag,
|
||||
JournalEntryLineItem.account_id,
|
||||
sa.func.count().label("freq"))\
|
||||
.filter(JournalEntry.summary.is_not(None),
|
||||
JournalEntry.summary.like("_%—_%"),
|
||||
JournalEntry.original_entry_id.is_(None))\
|
||||
.group_by(entry_type, tag_type, tag, JournalEntry.account_id)
|
||||
.filter(JournalEntryLineItem.description.is_not(None),
|
||||
JournalEntryLineItem.description.like("_%—_%"),
|
||||
JournalEntryLineItem.original_line_item_id.is_(None))\
|
||||
.group_by(debit_credit, tag_type, tag,
|
||||
JournalEntryLineItem.account_id)
|
||||
result: list[sa.Row] = db.session.execute(select).all()
|
||||
accounts: dict[int, Account] \
|
||||
= {x.id: x for x in Account.query
|
||||
.filter(Account.id.in_({x.account_id for x in result})).all()}
|
||||
entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \
|
||||
= {x.type: x for x in {self.debit, self.credit}}
|
||||
debit_credit_dict: dict[t.Literal["debit", "credit"],
|
||||
DescriptionDebitCredit] \
|
||||
= {x.debit_credit: x for x in {self.debit, self.credit}}
|
||||
for row in result:
|
||||
entry_type_dict[row.entry_type].add_tag(
|
||||
debit_credit_dict[row.debit_credit].add_tag(
|
||||
row.tag_type, row.tag, accounts[row.account_id], row.freq)
|
||||
|
||||
def __init_recurring(self) -> None:
|
||||
"""Initializes the recurring transactions.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
recurring: Recurring = options.recurring
|
||||
accounts: dict[str, Account] \
|
||||
= self.__get_accounts(recurring.codes)
|
||||
self.debit.recurring \
|
||||
= [DescriptionRecurring(x.name, accounts[x.account_code],
|
||||
x.description_template)
|
||||
for x in recurring.expenses]
|
||||
self.credit.recurring \
|
||||
= [DescriptionRecurring(x.name, accounts[x.account_code],
|
||||
x.description_template)
|
||||
for x in recurring.incomes]
|
||||
|
||||
@staticmethod
|
||||
def __get_accounts(codes: set[str]) -> dict[str, Account]:
|
||||
"""Finds and returns the accounts by codes.
|
||||
|
||||
:param codes: The account codes.
|
||||
:return: The account.
|
||||
"""
|
||||
if len(codes) == 0:
|
||||
return {}
|
||||
|
||||
def get_condition(code0: str) -> sa.BinaryExpression:
|
||||
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
|
||||
assert m is not None,\
|
||||
f"Malformed account code \"{code0}\" for regular transactions."
|
||||
return sa.and_(Account.base_code == m.group(1),
|
||||
Account.no == int(m.group(2)))
|
||||
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [get_condition(x) for x in codes]
|
||||
accounts: dict[str, Account] \
|
||||
= {x.code: x for x in
|
||||
Account.query.filter(sa.or_(*conditions)).all()}
|
||||
for code in codes:
|
||||
assert code in accounts,\
|
||||
f"Unknown account \"{code}\" for regular transactions."
|
||||
return accounts
|
||||
|
||||
|
||||
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
|
||||
-> sa.Function:
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/15
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -14,20 +14,20 @@
|
||||
# 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 entries.
|
||||
"""The SQLAlchemy alias for the offset items.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting.models import JournalEntry
|
||||
from accounting.models import JournalEntryLineItem
|
||||
|
||||
|
||||
def offset_alias() -> sa.Alias:
|
||||
"""Returns the SQLAlchemy alias for the offset entries.
|
||||
"""Returns the SQLAlchemy alias for the offset items.
|
||||
|
||||
:return: The SQLAlchemy alias for the offset entries.
|
||||
:return: The SQLAlchemy alias for the offset items.
|
||||
"""
|
||||
|
||||
def as_from(model_cls: t.Any) -> sa.FromClause:
|
||||
@ -36,4 +36,4 @@ def offset_alias() -> sa.Alias:
|
||||
def as_alias(alias: t.Any) -> sa.Alias:
|
||||
return alias
|
||||
|
||||
return as_alias(sa.alias(as_from(JournalEntry), name="offset"))
|
||||
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 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
|
85
src/accounting/journal_entry/utils/original_line_items.py
Normal file
85
src/accounting/journal_entry/utils/original_line_items.py
Normal file
@ -0,0 +1,85 @@
|
||||
# The Mia! Accounting 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, JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
|
||||
.options(selectinload(JournalEntryLineItem.currency),
|
||||
selectinload(JournalEntryLineItem.account),
|
||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||
line_items.reverse()
|
||||
for line_item in line_items:
|
||||
line_item.net_balance = line_item.amount \
|
||||
if net_balances[line_item.id] is None \
|
||||
else net_balances[line_item.id]
|
||||
return line_items
|
238
src/accounting/journal_entry/views.py
Normal file
238
src/accounting/journal_entry/views.py
Normal file
@ -0,0 +1,238 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The views for the journal entry management.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import Blueprint, render_template, session, redirect, request, \
|
||||
flash, url_for
|
||||
from werkzeug.datastructures import ImmutableMultiDict
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import JournalEntry
|
||||
from accounting.utils.cast import s
|
||||
from accounting.utils.flash_errors import flash_form_errors
|
||||
from accounting.utils.next_uri import inherit_next, or_next
|
||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||
from accounting.utils.journal_entry_types import JournalEntryType
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .forms import sort_journal_entries_in, JournalEntryReorderForm
|
||||
from .template_filters import with_type, to_transfer, format_amount_input, \
|
||||
text2html
|
||||
from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
|
||||
get_journal_entry_op
|
||||
|
||||
bp: Blueprint = Blueprint("journal-entry", __name__)
|
||||
"""The view blueprint for the journal entry management."""
|
||||
bp.add_app_template_filter(with_type, "accounting_journal_entry_with_type")
|
||||
bp.add_app_template_filter(to_transfer, "accounting_journal_entry_to_transfer")
|
||||
bp.add_app_template_filter(format_amount_input,
|
||||
"accounting_journal_entry_format_amount_input")
|
||||
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
|
||||
|
||||
|
||||
@bp.get("/create/<journalEntryType:journal_entry_type>", endpoint="create")
|
||||
@has_permission(can_edit)
|
||||
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
|
||||
"""Shows the form to add a journal entry.
|
||||
|
||||
:param journal_entry_type: The journal entry type.
|
||||
:return: The form to add a journal entry.
|
||||
"""
|
||||
journal_entry_op: JournalEntryOperator \
|
||||
= JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
|
||||
form: journal_entry_op.form
|
||||
if "form" in session:
|
||||
form = journal_entry_op.form(
|
||||
ImmutableMultiDict(parse_qsl(session["form"])))
|
||||
del session["form"]
|
||||
form.validate()
|
||||
else:
|
||||
form = journal_entry_op.form()
|
||||
form.date.data = date.today()
|
||||
return journal_entry_op.render_create_template(form)
|
||||
|
||||
|
||||
@bp.post("/store/<journalEntryType:journal_entry_type>", endpoint="store")
|
||||
@has_permission(can_edit)
|
||||
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
|
||||
"""Adds a journal entry.
|
||||
|
||||
:param journal_entry_type: The journal entry type.
|
||||
:return: The redirection to the journal entry detail on success, or the
|
||||
journal entry creation form on error.
|
||||
"""
|
||||
journal_entry_op: JournalEntryOperator \
|
||||
= JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
|
||||
form: journal_entry_op.form = journal_entry_op.form(request.form)
|
||||
if not form.validate():
|
||||
flash_form_errors(form)
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(inherit_next(with_type(
|
||||
url_for("accounting.journal-entry.create",
|
||||
journal_entry_type=journal_entry_type))))
|
||||
journal_entry: JournalEntry = JournalEntry()
|
||||
form.populate_obj(journal_entry)
|
||||
db.session.add(journal_entry)
|
||||
db.session.commit()
|
||||
flash(s(lazy_gettext("The journal entry is added successfully.")),
|
||||
"success")
|
||||
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||
|
||||
|
||||
@bp.get("/<journalEntry:journal_entry>", endpoint="detail")
|
||||
@has_permission(can_view)
|
||||
def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
|
||||
"""Shows the journal entry detail.
|
||||
|
||||
:param journal_entry: The journal entry.
|
||||
:return: The detail.
|
||||
"""
|
||||
journal_entry_op: JournalEntryOperator \
|
||||
= get_journal_entry_op(journal_entry)
|
||||
return journal_entry_op.render_detail_template(journal_entry)
|
||||
|
||||
|
||||
@bp.get("/<journalEntry:journal_entry>/edit", endpoint="edit")
|
||||
@has_permission(can_edit)
|
||||
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
|
||||
"""Shows the form to edit a journal entry.
|
||||
|
||||
:param journal_entry: The journal entry.
|
||||
:return: The form to edit the journal entry.
|
||||
"""
|
||||
journal_entry_op: JournalEntryOperator \
|
||||
= get_journal_entry_op(journal_entry, is_check_as=True)
|
||||
form: journal_entry_op.form
|
||||
if "form" in session:
|
||||
form = journal_entry_op.form(
|
||||
ImmutableMultiDict(parse_qsl(session["form"])))
|
||||
del session["form"]
|
||||
form.obj = journal_entry
|
||||
form.validate()
|
||||
else:
|
||||
form = journal_entry_op.form(obj=journal_entry)
|
||||
return journal_entry_op.render_edit_template(journal_entry, form)
|
||||
|
||||
|
||||
@bp.post("/<journalEntry:journal_entry>/update", endpoint="update")
|
||||
@has_permission(can_edit)
|
||||
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
|
||||
"""Updates a journal entry.
|
||||
|
||||
:param journal_entry: The journal entry.
|
||||
:return: The redirection to the journal entry detail on success, or the
|
||||
journal entry edit form on error.
|
||||
"""
|
||||
journal_entry_op: JournalEntryOperator \
|
||||
= get_journal_entry_op(journal_entry, is_check_as=True)
|
||||
form: journal_entry_op.form = journal_entry_op.form(request.form)
|
||||
form.obj = journal_entry
|
||||
if not form.validate():
|
||||
flash_form_errors(form)
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(inherit_next(with_type(
|
||||
url_for("accounting.journal-entry.edit",
|
||||
journal_entry=journal_entry))))
|
||||
with db.session.no_autoflush:
|
||||
form.populate_obj(journal_entry)
|
||||
if not form.is_modified:
|
||||
flash(s(lazy_gettext("The journal entry was not modified.")),
|
||||
"success")
|
||||
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||
journal_entry.updated_by_id = get_current_user_pk()
|
||||
journal_entry.updated_at = sa.func.now()
|
||||
db.session.commit()
|
||||
flash(s(lazy_gettext("The journal entry is updated successfully.")),
|
||||
"success")
|
||||
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||
|
||||
|
||||
@bp.post("/<journalEntry:journal_entry>/delete", endpoint="delete")
|
||||
@has_permission(can_edit)
|
||||
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
|
||||
"""Deletes a journal entry.
|
||||
|
||||
:param journal_entry: The journal entry.
|
||||
:return: The redirection to the journal entry list on success, or the
|
||||
journal entry detail on error.
|
||||
"""
|
||||
if not journal_entry.can_delete:
|
||||
flash(s(lazy_gettext("The journal entry cannot be deleted.")), "error")
|
||||
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||
journal_entry.delete()
|
||||
sort_journal_entries_in(journal_entry.date, journal_entry.id)
|
||||
db.session.commit()
|
||||
flash(s(lazy_gettext("The journal entry is deleted successfully.")),
|
||||
"success")
|
||||
return redirect(or_next(__get_default_page_uri()))
|
||||
|
||||
|
||||
@bp.get("/dates/<date:journal_entry_date>", endpoint="order")
|
||||
@has_permission(can_view)
|
||||
def show_journal_entry_order(journal_entry_date: date) -> str:
|
||||
"""Shows the order of the journal entries in a same date.
|
||||
|
||||
:param journal_entry_date: The date.
|
||||
:return: The order of the journal entries in the date.
|
||||
"""
|
||||
journal_entries: list[JournalEntry] = JournalEntry.query \
|
||||
.filter(JournalEntry.date == journal_entry_date) \
|
||||
.order_by(JournalEntry.no).all()
|
||||
return render_template("accounting/journal-entry/order.html",
|
||||
date=journal_entry_date, list=journal_entries)
|
||||
|
||||
|
||||
@bp.post("/dates/<date:journal_entry_date>", endpoint="sort")
|
||||
@has_permission(can_edit)
|
||||
def sort_journal_entries(journal_entry_date: date) -> redirect:
|
||||
"""Reorders the journal entries in a date.
|
||||
|
||||
:param journal_entry_date: The date.
|
||||
:return: The redirection to the incoming account or the account list. The
|
||||
reordering operation does not fail.
|
||||
"""
|
||||
form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date)
|
||||
form.save_order()
|
||||
if not form.is_modified:
|
||||
flash(s(lazy_gettext("The order was not modified.")), "success")
|
||||
return redirect(or_next(__get_default_page_uri()))
|
||||
db.session.commit()
|
||||
flash(s(lazy_gettext("The order is updated successfully.")), "success")
|
||||
return redirect(or_next(__get_default_page_uri()))
|
||||
|
||||
|
||||
def __get_detail_uri(journal_entry: JournalEntry) -> str:
|
||||
"""Returns the detail URI of a journal entry.
|
||||
|
||||
:param journal_entry: The journal entry.
|
||||
:return: The detail URI of the journal entry.
|
||||
"""
|
||||
return url_for("accounting.journal-entry.detail",
|
||||
journal_entry=journal_entry)
|
||||
|
||||
|
||||
def __get_default_page_uri() -> str:
|
||||
"""Returns the URI for the default page.
|
||||
|
||||
:return: The URI for the default page.
|
||||
"""
|
||||
return url_for("accounting-report.default")
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -21,12 +21,11 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import typing as t
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import current_app
|
||||
from flask_babel import get_locale
|
||||
from babel import Locale
|
||||
from flask_babel import get_locale, get_babel
|
||||
from sqlalchemy import text
|
||||
|
||||
from accounting import db
|
||||
@ -53,7 +52,7 @@ class BaseAccount(db.Model):
|
||||
|
||||
:return: The string representation of the base account.
|
||||
"""
|
||||
return f"{self.code} {self.title}"
|
||||
return f"{self.code} {self.title.title()}"
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
@ -61,11 +60,11 @@ class BaseAccount(db.Model):
|
||||
|
||||
:return: The title in the current locale.
|
||||
"""
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
current_locale: Locale = get_locale()
|
||||
if current_locale == get_babel().instance.default_locale:
|
||||
return self.title_l10n
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
if l10n.locale == str(current_locale):
|
||||
return l10n.title
|
||||
return self.title_l10n
|
||||
|
||||
@ -115,7 +114,7 @@ class Account(db.Model):
|
||||
title_l10n = db.Column("title", db.String, nullable=False)
|
||||
"""The title."""
|
||||
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,
|
||||
server_default=db.func.now())
|
||||
"""The time of creation."""
|
||||
@ -139,8 +138,9 @@ class Account(db.Model):
|
||||
l10n = db.relationship("AccountL10n", back_populates="account",
|
||||
lazy=False)
|
||||
"""The localized titles."""
|
||||
entries = db.relationship("JournalEntry", back_populates="account")
|
||||
"""The journal entries."""
|
||||
line_items = db.relationship("JournalEntryLineItem",
|
||||
back_populates="account")
|
||||
"""The journal entry line items."""
|
||||
|
||||
CASH_CODE: str = "1111-001"
|
||||
"""The code of the cash account,"""
|
||||
@ -154,7 +154,7 @@ class Account(db.Model):
|
||||
|
||||
:return: The string representation of this account.
|
||||
"""
|
||||
return f"{self.base_code}-{self.no:03d} {self.title}"
|
||||
return f"{self.base_code}-{self.no:03d} {self.title.title()}"
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
@ -170,11 +170,11 @@ class Account(db.Model):
|
||||
|
||||
:return: The title in the current locale.
|
||||
"""
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
current_locale: Locale = get_locale()
|
||||
if current_locale == get_babel().instance.default_locale:
|
||||
return self.title_l10n
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
if l10n.locale == str(current_locale):
|
||||
return l10n.title
|
||||
return self.title_l10n
|
||||
|
||||
@ -188,15 +188,15 @@ class Account(db.Model):
|
||||
if self.title_l10n is None:
|
||||
self.title_l10n = value
|
||||
return
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
current_locale: Locale = get_locale()
|
||||
if current_locale == get_babel().instance.default_locale:
|
||||
self.title_l10n = value
|
||||
return
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
if l10n.locale == str(current_locale):
|
||||
l10n.title = value
|
||||
return
|
||||
self.l10n.append(AccountL10n(locale=current_locale, title=value))
|
||||
self.l10n.append(AccountL10n(locale=str(current_locale), title=value))
|
||||
|
||||
@property
|
||||
def is_real(self) -> bool:
|
||||
@ -235,6 +235,16 @@ class Account(db.Model):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def can_delete(self) -> bool:
|
||||
"""Returns whether the account can be deleted.
|
||||
|
||||
:return: True if the account can be deleted, or False otherwise.
|
||||
"""
|
||||
if self.code in {"1111-001", "3351-001", "3353-001"}:
|
||||
return False
|
||||
return len(self.line_items) == 0
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Deletes this account.
|
||||
|
||||
@ -258,13 +268,15 @@ class Account(db.Model):
|
||||
cls.no == int(m.group(2))).first()
|
||||
|
||||
@classmethod
|
||||
def debit(cls) -> list[t.Self]:
|
||||
"""Returns the debit accounts.
|
||||
def selectable_debit(cls) -> list[t.Self]:
|
||||
"""Returns the selectable debit accounts.
|
||||
Payable line items can not start from debit.
|
||||
|
||||
:return: The debit accounts.
|
||||
:return: The selectable debit accounts.
|
||||
"""
|
||||
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
||||
cls.base_code.startswith("2"),
|
||||
sa.and_(cls.base_code.startswith("2"),
|
||||
sa.not_(cls.is_need_offset)),
|
||||
cls.base_code.startswith("3"),
|
||||
cls.base_code.startswith("5"),
|
||||
cls.base_code.startswith("6"),
|
||||
@ -279,12 +291,14 @@ class Account(db.Model):
|
||||
.order_by(cls.base_code, cls.no).all()
|
||||
|
||||
@classmethod
|
||||
def credit(cls) -> list[t.Self]:
|
||||
"""Returns the debit accounts.
|
||||
def selectable_credit(cls) -> list[t.Self]:
|
||||
"""Returns the selectable debit accounts.
|
||||
Receivable line items can not start from credit.
|
||||
|
||||
:return: The debit accounts.
|
||||
:return: The selectable debit accounts.
|
||||
"""
|
||||
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
||||
return cls.query.filter(sa.or_(sa.and_(cls.base_code.startswith("1"),
|
||||
sa.not_(cls.is_need_offset)),
|
||||
cls.base_code.startswith("2"),
|
||||
cls.base_code.startswith("3"),
|
||||
cls.base_code.startswith("4"),
|
||||
@ -363,15 +377,16 @@ class Currency(db.Model):
|
||||
l10n = db.relationship("CurrencyL10n", back_populates="currency",
|
||||
lazy=False)
|
||||
"""The localized names."""
|
||||
entries = db.relationship("JournalEntry", back_populates="currency")
|
||||
"""The journal entries."""
|
||||
line_items = db.relationship("JournalEntryLineItem",
|
||||
back_populates="currency")
|
||||
"""The journal entry line items."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the string representation of the currency.
|
||||
|
||||
:return: The string representation of the currency.
|
||||
"""
|
||||
return f"{self.name} ({self.code})"
|
||||
return f"{self.name.title()} ({self.code})"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@ -379,11 +394,11 @@ class Currency(db.Model):
|
||||
|
||||
:return: The name in the current locale.
|
||||
"""
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
current_locale: Locale = get_locale()
|
||||
if current_locale == get_babel().instance.default_locale:
|
||||
return self.name_l10n
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
if l10n.locale == str(current_locale):
|
||||
return l10n.name
|
||||
return self.name_l10n
|
||||
|
||||
@ -397,15 +412,15 @@ class Currency(db.Model):
|
||||
if self.name_l10n is None:
|
||||
self.name_l10n = value
|
||||
return
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
current_locale: Locale = get_locale()
|
||||
if current_locale == get_babel().instance.default_locale:
|
||||
self.name_l10n = value
|
||||
return
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
if l10n.locale == str(current_locale):
|
||||
l10n.name = value
|
||||
return
|
||||
self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
|
||||
self.l10n.append(CurrencyL10n(locale=str(current_locale), name=value))
|
||||
|
||||
@property
|
||||
def is_modified(self) -> bool:
|
||||
@ -420,6 +435,17 @@ class Currency(db.Model):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def can_delete(self) -> bool:
|
||||
"""Returns whether the currency can be deleted.
|
||||
|
||||
:return: True if the currency can be deleted, or False otherwise.
|
||||
"""
|
||||
from accounting.template_globals import default_currency_code
|
||||
if self.code == default_currency_code():
|
||||
return False
|
||||
return len(self.line_items) == 0
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Deletes the currency.
|
||||
|
||||
@ -447,23 +473,23 @@ class CurrencyL10n(db.Model):
|
||||
"""The localized name."""
|
||||
|
||||
|
||||
class TransactionCurrency:
|
||||
"""A currency in a transaction."""
|
||||
class JournalEntryCurrency:
|
||||
"""A currency in a journal entry."""
|
||||
|
||||
def __init__(self, code: str, debit: list[JournalEntry],
|
||||
credit: list[JournalEntry]):
|
||||
"""Constructs the currency in the transaction.
|
||||
def __init__(self, code: str, debit: list[JournalEntryLineItem],
|
||||
credit: list[JournalEntryLineItem]):
|
||||
"""Constructs the currency in the journal entry.
|
||||
|
||||
:param code: The currency code.
|
||||
:param debit: The debit entries.
|
||||
:param credit: The credit entries.
|
||||
:param debit: The debit line items.
|
||||
:param credit: The credit line items.
|
||||
"""
|
||||
self.code: str = code
|
||||
"""The currency code."""
|
||||
self.debit: list[JournalEntry] = debit
|
||||
"""The debit entries."""
|
||||
self.credit: list[JournalEntry] = credit
|
||||
"""The credit entries."""
|
||||
self.debit: list[JournalEntryLineItem] = debit
|
||||
"""The debit line items."""
|
||||
self.credit: list[JournalEntryLineItem] = credit
|
||||
"""The credit line items."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@ -475,28 +501,28 @@ class TransactionCurrency:
|
||||
|
||||
@property
|
||||
def debit_total(self) -> Decimal:
|
||||
"""Returns the total amount of the debit journal entries.
|
||||
"""Returns the total amount of the debit line items.
|
||||
|
||||
:return: The total amount of the debit journal entries.
|
||||
:return: The total amount of the debit line items.
|
||||
"""
|
||||
return sum([x.amount for x in self.debit])
|
||||
|
||||
@property
|
||||
def credit_total(self) -> str:
|
||||
"""Returns the total amount of the credit journal entries.
|
||||
"""Returns the total amount of the credit line items.
|
||||
|
||||
:return: The total amount of the credit journal entries.
|
||||
:return: The total amount of the credit line items.
|
||||
"""
|
||||
return sum([x.amount for x in self.credit])
|
||||
|
||||
|
||||
class Transaction(db.Model):
|
||||
"""A transaction."""
|
||||
__tablename__ = "accounting_transactions"
|
||||
class JournalEntry(db.Model):
|
||||
"""A journal entry."""
|
||||
__tablename__ = "accounting_journal_entries"
|
||||
"""The table name."""
|
||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||
autoincrement=False)
|
||||
"""The transaction ID."""
|
||||
"""The journal entry ID."""
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
"""The date."""
|
||||
no = db.Column(db.Integer, nullable=False, default=text("1"))
|
||||
@ -523,35 +549,38 @@ class Transaction(db.Model):
|
||||
"""The ID of the updator."""
|
||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||
"""The updator."""
|
||||
entries = db.relationship("JournalEntry", back_populates="transaction")
|
||||
"""The journal entries."""
|
||||
line_items = db.relationship("JournalEntryLineItem",
|
||||
back_populates="journal_entry")
|
||||
"""The line items."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the string representation of this transaction.
|
||||
"""Returns the string representation of this journal entry.
|
||||
|
||||
:return: The string representation of this transaction.
|
||||
:return: The string representation of this journal entry.
|
||||
"""
|
||||
if self.is_cash_expense:
|
||||
return gettext("Cash Expense Transaction#%(id)s", id=self.id)
|
||||
if self.is_cash_income:
|
||||
return gettext("Cash Income Transaction#%(id)s", id=self.id)
|
||||
return gettext("Transfer Transaction#%(id)s", id=self.id)
|
||||
if self.is_cash_disbursement:
|
||||
return gettext("Cash Disbursement Journal Entry#%(id)s",
|
||||
id=self.id)
|
||||
if self.is_cash_receipt:
|
||||
return gettext("Cash Receipt Journal Entry#%(id)s", id=self.id)
|
||||
return gettext("Transfer Journal Entry#%(id)s", id=self.id)
|
||||
|
||||
@property
|
||||
def currencies(self) -> list[TransactionCurrency]:
|
||||
"""Returns the journal entries categorized by their currencies.
|
||||
def currencies(self) -> list[JournalEntryCurrency]:
|
||||
"""Returns the line items categorized by their currencies.
|
||||
|
||||
:return: The currency categories.
|
||||
"""
|
||||
entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no)
|
||||
line_items: list[JournalEntryLineItem] = sorted(self.line_items,
|
||||
key=lambda x: x.no)
|
||||
codes: list[str] = []
|
||||
by_currency: dict[str, list[JournalEntry]] = {}
|
||||
for entry in entries:
|
||||
if entry.currency_code not in by_currency:
|
||||
codes.append(entry.currency_code)
|
||||
by_currency[entry.currency_code] = []
|
||||
by_currency[entry.currency_code].append(entry)
|
||||
return [TransactionCurrency(code=x,
|
||||
by_currency: dict[str, list[JournalEntryLineItem]] = {}
|
||||
for line_item in line_items:
|
||||
if line_item.currency_code not in by_currency:
|
||||
codes.append(line_item.currency_code)
|
||||
by_currency[line_item.currency_code] = []
|
||||
by_currency[line_item.currency_code].append(line_item)
|
||||
return [JournalEntryCurrency(code=x,
|
||||
debit=[y for y in by_currency[x]
|
||||
if y.is_debit],
|
||||
credit=[y for y in by_currency[x]
|
||||
@ -559,10 +588,11 @@ class Transaction(db.Model):
|
||||
for x in codes]
|
||||
|
||||
@property
|
||||
def is_cash_income(self) -> bool:
|
||||
"""Returns whether this is a cash income transaction.
|
||||
def is_cash_receipt(self) -> bool:
|
||||
"""Returns whether this is a cash receipt journal entry.
|
||||
|
||||
:return: True if this is a cash income transaction, or False otherwise.
|
||||
:return: True if this is a cash receipt journal entry, or False
|
||||
otherwise.
|
||||
"""
|
||||
for currency in self.currencies:
|
||||
if len(currency.debit) > 1:
|
||||
@ -572,10 +602,10 @@ class Transaction(db.Model):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_cash_expense(self) -> bool:
|
||||
"""Returns whether this is a cash expense transaction.
|
||||
def is_cash_disbursement(self) -> bool:
|
||||
"""Returns whether this is a cash disbursement journal entry.
|
||||
|
||||
:return: True if this is a cash expense transaction, or False
|
||||
:return: True if this is a cash disbursement journal entry, or False
|
||||
otherwise.
|
||||
"""
|
||||
for currency in self.currencies:
|
||||
@ -587,99 +617,88 @@ class Transaction(db.Model):
|
||||
|
||||
@property
|
||||
def can_delete(self) -> bool:
|
||||
"""Returns whether the transaction can be deleted.
|
||||
"""Returns whether the journal entry can be deleted.
|
||||
|
||||
:return: True if the transaction can be deleted, or False otherwise.
|
||||
:return: True if the journal entry can be deleted, or False otherwise.
|
||||
"""
|
||||
if not hasattr(self, "__can_delete"):
|
||||
def has_offset() -> bool:
|
||||
for entry in self.entries:
|
||||
if len(entry.offsets) > 0:
|
||||
return True
|
||||
for line_item in self.line_items:
|
||||
if len(line_item.offsets) > 0:
|
||||
return False
|
||||
setattr(self, "__can_delete", not has_offset())
|
||||
return getattr(self, "__can_delete")
|
||||
return True
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Deletes the transaction.
|
||||
"""Deletes the journal entry.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
JournalEntry.query\
|
||||
.filter(JournalEntry.transaction_id == self.id).delete()
|
||||
JournalEntryLineItem.query\
|
||||
.filter(JournalEntryLineItem.journal_entry_id == self.id).delete()
|
||||
db.session.delete(self)
|
||||
|
||||
|
||||
class JournalEntry(db.Model):
|
||||
"""An accounting journal entry."""
|
||||
__tablename__ = "accounting_journal_entries"
|
||||
class JournalEntryLineItem(db.Model):
|
||||
"""A line item in the journal entry."""
|
||||
__tablename__ = "accounting_journal_entry_line_items"
|
||||
"""The table name."""
|
||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||
autoincrement=False)
|
||||
"""The entry ID."""
|
||||
transaction_id = db.Column(db.Integer,
|
||||
db.ForeignKey(Transaction.id,
|
||||
"""The line item ID."""
|
||||
journal_entry_id = db.Column(db.Integer,
|
||||
db.ForeignKey(JournalEntry.id,
|
||||
onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
"""The transaction ID."""
|
||||
transaction = db.relationship(Transaction, back_populates="entries")
|
||||
"""The transaction."""
|
||||
"""The journal entry ID."""
|
||||
journal_entry = db.relationship(JournalEntry, back_populates="line_items")
|
||||
"""The journal entry."""
|
||||
is_debit = db.Column(db.Boolean, nullable=False)
|
||||
"""True for a debit entry, or False for a credit entry."""
|
||||
"""True for a debit line item, or False for a credit line item."""
|
||||
no = db.Column(db.Integer, nullable=False)
|
||||
"""The entry number under the transaction and debit or credit."""
|
||||
original_entry_id = db.Column(db.Integer,
|
||||
"""The line item number under the journal entry and debit or credit."""
|
||||
original_line_item_id = db.Column(db.Integer,
|
||||
db.ForeignKey(id, onupdate="CASCADE"),
|
||||
nullable=True)
|
||||
"""The ID of the original entry."""
|
||||
original_entry = db.relationship("JournalEntry", back_populates="offsets",
|
||||
"""The ID of the original line item."""
|
||||
original_line_item = db.relationship("JournalEntryLineItem",
|
||||
back_populates="offsets",
|
||||
remote_side=id, passive_deletes=True)
|
||||
"""The original entry."""
|
||||
offsets = db.relationship("JournalEntry", back_populates="original_entry")
|
||||
"""The offset entries."""
|
||||
"""The original line item."""
|
||||
offsets = db.relationship("JournalEntryLineItem",
|
||||
back_populates="original_line_item")
|
||||
"""The offset items."""
|
||||
currency_code = db.Column(db.String,
|
||||
db.ForeignKey(Currency.code, onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The currency code."""
|
||||
currency = db.relationship(Currency, back_populates="entries")
|
||||
currency = db.relationship(Currency, back_populates="line_items")
|
||||
"""The currency."""
|
||||
account_id = db.Column(db.Integer,
|
||||
db.ForeignKey(Account.id,
|
||||
onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The account ID."""
|
||||
account = db.relationship(Account, back_populates="entries", lazy=False)
|
||||
account = db.relationship(Account, back_populates="line_items", lazy=False)
|
||||
"""The account."""
|
||||
summary = db.Column(db.String, nullable=True)
|
||||
"""The summary."""
|
||||
description = db.Column(db.String, nullable=True)
|
||||
"""The description."""
|
||||
amount = db.Column(db.Numeric(14, 2), nullable=False)
|
||||
"""The amount."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the string representation of the journal entry.
|
||||
"""Returns the string representation of the line item.
|
||||
|
||||
:return: The string representation of the journal entry.
|
||||
:return: The string representation of the line item.
|
||||
"""
|
||||
if not hasattr(self, "__str"):
|
||||
from accounting.template_filters import format_date, format_amount
|
||||
setattr(self, "__str",
|
||||
gettext("%(date)s %(summary)s %(amount)s",
|
||||
date=format_date(self.transaction.date),
|
||||
summary="" if self.summary is None
|
||||
else self.summary,
|
||||
gettext("%(date)s %(description)s %(amount)s",
|
||||
date=format_date(self.journal_entry.date),
|
||||
description="" if self.description is None
|
||||
else self.description,
|
||||
amount=format_amount(self.amount)))
|
||||
return getattr(self, "__str")
|
||||
|
||||
@property
|
||||
def eid(self) -> int | None:
|
||||
"""Returns the journal entry ID. This is the alternative name of the
|
||||
ID field, to work with WTForms.
|
||||
|
||||
:return: The journal entry ID.
|
||||
"""
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def account_code(self) -> str:
|
||||
"""Returns the account code.
|
||||
@ -692,15 +711,15 @@ class JournalEntry(db.Model):
|
||||
def debit(self) -> Decimal | None:
|
||||
"""Returns the debit amount.
|
||||
|
||||
:return: The debit amount, or None if this is not a debit entry.
|
||||
:return: The debit amount, or None if this is not a debit line item.
|
||||
"""
|
||||
return self.amount if self.is_debit else None
|
||||
|
||||
@property
|
||||
def is_need_offset(self) -> bool:
|
||||
"""Returns whether the entry needs offset.
|
||||
"""Returns whether the line item needs offset.
|
||||
|
||||
:return: True if the entry needs offset, or False otherwise.
|
||||
:return: True if the line item needs offset, or False otherwise.
|
||||
"""
|
||||
if not self.account.is_need_offset:
|
||||
return False
|
||||
@ -714,7 +733,7 @@ class JournalEntry(db.Model):
|
||||
def credit(self) -> Decimal | None:
|
||||
"""Returns the credit amount.
|
||||
|
||||
:return: The credit amount, or None if this is not a credit entry.
|
||||
:return: The credit amount, or None if this is not a credit line item.
|
||||
"""
|
||||
return None if self.is_debit else self.amount
|
||||
|
||||
@ -740,7 +759,7 @@ class JournalEntry(db.Model):
|
||||
setattr(self, "__net_balance", net_balance)
|
||||
|
||||
@property
|
||||
def query_values(self) -> tuple[list[str], list[str]]:
|
||||
def query_values(self) -> list[str]:
|
||||
"""Returns the values to be queried.
|
||||
|
||||
:return: The values to be queried.
|
||||
@ -750,12 +769,38 @@ class JournalEntry(db.Model):
|
||||
frac: Decimal = (value - whole).normalize()
|
||||
return str(whole) + str(abs(frac))[1:]
|
||||
|
||||
txn_day: date = self.transaction.date
|
||||
summary: str = "" if self.summary is None else self.summary
|
||||
return ([summary],
|
||||
[str(txn_day.year),
|
||||
"{}/{}".format(txn_day.year, txn_day.month),
|
||||
"{}/{}".format(txn_day.month, txn_day.day),
|
||||
"{}/{}/{}".format(txn_day.year, txn_day.month, txn_day.day),
|
||||
format_amount(self.amount),
|
||||
format_amount(self.net_balance)])
|
||||
return ["{}/{}/{}".format(self.journal_entry.date.year,
|
||||
self.journal_entry.date.month,
|
||||
self.journal_entry.date.day),
|
||||
"" if self.description is None else self.description,
|
||||
format_amount(self.amount)]
|
||||
|
||||
|
||||
class Option(db.Model):
|
||||
"""An option."""
|
||||
__tablename__ = "accounting_options"
|
||||
"""The table name."""
|
||||
name = db.Column(db.String, nullable=False, primary_key=True)
|
||||
"""The name."""
|
||||
value = db.Column(db.Text, nullable=False)
|
||||
"""The option value."""
|
||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||
server_default=db.func.now())
|
||||
"""The time of creation."""
|
||||
created_by_id = db.Column(db.Integer,
|
||||
db.ForeignKey(user_pk_column,
|
||||
onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The ID of the creator."""
|
||||
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
||||
"""The creator."""
|
||||
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||
server_default=db.func.now())
|
||||
"""The time of last update."""
|
||||
updated_by_id = db.Column(db.Integer,
|
||||
db.ForeignKey(user_pk_column,
|
||||
onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The ID of the updator."""
|
||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||
"""The updator."""
|
||||
|
30
src/accounting/option/__init__.py
Normal file
30
src/accounting/option/__init__.py
Normal file
@ -0,0 +1,30 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The option management.
|
||||
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
def init_app(bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
from .views import bp as option_bp
|
||||
bp.register_blueprint(option_bp, url_prefix="/options")
|
269
src/accounting/option/forms.py
Normal file
269
src/accounting/option/forms.py
Normal file
@ -0,0 +1,269 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The forms for the option management.
|
||||
|
||||
"""
|
||||
from flask import render_template
|
||||
from flask_babel import LazyString
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, FieldList, FormField, IntegerField
|
||||
from wtforms.validators import DataRequired, ValidationError
|
||||
|
||||
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
|
||||
IsDebitAccount, IsCreditAccount
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Account
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.options import Options
|
||||
from accounting.utils.strip_text import strip_text
|
||||
|
||||
|
||||
class CurrentAccountExists:
|
||||
"""The validator to check that the current account exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
|
||||
return
|
||||
if Account.find_by_code(field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The account does not exist."))
|
||||
|
||||
|
||||
class AccountNotCurrent:
|
||||
"""The validator to check that the account is a current account."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
|
||||
return
|
||||
if field.data[:2] not in {"11", "12", "21", "22"}:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"This is not a current account."))
|
||||
|
||||
|
||||
class NotStartPayableFromExpense:
|
||||
"""The validator to check that a payable line item does not start from
|
||||
expense."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None or field.data[0] != "2":
|
||||
return
|
||||
account: Account | None = Account.find_by_code(field.data)
|
||||
if account is not None and account.is_need_offset:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"You cannot select a payable account as expense."))
|
||||
|
||||
|
||||
class NotStartReceivableFromIncome:
|
||||
"""The validator to check that a receivable line item does not start
|
||||
from income."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None or field.data[0] != "1":
|
||||
return
|
||||
account: Account | None = Account.find_by_code(field.data)
|
||||
if account is not None and account.is_need_offset:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"You cannot select a receivable account as income."))
|
||||
|
||||
|
||||
class RecurringItemForm(FlaskForm):
|
||||
"""The base sub-form to add or update the recurring item."""
|
||||
no = IntegerField()
|
||||
"""The order number of this recurring item."""
|
||||
name = StringField()
|
||||
"""The name of the recurring item."""
|
||||
account_code = StringField()
|
||||
"""The account code."""
|
||||
description_template = StringField()
|
||||
"""The description template."""
|
||||
|
||||
@property
|
||||
def account_text(self) -> str | None:
|
||||
"""Returns the account text.
|
||||
|
||||
:return: The account text.
|
||||
"""
|
||||
if self.account_code.data is None:
|
||||
return None
|
||||
account: Account | None = Account.find_by_code(self.account_code.data)
|
||||
return None if account is None else str(account)
|
||||
|
||||
@property
|
||||
def all_errors(self) -> list[str | LazyString]:
|
||||
"""Returns all the errors of the form.
|
||||
|
||||
:return: All the errors of the form.
|
||||
"""
|
||||
all_errors: list[str | LazyString] = []
|
||||
for key in self.errors:
|
||||
if key != "csrf_token":
|
||||
all_errors.extend(self.errors[key])
|
||||
return all_errors
|
||||
|
||||
|
||||
class RecurringExpenseForm(RecurringItemForm):
|
||||
"""The sub-form to add or update the recurring expenses."""
|
||||
no = IntegerField()
|
||||
"""The order number of this recurring item."""
|
||||
name = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
|
||||
"""The name of the recurring item."""
|
||||
account_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
ACCOUNT_REQUIRED,
|
||||
AccountExists(),
|
||||
IsDebitAccount(lazy_gettext("This account is not for expense.")),
|
||||
NotStartPayableFromExpense()])
|
||||
"""The account code."""
|
||||
description_template = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
DataRequired(lazy_gettext(
|
||||
"Please fill in the description template."))])
|
||||
"""The template for the line item description."""
|
||||
|
||||
|
||||
class RecurringIncomeForm(RecurringItemForm):
|
||||
"""The sub-form to add or update the recurring incomes."""
|
||||
no = IntegerField()
|
||||
"""The order number of this recurring item."""
|
||||
name = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
|
||||
"""The name of the recurring item."""
|
||||
account_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
ACCOUNT_REQUIRED,
|
||||
AccountExists(),
|
||||
IsCreditAccount(lazy_gettext("This account is not for income.")),
|
||||
NotStartReceivableFromIncome()])
|
||||
"""The account code."""
|
||||
description_template = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
DataRequired(lazy_gettext(
|
||||
"Please fill in the description template."))])
|
||||
"""The description template."""
|
||||
|
||||
|
||||
class RecurringForm(RecurringItemForm):
|
||||
"""The sub-form for the recurring expenses and incomes."""
|
||||
expenses = FieldList(FormField(RecurringExpenseForm), name="expense")
|
||||
"""The recurring expenses."""
|
||||
incomes = FieldList(FormField(RecurringIncomeForm), name="income")
|
||||
"""The recurring incomes."""
|
||||
|
||||
@property
|
||||
def item_template(self) -> str:
|
||||
"""Returns the template of a recurring item.
|
||||
|
||||
:return: The template of a recurring item.
|
||||
"""
|
||||
return render_template(
|
||||
"accounting/option/include/form-recurring-item.html",
|
||||
expense_income="EXPENSE_INCOME",
|
||||
item_index="ITEM_INDEX",
|
||||
form=RecurringItemForm())
|
||||
|
||||
@property
|
||||
def expense_accounts(self) -> list[Account]:
|
||||
"""The expense accounts.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
return Account.selectable_debit()
|
||||
|
||||
@property
|
||||
def income_accounts(self) -> list[Account]:
|
||||
"""The income accounts.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
return Account.selectable_credit()
|
||||
|
||||
@property
|
||||
def as_data(self) -> dict[str, list[tuple[str, str, str]]]:
|
||||
"""Returns the form data.
|
||||
|
||||
:return: The form data.
|
||||
"""
|
||||
def as_tuple(item: RecurringItemForm) -> tuple[str, str, str]:
|
||||
return (item.name.data, item.account_code.data,
|
||||
item.description_template.data)
|
||||
|
||||
expenses: list[RecurringItemForm] = [x.form for x in self.expenses]
|
||||
self.__sort_item_forms(expenses)
|
||||
incomes: list[RecurringItemForm] = [x.form for x in self.incomes]
|
||||
self.__sort_item_forms(incomes)
|
||||
return {"expense": [as_tuple(x) for x in expenses],
|
||||
"income": [as_tuple(x) for x in incomes]}
|
||||
|
||||
@staticmethod
|
||||
def __sort_item_forms(forms: list[RecurringItemForm]) -> None:
|
||||
"""Sorts the recurring item sub-forms.
|
||||
|
||||
:param forms: The recurring item sub-forms.
|
||||
:return: None.
|
||||
"""
|
||||
ord_by_form: dict[RecurringItemForm, int] \
|
||||
= {forms[i]: i for i in range(len(forms))}
|
||||
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
||||
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
||||
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
||||
ord_by_form.get(x)))
|
||||
|
||||
|
||||
class OptionForm(FlaskForm):
|
||||
"""The form to update the options."""
|
||||
default_currency_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
DataRequired(lazy_gettext("Please select the default currency.")),
|
||||
CurrencyExists()])
|
||||
"""The default currency code."""
|
||||
default_ie_account_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
DataRequired(lazy_gettext(
|
||||
"Please select the default account"
|
||||
" for the income and expenses log.")),
|
||||
CurrentAccountExists(),
|
||||
AccountNotCurrent()])
|
||||
"""The default account code for the income and expenses log."""
|
||||
recurring = FormField(RecurringForm)
|
||||
"""The recurring expenses and incomes."""
|
||||
|
||||
def populate_obj(self, obj: Options) -> None:
|
||||
"""Populates the form data into a currency object.
|
||||
|
||||
:param obj: The currency object.
|
||||
:return: None.
|
||||
"""
|
||||
obj.default_currency_code = self.default_currency_code.data
|
||||
obj.default_ie_account_code = self.default_ie_account_code.data
|
||||
obj.recurring_data = self.recurring.form.as_data
|
||||
|
||||
@property
|
||||
def current_accounts(self) -> list[CurrentAccount]:
|
||||
"""Returns the current accounts.
|
||||
|
||||
:return: The current accounts.
|
||||
"""
|
||||
return CurrentAccount.accounts()
|
83
src/accounting/option/views.py
Normal file
83
src/accounting/option/views.py
Normal file
@ -0,0 +1,83 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The views for the option management.
|
||||
|
||||
"""
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
from flask import Blueprint, render_template, redirect, session, request, \
|
||||
flash, url_for
|
||||
from werkzeug.datastructures import ImmutableMultiDict
|
||||
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.utils.cast import s
|
||||
from accounting.utils.flash_errors import flash_form_errors
|
||||
from accounting.utils.next_uri import inherit_next
|
||||
from accounting.utils.options import options
|
||||
from accounting.utils.permission import has_permission, can_admin
|
||||
from .forms import OptionForm
|
||||
|
||||
bp: Blueprint = Blueprint("option", __name__)
|
||||
"""The view blueprint for the currency management."""
|
||||
|
||||
|
||||
@bp.get("", endpoint="detail")
|
||||
@has_permission(can_admin)
|
||||
def show_options() -> str:
|
||||
"""Shows the options.
|
||||
|
||||
:return: The options.
|
||||
"""
|
||||
return render_template("accounting/option/detail.html", obj=options)
|
||||
|
||||
|
||||
@bp.get("edit", endpoint="edit")
|
||||
@has_permission(can_admin)
|
||||
def show_option_form() -> str:
|
||||
"""Shows the option form.
|
||||
|
||||
:return: The option form.
|
||||
"""
|
||||
form: OptionForm
|
||||
if "form" in session:
|
||||
form = OptionForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||
del session["form"]
|
||||
form.validate()
|
||||
else:
|
||||
form = OptionForm(obj=options)
|
||||
return render_template("accounting/option/form.html", form=form)
|
||||
|
||||
|
||||
@bp.post("update", endpoint="update")
|
||||
@has_permission(can_admin)
|
||||
def update_options() -> redirect:
|
||||
"""Updates the options.
|
||||
|
||||
:return: The redirection to the option form.
|
||||
"""
|
||||
form = OptionForm(request.form)
|
||||
if not form.validate():
|
||||
flash_form_errors(form)
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(inherit_next(url_for("accounting.option.edit")))
|
||||
form.populate_obj(options)
|
||||
if not options.is_modified:
|
||||
flash(s(lazy_gettext("The settings were not modified.")), "success")
|
||||
return redirect(inherit_next(url_for("accounting.option.detail")))
|
||||
options.commit()
|
||||
flash(s(lazy_gettext("The settings are saved successfully.")), "success")
|
||||
return redirect(inherit_next(url_for("accounting.option.detail")))
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -17,14 +17,14 @@
|
||||
"""The report management.
|
||||
|
||||
"""
|
||||
from flask import Flask, Blueprint
|
||||
from flask import Flask
|
||||
|
||||
|
||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
def init_app(app: Flask, url_prefix: str) -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param app: The Flask application.
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:param url_prefix: The URL prefix of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
from .converters import PeriodConverter, IncomeExpensesAccountConverter
|
||||
@ -32,4 +32,4 @@ def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter
|
||||
|
||||
from .views import bp as report_bp
|
||||
bp.register_blueprint(report_bp, url_prefix="/reports")
|
||||
app.register_blueprint(report_bp, url_prefix=url_prefix)
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -23,8 +23,8 @@ from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting.models import Account
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from .period import Period, get_period
|
||||
from .utils.ie_account import IncomeExpensesAccount
|
||||
|
||||
|
||||
class PeriodConverter(BaseConverter):
|
||||
@ -55,22 +55,22 @@ class IncomeExpensesAccountConverter(BaseConverter):
|
||||
"""The supplier converter to convert the income and expenses log pseudo
|
||||
account code from and to the corresponding pseudo account in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> IncomeExpensesAccount:
|
||||
def to_python(self, value: str) -> CurrentAccount:
|
||||
"""Converts an account code to an account.
|
||||
|
||||
:param value: The account code.
|
||||
:return: The corresponding account.
|
||||
"""
|
||||
if value == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||
return IncomeExpensesAccount.current_assets_and_liabilities()
|
||||
if value == CurrentAccount.CURRENT_AL_CODE:
|
||||
return CurrentAccount.current_assets_and_liabilities()
|
||||
if not re.match("^[12][12]", value):
|
||||
abort(404)
|
||||
account: Account | None = Account.find_by_code(value)
|
||||
if account is None:
|
||||
abort(404)
|
||||
return IncomeExpensesAccount(account)
|
||||
return CurrentAccount(account)
|
||||
|
||||
def to_url(self, value: IncomeExpensesAccount) -> str:
|
||||
def to_url(self, value: CurrentAccount) -> str:
|
||||
"""Converts an account to account code.
|
||||
|
||||
:param value: The account.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -23,7 +23,7 @@ This file is largely taken from the NanoParma ERP project, first written in
|
||||
import typing as t
|
||||
from datetime import date
|
||||
|
||||
from accounting.models import Transaction
|
||||
from accounting.models import JournalEntry
|
||||
from .period import Period
|
||||
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
||||
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
|
||||
@ -61,8 +61,8 @@ class PeriodChooser:
|
||||
self.url_template: str = get_url(TemplatePeriod())
|
||||
"""The URL template."""
|
||||
|
||||
first: Transaction | None \
|
||||
= Transaction.query.order_by(Transaction.date).first()
|
||||
first: JournalEntry | None \
|
||||
= JournalEntry.query.order_by(JournalEntry.date).first()
|
||||
start: date | None = None if first is None else first.date
|
||||
|
||||
# Attributes
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -35,7 +35,7 @@ class ThisMonth(Period):
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "this-month"
|
||||
self.desc = gettext("This month")
|
||||
self.desc = gettext("This Month")
|
||||
self.is_a_month = True
|
||||
self.is_type_month = True
|
||||
|
||||
@ -55,7 +55,7 @@ class LastMonth(Period):
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "last-month"
|
||||
self.desc = gettext("Last month")
|
||||
self.desc = gettext("Last Month")
|
||||
self.is_a_month = True
|
||||
self.is_type_month = True
|
||||
|
||||
@ -75,7 +75,7 @@ class SinceLastMonth(Period):
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "since-last-month"
|
||||
self.desc = gettext("Since last month")
|
||||
self.desc = gettext("Since Last Month")
|
||||
self.is_type_month = True
|
||||
|
||||
|
||||
@ -90,7 +90,7 @@ class ThisYear(Period):
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "this-year"
|
||||
self.desc = gettext("This year")
|
||||
self.desc = gettext("This Year")
|
||||
self.is_a_year = True
|
||||
|
||||
|
||||
@ -105,7 +105,7 @@ class LastYear(Period):
|
||||
|
||||
def _set_properties(self) -> None:
|
||||
self.spec = "last-year"
|
||||
self.desc = gettext("Last year")
|
||||
self.desc = gettext("Last Year")
|
||||
self.is_a_year = True
|
||||
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -24,8 +24,8 @@ from flask import render_template, Response
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
||||
JournalEntry
|
||||
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
@ -124,19 +124,20 @@ class AccountCollector:
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code,
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
sa.or_(*sub_conditions)]
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
conditions.append(JournalEntry.date <= self.__period.end)
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntry.is_debit, JournalEntry.amount),
|
||||
else_=-JournalEntry.amount)).label("balance")
|
||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||
else_=-JournalEntryLineItem.amount)).label("balance")
|
||||
select_balance: sa.Select \
|
||||
= sa.select(Account.id, Account.base_code, Account.no,
|
||||
balance_func)\
|
||||
.join(Transaction).join(Account)\
|
||||
.join(JournalEntry).join(Account)\
|
||||
.filter(*conditions)\
|
||||
.group_by(Account.id, Account.base_code, Account.no)\
|
||||
.having(balance_func != 0)\
|
||||
.order_by(Account.base_code, Account.no)
|
||||
account_balances: list[sa.Row] \
|
||||
= db.session.execute(select_balance).all()
|
||||
@ -178,8 +179,8 @@ class AccountCollector:
|
||||
if self.__period.start is None:
|
||||
return None
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code,
|
||||
Transaction.date < self.__period.start]
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
JournalEntry.date < self.__period.start]
|
||||
return self.__query_balance(conditions)
|
||||
|
||||
def __add_current_period(self) -> None:
|
||||
@ -197,11 +198,11 @@ class AccountCollector:
|
||||
:return: The net income or loss for current period.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code]
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(Transaction.date >= self.__period.start)
|
||||
conditions.append(JournalEntry.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
conditions.append(JournalEntry.date <= self.__period.end)
|
||||
return self.__query_balance(conditions)
|
||||
|
||||
@staticmethod
|
||||
@ -215,10 +216,10 @@ class AccountCollector:
|
||||
conditions.extend([sa.not_(Account.base_code.startswith(x))
|
||||
for x in {"1", "2", "3"}])
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntry.is_debit, JournalEntry.amount),
|
||||
else_=-JournalEntry.amount))
|
||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||
else_=-JournalEntryLineItem.amount))
|
||||
select_balance: sa.Select = sa.select(balance_func)\
|
||||
.join(Transaction).join(Account).filter(*conditions)
|
||||
.join(JournalEntry).join(Account).filter(*conditions)
|
||||
return db.session.scalar(select_balance)
|
||||
|
||||
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -26,39 +26,40 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||
period_spec
|
||||
from accounting.report.utils.ie_account import IncomeExpensesAccount
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.urls import income_expenses_url
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.pagination import Pagination
|
||||
|
||||
|
||||
class ReportEntry:
|
||||
"""An entry in the report."""
|
||||
class ReportLineItem:
|
||||
"""A line item in the report."""
|
||||
|
||||
def __init__(self, entry: JournalEntry | None = None):
|
||||
"""Constructs the entry in the report.
|
||||
def __init__(self, line_item: JournalEntryLineItem | None = None):
|
||||
"""Constructs the line item in the report.
|
||||
|
||||
:param entry: The journal entry.
|
||||
:param line_item: The journal entry line item.
|
||||
"""
|
||||
self.is_brought_forward: bool = False
|
||||
"""Whether this is the brought-forward entry."""
|
||||
"""Whether this is the brought-forward line item."""
|
||||
self.is_total: bool = False
|
||||
"""Whether this is the total entry."""
|
||||
"""Whether this is the total line item."""
|
||||
self.date: date | None = None
|
||||
"""The date."""
|
||||
self.account: Account | None = None
|
||||
"""The account."""
|
||||
self.summary: str | None = None
|
||||
"""The summary."""
|
||||
self.description: str | None = None
|
||||
"""The description."""
|
||||
self.income: Decimal | None = None
|
||||
"""The income amount."""
|
||||
self.expense: Decimal | None = None
|
||||
@ -68,24 +69,24 @@ class ReportEntry:
|
||||
self.note: str | None = None
|
||||
"""The note."""
|
||||
self.url: str | None = None
|
||||
"""The URL to the journal entry."""
|
||||
if entry is not None:
|
||||
self.date = entry.transaction.date
|
||||
self.account = entry.account
|
||||
self.summary = entry.summary
|
||||
self.income = None if entry.is_debit else entry.amount
|
||||
self.expense = entry.amount if entry.is_debit else None
|
||||
self.note = entry.transaction.note
|
||||
self.url = url_for("accounting.transaction.detail",
|
||||
txn=entry.transaction)
|
||||
"""The URL to the journal entry line item."""
|
||||
if line_item is not None:
|
||||
self.date = line_item.journal_entry.date
|
||||
self.account = line_item.account
|
||||
self.description = line_item.description
|
||||
self.income = None if line_item.is_debit else line_item.amount
|
||||
self.expense = line_item.amount if line_item.is_debit else None
|
||||
self.note = line_item.journal_entry.note
|
||||
self.url = url_for("accounting.journal-entry.detail",
|
||||
journal_entry=line_item.journal_entry)
|
||||
|
||||
|
||||
class EntryCollector:
|
||||
"""The report entry collector."""
|
||||
class LineItemCollector:
|
||||
"""The line item collector."""
|
||||
|
||||
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
|
||||
def __init__(self, currency: Currency, account: CurrentAccount,
|
||||
period: Period):
|
||||
"""Constructs the report entry collector.
|
||||
"""Constructs the line item collector.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
@ -93,148 +94,150 @@ class EntryCollector:
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__account: IncomeExpensesAccount = account
|
||||
self.__account: CurrentAccount = account
|
||||
"""The account."""
|
||||
self.__period: Period = period
|
||||
"""The period"""
|
||||
self.brought_forward: ReportEntry | None
|
||||
"""The brought-forward entry."""
|
||||
self.entries: list[ReportEntry]
|
||||
"""The log entries."""
|
||||
self.total: ReportEntry | None
|
||||
"""The total entry."""
|
||||
self.brought_forward = self.__get_brought_forward_entry()
|
||||
self.entries = self.__query_entries()
|
||||
self.total = self.__get_total_entry()
|
||||
self.brought_forward: ReportLineItem | None
|
||||
"""The brought-forward line item."""
|
||||
self.line_items: list[ReportLineItem]
|
||||
"""The line items."""
|
||||
self.total: ReportLineItem | None
|
||||
"""The total line item."""
|
||||
self.brought_forward = self.__get_brought_forward()
|
||||
self.line_items = self.__query_line_items()
|
||||
self.total = self.__get_total()
|
||||
self.__populate_balance()
|
||||
|
||||
def __get_brought_forward_entry(self) -> ReportEntry | None:
|
||||
"""Queries, composes and returns the brought-forward entry.
|
||||
def __get_brought_forward(self) -> ReportLineItem | None:
|
||||
"""Queries, composes and returns the brought-forward line item.
|
||||
|
||||
:return: The brought-forward entry, or None if the period starts from
|
||||
the beginning.
|
||||
:return: The brought-forward line item, or None if the period starts
|
||||
from the beginning.
|
||||
"""
|
||||
if self.__period.start is None:
|
||||
return None
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntry.is_debit, JournalEntry.amount),
|
||||
else_=-JournalEntry.amount))
|
||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||
else_=-JournalEntryLineItem.amount))
|
||||
select: sa.Select = sa.Select(balance_func)\
|
||||
.join(Transaction).join(Account)\
|
||||
.filter(be(JournalEntry.currency_code == self.__currency.code),
|
||||
.join(JournalEntry).join(Account)\
|
||||
.filter(be(JournalEntryLineItem.currency_code
|
||||
== self.__currency.code),
|
||||
self.__account_condition,
|
||||
Transaction.date < self.__period.start)
|
||||
JournalEntry.date < self.__period.start)
|
||||
balance: int | None = db.session.scalar(select)
|
||||
if balance is None:
|
||||
return None
|
||||
entry: ReportEntry = ReportEntry()
|
||||
entry.is_brought_forward = True
|
||||
entry.date = self.__period.start
|
||||
entry.account = Account.accumulated_change()
|
||||
entry.summary = gettext("Brought forward")
|
||||
line_item: ReportLineItem = ReportLineItem()
|
||||
line_item.is_brought_forward = True
|
||||
line_item.date = self.__period.start
|
||||
line_item.account = Account.accumulated_change()
|
||||
line_item.description = gettext("Brought forward")
|
||||
if balance > 0:
|
||||
entry.income = balance
|
||||
line_item.income = balance
|
||||
elif balance < 0:
|
||||
entry.expense = -balance
|
||||
entry.balance = balance
|
||||
return entry
|
||||
line_item.expense = -balance
|
||||
line_item.balance = balance
|
||||
return line_item
|
||||
|
||||
def __query_entries(self) -> list[ReportEntry]:
|
||||
"""Queries and returns the log entries.
|
||||
def __query_line_items(self) -> list[ReportLineItem]:
|
||||
"""Queries and returns the line items.
|
||||
|
||||
:return: The log entries.
|
||||
:return: The line items.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code,
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
self.__account_condition]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(Transaction.date >= self.__period.start)
|
||||
conditions.append(JournalEntry.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
txn_with_account: sa.Select = sa.Select(Transaction.id).\
|
||||
join(JournalEntry).join(Account).filter(*conditions)
|
||||
conditions.append(JournalEntry.date <= self.__period.end)
|
||||
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
|
||||
join(JournalEntryLineItem).join(Account).filter(*conditions)
|
||||
|
||||
return [ReportEntry(x)
|
||||
for x in JournalEntry.query.join(Transaction).join(Account)
|
||||
.filter(JournalEntry.transaction_id.in_(txn_with_account),
|
||||
JournalEntry.currency_code == self.__currency.code,
|
||||
return [ReportLineItem(x)
|
||||
for x in JournalEntryLineItem.query
|
||||
.join(JournalEntry).join(Account)
|
||||
.filter(JournalEntryLineItem.journal_entry_id
|
||||
.in_(journal_entry_with_account),
|
||||
JournalEntryLineItem.currency_code
|
||||
== self.__currency.code,
|
||||
sa.not_(self.__account_condition))
|
||||
.order_by(Transaction.date,
|
||||
Transaction.no,
|
||||
JournalEntry.is_debit,
|
||||
JournalEntry.no)
|
||||
.options(selectinload(JournalEntry.account),
|
||||
selectinload(JournalEntry.transaction))]
|
||||
.order_by(JournalEntry.date,
|
||||
JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit,
|
||||
JournalEntryLineItem.no)
|
||||
.options(selectinload(JournalEntryLineItem.account),
|
||||
selectinload(JournalEntryLineItem.journal_entry))]
|
||||
|
||||
@property
|
||||
def __account_condition(self) -> sa.BinaryExpression:
|
||||
if self.__account.code == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||
return sa.or_(Account.base_code.startswith("11"),
|
||||
Account.base_code.startswith("12"),
|
||||
Account.base_code.startswith("21"),
|
||||
Account.base_code.startswith("22"))
|
||||
if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
|
||||
return CurrentAccount.sql_condition()
|
||||
return Account.id == self.__account.id
|
||||
|
||||
def __get_total_entry(self) -> ReportEntry | None:
|
||||
"""Composes the total entry.
|
||||
def __get_total(self) -> ReportLineItem | None:
|
||||
"""Composes the total line item.
|
||||
|
||||
:return: The total entry, or None if there is no data.
|
||||
:return: The total line item, or None if there is no data.
|
||||
"""
|
||||
if self.brought_forward is None and len(self.entries) == 0:
|
||||
if self.brought_forward is None and len(self.line_items) == 0:
|
||||
return None
|
||||
entry: ReportEntry = ReportEntry()
|
||||
entry.is_total = True
|
||||
entry.summary = gettext("Total")
|
||||
entry.income = sum([x.income for x in self.entries
|
||||
line_item: ReportLineItem = ReportLineItem()
|
||||
line_item.is_total = True
|
||||
line_item.description = gettext("Total")
|
||||
line_item.income = sum([x.income for x in self.line_items
|
||||
if x.income is not None])
|
||||
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])
|
||||
entry.balance = entry.income - entry.expense
|
||||
line_item.balance = line_item.income - line_item.expense
|
||||
if self.brought_forward is not None:
|
||||
entry.balance = self.brought_forward.balance + entry.balance
|
||||
return entry
|
||||
line_item.balance \
|
||||
= self.brought_forward.balance + line_item.balance
|
||||
return line_item
|
||||
|
||||
def __populate_balance(self) -> None:
|
||||
"""Populates the balance of the entries.
|
||||
"""Populates the balance of the line items.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
balance: Decimal = 0 if self.brought_forward is None \
|
||||
else self.brought_forward.balance
|
||||
for entry in self.entries:
|
||||
if entry.income is not None:
|
||||
balance = balance + entry.income
|
||||
if entry.expense is not None:
|
||||
balance = balance - entry.expense
|
||||
entry.balance = balance
|
||||
for line_item in self.line_items:
|
||||
if line_item.income is not None:
|
||||
balance = balance + line_item.income
|
||||
if line_item.expense is not None:
|
||||
balance = balance - line_item.expense
|
||||
line_item.balance = balance
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, txn_date: date | str | None,
|
||||
def __init__(self, journal_entry_date: date | str | None,
|
||||
account: str | None,
|
||||
summary: str | None,
|
||||
description: str | None,
|
||||
income: str | Decimal | None,
|
||||
expense: str | Decimal | None,
|
||||
balance: str | Decimal | None,
|
||||
note: str | None):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param txn_date: The transaction date.
|
||||
:param journal_entry_date: The journal entry date.
|
||||
:param account: The account.
|
||||
:param summary: The summary.
|
||||
:param description: The description.
|
||||
:param income: The income.
|
||||
:param expense: The expense.
|
||||
:param balance: The balance.
|
||||
:param note: The note.
|
||||
"""
|
||||
self.date: date | str | None = txn_date
|
||||
self.date: date | str | None = journal_entry_date
|
||||
"""The date."""
|
||||
self.account: str | None = account
|
||||
"""The account."""
|
||||
self.summary: str | None = summary
|
||||
"""The summary."""
|
||||
self.description: str | None = description
|
||||
"""The description."""
|
||||
self.income: str | Decimal | None = income
|
||||
"""The income."""
|
||||
self.expense: str | Decimal | None = expense
|
||||
@ -250,7 +253,7 @@ class CSVRow(BaseCSVRow):
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.date, self.account, self.summary,
|
||||
return [self.date, self.account, self.description,
|
||||
self.income, self.expense, self.balance, self.note]
|
||||
|
||||
|
||||
@ -258,39 +261,39 @@ class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, currency: Currency,
|
||||
account: IncomeExpensesAccount,
|
||||
account: CurrentAccount,
|
||||
period: Period,
|
||||
has_data: bool,
|
||||
pagination: Pagination[ReportEntry],
|
||||
brought_forward: ReportEntry | None,
|
||||
entries: list[ReportEntry],
|
||||
total: ReportEntry | None):
|
||||
pagination: Pagination[ReportLineItem],
|
||||
brought_forward: ReportLineItem | None,
|
||||
line_items: list[ReportLineItem],
|
||||
total: ReportLineItem | None):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
:param has_data: True if there is any data, or False otherwise.
|
||||
:param brought_forward: The brought-forward entry.
|
||||
:param entries: The log entries.
|
||||
:param total: The total entry.
|
||||
:param brought_forward: The brought-forward line item.
|
||||
:param line_items: The line items.
|
||||
:param total: The total line item.
|
||||
"""
|
||||
self.currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.account: IncomeExpensesAccount = account
|
||||
self.account: CurrentAccount = account
|
||||
"""The account."""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
self.__has_data: bool = has_data
|
||||
"""True if there is any data, or False otherwise."""
|
||||
self.pagination: Pagination[ReportEntry] = pagination
|
||||
self.pagination: Pagination[ReportLineItem] = pagination
|
||||
"""The pagination."""
|
||||
self.brought_forward: ReportEntry | None = brought_forward
|
||||
"""The brought-forward entry."""
|
||||
self.entries: list[ReportEntry] = entries
|
||||
"""The report entries."""
|
||||
self.total: ReportEntry | None = total
|
||||
"""The total entry."""
|
||||
self.brought_forward: ReportLineItem | None = brought_forward
|
||||
"""The brought-forward line item."""
|
||||
self.line_items: list[ReportLineItem] = line_items
|
||||
"""The line items."""
|
||||
self.total: ReportLineItem | None = total
|
||||
"""The total line item."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: income_expenses_url(currency, account, x))
|
||||
"""The period chooser."""
|
||||
@ -335,25 +338,23 @@ class PageParams(BasePageParams):
|
||||
|
||||
:return: The account options.
|
||||
"""
|
||||
current_al: IncomeExpensesAccount \
|
||||
= IncomeExpensesAccount.current_assets_and_liabilities()
|
||||
current_al: CurrentAccount \
|
||||
= CurrentAccount.current_assets_and_liabilities()
|
||||
options: list[OptionLink] \
|
||||
= [OptionLink(str(current_al),
|
||||
income_expenses_url(self.currency, current_al,
|
||||
self.period),
|
||||
self.account.id == 0)]
|
||||
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
||||
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
|
||||
.join(Account)\
|
||||
.filter(be(JournalEntry.currency_code == self.currency.code),
|
||||
sa.or_(Account.base_code.startswith("11"),
|
||||
Account.base_code.startswith("12"),
|
||||
Account.base_code.startswith("21"),
|
||||
Account.base_code.startswith("22")))\
|
||||
.group_by(JournalEntry.account_id)
|
||||
.filter(be(JournalEntryLineItem.currency_code
|
||||
== self.currency.code),
|
||||
CurrentAccount.sql_condition())\
|
||||
.group_by(JournalEntryLineItem.account_id)
|
||||
options.extend([OptionLink(str(x),
|
||||
income_expenses_url(
|
||||
self.currency,
|
||||
IncomeExpensesAccount(x),
|
||||
CurrentAccount(x),
|
||||
self.period),
|
||||
x.id == self.account.id)
|
||||
for x in Account.query.filter(Account.id.in_(in_use))
|
||||
@ -364,7 +365,7 @@ class PageParams(BasePageParams):
|
||||
class IncomeExpenses(BaseReport):
|
||||
"""The income and expenses log."""
|
||||
|
||||
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
|
||||
def __init__(self, currency: Currency, account: CurrentAccount,
|
||||
period: Period):
|
||||
"""Constructs an income and expenses log.
|
||||
|
||||
@ -374,18 +375,19 @@ class IncomeExpenses(BaseReport):
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__account: IncomeExpensesAccount = account
|
||||
self.__account: CurrentAccount = account
|
||||
"""The account."""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
collector: EntryCollector = EntryCollector(
|
||||
collector: LineItemCollector = LineItemCollector(
|
||||
self.__currency, self.__account, self.__period)
|
||||
self.__brought_forward: ReportEntry | None = collector.brought_forward
|
||||
"""The brought-forward entry."""
|
||||
self.__entries: list[ReportEntry] = collector.entries
|
||||
"""The report entries."""
|
||||
self.__total: ReportEntry | None = collector.total
|
||||
"""The total entry."""
|
||||
self.__brought_forward: ReportLineItem | None \
|
||||
= collector.brought_forward
|
||||
"""The brought-forward line item."""
|
||||
self.__line_items: list[ReportLineItem] = collector.line_items
|
||||
"""The line items."""
|
||||
self.__total: ReportLineItem | None = collector.total
|
||||
"""The total line item."""
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
@ -403,20 +405,20 @@ class IncomeExpenses(BaseReport):
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
|
||||
gettext("Summary"), gettext("Income"),
|
||||
gettext("Description"), gettext("Income"),
|
||||
gettext("Expense"), gettext("Balance"),
|
||||
gettext("Note"))]
|
||||
if self.__brought_forward is not None:
|
||||
rows.append(CSVRow(self.__brought_forward.date,
|
||||
str(self.__brought_forward.account).title(),
|
||||
self.__brought_forward.summary,
|
||||
self.__brought_forward.description,
|
||||
self.__brought_forward.income,
|
||||
self.__brought_forward.expense,
|
||||
self.__brought_forward.balance,
|
||||
None))
|
||||
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
|
||||
rows.extend([CSVRow(x.date, str(x.account).title(), x.description,
|
||||
x.income, x.expense, x.balance, x.note)
|
||||
for x in self.__entries])
|
||||
for x in self.__line_items])
|
||||
if self.__total is not None:
|
||||
rows.append(CSVRow(gettext("Total"), None, None,
|
||||
self.__total.income, self.__total.expense,
|
||||
@ -428,31 +430,31 @@ class IncomeExpenses(BaseReport):
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
all_entries: list[ReportEntry] = []
|
||||
all_line_items: list[ReportLineItem] = []
|
||||
if self.__brought_forward is not None:
|
||||
all_entries.append(self.__brought_forward)
|
||||
all_entries.extend(self.__entries)
|
||||
all_line_items.append(self.__brought_forward)
|
||||
all_line_items.extend(self.__line_items)
|
||||
if self.__total is not None:
|
||||
all_entries.append(self.__total)
|
||||
pagination: Pagination[ReportEntry] \
|
||||
= Pagination[ReportEntry](all_entries, is_reversed=True)
|
||||
page_entries: list[ReportEntry] = pagination.list
|
||||
has_data: bool = len(page_entries) > 0
|
||||
brought_forward: ReportEntry | None = None
|
||||
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
|
||||
brought_forward = page_entries[0]
|
||||
page_entries = page_entries[1:]
|
||||
total: ReportEntry | None = None
|
||||
if len(page_entries) > 0 and page_entries[-1].is_total:
|
||||
total = page_entries[-1]
|
||||
page_entries = page_entries[:-1]
|
||||
all_line_items.append(self.__total)
|
||||
pagination: Pagination[ReportLineItem] \
|
||||
= Pagination[ReportLineItem](all_line_items, is_reversed=True)
|
||||
page_line_items: list[ReportLineItem] = pagination.list
|
||||
has_data: bool = len(page_line_items) > 0
|
||||
brought_forward: ReportLineItem | None = None
|
||||
if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
|
||||
brought_forward = page_line_items[0]
|
||||
page_line_items = page_line_items[1:]
|
||||
total: ReportLineItem | None = None
|
||||
if len(page_line_items) > 0 and page_line_items[-1].is_total:
|
||||
total = page_line_items[-1]
|
||||
page_line_items = page_line_items[:-1]
|
||||
params: PageParams = PageParams(currency=self.__currency,
|
||||
account=self.__account,
|
||||
period=self.__period,
|
||||
has_data=has_data,
|
||||
pagination=pagination,
|
||||
brought_forward=brought_forward,
|
||||
entries=page_entries,
|
||||
line_items=page_line_items,
|
||||
total=total)
|
||||
return render_template("accounting/report/income-expenses.html",
|
||||
report=params)
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -24,8 +24,8 @@ from flask import render_template, Response
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
||||
JournalEntry
|
||||
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
@ -256,19 +256,20 @@ class IncomeStatement(BaseReport):
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code,
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
sa.or_(*sub_conditions)]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(Transaction.date >= self.__period.start)
|
||||
conditions.append(JournalEntry.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
conditions.append(JournalEntry.date <= self.__period.end)
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntry.is_debit, -JournalEntry.amount),
|
||||
else_=JournalEntry.amount)).label("balance")
|
||||
(JournalEntryLineItem.is_debit, -JournalEntryLineItem.amount),
|
||||
else_=JournalEntryLineItem.amount)).label("balance")
|
||||
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||
.join(Transaction).join(Account)\
|
||||
.join(JournalEntry).join(Account)\
|
||||
.filter(*conditions)\
|
||||
.group_by(Account.id)\
|
||||
.having(balance_func != 0)\
|
||||
.order_by(Account.base_code, Account.no)
|
||||
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
||||
accounts: dict[int, Account] \
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -25,7 +25,8 @@ from flask import render_template, Response
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
@ -37,58 +38,58 @@ from accounting.report.utils.urls import journal_url
|
||||
from accounting.utils.pagination import Pagination
|
||||
|
||||
|
||||
class ReportEntry:
|
||||
"""An entry in the report."""
|
||||
class ReportLineItem:
|
||||
"""A line item in the report."""
|
||||
|
||||
def __init__(self, entry: JournalEntry):
|
||||
"""Constructs the entry in the report.
|
||||
def __init__(self, line_item: JournalEntryLineItem):
|
||||
"""Constructs the line item in the report.
|
||||
|
||||
:param entry: The journal entry.
|
||||
:param line_item: The journal entry line item.
|
||||
"""
|
||||
self.entry: JournalEntry = entry
|
||||
self.line_item: JournalEntryLineItem = line_item
|
||||
"""The journal entry line item."""
|
||||
self.journal_entry: JournalEntry = line_item.journal_entry
|
||||
"""The journal entry."""
|
||||
self.transaction: Transaction = entry.transaction
|
||||
"""The transaction."""
|
||||
self.currency: Currency = entry.currency
|
||||
self.currency: Currency = line_item.currency
|
||||
"""The account."""
|
||||
self.account: Account = entry.account
|
||||
self.account: Account = line_item.account
|
||||
"""The account."""
|
||||
self.summary: str | None = entry.summary
|
||||
"""The summary."""
|
||||
self.debit: Decimal | None = entry.debit
|
||||
self.description: str | None = line_item.description
|
||||
"""The description."""
|
||||
self.debit: Decimal | None = line_item.debit
|
||||
"""The debit amount."""
|
||||
self.credit: Decimal | None = entry.credit
|
||||
self.credit: Decimal | None = line_item.credit
|
||||
"""The credit amount."""
|
||||
self.amount: Decimal = entry.amount
|
||||
self.amount: Decimal = line_item.amount
|
||||
"""The amount."""
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, txn_date: str | date,
|
||||
def __init__(self, journal_entry_date: str | date,
|
||||
currency: str,
|
||||
account: str,
|
||||
summary: str | None,
|
||||
description: str | None,
|
||||
debit: str | Decimal | None,
|
||||
credit: str | Decimal | None,
|
||||
note: str | None):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param txn_date: The transaction date.
|
||||
:param summary: The summary.
|
||||
:param journal_entry_date: The journal entry date.
|
||||
:param description: The description.
|
||||
:param debit: The debit amount.
|
||||
:param credit: The credit amount.
|
||||
:param note: The note.
|
||||
"""
|
||||
self.date: str | date = txn_date
|
||||
self.date: str | date = journal_entry_date
|
||||
"""The date."""
|
||||
self.currency: str = currency
|
||||
"""The currency."""
|
||||
self.account: str = account
|
||||
"""The account."""
|
||||
self.summary: str | None = summary
|
||||
"""The summary."""
|
||||
self.description: str | None = description
|
||||
"""The description."""
|
||||
self.debit: str | Decimal | None = debit
|
||||
"""The debit amount."""
|
||||
self.credit: str | Decimal | None = credit
|
||||
@ -102,7 +103,7 @@ class CSVRow(BaseCSVRow):
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.date, self.currency, self.account, self.summary,
|
||||
return [self.date, self.currency, self.account, self.description,
|
||||
self.debit, self.credit, self.note]
|
||||
|
||||
|
||||
@ -110,19 +111,19 @@ class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, period: Period,
|
||||
pagination: Pagination[JournalEntry],
|
||||
entries: list[JournalEntry]):
|
||||
pagination: Pagination[JournalEntryLineItem],
|
||||
line_items: list[JournalEntryLineItem]):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param period: The period.
|
||||
:param entries: The journal entries.
|
||||
:param line_items: The line items.
|
||||
"""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
self.pagination: Pagination[JournalEntry] = pagination
|
||||
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
||||
"""The pagination."""
|
||||
self.entries: list[JournalEntry] = entries
|
||||
"""The entries."""
|
||||
self.line_items: list[JournalEntryLineItem] = line_items
|
||||
"""The line items."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: journal_url(x))
|
||||
"""The period chooser."""
|
||||
@ -133,7 +134,7 @@ class PageParams(BasePageParams):
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return len(self.entries) > 0
|
||||
return len(self.line_items) > 0
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
@ -145,20 +146,20 @@ class PageParams(BasePageParams):
|
||||
period=self.period)
|
||||
|
||||
|
||||
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
|
||||
"""Composes and returns the CSV rows from the report entries.
|
||||
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
|
||||
"""Composes and returns the CSV rows from the line items.
|
||||
|
||||
:param entries: The report entries.
|
||||
:param line_items: The line items.
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
|
||||
gettext("Account"), gettext("Summary"),
|
||||
gettext("Account"), gettext("Description"),
|
||||
gettext("Debit"), gettext("Credit"),
|
||||
gettext("Note"))]
|
||||
rows.extend([CSVRow(x.transaction.date, x.currency.code,
|
||||
str(x.account).title(), x.summary,
|
||||
x.debit, x.credit, x.transaction.note)
|
||||
for x in entries])
|
||||
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
|
||||
str(x.account).title(), x.description,
|
||||
x.debit, x.credit, x.journal_entry.note)
|
||||
for x in line_items])
|
||||
return rows
|
||||
|
||||
|
||||
@ -172,28 +173,29 @@ class Journal(BaseReport):
|
||||
"""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
self.__entries: list[JournalEntry] = self.__query_entries()
|
||||
"""The journal entries."""
|
||||
self.__line_items: list[JournalEntryLineItem] \
|
||||
= self.__query_line_items()
|
||||
"""The line items."""
|
||||
|
||||
def __query_entries(self) -> list[JournalEntry]:
|
||||
"""Queries and returns the journal entries.
|
||||
def __query_line_items(self) -> list[JournalEntryLineItem]:
|
||||
"""Queries and returns the line items.
|
||||
|
||||
:return: The journal entries.
|
||||
:return: The line items.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
if self.__period.start is not None:
|
||||
conditions.append(Transaction.date >= self.__period.start)
|
||||
conditions.append(JournalEntry.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
return JournalEntry.query.join(Transaction)\
|
||||
conditions.append(JournalEntry.date <= self.__period.end)
|
||||
return JournalEntryLineItem.query.join(JournalEntry)\
|
||||
.filter(*conditions)\
|
||||
.order_by(Transaction.date,
|
||||
Transaction.no,
|
||||
JournalEntry.is_debit.desc(),
|
||||
JournalEntry.no)\
|
||||
.options(selectinload(JournalEntry.account),
|
||||
selectinload(JournalEntry.currency),
|
||||
selectinload(JournalEntry.transaction)).all()
|
||||
.order_by(JournalEntry.date,
|
||||
JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit.desc(),
|
||||
JournalEntryLineItem.no)\
|
||||
.options(selectinload(JournalEntryLineItem.account),
|
||||
selectinload(JournalEntryLineItem.currency),
|
||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
@ -201,17 +203,18 @@ class Journal(BaseReport):
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = f"journal-{period_spec(self.__period)}.csv"
|
||||
return csv_download(filename, get_csv_rows(self.__entries))
|
||||
return csv_download(filename, get_csv_rows(self.__line_items))
|
||||
|
||||
def html(self) -> str:
|
||||
"""Composes and returns the report as HTML.
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
pagination: Pagination[JournalEntry] \
|
||||
= Pagination[JournalEntry](self.__entries, is_reversed=True)
|
||||
pagination: Pagination[JournalEntryLineItem] \
|
||||
= Pagination[JournalEntryLineItem](self.__line_items,
|
||||
is_reversed=True)
|
||||
params: PageParams = PageParams(period=self.__period,
|
||||
pagination=pagination,
|
||||
entries=pagination.list)
|
||||
line_items=pagination.list)
|
||||
return render_template("accounting/report/journal.html",
|
||||
report=params)
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -26,7 +26,8 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
@ -40,22 +41,22 @@ from accounting.utils.cast import be
|
||||
from accounting.utils.pagination import Pagination
|
||||
|
||||
|
||||
class ReportEntry:
|
||||
"""An entry in the report."""
|
||||
class ReportLineItem:
|
||||
"""A line item in the report."""
|
||||
|
||||
def __init__(self, entry: JournalEntry | None = None):
|
||||
"""Constructs the entry in the report.
|
||||
def __init__(self, line_item: JournalEntryLineItem | None = None):
|
||||
"""Constructs the line item in the report.
|
||||
|
||||
:param entry: The journal entry.
|
||||
:param line_item: The journal entry line item.
|
||||
"""
|
||||
self.is_brought_forward: bool = False
|
||||
"""Whether this is the brought-forward entry."""
|
||||
"""Whether this is the brought-forward line item."""
|
||||
self.is_total: bool = False
|
||||
"""Whether this is the total entry."""
|
||||
"""Whether this is the total line item."""
|
||||
self.date: date | None = None
|
||||
"""The date."""
|
||||
self.summary: str | None = None
|
||||
"""The summary."""
|
||||
self.description: str | None = None
|
||||
"""The description."""
|
||||
self.debit: Decimal | None = None
|
||||
"""The debit amount."""
|
||||
self.credit: Decimal | None = None
|
||||
@ -65,22 +66,22 @@ class ReportEntry:
|
||||
self.note: str | None = None
|
||||
"""The note."""
|
||||
self.url: str | None = None
|
||||
"""The URL to the journal entry."""
|
||||
if entry is not None:
|
||||
self.date = entry.transaction.date
|
||||
self.summary = entry.summary
|
||||
self.debit = entry.amount if entry.is_debit else None
|
||||
self.credit = None if entry.is_debit else entry.amount
|
||||
self.note = entry.transaction.note
|
||||
self.url = url_for("accounting.transaction.detail",
|
||||
txn=entry.transaction)
|
||||
"""The URL to the journal entry line item."""
|
||||
if line_item is not None:
|
||||
self.date = line_item.journal_entry.date
|
||||
self.description = line_item.description
|
||||
self.debit = line_item.amount if line_item.is_debit else None
|
||||
self.credit = None if line_item.is_debit else line_item.amount
|
||||
self.note = line_item.journal_entry.note
|
||||
self.url = url_for("accounting.journal-entry.detail",
|
||||
journal_entry=line_item.journal_entry)
|
||||
|
||||
|
||||
class EntryCollector:
|
||||
"""The report entry collector."""
|
||||
class LineItemCollector:
|
||||
"""The line item collector."""
|
||||
|
||||
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||
"""Constructs the report entry collector.
|
||||
"""Constructs the line item collector.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
@ -92,89 +93,94 @@ class EntryCollector:
|
||||
"""The account."""
|
||||
self.__period: Period = period
|
||||
"""The period"""
|
||||
self.brought_forward: ReportEntry | None
|
||||
"""The brought-forward entry."""
|
||||
self.entries: list[ReportEntry]
|
||||
"""The report entries."""
|
||||
self.total: ReportEntry | None
|
||||
"""The total entry."""
|
||||
self.brought_forward = self.__get_brought_forward_entry()
|
||||
self.entries = self.__query_entries()
|
||||
self.total = self.__get_total_entry()
|
||||
self.brought_forward: ReportLineItem | None
|
||||
"""The brought-forward line item."""
|
||||
self.line_items: list[ReportLineItem]
|
||||
"""The line items."""
|
||||
self.total: ReportLineItem | None
|
||||
"""The total line item."""
|
||||
self.brought_forward = self.__get_brought_forward()
|
||||
self.line_items = self.__query_line_items()
|
||||
self.total = self.__get_total()
|
||||
self.__populate_balance()
|
||||
|
||||
def __get_brought_forward_entry(self) -> ReportEntry | None:
|
||||
"""Queries, composes and returns the brought-forward entry.
|
||||
def __get_brought_forward(self) -> ReportLineItem | None:
|
||||
"""Queries, composes and returns the brought-forward line item.
|
||||
|
||||
:return: The brought-forward entry, or None if the report starts from
|
||||
the beginning.
|
||||
:return: The brought-forward line item, or None if the report starts
|
||||
from the beginning.
|
||||
"""
|
||||
if self.__period.start is None:
|
||||
return None
|
||||
if self.__account.is_nominal:
|
||||
return None
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntry.is_debit, JournalEntry.amount),
|
||||
else_=-JournalEntry.amount))
|
||||
select: sa.Select = sa.Select(balance_func).join(Transaction)\
|
||||
.filter(be(JournalEntry.currency_code == self.__currency.code),
|
||||
be(JournalEntry.account_id == self.__account.id),
|
||||
Transaction.date < self.__period.start)
|
||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||
else_=-JournalEntryLineItem.amount))
|
||||
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
|
||||
.filter(be(JournalEntryLineItem.currency_code
|
||||
== self.__currency.code),
|
||||
be(JournalEntryLineItem.account_id
|
||||
== self.__account.id),
|
||||
JournalEntry.date < self.__period.start)
|
||||
balance: int | None = db.session.scalar(select)
|
||||
if balance is None:
|
||||
return None
|
||||
entry: ReportEntry = ReportEntry()
|
||||
entry.is_brought_forward = True
|
||||
entry.date = self.__period.start
|
||||
entry.summary = gettext("Brought forward")
|
||||
line_item: ReportLineItem = ReportLineItem()
|
||||
line_item.is_brought_forward = True
|
||||
line_item.date = self.__period.start
|
||||
line_item.description = gettext("Brought forward")
|
||||
if balance > 0:
|
||||
entry.debit = balance
|
||||
line_item.debit = balance
|
||||
elif balance < 0:
|
||||
entry.credit = -balance
|
||||
entry.balance = balance
|
||||
return entry
|
||||
line_item.credit = -balance
|
||||
line_item.balance = balance
|
||||
return line_item
|
||||
|
||||
def __query_entries(self) -> list[ReportEntry]:
|
||||
"""Queries and returns the report entries.
|
||||
def __query_line_items(self) -> list[ReportLineItem]:
|
||||
"""Queries and returns the line items.
|
||||
|
||||
:return: The report entries.
|
||||
:return: The line items.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code,
|
||||
JournalEntry.account_id == self.__account.id]
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
JournalEntryLineItem.account_id == self.__account.id]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(Transaction.date >= self.__period.start)
|
||||
conditions.append(JournalEntry.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
return [ReportEntry(x) for x in JournalEntry.query.join(Transaction)
|
||||
conditions.append(JournalEntry.date <= self.__period.end)
|
||||
return [ReportLineItem(x) for x in JournalEntryLineItem.query
|
||||
.join(JournalEntry)
|
||||
.filter(*conditions)
|
||||
.order_by(Transaction.date,
|
||||
Transaction.no,
|
||||
JournalEntry.is_debit.desc(),
|
||||
JournalEntry.no)
|
||||
.options(selectinload(JournalEntry.transaction)).all()]
|
||||
.order_by(JournalEntry.date,
|
||||
JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit.desc(),
|
||||
JournalEntryLineItem.no)
|
||||
.options(selectinload(JournalEntryLineItem.journal_entry))
|
||||
.all()]
|
||||
|
||||
def __get_total_entry(self) -> ReportEntry | None:
|
||||
"""Composes the total entry.
|
||||
def __get_total(self) -> ReportLineItem | None:
|
||||
"""Composes the total line item.
|
||||
|
||||
:return: The total entry, or None if there is no data.
|
||||
:return: The total line item, or None if there is no data.
|
||||
"""
|
||||
if self.brought_forward is None and len(self.entries) == 0:
|
||||
if self.brought_forward is None and len(self.line_items) == 0:
|
||||
return None
|
||||
entry: ReportEntry = ReportEntry()
|
||||
entry.is_total = True
|
||||
entry.summary = gettext("Total")
|
||||
entry.debit = sum([x.debit for x in self.entries
|
||||
line_item: ReportLineItem = ReportLineItem()
|
||||
line_item.is_total = True
|
||||
line_item.description = gettext("Total")
|
||||
line_item.debit = sum([x.debit for x in self.line_items
|
||||
if x.debit is not None])
|
||||
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])
|
||||
entry.balance = entry.debit - entry.credit
|
||||
line_item.balance = line_item.debit - line_item.credit
|
||||
if self.brought_forward is not None:
|
||||
entry.balance = self.brought_forward.balance + entry.balance
|
||||
return entry
|
||||
line_item.balance \
|
||||
= self.brought_forward.balance + line_item.balance
|
||||
return line_item
|
||||
|
||||
def __populate_balance(self) -> None:
|
||||
"""Populates the balance of the entries.
|
||||
"""Populates the balance of the line items.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
@ -182,36 +188,36 @@ class EntryCollector:
|
||||
return None
|
||||
balance: Decimal = 0 if self.brought_forward is None \
|
||||
else self.brought_forward.balance
|
||||
for entry in self.entries:
|
||||
if entry.debit is not None:
|
||||
balance = balance + entry.debit
|
||||
if entry.credit is not None:
|
||||
balance = balance - entry.credit
|
||||
entry.balance = balance
|
||||
for line_item in self.line_items:
|
||||
if line_item.debit is not None:
|
||||
balance = balance + line_item.debit
|
||||
if line_item.credit is not None:
|
||||
balance = balance - line_item.credit
|
||||
line_item.balance = balance
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, txn_date: date | str | None,
|
||||
summary: str | None,
|
||||
def __init__(self, journal_entry_date: date | str | None,
|
||||
description: str | None,
|
||||
debit: str | Decimal | None,
|
||||
credit: str | Decimal | None,
|
||||
balance: str | Decimal | None,
|
||||
note: str | None):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param txn_date: The transaction date.
|
||||
:param summary: The summary.
|
||||
:param journal_entry_date: The journal entry date.
|
||||
:param description: The description.
|
||||
:param debit: The debit amount.
|
||||
:param credit: The credit amount.
|
||||
:param balance: The balance.
|
||||
:param note: The note.
|
||||
"""
|
||||
self.date: date | str | None = txn_date
|
||||
self.date: date | str | None = journal_entry_date
|
||||
"""The date."""
|
||||
self.summary: str | None = summary
|
||||
"""The summary."""
|
||||
self.description: str | None = description
|
||||
"""The description."""
|
||||
self.debit: str | Decimal | None = debit
|
||||
"""The debit amount."""
|
||||
self.credit: str | Decimal | None = credit
|
||||
@ -227,7 +233,7 @@ class CSVRow(BaseCSVRow):
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.date, self.summary,
|
||||
return [self.date, self.description,
|
||||
self.debit, self.credit, self.balance, self.note]
|
||||
|
||||
|
||||
@ -238,19 +244,19 @@ class PageParams(BasePageParams):
|
||||
account: Account,
|
||||
period: Period,
|
||||
has_data: bool,
|
||||
pagination: Pagination[ReportEntry],
|
||||
brought_forward: ReportEntry | None,
|
||||
entries: list[ReportEntry],
|
||||
total: ReportEntry | None):
|
||||
pagination: Pagination[ReportLineItem],
|
||||
brought_forward: ReportLineItem | None,
|
||||
line_items: list[ReportLineItem],
|
||||
total: ReportLineItem | None):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
:param has_data: True if there is any data, or False otherwise.
|
||||
:param brought_forward: The brought-forward entry.
|
||||
:param entries: The report entries.
|
||||
:param total: The total entry.
|
||||
:param brought_forward: The brought-forward line item.
|
||||
:param line_items: The line items.
|
||||
:param total: The total line item.
|
||||
"""
|
||||
self.currency: Currency = currency
|
||||
"""The currency."""
|
||||
@ -260,14 +266,14 @@ class PageParams(BasePageParams):
|
||||
"""The period."""
|
||||
self.__has_data: bool = has_data
|
||||
"""True if there is any data, or False otherwise."""
|
||||
self.pagination: Pagination[ReportEntry] = pagination
|
||||
self.pagination: Pagination[ReportLineItem] = pagination
|
||||
"""The pagination."""
|
||||
self.brought_forward: ReportEntry | None = brought_forward
|
||||
"""The brought-forward entry."""
|
||||
self.entries: list[ReportEntry] = entries
|
||||
"""The entries."""
|
||||
self.total: ReportEntry | None = total
|
||||
"""The total entry."""
|
||||
self.brought_forward: ReportLineItem | None = brought_forward
|
||||
"""The brought-forward line item."""
|
||||
self.line_items: list[ReportLineItem] = line_items
|
||||
"""The line items."""
|
||||
self.total: ReportLineItem | None = total
|
||||
"""The total line item."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: ledger_url(currency, account, x))
|
||||
"""The period chooser."""
|
||||
@ -306,9 +312,10 @@ class PageParams(BasePageParams):
|
||||
|
||||
:return: The account options.
|
||||
"""
|
||||
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
||||
.filter(be(JournalEntry.currency_code == self.currency.code))\
|
||||
.group_by(JournalEntry.account_id)
|
||||
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
|
||||
.filter(be(JournalEntryLineItem.currency_code
|
||||
== self.currency.code))\
|
||||
.group_by(JournalEntryLineItem.account_id)
|
||||
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
|
||||
x.id == self.account.id)
|
||||
for x in Account.query.filter(Account.id.in_(in_use))
|
||||
@ -331,14 +338,15 @@ class Ledger(BaseReport):
|
||||
"""The account."""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
collector: EntryCollector = EntryCollector(
|
||||
collector: LineItemCollector = LineItemCollector(
|
||||
self.__currency, self.__account, self.__period)
|
||||
self.__brought_forward: ReportEntry | None = collector.brought_forward
|
||||
"""The brought-forward entry."""
|
||||
self.__entries: list[ReportEntry] = collector.entries
|
||||
"""The report entries."""
|
||||
self.__total: ReportEntry | None = collector.total
|
||||
"""The total entry."""
|
||||
self.__brought_forward: ReportLineItem | None \
|
||||
= collector.brought_forward
|
||||
"""The brought-forward line item."""
|
||||
self.__line_items: list[ReportLineItem] = collector.line_items
|
||||
"""The line items."""
|
||||
self.__total: ReportLineItem | None = collector.total
|
||||
"""The total line item."""
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
@ -355,19 +363,19 @@ class Ledger(BaseReport):
|
||||
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Description"),
|
||||
gettext("Debit"), gettext("Credit"),
|
||||
gettext("Balance"), gettext("Note"))]
|
||||
if self.__brought_forward is not None:
|
||||
rows.append(CSVRow(self.__brought_forward.date,
|
||||
self.__brought_forward.summary,
|
||||
self.__brought_forward.description,
|
||||
self.__brought_forward.debit,
|
||||
self.__brought_forward.credit,
|
||||
self.__brought_forward.balance,
|
||||
None))
|
||||
rows.extend([CSVRow(x.date, x.summary,
|
||||
rows.extend([CSVRow(x.date, x.description,
|
||||
x.debit, x.credit, x.balance, x.note)
|
||||
for x in self.__entries])
|
||||
for x in self.__line_items])
|
||||
if self.__total is not None:
|
||||
rows.append(CSVRow(gettext("Total"), None,
|
||||
self.__total.debit, self.__total.credit,
|
||||
@ -379,31 +387,31 @@ class Ledger(BaseReport):
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
all_entries: list[ReportEntry] = []
|
||||
all_line_items: list[ReportLineItem] = []
|
||||
if self.__brought_forward is not None:
|
||||
all_entries.append(self.__brought_forward)
|
||||
all_entries.extend(self.__entries)
|
||||
all_line_items.append(self.__brought_forward)
|
||||
all_line_items.extend(self.__line_items)
|
||||
if self.__total is not None:
|
||||
all_entries.append(self.__total)
|
||||
pagination: Pagination[ReportEntry] \
|
||||
= Pagination[ReportEntry](all_entries, is_reversed=True)
|
||||
page_entries: list[ReportEntry] = pagination.list
|
||||
has_data: bool = len(page_entries) > 0
|
||||
brought_forward: ReportEntry | None = None
|
||||
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
|
||||
brought_forward = page_entries[0]
|
||||
page_entries = page_entries[1:]
|
||||
total: ReportEntry | None = None
|
||||
if len(page_entries) > 0 and page_entries[-1].is_total:
|
||||
total = page_entries[-1]
|
||||
page_entries = page_entries[:-1]
|
||||
all_line_items.append(self.__total)
|
||||
pagination: Pagination[ReportLineItem] \
|
||||
= Pagination[ReportLineItem](all_line_items, is_reversed=True)
|
||||
page_line_items: list[ReportLineItem] = pagination.list
|
||||
has_data: bool = len(page_line_items) > 0
|
||||
brought_forward: ReportLineItem | None = None
|
||||
if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
|
||||
brought_forward = page_line_items[0]
|
||||
page_line_items = page_line_items[1:]
|
||||
total: ReportLineItem | None = None
|
||||
if len(page_line_items) > 0 and page_line_items[-1].is_total:
|
||||
total = page_line_items[-1]
|
||||
page_line_items = page_line_items[:-1]
|
||||
params: PageParams = PageParams(currency=self.__currency,
|
||||
account=self.__account,
|
||||
period=self.__period,
|
||||
has_data=has_data,
|
||||
pagination=pagination,
|
||||
brought_forward=brought_forward,
|
||||
entries=page_entries,
|
||||
line_items=page_line_items,
|
||||
total=total)
|
||||
return render_template("accounting/report/ledger.html",
|
||||
report=params)
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -26,7 +26,7 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
|
||||
Transaction, JournalEntry
|
||||
JournalEntry, JournalEntryLineItem
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import csv_download
|
||||
@ -38,18 +38,18 @@ from accounting.utils.query import parse_query_keywords
|
||||
from .journal import get_csv_rows
|
||||
|
||||
|
||||
class EntryCollector:
|
||||
"""The report entry collector."""
|
||||
class LineItemCollector:
|
||||
"""The line item collector."""
|
||||
|
||||
def __init__(self):
|
||||
"""Constructs the report entry collector."""
|
||||
self.entries: list[JournalEntry] = self.__query_entries()
|
||||
"""The report entries."""
|
||||
"""Constructs the line item collector."""
|
||||
self.line_items: list[JournalEntryLineItem] = self.__query_line_items()
|
||||
"""The line items."""
|
||||
|
||||
def __query_entries(self) -> list[JournalEntry]:
|
||||
"""Queries and returns the journal entries.
|
||||
def __query_line_items(self) -> list[JournalEntryLineItem]:
|
||||
"""Queries and returns the line items.
|
||||
|
||||
:return: The journal entries.
|
||||
:return: The line items.
|
||||
"""
|
||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||
if len(keywords) == 0:
|
||||
@ -57,26 +57,28 @@ class EntryCollector:
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
for k in keywords:
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.summary.contains(k),
|
||||
JournalEntry.account_id.in_(
|
||||
= [JournalEntryLineItem.description.icontains(k),
|
||||
JournalEntryLineItem.account_id.in_(
|
||||
self.__get_account_condition(k)),
|
||||
JournalEntry.currency_code.in_(
|
||||
JournalEntryLineItem.currency_code.in_(
|
||||
self.__get_currency_condition(k)),
|
||||
JournalEntry.transaction_id.in_(
|
||||
self.__get_transaction_condition(k))]
|
||||
JournalEntryLineItem.journal_entry_id.in_(
|
||||
self.__get_journal_entry_condition(k))]
|
||||
try:
|
||||
sub_conditions.append(JournalEntry.amount == Decimal(k))
|
||||
sub_conditions.append(
|
||||
JournalEntryLineItem.amount == Decimal(k))
|
||||
except ArithmeticError:
|
||||
pass
|
||||
conditions.append(sa.or_(*sub_conditions))
|
||||
return JournalEntry.query.join(Transaction).filter(*conditions)\
|
||||
.order_by(Transaction.date,
|
||||
Transaction.no,
|
||||
JournalEntry.is_debit,
|
||||
JournalEntry.no)\
|
||||
.options(selectinload(JournalEntry.account),
|
||||
selectinload(JournalEntry.currency),
|
||||
selectinload(JournalEntry.transaction)).all()
|
||||
return JournalEntryLineItem.query.join(JournalEntry)\
|
||||
.filter(*conditions)\
|
||||
.order_by(JournalEntry.date,
|
||||
JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit,
|
||||
JournalEntryLineItem.no)\
|
||||
.options(selectinload(JournalEntryLineItem.account),
|
||||
selectinload(JournalEntryLineItem.currency),
|
||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||
|
||||
@staticmethod
|
||||
def __get_account_condition(k: str) -> sa.Select:
|
||||
@ -90,13 +92,13 @@ class EntryCollector:
|
||||
sa.func.char_length(sa.cast(Account.no,
|
||||
sa.String)) + 1)
|
||||
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
|
||||
.filter(AccountL10n.title.contains(k))
|
||||
.filter(AccountL10n.title.icontains(k))
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [Account.base_code.contains(k),
|
||||
Account.title_l10n.contains(k),
|
||||
Account.title_l10n.icontains(k),
|
||||
code.contains(k),
|
||||
Account.id.in_(select_l10n)]
|
||||
if k in gettext("Need offset"):
|
||||
if k in gettext("Needs Offset"):
|
||||
conditions.append(Account.is_need_offset)
|
||||
return sa.select(Account.id).filter(sa.or_(*conditions))
|
||||
|
||||
@ -108,57 +110,63 @@ class EntryCollector:
|
||||
:return: The condition to filter the currency.
|
||||
"""
|
||||
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
|
||||
.filter(CurrencyL10n.name.contains(k))
|
||||
.filter(CurrencyL10n.name.icontains(k))
|
||||
return sa.select(Currency.code).filter(
|
||||
sa.or_(Currency.code.contains(k),
|
||||
Currency.name_l10n.contains(k),
|
||||
sa.or_(Currency.code.icontains(k),
|
||||
Currency.name_l10n.icontains(k),
|
||||
Currency.code.in_(select_l10n)))
|
||||
|
||||
@staticmethod
|
||||
def __get_transaction_condition(k: str) -> sa.Select:
|
||||
"""Composes and returns the condition to filter the transaction.
|
||||
def __get_journal_entry_condition(k: str) -> sa.Select:
|
||||
"""Composes and returns the condition to filter the journal entry.
|
||||
|
||||
:param k: The keyword.
|
||||
:return: The condition to filter the transaction.
|
||||
:return: The condition to filter the journal entry.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)]
|
||||
txn_date: datetime
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.note.icontains(k)]
|
||||
journal_entry_date: datetime
|
||||
try:
|
||||
txn_date = datetime.strptime(k, "%Y")
|
||||
journal_entry_date = datetime.strptime(k, "%Y")
|
||||
conditions.append(
|
||||
be(sa.extract("year", Transaction.date) == txn_date.year))
|
||||
be(sa.extract("year", JournalEntry.date)
|
||||
== journal_entry_date.year))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
txn_date = datetime.strptime(k, "%Y/%m")
|
||||
journal_entry_date = datetime.strptime(k, "%Y/%m")
|
||||
conditions.append(sa.and_(
|
||||
sa.extract("year", Transaction.date) == txn_date.year,
|
||||
sa.extract("month", Transaction.date) == txn_date.month))
|
||||
sa.extract("year", JournalEntry.date)
|
||||
== journal_entry_date.year,
|
||||
sa.extract("month", JournalEntry.date)
|
||||
== journal_entry_date.month))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||
journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||
conditions.append(sa.and_(
|
||||
sa.extract("month", Transaction.date) == txn_date.month,
|
||||
sa.extract("day", Transaction.date) == txn_date.day))
|
||||
sa.extract("month", JournalEntry.date)
|
||||
== journal_entry_date.month,
|
||||
sa.extract("day", JournalEntry.date)
|
||||
== journal_entry_date.day))
|
||||
except ValueError:
|
||||
pass
|
||||
return sa.select(Transaction.id).filter(sa.or_(*conditions))
|
||||
return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, pagination: Pagination[JournalEntry],
|
||||
entries: list[JournalEntry]):
|
||||
def __init__(self, pagination: Pagination[JournalEntryLineItem],
|
||||
line_items: list[JournalEntryLineItem]):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param entries: The search result entries.
|
||||
:param line_items: The search result line items.
|
||||
"""
|
||||
self.pagination: Pagination[JournalEntry] = pagination
|
||||
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
||||
"""The pagination."""
|
||||
self.entries: list[JournalEntry] = entries
|
||||
"""The entries."""
|
||||
self.line_items: list[JournalEntryLineItem] = line_items
|
||||
"""The line items."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
@ -166,7 +174,7 @@ class PageParams(BasePageParams):
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return len(self.entries) > 0
|
||||
return len(self.line_items) > 0
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
@ -182,8 +190,9 @@ class Search(BaseReport):
|
||||
|
||||
def __init__(self):
|
||||
"""Constructs a search."""
|
||||
self.__entries: list[JournalEntry] = EntryCollector().entries
|
||||
"""The journal entries."""
|
||||
self.__line_items: list[JournalEntryLineItem] \
|
||||
= LineItemCollector().line_items
|
||||
"""The line items."""
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
@ -191,16 +200,17 @@ class Search(BaseReport):
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = "search-{q}.csv".format(q=request.args["q"])
|
||||
return csv_download(filename, get_csv_rows(self.__entries))
|
||||
return csv_download(filename, get_csv_rows(self.__line_items))
|
||||
|
||||
def html(self) -> str:
|
||||
"""Composes and returns the report as HTML.
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
pagination: Pagination[JournalEntry] \
|
||||
= Pagination[JournalEntry](self.__entries, is_reversed=True)
|
||||
pagination: Pagination[JournalEntryLineItem] \
|
||||
= Pagination[JournalEntryLineItem](self.__line_items,
|
||||
is_reversed=True)
|
||||
params: PageParams = PageParams(pagination=pagination,
|
||||
entries=pagination.list)
|
||||
line_items=pagination.list)
|
||||
return render_template("accounting/report/search.html",
|
||||
report=params)
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -24,7 +24,8 @@ from flask import Response, render_template
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
@ -178,18 +179,19 @@ class TrialBalance(BaseReport):
|
||||
:return: None.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.currency_code == self.__currency.code]
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(Transaction.date >= self.__period.start)
|
||||
conditions.append(JournalEntry.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(Transaction.date <= self.__period.end)
|
||||
conditions.append(JournalEntry.date <= self.__period.end)
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntry.is_debit, JournalEntry.amount),
|
||||
else_=-JournalEntry.amount)).label("balance")
|
||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||
else_=-JournalEntryLineItem.amount)).label("balance")
|
||||
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||
.join(Transaction).join(Account)\
|
||||
.join(JournalEntry).join(Account)\
|
||||
.filter(*conditions)\
|
||||
.group_by(Account.id)\
|
||||
.having(balance_func != 0)\
|
||||
.order_by(Account.base_code, Account.no)
|
||||
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
||||
accounts: dict[int, Account] \
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -26,8 +26,8 @@ import sqlalchemy as sa
|
||||
from flask import request
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Currency, JournalEntry
|
||||
from accounting.utils.txn_types import TransactionType
|
||||
from accounting.models import Currency, JournalEntryLineItem
|
||||
from accounting.utils.journal_entry_types import JournalEntryType
|
||||
from .option_link import OptionLink
|
||||
from .report_chooser import ReportChooser
|
||||
|
||||
@ -52,12 +52,12 @@ class BasePageParams(ABC):
|
||||
"""
|
||||
|
||||
@property
|
||||
def txn_types(self) -> t.Type[TransactionType]:
|
||||
"""Returns the transaction types.
|
||||
def journal_entry_types(self) -> t.Type[JournalEntryType]:
|
||||
"""Returns the journal entry types.
|
||||
|
||||
:return: The transaction types.
|
||||
:return: The journal entry types.
|
||||
"""
|
||||
return TransactionType
|
||||
return JournalEntryType
|
||||
|
||||
@property
|
||||
def csv_uri(self) -> str:
|
||||
@ -81,8 +81,8 @@ class BasePageParams(ABC):
|
||||
:return: The currency options.
|
||||
"""
|
||||
in_use: set[str] = set(db.session.scalars(
|
||||
sa.select(JournalEntry.currency_code)
|
||||
.group_by(JournalEntry.currency_code)).all())
|
||||
sa.select(JournalEntryLineItem.currency_code)
|
||||
.group_by(JournalEntryLineItem.currency_code)).all())
|
||||
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
|
||||
for x in Currency.query.filter(Currency.code.in_(in_use))
|
||||
.order_by(Currency.code).all()]
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -30,7 +30,7 @@ from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period, get_period
|
||||
from accounting.template_globals import default_currency_code
|
||||
from .ie_account import IncomeExpensesAccount
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from .option_link import OptionLink
|
||||
from .report_type import ReportType
|
||||
from .urls import journal_url, ledger_url, income_expenses_url, \
|
||||
@ -68,9 +68,9 @@ class ReportChooser:
|
||||
"""The title of the current report."""
|
||||
self.is_search: bool = active_report == ReportType.SEARCH
|
||||
"""Whether the current report is the search page."""
|
||||
self.__reports.append(self.__journal)
|
||||
self.__reports.append(self.__ledger)
|
||||
self.__reports.append(self.__income_expenses)
|
||||
self.__reports.append(self.__ledger)
|
||||
self.__reports.append(self.__journal)
|
||||
self.__reports.append(self.__trial_balance)
|
||||
self.__reports.append(self.__income_statement)
|
||||
self.__reports.append(self.__balance_sheet)
|
||||
@ -81,14 +81,20 @@ class ReportChooser:
|
||||
self.current_report = gettext("Search")
|
||||
|
||||
@property
|
||||
def __journal(self) -> OptionLink:
|
||||
"""Returns the journal.
|
||||
def __income_expenses(self) -> OptionLink:
|
||||
"""Returns the income and expenses log.
|
||||
|
||||
:return: The journal.
|
||||
:return: The income and expenses log.
|
||||
"""
|
||||
return OptionLink(gettext("Journal"), journal_url(self.__period),
|
||||
self.__active_report == ReportType.JOURNAL,
|
||||
fa_icon="fa-solid fa-book")
|
||||
account: Account = self.__account
|
||||
if not re.match(r"[12][12]", account.base_code):
|
||||
account: Account = Account.cash()
|
||||
return OptionLink(gettext("Income and Expenses Log"),
|
||||
income_expenses_url(self.__currency,
|
||||
CurrentAccount(account),
|
||||
self.__period),
|
||||
self.__active_report == ReportType.INCOME_EXPENSES,
|
||||
fa_icon="fa-solid fa-money-bill-wave")
|
||||
|
||||
@property
|
||||
def __ledger(self) -> OptionLink:
|
||||
@ -103,20 +109,14 @@ class ReportChooser:
|
||||
fa_icon="fa-solid fa-clipboard")
|
||||
|
||||
@property
|
||||
def __income_expenses(self) -> OptionLink:
|
||||
"""Returns the income and expenses log.
|
||||
def __journal(self) -> OptionLink:
|
||||
"""Returns the journal.
|
||||
|
||||
:return: The income and expenses log.
|
||||
:return: The journal.
|
||||
"""
|
||||
account: Account = self.__account
|
||||
if not re.match(r"[12][12]", account.base_code):
|
||||
account: Account = Account.cash()
|
||||
return OptionLink(gettext("Income and Expenses Log"),
|
||||
income_expenses_url(self.__currency,
|
||||
IncomeExpensesAccount(account),
|
||||
self.__period),
|
||||
self.__active_report == ReportType.INCOME_EXPENSES,
|
||||
fa_icon="fa-solid fa-money-bill-wave")
|
||||
return OptionLink(gettext("Journal"), journal_url(self.__period),
|
||||
self.__active_report == ReportType.JOURNAL,
|
||||
fa_icon="fa-solid fa-book")
|
||||
|
||||
@property
|
||||
def __trial_balance(self) -> OptionLink:
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -22,7 +22,8 @@ from flask import url_for
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period
|
||||
from accounting.template_globals import default_currency_code
|
||||
from .ie_account import IncomeExpensesAccount, default_ie_account_code
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.options import options
|
||||
|
||||
|
||||
def journal_url(period: Period) \
|
||||
@ -33,8 +34,8 @@ def journal_url(period: Period) \
|
||||
:return: The URL of the journal.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.journal-default")
|
||||
return url_for("accounting.report.journal", period=period)
|
||||
return url_for("accounting-report.journal-default")
|
||||
return url_for("accounting-report.journal", period=period)
|
||||
|
||||
|
||||
def ledger_url(currency: Currency, account: Account, period: Period) \
|
||||
@ -47,14 +48,14 @@ def ledger_url(currency: Currency, account: Account, period: Period) \
|
||||
:return: The URL of the ledger.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.ledger-default",
|
||||
return url_for("accounting-report.ledger-default",
|
||||
currency=currency, account=account)
|
||||
return url_for("accounting.report.ledger",
|
||||
return url_for("accounting-report.ledger",
|
||||
currency=currency, account=account,
|
||||
period=period)
|
||||
|
||||
|
||||
def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
|
||||
def income_expenses_url(currency: Currency, account: CurrentAccount,
|
||||
period: Period) -> str:
|
||||
"""Returns the URL of an income and expenses log.
|
||||
|
||||
@ -64,13 +65,13 @@ def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
|
||||
:return: The URL of the income and expenses log.
|
||||
"""
|
||||
if currency.code == default_currency_code() \
|
||||
and account.code == default_ie_account_code() \
|
||||
and account.code == options.default_ie_account_code \
|
||||
and period.is_default:
|
||||
return url_for("accounting.report.default")
|
||||
return url_for("accounting-report.default")
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.income-expenses-default",
|
||||
return url_for("accounting-report.income-expenses-default",
|
||||
currency=currency, account=account)
|
||||
return url_for("accounting.report.income-expenses",
|
||||
return url_for("accounting-report.income-expenses",
|
||||
currency=currency, account=account,
|
||||
period=period)
|
||||
|
||||
@ -83,9 +84,9 @@ def trial_balance_url(currency: Currency, period: Period) -> str:
|
||||
:return: The URL of the trial balance.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.trial-balance-default",
|
||||
return url_for("accounting-report.trial-balance-default",
|
||||
currency=currency)
|
||||
return url_for("accounting.report.trial-balance",
|
||||
return url_for("accounting-report.trial-balance",
|
||||
currency=currency, period=period)
|
||||
|
||||
|
||||
@ -97,9 +98,9 @@ def income_statement_url(currency: Currency, period: Period) -> str:
|
||||
:return: The URL of the income statement.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.income-statement-default",
|
||||
return url_for("accounting-report.income-statement-default",
|
||||
currency=currency)
|
||||
return url_for("accounting.report.income-statement",
|
||||
return url_for("accounting-report.income-statement",
|
||||
currency=currency, period=period)
|
||||
|
||||
|
||||
@ -111,7 +112,7 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
|
||||
:return: The URL of the balance sheet.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.balance-sheet-default",
|
||||
return url_for("accounting-report.balance-sheet-default",
|
||||
currency=currency)
|
||||
return url_for("accounting.report.balance-sheet",
|
||||
return url_for("accounting-report.balance-sheet",
|
||||
currency=currency, period=period)
|
||||
|
@ -1,4 +1,4 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
@ -23,13 +23,14 @@ from accounting import db
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period, get_period
|
||||
from accounting.template_globals import default_currency_code
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.options import options
|
||||
from accounting.utils.permission import has_permission, can_view
|
||||
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
|
||||
IncomeStatement, BalanceSheet, Search
|
||||
from .template_filters import format_amount
|
||||
from .utils.ie_account import IncomeExpensesAccount, default_ie_account
|
||||
|
||||
bp: Blueprint = Blueprint("report", __name__)
|
||||
bp: Blueprint = Blueprint("accounting-report", __name__)
|
||||
"""The view blueprint for the reports."""
|
||||
bp.add_app_template_filter(format_amount, "accounting_report_format_amount")
|
||||
|
||||
@ -43,7 +44,7 @@ def get_default_report() -> str | Response:
|
||||
"""
|
||||
return __get_income_expenses(
|
||||
db.session.get(Currency, default_currency_code()),
|
||||
default_ie_account(),
|
||||
options.default_ie_account,
|
||||
get_period())
|
||||
|
||||
|
||||
@ -126,8 +127,7 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
|
||||
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
|
||||
endpoint="income-expenses-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_income_expenses(currency: Currency,
|
||||
account: IncomeExpensesAccount) \
|
||||
def get_default_income_expenses(currency: Currency, account: CurrentAccount) \
|
||||
-> str | Response:
|
||||
"""Returns the income and expenses log in the default period.
|
||||
|
||||
@ -142,7 +142,7 @@ def get_default_income_expenses(currency: Currency,
|
||||
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
|
||||
endpoint="income-expenses")
|
||||
@has_permission(can_view)
|
||||
def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
|
||||
def get_income_expenses(currency: Currency, account: CurrentAccount,
|
||||
period: Period) -> str | Response:
|
||||
"""Returns the income and expenses log.
|
||||
|
||||
@ -154,7 +154,7 @@ def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
|
||||
return __get_income_expenses(currency, account, period)
|
||||
|
||||
|
||||
def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
|
||||
def __get_income_expenses(currency: Currency, account: CurrentAccount,
|
||||
period: Period) -> str | Response:
|
||||
"""Returns the income and expenses log.
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
/* The Mia! Accounting Project
|
||||
* style.css: The style sheet for the accounting application.
|
||||
*/
|
||||
|
||||
@ -76,7 +76,7 @@
|
||||
height: 3.2rem;
|
||||
width: 3.2rem;
|
||||
border-radius: 50%;
|
||||
margin-left: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
|
||||
padding-top: 0.7rem;
|
||||
@ -117,29 +117,29 @@
|
||||
}
|
||||
|
||||
/* Links between objects */
|
||||
.accounting-original-entry {
|
||||
.accounting-original-line-item {
|
||||
border-top: thin solid darkslategray;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
.accounting-original-entry a {
|
||||
.accounting-original-line-item a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.accounting-original-entry a:hover {
|
||||
.accounting-original-line-item a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
.accounting-offset-entries {
|
||||
.accounting-offset-line-items {
|
||||
border-top: thin solid darkslategray;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
.accounting-offset-entries ul li {
|
||||
.accounting-offset-line-items ul li {
|
||||
list-style: none;
|
||||
}
|
||||
.accounting-offset-entries ul li a {
|
||||
.accounting-offset-line-items ul li a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.accounting-offset-entries ul li a:hover {
|
||||
.accounting-offset-line-items ul li a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@ -149,38 +149,35 @@
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
/** The transaction management */
|
||||
/** The journal entry management */
|
||||
.accounting-currency-control {
|
||||
background-color: transparent;
|
||||
}
|
||||
.accounting-currency-content {
|
||||
width: calc(100% - 3rem);
|
||||
}
|
||||
.accounting-entry-content {
|
||||
.accounting-line-item-content {
|
||||
width: calc(100% - 3rem);
|
||||
background-color: transparent;
|
||||
}
|
||||
.accounting-entry-control {
|
||||
border-color: transparent;
|
||||
}
|
||||
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
.accounting-list-group-hover .list-group-item:hover {
|
||||
background-color: #ececec;
|
||||
}
|
||||
.accounting-transaction-entry {
|
||||
.accounting-journal-entry-line-item {
|
||||
border: none;
|
||||
}
|
||||
.accounting-transaction-entry-header {
|
||||
.accounting-journal-entry-line-item-header {
|
||||
font-weight: bolder;
|
||||
border-bottom: thick double slategray;
|
||||
}
|
||||
.list-group-item.accounting-transaction-entry-total {
|
||||
.list-group-item.accounting-journal-entry-line-item-total {
|
||||
font-weight: bolder;
|
||||
border-top: thick double slategray;
|
||||
}
|
||||
.accounting-entry-editor-original-entry-content {
|
||||
.accounting-line-item-editor-original-line-item-content {
|
||||
width: calc(100% - 3rem);
|
||||
}
|
||||
|
||||
@ -318,6 +315,34 @@ a.accounting-report-table-row {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
/* The description editor */
|
||||
.accounting-description-editor-buttons {
|
||||
max-height: 7rem;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.accounting-description-editor-buttons .btn {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
/* The order of the journal entries in a same day */
|
||||
.accounting-journal-entry-order-item, .accounting-journal-entry-order-item:hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.accounting-journal-entry-order-item-currency {
|
||||
margin-left: 0.5rem;
|
||||
border-top: thin solid lightgray;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* The illustration of the description template for the recurring transactions */
|
||||
.accounting-recurring-description-template-illustration p {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
.accounting-recurring-description-template-illustration ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* The Material Design text field (floating form control in Bootstrap) */
|
||||
.accounting-material-text-field {
|
||||
position: relative;
|
||||
@ -342,7 +367,7 @@ a.accounting-report-table-row {
|
||||
.accounting-material-fab {
|
||||
position: fixed;
|
||||
right: 2rem;
|
||||
bottom: 1rem;
|
||||
bottom: 2rem;
|
||||
z-index: 10;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
/* The Mia! Accounting Project
|
||||
* account-form.js: The JavaScript for the account form
|
||||
*/
|
||||
|
||||
@ -110,14 +110,23 @@ class AccountForm {
|
||||
this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
|
||||
this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
|
||||
this.#formElement.onsubmit = () => {
|
||||
return this.#validateForm();
|
||||
return this.#validate();
|
||||
};
|
||||
this.#baseControl.onclick = () => {
|
||||
this.#baseControl.classList.add("accounting-not-empty");
|
||||
this.#baseAccountSelector.onOpen(this.#baseCode.value);
|
||||
this.#baseAccountSelector.onOpen();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base code.
|
||||
*
|
||||
* @return {string|null}
|
||||
*/
|
||||
get baseCode() {
|
||||
return this.#baseCode.value === ""? null: this.#baseCode.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the base account selector is closed.
|
||||
*
|
||||
@ -129,15 +138,14 @@ class AccountForm {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the base account.
|
||||
* Saves the selected base account.
|
||||
*
|
||||
* @param code {string} the base account code
|
||||
* @param text {string} the text for the base account
|
||||
* @param account {BaseAccountOption} the selected base account
|
||||
*/
|
||||
setBaseAccount(code, text) {
|
||||
this.#baseCode.value = code;
|
||||
this.#base.innerText = text;
|
||||
if (["1", "2", "3"].includes(code.substring(0, 1))) {
|
||||
saveBaseAccount(account) {
|
||||
this.#baseCode.value = account.code;
|
||||
this.#base.innerText = account.text;
|
||||
if (["1", "2", "3"].includes(account.code.substring(0, 1))) {
|
||||
this.#isNeedOffsetControl.classList.remove("d-none");
|
||||
this.#isNeedOffset.disabled = false;
|
||||
} else {
|
||||
@ -163,7 +171,7 @@ class AccountForm {
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateForm() {
|
||||
#validate() {
|
||||
let isValid = true;
|
||||
isValid = this.#validateBase() && isValid;
|
||||
isValid = this.#validateTitle() && isValid;
|
||||
@ -225,7 +233,7 @@ class BaseAccountSelector {
|
||||
* The account form
|
||||
* @type {AccountForm}
|
||||
*/
|
||||
#form;
|
||||
form;
|
||||
|
||||
/**
|
||||
* The selector modal
|
||||
@ -253,7 +261,7 @@ class BaseAccountSelector {
|
||||
|
||||
/**
|
||||
* The options
|
||||
* @type {HTMLLIElement[]}
|
||||
* @type {BaseAccountOption[]}
|
||||
*/
|
||||
#options;
|
||||
|
||||
@ -269,83 +277,54 @@ class BaseAccountSelector {
|
||||
* @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();
|
||||
this.form = form;
|
||||
const prefix = "accounting-base-selector";
|
||||
this.#modal = document.getElementById(`${prefix}-modal`);
|
||||
this.#query = document.getElementById(`${prefix}-query`);
|
||||
this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
|
||||
this.#optionList = document.getElementById(`${prefix}-option-list`);
|
||||
this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(this, element));
|
||||
this.#clearButton = document.getElementById(`${prefix}-clear`);
|
||||
|
||||
this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed());
|
||||
this.#query.oninput = () => this.#filterOptions();
|
||||
this.#clearButton.onclick = () => this.form.clearBaseAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the query.
|
||||
* Filters the options.
|
||||
*
|
||||
*/
|
||||
#initializeBaseAccountQuery() {
|
||||
this.#query.addEventListener("input", () => {
|
||||
if (this.#query.value === "") {
|
||||
#filterOptions() {
|
||||
let isAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
option.classList.remove("d-none");
|
||||
}
|
||||
this.#optionList.classList.remove("d-none");
|
||||
this.#queryNoResult.classList.add("d-none");
|
||||
return
|
||||
}
|
||||
let hasAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||
let isMatched = false;
|
||||
for (const queryValue of queryValues) {
|
||||
if (queryValue.includes(this.#query.value)) {
|
||||
isMatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isMatched) {
|
||||
option.classList.remove("d-none");
|
||||
hasAnyMatched = true;
|
||||
if (option.isMatched(this.#query.value)) {
|
||||
option.setShown(true);
|
||||
isAnyMatched = true;
|
||||
} else {
|
||||
option.classList.add("d-none");
|
||||
option.setShown(false);
|
||||
}
|
||||
}
|
||||
if (!hasAnyMatched) {
|
||||
if (!isAnyMatched) {
|
||||
this.#optionList.classList.add("d-none");
|
||||
this.#queryNoResult.classList.remove("d-none");
|
||||
} else {
|
||||
this.#optionList.classList.remove("d-none");
|
||||
this.#queryNoResult.classList.add("d-none");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the base account selector is shown.
|
||||
*
|
||||
* @param baseCode {string} the active base code
|
||||
*/
|
||||
onOpen(baseCode) {
|
||||
onOpen() {
|
||||
this.#query.value = "";
|
||||
this.#filterOptions();
|
||||
for (const option of this.#options) {
|
||||
if (option.dataset.code === baseCode) {
|
||||
option.classList.add("active");
|
||||
} else {
|
||||
option.classList.remove("active");
|
||||
option.setActive(option.code === this.form.baseCode);
|
||||
}
|
||||
}
|
||||
if (baseCode === "") {
|
||||
if (this.form.baseCode === null) {
|
||||
this.#clearButton.classList.add("btn-secondary")
|
||||
this.#clearButton.classList.remove("btn-danger");
|
||||
this.#clearButton.disabled = true;
|
||||
@ -356,3 +335,93 @@ class BaseAccountSelector {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A base account option.
|
||||
*
|
||||
*/
|
||||
class BaseAccountOption {
|
||||
|
||||
/**
|
||||
* The element
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
#element;
|
||||
|
||||
/**
|
||||
* The account code
|
||||
* @type {string}
|
||||
*/
|
||||
code;
|
||||
|
||||
/**
|
||||
* The account text
|
||||
* @type {string}
|
||||
*/
|
||||
text;
|
||||
|
||||
/**
|
||||
* The values to query against
|
||||
* @type {string[]}
|
||||
*/
|
||||
#queryValues;
|
||||
|
||||
/**
|
||||
* Constructs the account in the base account selector.
|
||||
*
|
||||
* @param selector {BaseAccountSelector} the base account selector
|
||||
* @param element {HTMLLIElement} the element
|
||||
*/
|
||||
constructor(selector, element) {
|
||||
this.#element = element;
|
||||
this.code = element.dataset.code;
|
||||
this.text = element.dataset.text;
|
||||
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
||||
|
||||
this.#element.onclick = () => selector.form.saveBaseAccount(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the account matches the query.
|
||||
*
|
||||
* @param query {string} the query term
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
isMatched(query) {
|
||||
if (query === "") {
|
||||
return true;
|
||||
}
|
||||
for (const queryValue of this.#queryValues) {
|
||||
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
/* The Mia! Accounting Project
|
||||
* account-order.js: The JavaScript for the account order
|
||||
*/
|
||||
|
||||
@ -29,10 +29,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const onReorder = () => {
|
||||
const accounts = Array.from(list.children);
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
||||
const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code");
|
||||
const no = document.getElementById(`accounting-order-${accounts[i].dataset.id}-no`);
|
||||
const code = document.getElementById(`accounting-order-${accounts[i].dataset.id}-code`);
|
||||
no.value = String(i + 1);
|
||||
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3);
|
||||
const zeroPaddedNo = `000${no.value}`.slice(-3)
|
||||
code.innerText = `${list.dataset.baseCode}-${zeroPaddedNo}`;
|
||||
}
|
||||
};
|
||||
initializeDragAndDropReordering(list, onReorder);
|
||||
|
@ -1,224 +0,0 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/28
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* The account selector.
|
||||
*
|
||||
*/
|
||||
class AccountSelector {
|
||||
|
||||
/**
|
||||
* The journal entry editor
|
||||
* @type {JournalEntryEditor}
|
||||
*/
|
||||
#entryEditor;
|
||||
|
||||
/**
|
||||
* The entry type
|
||||
* @type {string}
|
||||
*/
|
||||
entryType;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
#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.
|
||||
*
|
||||
* @param entryEditor {JournalEntryEditor} the journal entry editor
|
||||
* @param entryType {string} the entry type, either "debit" or "credit"
|
||||
*/
|
||||
constructor(entryEditor, entryType) {
|
||||
this.#entryEditor = entryEditor
|
||||
this.entryType = entryType;
|
||||
this.#prefix = "accounting-account-selector-" + entryType;
|
||||
this.#query = document.getElementById(this.#prefix + "-query");
|
||||
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
||||
this.#optionList = document.getElementById(this.#prefix + "-option-list");
|
||||
// noinspection JSValidateTypes
|
||||
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||
this.#more = document.getElementById(this.#prefix + "-more");
|
||||
this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
|
||||
this.#more.onclick = () => {
|
||||
this.#more.classList.add("d-none");
|
||||
this.#filterOptions();
|
||||
};
|
||||
this.#clearButton.onclick = () => this.#entryEditor.clearAccount();
|
||||
for (const option of this.#options) {
|
||||
option.onclick = () => this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
|
||||
}
|
||||
this.#query.addEventListener("input", () => {
|
||||
this.#filterOptions();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the options.
|
||||
*
|
||||
*/
|
||||
#filterOptions() {
|
||||
const codesInUse = this.#getCodesUsedInForm();
|
||||
let shouldAnyShow = false;
|
||||
for (const option of this.#options) {
|
||||
const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
|
||||
if (shouldShow) {
|
||||
option.classList.remove("d-none");
|
||||
shouldAnyShow = true;
|
||||
} else {
|
||||
option.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
|
||||
this.#optionList.classList.add("d-none");
|
||||
this.#queryNoResult.classList.remove("d-none");
|
||||
} else {
|
||||
this.#optionList.classList.remove("d-none");
|
||||
this.#queryNoResult.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account codes that are used in the form.
|
||||
*
|
||||
* @return {string[]} the account codes that are used in the form
|
||||
*/
|
||||
#getCodesUsedInForm() {
|
||||
const inUse = this.#entryEditor.form.getAccountCodesUsed(this.entryType);
|
||||
if (this.#entryEditor.accountCode !== null) {
|
||||
inUse.push(this.#entryEditor.accountCode);
|
||||
}
|
||||
return inUse
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether an option should show.
|
||||
*
|
||||
* @param option {HTMLLIElement} the option
|
||||
* @param more {HTMLLIElement} the more element
|
||||
* @param inUse {string[]} the account codes that are used in the form
|
||||
* @param query {HTMLInputElement} the query element, if any
|
||||
* @return {boolean} true if the option should show, or false otherwise
|
||||
*/
|
||||
#shouldOptionShow(option, more, inUse, query) {
|
||||
const isQueryMatched = () => {
|
||||
if (query.value === "") {
|
||||
return true;
|
||||
}
|
||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||
for (const queryValue of queryValues) {
|
||||
if (queryValue.includes(query.value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const isMoreMatched = () => {
|
||||
if (more.classList.contains("d-none")) {
|
||||
return true;
|
||||
}
|
||||
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
|
||||
};
|
||||
return isMoreMatched() && isQueryMatched();
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the account selector is shown.
|
||||
*
|
||||
*/
|
||||
onOpen() {
|
||||
this.#query.value = "";
|
||||
this.#more.classList.remove("d-none");
|
||||
this.#filterOptions();
|
||||
for (const option of this.#options) {
|
||||
if (option.dataset.code === this.#entryEditor.accountCode) {
|
||||
option.classList.add("active");
|
||||
} else {
|
||||
option.classList.remove("active");
|
||||
}
|
||||
}
|
||||
if (this.#entryEditor.accountCode === null) {
|
||||
this.#clearButton.classList.add("btn-secondary");
|
||||
this.#clearButton.classList.remove("btn-danger");
|
||||
this.#clearButton.disabled = true;
|
||||
} else {
|
||||
this.#clearButton.classList.add("btn-danger");
|
||||
this.#clearButton.classList.remove("btn-secondary");
|
||||
this.#clearButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account selector instances.
|
||||
*
|
||||
* @param entryEditor {JournalEntryEditor} the journal entry editor
|
||||
* @return {{debit: AccountSelector, credit: AccountSelector}}
|
||||
*/
|
||||
static getInstances(entryEditor) {
|
||||
const selectors = {}
|
||||
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
|
||||
for (const modal of modals) {
|
||||
selectors[modal.dataset.entryType] = new AccountSelector(entryEditor, modal.dataset.entryType);
|
||||
}
|
||||
return selectors;
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
/* The Mia! Accounting Project
|
||||
* currency-form.js: The JavaScript for the currency form
|
||||
*/
|
||||
|
||||
@ -81,7 +81,7 @@ class CurrencyForm {
|
||||
this.#validateName();
|
||||
};
|
||||
this.#formElement.onsubmit = () => {
|
||||
this.#validateForm().then((isValid) => {
|
||||
this.#validate().then((isValid) => {
|
||||
if (isValid) {
|
||||
this.#formElement.submit();
|
||||
}
|
||||
@ -95,7 +95,7 @@ class CurrencyForm {
|
||||
*
|
||||
* @returns {Promise<boolean>} true if valid, or false otherwise
|
||||
*/
|
||||
async #validateForm() {
|
||||
async #validate() {
|
||||
let isValid = true;
|
||||
isValid = await this.#validateCode() && isValid;
|
||||
isValid = this.#validateName() && isValid;
|
||||
@ -128,7 +128,7 @@ class CurrencyForm {
|
||||
}
|
||||
const original = this.#code.dataset.original;
|
||||
if (original === "" || this.#code.value !== original) {
|
||||
const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value));
|
||||
const response = await fetch(`${this.#code.dataset.existsUrl}?q=${encodeURIComponent(this.#code.value)}`);
|
||||
const data = await response.json();
|
||||
if (data["exists"]) {
|
||||
this.#code.classList.add("is-invalid");
|
||||
|
1389
src/accounting/static/js/description-editor.js
Normal file
1389
src/accounting/static/js/description-editor.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
/* The Mia! Accounting Project
|
||||
* drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
|
||||
*/
|
||||
|
||||
|
312
src/accounting/static/js/journal-entry-account-selector.js
Normal file
312
src/accounting/static/js/journal-entry-account-selector.js
Normal file
@ -0,0 +1,312 @@
|
||||
/* The Mia! Accounting Project
|
||||
* journal-entry-account-selector.js: The JavaScript for the account selector of the journal entry form
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/28
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* The account selector.
|
||||
*
|
||||
*/
|
||||
class JournalEntryAccountSelector {
|
||||
|
||||
/**
|
||||
* The line item editor
|
||||
* @type {JournalEntryLineItemEditor}
|
||||
*/
|
||||
lineItemEditor;
|
||||
|
||||
/**
|
||||
* Either "debit" or "credit"
|
||||
* @type {string}
|
||||
*/
|
||||
#debitCredit;
|
||||
|
||||
/**
|
||||
* The button to clear the account
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#clearButton
|
||||
|
||||
/**
|
||||
* The query input
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#query;
|
||||
|
||||
/**
|
||||
* The error message when the query has no result
|
||||
* @type {HTMLParagraphElement}
|
||||
*/
|
||||
#queryNoResult;
|
||||
|
||||
/**
|
||||
* The option list
|
||||
* @type {HTMLUListElement}
|
||||
*/
|
||||
#optionList;
|
||||
|
||||
/**
|
||||
* The options
|
||||
* @type {JournalEntryAccountOption[]}
|
||||
*/
|
||||
#options;
|
||||
|
||||
/**
|
||||
* The more item to show all accounts
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
#more;
|
||||
|
||||
/**
|
||||
* Whether to show all accounts
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isShowMore = false;
|
||||
|
||||
/**
|
||||
* Constructs an account selector.
|
||||
*
|
||||
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||
* @param debitCredit {string} either "debit" or "credit"
|
||||
*/
|
||||
constructor(lineItemEditor, debitCredit) {
|
||||
this.lineItemEditor = lineItemEditor
|
||||
this.#debitCredit = debitCredit;
|
||||
const prefix = `accounting-account-selector-${debitCredit}`;
|
||||
this.#query = document.getElementById(`${prefix}-query`);
|
||||
this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
|
||||
this.#optionList = document.getElementById(`${prefix}-option-list`);
|
||||
this.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new JournalEntryAccountOption(this, element));
|
||||
this.#more = document.getElementById(`${prefix}-more`);
|
||||
this.#clearButton = document.getElementById(`${prefix}-btn-clear`);
|
||||
|
||||
this.#more.onclick = () => {
|
||||
this.#isShowMore = true;
|
||||
this.#more.classList.add("d-none");
|
||||
this.#filterOptions();
|
||||
};
|
||||
this.#query.oninput = () => this.#filterOptions();
|
||||
this.#clearButton.onclick = () => this.lineItemEditor.clearAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the options.
|
||||
*
|
||||
*/
|
||||
#filterOptions() {
|
||||
const codesInUse = this.#getCodesUsedInForm();
|
||||
let isAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
if (option.isMatched(this.#isShowMore, codesInUse, this.#query.value)) {
|
||||
option.setShown(true);
|
||||
isAnyMatched = true;
|
||||
} else {
|
||||
option.setShown(false);
|
||||
}
|
||||
}
|
||||
if (!isAnyMatched) {
|
||||
this.#optionList.classList.add("d-none");
|
||||
this.#queryNoResult.classList.remove("d-none");
|
||||
} else {
|
||||
this.#optionList.classList.remove("d-none");
|
||||
this.#queryNoResult.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account codes that are used in the form.
|
||||
*
|
||||
* @return {string[]} the account codes that are used in the form
|
||||
*/
|
||||
#getCodesUsedInForm() {
|
||||
const inUse = this.lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
|
||||
if (this.lineItemEditor.account !== null) {
|
||||
inUse.push(this.lineItemEditor.account.code);
|
||||
}
|
||||
return inUse
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the account selector is shown.
|
||||
*
|
||||
*/
|
||||
onOpen() {
|
||||
this.#query.value = "";
|
||||
this.#isShowMore = false;
|
||||
this.#more.classList.remove("d-none");
|
||||
this.#filterOptions();
|
||||
for (const option of this.#options) {
|
||||
option.setActive(this.lineItemEditor.account !== null && option.code === this.lineItemEditor.account.code);
|
||||
}
|
||||
if (this.lineItemEditor.account === null) {
|
||||
this.#clearButton.classList.add("btn-secondary");
|
||||
this.#clearButton.classList.remove("btn-danger");
|
||||
this.#clearButton.disabled = true;
|
||||
} else {
|
||||
this.#clearButton.classList.add("btn-danger");
|
||||
this.#clearButton.classList.remove("btn-secondary");
|
||||
this.#clearButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the account selector instances.
|
||||
*
|
||||
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||
* @return {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}}
|
||||
*/
|
||||
static getInstances(lineItemEditor) {
|
||||
const selectors = {}
|
||||
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
|
||||
for (const modal of modals) {
|
||||
selectors[modal.dataset.debitCredit] = new JournalEntryAccountSelector(lineItemEditor, modal.dataset.debitCredit);
|
||||
}
|
||||
return selectors;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An account option
|
||||
*
|
||||
*/
|
||||
class JournalEntryAccountOption {
|
||||
|
||||
/**
|
||||
* The element
|
||||
* @type {HTMLLIElement}
|
||||
*/
|
||||
#element;
|
||||
|
||||
/**
|
||||
* The account code
|
||||
* @type {string}
|
||||
*/
|
||||
code;
|
||||
|
||||
/**
|
||||
* The account text
|
||||
* @type {string}
|
||||
*/
|
||||
text;
|
||||
|
||||
/**
|
||||
* Whether the account is in use
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isInUse;
|
||||
|
||||
/**
|
||||
* Whether line items in the account need offset
|
||||
* @type {boolean}
|
||||
*/
|
||||
isNeedOffset;
|
||||
|
||||
/**
|
||||
* The values to query against
|
||||
* @type {string[]}
|
||||
*/
|
||||
#queryValues;
|
||||
|
||||
/**
|
||||
* Constructs the account in the account selector.
|
||||
*
|
||||
* @param selector {JournalEntryAccountSelector} the account selector
|
||||
* @param element {HTMLLIElement} the element
|
||||
*/
|
||||
constructor(selector, element) {
|
||||
this.#element = element;
|
||||
this.code = element.dataset.code;
|
||||
this.text = element.dataset.text;
|
||||
this.#isInUse = element.classList.contains("accounting-account-is-in-use");
|
||||
this.isNeedOffset = element.classList.contains("accounting-account-is-need-offset");
|
||||
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
||||
|
||||
this.#element.onclick = () => selector.lineItemEditor.saveAccount(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the account matches the query.
|
||||
*
|
||||
* @param isShowMore {boolean} true to show all accounts, or false to show only those in use
|
||||
* @param codesInUse {string[]} the account codes that are used in the form
|
||||
* @param query {string} the query term
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
isMatched(isShowMore, codesInUse, query) {
|
||||
return this.#isInUseMatched(isShowMore, codesInUse) && this.#isQueryMatched(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the account matches the "in-use" condition.
|
||||
*
|
||||
* @param isShowMore {boolean} true to show all accounts, or false to show only those in use
|
||||
* @param codesInUse {string[]} the account codes that are used in the form
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
#isInUseMatched(isShowMore, codesInUse) {
|
||||
return isShowMore || this.#isInUse || codesInUse.includes(this.code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the account matches the query term.
|
||||
*
|
||||
* @param query {string} the query term
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
#isQueryMatched(query) {
|
||||
if (query === "") {
|
||||
return true;
|
||||
}
|
||||
for (const queryValue of this.#queryValues) {
|
||||
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the option is shown.
|
||||
*
|
||||
* @param isShown {boolean} true to show, or false otherwise
|
||||
*/
|
||||
setShown(isShown) {
|
||||
if (isShown) {
|
||||
this.#element.classList.remove("d-none");
|
||||
} else {
|
||||
this.#element.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the option is active.
|
||||
*
|
||||
* @param isActive {boolean} true if active, or false otherwise
|
||||
*/
|
||||
setActive(isActive) {
|
||||
if (isActive) {
|
||||
this.#element.classList.add("active");
|
||||
} else {
|
||||
this.#element.classList.remove("active");
|
||||
}
|
||||
}
|
||||
}
|
@ -1,596 +0,0 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* journal-entry-editor.js: The JavaScript for the journal entry 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 editor.
|
||||
*
|
||||
*/
|
||||
class JournalEntryEditor {
|
||||
|
||||
/**
|
||||
* The transaction form
|
||||
* @type {TransactionForm}
|
||||
*/
|
||||
form;
|
||||
|
||||
/**
|
||||
* The journal entry editor
|
||||
* @type {HTMLFormElement}
|
||||
*/
|
||||
#element;
|
||||
|
||||
/**
|
||||
* The bootstrap modal
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#modal;
|
||||
|
||||
/**
|
||||
* The entry type, either "debit" or "credit"
|
||||
* @type {string}
|
||||
*/
|
||||
entryType;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix = "accounting-entry-editor"
|
||||
|
||||
/**
|
||||
* The container of the original entry
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#originalEntryContainer;
|
||||
|
||||
/**
|
||||
* The control of the original entry
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#originalEntryControl;
|
||||
|
||||
/**
|
||||
* The original entry
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#originalEntry;
|
||||
|
||||
/**
|
||||
* The error message of the original entry
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#originalEntryError;
|
||||
|
||||
/**
|
||||
* The delete button of the original entry
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#originalEntryDelete;
|
||||
|
||||
/**
|
||||
* The control of the summary
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#summaryControl;
|
||||
|
||||
/**
|
||||
* The summary
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#summary;
|
||||
|
||||
/**
|
||||
* The error message of the summary
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#summaryError;
|
||||
|
||||
/**
|
||||
* The control of the account
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#accountControl;
|
||||
|
||||
/**
|
||||
* The account
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#account;
|
||||
|
||||
/**
|
||||
* The error message of the account
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#accountError;
|
||||
|
||||
/**
|
||||
* The amount
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#amount;
|
||||
|
||||
/**
|
||||
* The error message of the amount
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#amountError;
|
||||
|
||||
/**
|
||||
* The journal entry to edit
|
||||
* @type {JournalEntrySubForm|null}
|
||||
*/
|
||||
entry;
|
||||
|
||||
/**
|
||||
* The debit or credit entry side sub-form
|
||||
* @type {DebitCreditSideSubForm}
|
||||
*/
|
||||
#side;
|
||||
|
||||
/**
|
||||
* Whether the journal entry needs offset
|
||||
* @type {boolean}
|
||||
*/
|
||||
isNeedOffset = false;
|
||||
|
||||
/**
|
||||
* The ID of the original entry
|
||||
* @type {string|null}
|
||||
*/
|
||||
originalEntryId = null;
|
||||
|
||||
/**
|
||||
* The date of the original entry
|
||||
* @type {string|null}
|
||||
*/
|
||||
originalEntryDate = null;
|
||||
|
||||
/**
|
||||
* The text of the original entry
|
||||
* @type {string|null}
|
||||
*/
|
||||
originalEntryText = null;
|
||||
|
||||
/**
|
||||
* The account code
|
||||
* @type {string|null}
|
||||
*/
|
||||
accountCode = null;
|
||||
|
||||
/**
|
||||
* The account text
|
||||
* @type {string|null}
|
||||
*/
|
||||
accountText = null;
|
||||
|
||||
/**
|
||||
* The summary
|
||||
* @type {string|null}
|
||||
*/
|
||||
summary = null;
|
||||
|
||||
/**
|
||||
* The amount
|
||||
* @type {string}
|
||||
*/
|
||||
amount = "";
|
||||
|
||||
/**
|
||||
* The summary editors
|
||||
* @type {{debit: SummaryEditor, credit: SummaryEditor}}
|
||||
*/
|
||||
#summaryEditors;
|
||||
|
||||
/**
|
||||
* The account selectors
|
||||
* @type {{debit: AccountSelector, credit: AccountSelector}}
|
||||
*/
|
||||
#accountSelectors;
|
||||
|
||||
/**
|
||||
* The original entry selector
|
||||
* @type {OriginalEntrySelector}
|
||||
*/
|
||||
originalEntrySelector;
|
||||
|
||||
/**
|
||||
* Constructs a new journal entry editor.
|
||||
*
|
||||
* @param form {TransactionForm} the transaction form
|
||||
*/
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
this.#element = document.getElementById(this.#prefix);
|
||||
this.#modal = document.getElementById(this.#prefix + "-modal");
|
||||
this.#originalEntryContainer = document.getElementById(this.#prefix + "-original-entry-container");
|
||||
this.#originalEntryControl = document.getElementById(this.#prefix + "-original-entry-control");
|
||||
this.#originalEntry = document.getElementById(this.#prefix + "-original-entry");
|
||||
this.#originalEntryError = document.getElementById(this.#prefix + "-original-entry-error");
|
||||
this.#originalEntryDelete = document.getElementById(this.#prefix + "-original-entry-delete");
|
||||
this.#summaryControl = document.getElementById(this.#prefix + "-summary-control");
|
||||
this.#summary = document.getElementById(this.#prefix + "-summary");
|
||||
this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
|
||||
this.#accountControl = document.getElementById(this.#prefix + "-account-control");
|
||||
this.#account = document.getElementById(this.#prefix + "-account");
|
||||
this.#accountError = document.getElementById(this.#prefix + "-account-error")
|
||||
this.#amount = document.getElementById(this.#prefix + "-amount");
|
||||
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
|
||||
this.#summaryEditors = SummaryEditor.getInstances(this);
|
||||
this.#accountSelectors = AccountSelector.getInstances(this);
|
||||
this.originalEntrySelector = new OriginalEntrySelector();
|
||||
this.#originalEntryControl.onclick = () => this.originalEntrySelector.onOpen(this, this.originalEntryId)
|
||||
this.#originalEntryDelete.onclick = () => this.clearOriginalEntry();
|
||||
this.#summaryControl.onclick = () => this.#summaryEditors[this.entryType].onOpen();
|
||||
this.#accountControl.onclick = () => this.#accountSelectors[this.entryType].onOpen();
|
||||
this.#amount.onchange = () => this.#validateAmount();
|
||||
this.#element.onsubmit = () => {
|
||||
if (this.#validate()) {
|
||||
if (this.entry === null) {
|
||||
this.entry = this.#side.addJournalEntry();
|
||||
}
|
||||
this.amount = this.#amount.value;
|
||||
this.entry.save(this);
|
||||
bootstrap.Modal.getInstance(this.#modal).hide();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the original entry from the original entry selector.
|
||||
*
|
||||
* @param originalEntry {OriginalEntry} the original entry
|
||||
*/
|
||||
saveOriginalEntry(originalEntry) {
|
||||
this.isNeedOffset = false;
|
||||
this.#originalEntryContainer.classList.remove("d-none");
|
||||
this.#originalEntryControl.classList.add("accounting-not-empty");
|
||||
this.originalEntryId = originalEntry.id;
|
||||
this.originalEntryDate = originalEntry.date;
|
||||
this.originalEntryText = originalEntry.text;
|
||||
this.#originalEntry.innerText = originalEntry.text;
|
||||
this.#setEnableSummaryAccount(false);
|
||||
if (originalEntry.summary === "") {
|
||||
this.#summaryControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#summaryControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.summary = originalEntry.summary === ""? null: originalEntry.summary;
|
||||
this.#summary.innerText = originalEntry.summary;
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
this.accountCode = originalEntry.accountCode;
|
||||
this.accountText = originalEntry.accountText;
|
||||
this.#account.innerText = originalEntry.accountText;
|
||||
this.#amount.value = String(originalEntry.netBalance);
|
||||
this.#amount.max = String(originalEntry.netBalance);
|
||||
this.#amount.min = "0";
|
||||
this.#validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the original entry.
|
||||
*
|
||||
*/
|
||||
clearOriginalEntry() {
|
||||
this.isNeedOffset = false;
|
||||
this.#originalEntryContainer.classList.add("d-none");
|
||||
this.#originalEntryControl.classList.remove("accounting-not-empty");
|
||||
this.originalEntryId = null;
|
||||
this.originalEntryDate = null;
|
||||
this.originalEntryText = null;
|
||||
this.#originalEntry.innerText = "";
|
||||
this.#setEnableSummaryAccount(true);
|
||||
this.#accountControl.classList.remove("accounting-not-empty");
|
||||
this.accountCode = null;
|
||||
this.accountText = null;
|
||||
this.#account.innerText = "";
|
||||
this.#amount.max = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currency code.
|
||||
*
|
||||
* @return {string} the currency code
|
||||
*/
|
||||
getCurrencyCode() {
|
||||
return this.#side.currency.getCurrencyCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the summary from the summary editor.
|
||||
*
|
||||
* @param summary {string} the summary
|
||||
*/
|
||||
saveSummary(summary) {
|
||||
if (summary === "") {
|
||||
this.#summaryControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#summaryControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.summary = summary === ""? null: summary;
|
||||
this.#summary.innerText = summary;
|
||||
this.#validateSummary();
|
||||
bootstrap.Modal.getOrCreateInstance(this.#modal).show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the summary with the suggested account from the summary editor.
|
||||
*
|
||||
* @param summary {string} the summary
|
||||
* @param accountCode {string} the account code
|
||||
* @param accountText {string} the account text
|
||||
* @param isAccountNeedOffset {boolean} true if the journal entries in the account need offset, or false otherwise
|
||||
*/
|
||||
saveSummaryWithAccount(summary, accountCode, accountText, isAccountNeedOffset) {
|
||||
this.isNeedOffset = isAccountNeedOffset;
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
this.accountCode = accountCode;
|
||||
this.accountText = accountText;
|
||||
this.#account.innerText = accountText;
|
||||
this.#validateAccount();
|
||||
this.saveSummary(summary)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the account.
|
||||
*
|
||||
*/
|
||||
clearAccount() {
|
||||
this.isNeedOffset = false;
|
||||
this.#accountControl.classList.remove("accounting-not-empty");
|
||||
this.accountCode = null;
|
||||
this.accountText = null;
|
||||
this.#account.innerText = "";
|
||||
this.#validateAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the account.
|
||||
*
|
||||
* @param code {string} the account code
|
||||
* @param text {string} the account text
|
||||
* @param isNeedOffset {boolean} true if the journal entries 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.#account.innerText = text;
|
||||
this.#validateAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validate() {
|
||||
let isValid = true;
|
||||
isValid = this.#validateOriginalEntry() && isValid;
|
||||
isValid = this.#validateSummary() && isValid;
|
||||
isValid = this.#validateAccount() && isValid;
|
||||
isValid = this.#validateAmount() && isValid
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the original entry.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
#validateOriginalEntry() {
|
||||
this.#originalEntryControl.classList.remove("is-invalid");
|
||||
this.#originalEntryError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the summary.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
#validateSummary() {
|
||||
this.#summary.classList.remove("is-invalid");
|
||||
this.#summaryError.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.#amount.value = this.#amount.value.trim();
|
||||
this.#amount.classList.remove("is-invalid");
|
||||
if (this.#amount.value === "") {
|
||||
this.#amount.classList.add("is-invalid");
|
||||
this.#amountError.innerText = A_("Please fill in the amount.");
|
||||
return false;
|
||||
}
|
||||
const amount =new Decimal(this.#amount.value);
|
||||
if (amount.lessThanOrEqualTo(0)) {
|
||||
this.#amount.classList.add("is-invalid");
|
||||
this.#amountError.innerText = A_("Please fill in a positive amount.");
|
||||
return false;
|
||||
}
|
||||
if (this.#amount.max !== "") {
|
||||
if (amount.greaterThan(new Decimal(this.#amount.max))) {
|
||||
this.#amount.classList.add("is-invalid");
|
||||
this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original entry.", {balance: new Decimal(this.#amount.max)});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (this.#amount.min !== "") {
|
||||
const min = new Decimal(this.#amount.min);
|
||||
if (amount.lessThan(min)) {
|
||||
this.#amount.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.#amount.classList.remove("is-invalid");
|
||||
this.#amountError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when adding a new journal entry.
|
||||
*
|
||||
* @param side {DebitCreditSideSubForm} the debit or credit side sub-form
|
||||
*/
|
||||
onAddNew(side) {
|
||||
this.entry = null;
|
||||
this.#side = side;
|
||||
this.entryType = this.#side.entryType;
|
||||
this.isNeedOffset = false;
|
||||
this.#originalEntryContainer.classList.add("d-none");
|
||||
this.#originalEntryControl.classList.remove("accounting-not-empty");
|
||||
this.#originalEntryControl.classList.remove("is-invalid");
|
||||
this.originalEntryId = null;
|
||||
this.originalEntryDate = null;
|
||||
this.originalEntryText = null;
|
||||
this.#originalEntry.innerText = "";
|
||||
this.#setEnableSummaryAccount(true);
|
||||
this.#summaryControl.classList.remove("accounting-not-empty");
|
||||
this.#summaryControl.classList.remove("is-invalid");
|
||||
this.summary = null;
|
||||
this.#summary.innerText = ""
|
||||
this.#summaryError.innerText = ""
|
||||
this.#accountControl.classList.remove("accounting-not-empty");
|
||||
this.#accountControl.classList.remove("is-invalid");
|
||||
this.accountCode = null;
|
||||
this.accountText = null;
|
||||
this.#account.innerText = "";
|
||||
this.#accountError.innerText = "";
|
||||
this.#amount.value = "";
|
||||
this.#amount.max = "";
|
||||
this.#amount.min = "0";
|
||||
this.#amount.classList.remove("is-invalid");
|
||||
this.#amountError.innerText = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when editing a journal entry.
|
||||
*
|
||||
* @param entry {JournalEntrySubForm} the journal entry sub-form
|
||||
*/
|
||||
onEdit(entry) {
|
||||
this.entry = entry;
|
||||
this.#side = entry.side;
|
||||
this.entryType = this.#side.entryType;
|
||||
this.isNeedOffset = entry.isNeedOffset();
|
||||
this.originalEntryId = entry.getOriginalEntryId();
|
||||
this.originalEntryDate = entry.getOriginalEntryDate();
|
||||
this.originalEntryText = entry.getOriginalEntryText();
|
||||
this.#originalEntry.innerText = this.originalEntryText;
|
||||
if (this.originalEntryId === null) {
|
||||
this.#originalEntryContainer.classList.add("d-none");
|
||||
this.#originalEntryControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#originalEntryContainer.classList.remove("d-none");
|
||||
this.#originalEntryControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.#setEnableSummaryAccount(!entry.isMatched && this.originalEntryId === null);
|
||||
this.summary = entry.getSummary();
|
||||
if (this.summary === null) {
|
||||
this.#summaryControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#summaryControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.#summary.innerText = this.summary === null? "": this.summary;
|
||||
if (entry.getAccountCode() === null) {
|
||||
this.#accountControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.accountCode = entry.getAccountCode();
|
||||
this.accountText = entry.getAccountText();
|
||||
this.#account.innerText = this.accountText;
|
||||
this.#amount.value = entry.getAmount() === null? "": String(entry.getAmount());
|
||||
const maxAmount = this.#getMaxAmount();
|
||||
this.#amount.max = maxAmount === null? "": maxAmount;
|
||||
this.#amount.min = entry.getAmountMin() === null? "": String(entry.getAmountMin());
|
||||
this.#validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds out the max amount.
|
||||
*
|
||||
* @return {Decimal|null} the max amount
|
||||
*/
|
||||
#getMaxAmount() {
|
||||
if (this.originalEntryId === null) {
|
||||
return null;
|
||||
}
|
||||
return this.originalEntrySelector.getNetBalance(this.entry, this.form, this.originalEntryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the enable status of the summary and account.
|
||||
*
|
||||
* @param isEnabled {boolean} true to enable, or false otherwise
|
||||
*/
|
||||
#setEnableSummaryAccount(isEnabled) {
|
||||
if (isEnabled) {
|
||||
this.#summaryControl.dataset.bsToggle = "modal";
|
||||
this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + this.#side.entryType + "-modal";
|
||||
this.#summaryControl.classList.remove("accounting-disabled");
|
||||
this.#summaryControl.classList.add("accounting-clickable");
|
||||
this.#accountControl.dataset.bsToggle = "modal";
|
||||
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#side.entryType + "-modal";
|
||||
this.#accountControl.classList.remove("accounting-disabled");
|
||||
this.#accountControl.classList.add("accounting-clickable");
|
||||
} else {
|
||||
this.#summaryControl.dataset.bsToggle = "";
|
||||
this.#summaryControl.dataset.bsTarget = "";
|
||||
this.#summaryControl.classList.add("accounting-disabled");
|
||||
this.#summaryControl.classList.remove("accounting-clickable");
|
||||
this.#accountControl.dataset.bsToggle = "";
|
||||
this.#accountControl.dataset.bsTarget = "";
|
||||
this.#accountControl.classList.add("accounting-disabled");
|
||||
this.#accountControl.classList.remove("accounting-clickable");
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
576
src/accounting/static/js/journal-entry-line-item-editor.js
Normal file
576
src/accounting/static/js/journal-entry-line-item-editor.js
Normal file
@ -0,0 +1,576 @@
|
||||
/* The Mia! Accounting Project
|
||||
* journal-entry-line-item-editor.js: The JavaScript for the journal entry line item editor
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
* First written: 2023/2/25
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* The journal entry line item editor.
|
||||
*
|
||||
*/
|
||||
class JournalEntryLineItemEditor {
|
||||
|
||||
/**
|
||||
* The journal entry form
|
||||
* @type {JournalEntryForm}
|
||||
*/
|
||||
form;
|
||||
|
||||
/**
|
||||
* The journal entry line item editor
|
||||
* @type {HTMLFormElement}
|
||||
*/
|
||||
#element;
|
||||
|
||||
/**
|
||||
* The bootstrap modal
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
modal;
|
||||
|
||||
/**
|
||||
* Either "debit" or "credit"
|
||||
* @type {string}
|
||||
*/
|
||||
debitCredit;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix = "accounting-line-item-editor"
|
||||
|
||||
/**
|
||||
* The container of the original line item
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#originalLineItemContainer;
|
||||
|
||||
/**
|
||||
* The control of the original line item
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#originalLineItemControl;
|
||||
|
||||
/**
|
||||
* The original line item
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#originalLineItemText;
|
||||
|
||||
/**
|
||||
* The error message of the original line item
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#originalLineItemError;
|
||||
|
||||
/**
|
||||
* The delete button of the original line item
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#originalLineItemDelete;
|
||||
|
||||
/**
|
||||
* The control of the description
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#descriptionControl;
|
||||
|
||||
/**
|
||||
* The description
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#descriptionText;
|
||||
|
||||
/**
|
||||
* The error message of the description
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#descriptionError;
|
||||
|
||||
/**
|
||||
* The control of the account
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#accountControl;
|
||||
|
||||
/**
|
||||
* The account
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#accountText;
|
||||
|
||||
/**
|
||||
* The error message of the account
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#accountError;
|
||||
|
||||
/**
|
||||
* The amount
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#amountInput;
|
||||
|
||||
/**
|
||||
* The error message of the amount
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#amountError;
|
||||
|
||||
/**
|
||||
* The journal entry line item to edit
|
||||
* @type {LineItemSubForm|null}
|
||||
*/
|
||||
lineItem;
|
||||
|
||||
/**
|
||||
* The debit or credit sub-form
|
||||
* @type {DebitCreditSubForm}
|
||||
*/
|
||||
#debitCreditSubForm;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @type {JournalEntryAccount|null}
|
||||
*/
|
||||
account = null;
|
||||
|
||||
/**
|
||||
* Whether the user has confirmed the account
|
||||
* @type {boolean}
|
||||
*/
|
||||
isAccountConfirmed = false;
|
||||
|
||||
/**
|
||||
* The description
|
||||
* @type {string|null}
|
||||
*/
|
||||
description = null;
|
||||
|
||||
/**
|
||||
* The description editors
|
||||
* @type {{debit: DescriptionEditor, credit: DescriptionEditor}}
|
||||
*/
|
||||
#descriptionEditors;
|
||||
|
||||
/**
|
||||
* The account selectors
|
||||
* @type {{debit: JournalEntryAccountSelector, credit: JournalEntryAccountSelector}}
|
||||
*/
|
||||
#accountSelectors;
|
||||
|
||||
/**
|
||||
* The original line item selector
|
||||
* @type {OriginalLineItemSelector}
|
||||
*/
|
||||
originalLineItemSelector;
|
||||
|
||||
/**
|
||||
* Constructs a new journal entry line item editor.
|
||||
*
|
||||
* @param form {JournalEntryForm} the journal entry form
|
||||
*/
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
this.#element = document.getElementById(this.#prefix);
|
||||
this.modal = document.getElementById(`${this.#prefix}-modal`);
|
||||
this.#originalLineItemContainer = document.getElementById(`${this.#prefix}-original-line-item-container`);
|
||||
this.#originalLineItemControl = document.getElementById(`${this.#prefix}-original-line-item-control`);
|
||||
this.#originalLineItemText = document.getElementById(`${this.#prefix}-original-line-item`);
|
||||
this.#originalLineItemError = document.getElementById(`${this.#prefix}-original-line-item-error`);
|
||||
this.#originalLineItemDelete = document.getElementById(`${this.#prefix}-original-line-item-delete`);
|
||||
this.#descriptionControl = document.getElementById(`${this.#prefix}-description-control`);
|
||||
this.#descriptionText = document.getElementById(`${this.#prefix}-description`);
|
||||
this.#descriptionError = document.getElementById(`${this.#prefix}-description-error`);
|
||||
this.#accountControl = document.getElementById(`${this.#prefix}-account-control`);
|
||||
this.#accountText = document.getElementById(`${this.#prefix}-account`);
|
||||
this.#accountError = document.getElementById(`${this.#prefix}-account-error`)
|
||||
this.#amountInput = document.getElementById(`${this.#prefix}-amount`);
|
||||
this.#amountError = document.getElementById(`${this.#prefix}-amount-error`);
|
||||
this.#descriptionEditors = DescriptionEditor.getInstances(this);
|
||||
this.#accountSelectors = JournalEntryAccountSelector.getInstances(this);
|
||||
this.originalLineItemSelector = new OriginalLineItemSelector(this);
|
||||
|
||||
this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen()
|
||||
this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
|
||||
this.#descriptionControl.onclick = () => this.#descriptionEditors[this.debitCredit].onOpen();
|
||||
this.#accountControl.onclick = () => this.#accountSelectors[this.debitCredit].onOpen();
|
||||
this.#amountInput.onchange = () => this.#validateAmount();
|
||||
this.#element.onsubmit = () => {
|
||||
if (this.#validate()) {
|
||||
if (this.lineItem === null) {
|
||||
this.lineItem = this.#debitCreditSubForm.addLineItem();
|
||||
}
|
||||
this.lineItem.save(this);
|
||||
bootstrap.Modal.getInstance(this.modal).hide();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
this.modal.addEventListener("hidden.bs.modal", () => this.#debitCreditSubForm.onLineItemEditorClosed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount.
|
||||
*
|
||||
* @return {string} the amount
|
||||
*/
|
||||
get amount() {
|
||||
return this.#amountInput.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currency code.
|
||||
*
|
||||
* @return {string} the currency code
|
||||
*/
|
||||
get currencyCode() {
|
||||
return this.#debitCreditSubForm.currency.currencyCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the original line item from the original line item selector.
|
||||
*
|
||||
* @param originalLineItem {OriginalLineItem} the original line item
|
||||
*/
|
||||
saveOriginalLineItem(originalLineItem) {
|
||||
this.#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.account = originalLineItem.account.copy();
|
||||
this.isAccountConfirmed = false;
|
||||
this.#accountText.innerText = this.account.text;
|
||||
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.#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.account = null;
|
||||
this.isAccountConfirmed = false;
|
||||
this.#accountText.innerText = "";
|
||||
this.#amountInput.max = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the description from the description editor.
|
||||
*
|
||||
* @param editor {DescriptionEditor} the description editor
|
||||
*/
|
||||
saveDescription(editor) {
|
||||
if (editor.selectedAccount !== null) {
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
this.account = editor.selectedAccount.copy();
|
||||
this.#accountText.innerText = editor.selectedAccount.text;
|
||||
this.isAccountConfirmed = editor.isAccountConfirmed;
|
||||
this.#validateAccount();
|
||||
}
|
||||
if (editor.description === "") {
|
||||
this.#descriptionControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#descriptionControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.description = editor.description === ""? null: editor.description;
|
||||
this.#descriptionText.innerText = editor.description;
|
||||
this.#validateDescription();
|
||||
bootstrap.Modal.getOrCreateInstance(this.modal).show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the account.
|
||||
*
|
||||
*/
|
||||
clearAccount() {
|
||||
this.#accountControl.classList.remove("accounting-not-empty");
|
||||
this.account = null;
|
||||
this.isAccountConfirmed = false;
|
||||
this.#accountText.innerText = "";
|
||||
this.#validateAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the selected account.
|
||||
*
|
||||
* @param account {JournalEntryAccountOption} the selected account
|
||||
*/
|
||||
saveAccount(account) {
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
this.account = new JournalEntryAccount(account.code, account.text, account.isNeedOffset);
|
||||
this.isAccountConfirmed = true;
|
||||
this.#accountText.innerText = account.text;
|
||||
this.#validateAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validate() {
|
||||
let isValid = true;
|
||||
isValid = this.#validateOriginalLineItem() && isValid;
|
||||
isValid = this.#validateDescription() && isValid;
|
||||
isValid = this.#validateAccount() && isValid;
|
||||
isValid = this.#validateAmount() && isValid
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the original line item.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
#validateOriginalLineItem() {
|
||||
this.#originalLineItemControl.classList.remove("is-invalid");
|
||||
this.#originalLineItemError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the description.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
#validateDescription() {
|
||||
this.#descriptionText.classList.remove("is-invalid");
|
||||
this.#descriptionError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the account.
|
||||
*
|
||||
* @return {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateAccount() {
|
||||
if (this.account === 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.#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.account = null;
|
||||
this.isAccountConfirmed = false;
|
||||
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.originalLineItemId = lineItem.originalLineItemId;
|
||||
this.originalLineItemDate = lineItem.originalLineItemDate;
|
||||
this.originalLineItemText = lineItem.originalLineItemText;
|
||||
this.#originalLineItemText.innerText = this.originalLineItemText;
|
||||
if (this.originalLineItemId === null) {
|
||||
this.#originalLineItemContainer.classList.add("d-none");
|
||||
this.#originalLineItemControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#originalLineItemContainer.classList.remove("d-none");
|
||||
this.#originalLineItemControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null);
|
||||
this.description = lineItem.description;
|
||||
if (this.description === null) {
|
||||
this.#descriptionControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#descriptionControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.#descriptionText.innerText = this.description === null? "": this.description;
|
||||
this.account = lineItem.account;
|
||||
this.isAccountConfirmed = true;
|
||||
if (this.account === null) {
|
||||
this.#accountControl.classList.remove("accounting-not-empty");
|
||||
} else {
|
||||
this.#accountControl.classList.add("accounting-not-empty");
|
||||
}
|
||||
this.#accountText.innerText = this.account.text;
|
||||
this.#amountInput.value = lineItem.amount === null? "": String(lineItem.amount);
|
||||
const maxAmount = this.#getMaxAmount();
|
||||
this.#amountInput.max = maxAmount === null? "": maxAmount;
|
||||
this.#amountInput.min = lineItem.amountMin === null? "": String(lineItem.amountMin);
|
||||
this.#validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds out the max amount.
|
||||
*
|
||||
* @return {Decimal|null} the max amount
|
||||
*/
|
||||
#getMaxAmount() {
|
||||
if (this.originalLineItemId === null) {
|
||||
return null;
|
||||
}
|
||||
return this.originalLineItemSelector.getNetBalance(this.lineItem, this.form, this.originalLineItemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the enable status of the description and account.
|
||||
*
|
||||
* @param isEnabled {boolean} true to enable, or false otherwise
|
||||
*/
|
||||
#setEnableDescriptionAccount(isEnabled) {
|
||||
if (isEnabled) {
|
||||
this.#descriptionControl.dataset.bsToggle = "modal";
|
||||
this.#descriptionControl.dataset.bsTarget = `#accounting-description-editor-${this.#debitCreditSubForm.debitCredit}-modal`;
|
||||
this.#descriptionControl.classList.remove("accounting-disabled");
|
||||
this.#descriptionControl.classList.add("accounting-clickable");
|
||||
this.#accountControl.dataset.bsToggle = "modal";
|
||||
this.#accountControl.dataset.bsTarget = `#accounting-account-selector-${this.#debitCreditSubForm.debitCredit}-modal`;
|
||||
this.#accountControl.classList.remove("accounting-disabled");
|
||||
this.#accountControl.classList.add("accounting-clickable");
|
||||
} else {
|
||||
this.#descriptionControl.dataset.bsToggle = "";
|
||||
this.#descriptionControl.dataset.bsTarget = "";
|
||||
this.#descriptionControl.classList.add("accounting-disabled");
|
||||
this.#descriptionControl.classList.remove("accounting-clickable");
|
||||
this.#accountControl.dataset.bsToggle = "";
|
||||
this.#accountControl.dataset.bsTarget = "";
|
||||
this.#accountControl.classList.add("accounting-disabled");
|
||||
this.#accountControl.classList.remove("accounting-clickable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* transaction-order.js: The JavaScript for the transaction order
|
||||
/* The Mia! Accounting Project
|
||||
* journal-entry-order.js: The JavaScript for the journal entry order
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
@ -29,7 +29,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const onReorder = () => {
|
||||
const accounts = Array.from(list.children);
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
||||
const no = document.getElementById(`accounting-order-${accounts[i].dataset.id}-no`);
|
||||
no.value = String(i + 1);
|
||||
}
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
/* The Mia! Accounting Project
|
||||
* material-fab-speed-dial.js: The JavaScript for the speed dial for the material floating buttons
|
||||
*/
|
||||
|
||||
|
1029
src/accounting/static/js/option-form.js
Normal file
1029
src/accounting/static/js/option-form.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* original-entry-selector.js: The JavaScript for the original entry selector
|
||||
/* The Mia! Accounting Project
|
||||
* original-line-item-selector.js: The JavaScript for the original line item selector
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
@ -23,22 +23,22 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* The original entry selector.
|
||||
* The original line item selector.
|
||||
*
|
||||
*/
|
||||
class OriginalEntrySelector {
|
||||
class OriginalLineItemSelector {
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* The line item editor
|
||||
* @type {JournalEntryLineItemEditor}
|
||||
*/
|
||||
lineItemEditor;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix = "accounting-original-entry-selector";
|
||||
|
||||
/**
|
||||
* The modal of the original entry editor
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#modal;
|
||||
#prefix = "accounting-original-line-item-selector";
|
||||
|
||||
/**
|
||||
* The query input
|
||||
@ -60,22 +60,16 @@ class OriginalEntrySelector {
|
||||
|
||||
/**
|
||||
* The options
|
||||
* @type {OriginalEntry[]}
|
||||
* @type {OriginalLineItem[]}
|
||||
*/
|
||||
#options;
|
||||
|
||||
/**
|
||||
* The options by their ID
|
||||
* @type {Object.<string, OriginalEntry>}
|
||||
* @type {Object.<string, OriginalLineItem>}
|
||||
*/
|
||||
#optionById;
|
||||
|
||||
/**
|
||||
* The journal entry editor
|
||||
* @type {JournalEntryEditor}
|
||||
*/
|
||||
entryEditor;
|
||||
|
||||
/**
|
||||
* The currency code
|
||||
* @type {string}
|
||||
@ -83,68 +77,67 @@ class OriginalEntrySelector {
|
||||
#currencyCode;
|
||||
|
||||
/**
|
||||
* The entry
|
||||
* Either "credit" or "debit"
|
||||
*/
|
||||
#entryType;
|
||||
#debitCredit;
|
||||
|
||||
/**
|
||||
* Constructs an original entry selector.
|
||||
* Constructs an original line item selector.
|
||||
*
|
||||
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
||||
*/
|
||||
constructor() {
|
||||
this.#modal = document.getElementById(this.#prefix + "-modal");
|
||||
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 OriginalEntry(this, element));
|
||||
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();
|
||||
});
|
||||
this.#query.oninput = () => this.#filterOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the net balance for an original entry.
|
||||
* Returns the net balance for an original line item.
|
||||
*
|
||||
* @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing
|
||||
* @param form {TransactionForm} the transaction form
|
||||
* @param originalEntryId {string} the ID of the original entry
|
||||
* @return {Decimal} the net balance of the original entry
|
||||
* @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(currentEntry, form, originalEntryId) {
|
||||
const otherEntries = form.getEntries().filter((entry) => entry !== currentEntry);
|
||||
getNetBalance(currentLineItem, form, originalLineItemId) {
|
||||
const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem);
|
||||
let otherOffset = new Decimal(0);
|
||||
for (const otherEntry of otherEntries) {
|
||||
if (otherEntry.getOriginalEntryId() === originalEntryId) {
|
||||
const amount = otherEntry.getAmount();
|
||||
for (const otherLineItem of otherLineItems) {
|
||||
if (otherLineItem.originalLineItemId === originalLineItemId) {
|
||||
const amount = otherLineItem.amount;
|
||||
if (amount !== null) {
|
||||
otherOffset = otherOffset.plus(amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.#optionById[originalEntryId].bareNetBalance.minus(otherOffset);
|
||||
return this.#optionById[originalLineItemId].bareNetBalance.minus(otherOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the net balances, subtracting the offset amounts on the form but the currently editing journal entry
|
||||
* Updates the net balances, subtracting the offset amounts on the form but the currently editing line item
|
||||
*
|
||||
*/
|
||||
#updateNetBalances() {
|
||||
const otherEntries = this.entryEditor.form.getEntries().filter((entry) => entry !== this.entryEditor.entry);
|
||||
const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem);
|
||||
const otherOffsets = {}
|
||||
for (const otherEntry of otherEntries) {
|
||||
const otherOriginalEntryId = otherEntry.getOriginalEntryId();
|
||||
const amount = otherEntry.getAmount();
|
||||
if (otherOriginalEntryId === null || amount === null) {
|
||||
for (const otherLineItem of otherLineItems) {
|
||||
const otherOriginalLineItemId = otherLineItem.originalLineItemId;
|
||||
const amount = otherLineItem.amount;
|
||||
if (otherOriginalLineItemId === null || amount === null) {
|
||||
continue;
|
||||
}
|
||||
if (!(otherOriginalEntryId in otherOffsets)) {
|
||||
otherOffsets[otherOriginalEntryId] = new Decimal("0");
|
||||
if (!(otherOriginalLineItemId in otherOffsets)) {
|
||||
otherOffsets[otherOriginalLineItemId] = new Decimal("0");
|
||||
}
|
||||
otherOffsets[otherOriginalEntryId] = otherOffsets[otherOriginalEntryId].plus(amount);
|
||||
otherOffsets[otherOriginalLineItemId] = otherOffsets[otherOriginalLineItemId].plus(amount);
|
||||
}
|
||||
for (const option of this.#options) {
|
||||
if (option.id in otherOffsets) {
|
||||
@ -160,16 +153,16 @@ class OriginalEntrySelector {
|
||||
*
|
||||
*/
|
||||
#filterOptions() {
|
||||
let hasAnyMatched = false;
|
||||
let isAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
if (option.isMatched(this.#entryType, this.#currencyCode, this.#query.value)) {
|
||||
if (option.isMatched(this.#debitCredit, this.#currencyCode, this.#query.value)) {
|
||||
option.setShown(true);
|
||||
hasAnyMatched = true;
|
||||
isAnyMatched = true;
|
||||
} else {
|
||||
option.setShown(false);
|
||||
}
|
||||
}
|
||||
if (!hasAnyMatched) {
|
||||
if (!isAnyMatched) {
|
||||
this.#optionList.classList.add("d-none");
|
||||
this.#queryNoResult.classList.remove("d-none");
|
||||
} else {
|
||||
@ -179,17 +172,14 @@ class OriginalEntrySelector {
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the original entry selector is shown.
|
||||
* The callback when the original line item selector is shown.
|
||||
*
|
||||
* @param entryEditor {JournalEntryEditor} the journal entry editor
|
||||
* @param originalEntryId {string|null} the ID of the original entry
|
||||
*/
|
||||
onOpen(entryEditor, originalEntryId = null) {
|
||||
this.entryEditor = entryEditor
|
||||
this.#currencyCode = entryEditor.getCurrencyCode();
|
||||
this.#entryType = entryEditor.entryType;
|
||||
onOpen() {
|
||||
this.#currencyCode = this.lineItemEditor.currencyCode;
|
||||
this.#debitCredit = this.lineItemEditor.debitCredit;
|
||||
for (const option of this.#options) {
|
||||
option.setActive(option.id === originalEntryId);
|
||||
option.setActive(option.id === this.lineItemEditor.originalLineItemId);
|
||||
}
|
||||
this.#query.value = "";
|
||||
this.#updateNetBalances();
|
||||
@ -198,16 +188,16 @@ class OriginalEntrySelector {
|
||||
}
|
||||
|
||||
/**
|
||||
* An original entry.
|
||||
* An original line item.
|
||||
*
|
||||
*/
|
||||
class OriginalEntry {
|
||||
class OriginalLineItem {
|
||||
|
||||
/**
|
||||
* The original entry selector
|
||||
* @type {OriginalEntrySelector}
|
||||
* The journal entry form
|
||||
* @type {JournalEntryForm}
|
||||
*/
|
||||
#selector;
|
||||
#form;
|
||||
|
||||
/**
|
||||
* The element
|
||||
@ -228,10 +218,10 @@ class OriginalEntry {
|
||||
date;
|
||||
|
||||
/**
|
||||
* The entry type, either "debit" or "credit"
|
||||
* Either "debit" or "credit"
|
||||
* @type {string}
|
||||
*/
|
||||
#entryType;
|
||||
#debitCredit;
|
||||
|
||||
/**
|
||||
* The currency code
|
||||
@ -240,22 +230,16 @@ class OriginalEntry {
|
||||
#currencyCode;
|
||||
|
||||
/**
|
||||
* The account code
|
||||
* @type {string}
|
||||
* The account
|
||||
* @type {JournalEntryAccount}
|
||||
*/
|
||||
accountCode;
|
||||
account;
|
||||
|
||||
/**
|
||||
* The account text
|
||||
* The description
|
||||
* @type {string}
|
||||
*/
|
||||
accountText;
|
||||
|
||||
/**
|
||||
* The summary
|
||||
* @type {string}
|
||||
*/
|
||||
summary;
|
||||
description;
|
||||
|
||||
/**
|
||||
* The net balance, without the offset amounts on the form
|
||||
@ -276,39 +260,38 @@ class OriginalEntry {
|
||||
netBalanceText;
|
||||
|
||||
/**
|
||||
* The text representation of the original entry
|
||||
* The text representation of the original line item
|
||||
* @type {string}
|
||||
*/
|
||||
text;
|
||||
|
||||
/**
|
||||
* The values to query against
|
||||
* @type {string[][]}
|
||||
* @type {string[]}
|
||||
*/
|
||||
#queryValues;
|
||||
|
||||
/**
|
||||
* Constructs an original entry.
|
||||
* Constructs an original line item.
|
||||
*
|
||||
* @param selector {OriginalEntrySelector} the original entry selector
|
||||
* @param selector {OriginalLineItemSelector} the original line item selector
|
||||
* @param element {HTMLLIElement} the element
|
||||
*/
|
||||
constructor(selector, element) {
|
||||
this.#selector = selector;
|
||||
this.#form = selector.lineItemEditor.form;
|
||||
this.#element = element;
|
||||
this.id = element.dataset.id;
|
||||
this.date = element.dataset.date;
|
||||
this.#entryType = element.dataset.entryType;
|
||||
this.#debitCredit = element.dataset.debitCredit;
|
||||
this.#currencyCode = element.dataset.currencyCode;
|
||||
this.accountCode = element.dataset.accountCode;
|
||||
this.accountText = element.dataset.accountText;
|
||||
this.summary = element.dataset.summary;
|
||||
this.account = new JournalEntryAccount(element.dataset.accountCode, element.dataset.accountText, false);
|
||||
this.description = element.dataset.description;
|
||||
this.bareNetBalance = new Decimal(element.dataset.netBalance);
|
||||
this.netBalance = this.bareNetBalance;
|
||||
this.netBalanceText = document.getElementById("accounting-original-entry-selector-option-" + this.id + "-net-balance");
|
||||
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.entryEditor.saveOriginalEntry(this);
|
||||
this.#element.onclick = () => selector.lineItemEditor.saveOriginalLineItem(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -343,52 +326,61 @@ class OriginalEntry {
|
||||
/**
|
||||
* Returns whether the original matches.
|
||||
*
|
||||
* @param entryType {string} the entry type, either "debit" or "credit"
|
||||
* @param debitCredit {string} either "debit" or "credit"
|
||||
* @param currencyCode {string} the currency code
|
||||
* @param query {string|null} the query term
|
||||
*/
|
||||
isMatched(entryType, currencyCode, query = null) {
|
||||
isMatched(debitCredit, currencyCode, query = null) {
|
||||
return this.netBalance.greaterThan(0)
|
||||
&& this.date <= this.#selector.entryEditor.form.getDate()
|
||||
&& this.#isEntryTypeMatches(entryType)
|
||||
&& this.date <= this.#form.date
|
||||
&& this.#isDebitCreditMatched(debitCredit)
|
||||
&& this.#currencyCode === currencyCode
|
||||
&& this.#isQueryMatches(query);
|
||||
&& this.#isQueryMatched(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the original entry matches the entry type.
|
||||
* Returns whether the original line item matches the debit or credit.
|
||||
*
|
||||
* @param entryType {string} the entry type, either "debit" or credit
|
||||
* @param debitCredit {string} either "debit" or credit
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
#isEntryTypeMatches(entryType) {
|
||||
return (entryType === "debit" && this.#entryType === "credit")
|
||||
|| (entryType === "credit" && this.#entryType === "debit");
|
||||
#isDebitCreditMatched(debitCredit) {
|
||||
return (debitCredit === "debit" && this.#debitCredit === "credit")
|
||||
|| (debitCredit === "credit" && this.#debitCredit === "debit");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the original entry matches the query.
|
||||
* Returns whether the original line item matches the query term.
|
||||
*
|
||||
* @param query {string|null} the query term
|
||||
* @return {boolean} true if the option matches, or false otherwise
|
||||
*/
|
||||
#isQueryMatches(query) {
|
||||
#isQueryMatched(query) {
|
||||
if (query === "") {
|
||||
return true;
|
||||
}
|
||||
for (const queryValue of this.#queryValues[0]) {
|
||||
if (this.#getNetBalanceForQuery().includes(query.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
for (const queryValue of this.#queryValues) {
|
||||
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const queryValue of this.#queryValues[1]) {
|
||||
if (queryValue === query) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the net balance in the format for query match.
|
||||
*
|
||||
* @return {string} the net balance in the format for query match
|
||||
*/
|
||||
#getNetBalanceForQuery() {
|
||||
const frac = this.netBalance.modulo(1);
|
||||
const whole = Number(this.netBalance.minus(frac));
|
||||
return String(whole) + String(frac).substring(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the option is shown.
|
||||
*
|
@ -1,4 +1,4 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
/* The Mia! Accounting Project
|
||||
* period-chooser.js: The JavaScript for the period chooser
|
||||
*/
|
||||
|
||||
@ -33,12 +33,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
*/
|
||||
class PeriodChooser {
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
|
||||
/**
|
||||
* The modal of the period chooser
|
||||
* @type {HTMLDivElement}
|
||||
@ -56,8 +50,8 @@ class PeriodChooser {
|
||||
*
|
||||
*/
|
||||
constructor() {
|
||||
this.prefix = "accounting-period-chooser";
|
||||
this.modal = document.getElementById(this.prefix + "-modal");
|
||||
const prefix = "accounting-period-chooser";
|
||||
this.modal = document.getElementById(`${prefix}-modal`);
|
||||
for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) {
|
||||
const tab = new cls(this);
|
||||
this.tabPlanes[tab.tabId()] = tab;
|
||||
@ -94,7 +88,7 @@ class TabPlane {
|
||||
chooser;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* The prefix of the HTML ID and class names
|
||||
* @type {string}
|
||||
*/
|
||||
prefix;
|
||||
@ -118,9 +112,9 @@ class TabPlane {
|
||||
*/
|
||||
constructor(chooser) {
|
||||
this.chooser = chooser;
|
||||
this.prefix = "accounting-period-chooser-" + this.tabId();
|
||||
this.#tab = document.getElementById(this.prefix + "-tab");
|
||||
this.#page = document.getElementById(this.prefix + "-page");
|
||||
this.prefix = `accounting-period-chooser-${this.tabId()}`;
|
||||
this.#tab = document.getElementById(`${this.prefix}-tab`);
|
||||
this.#page = document.getElementById(`${this.prefix}-page`);
|
||||
this.#tab.onclick = () => this.#switchToMe();
|
||||
}
|
||||
|
||||
@ -170,12 +164,11 @@ class MonthTab extends TabPlane {
|
||||
*/
|
||||
constructor(chooser) {
|
||||
super(chooser);
|
||||
const monthChooser = document.getElementById(this.prefix + "-chooser");
|
||||
const monthChooser = document.getElementById(`${this.prefix}-chooser`);
|
||||
if (monthChooser !== null) {
|
||||
let start = monthChooser.dataset.start;
|
||||
this.#monthChooser = new tempusDominus.TempusDominus(monthChooser, {
|
||||
restrictions: {
|
||||
minDate: start,
|
||||
minDate: new Date(monthChooser.dataset.start),
|
||||
},
|
||||
display: {
|
||||
inline: true,
|
||||
@ -184,13 +177,12 @@ class MonthTab extends TabPlane {
|
||||
clock: false,
|
||||
},
|
||||
},
|
||||
defaultDate: monthChooser.dataset.default,
|
||||
defaultDate: new Date(monthChooser.dataset.default),
|
||||
});
|
||||
monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
|
||||
const date = e.detail.date;
|
||||
const year = date.year;
|
||||
const month = date.month + 1;
|
||||
const period = month < 10? year + "-0" + month: year + "-" + month;
|
||||
const zeroPaddedMonth = `0${date.month + 1}`.slice(-2)
|
||||
const period = `${date.year}-${zeroPaddedMonth}`;
|
||||
window.location = chooser.modal.dataset.urlTemplate
|
||||
.replaceAll("PERIOD", period);
|
||||
});
|
||||
@ -250,8 +242,8 @@ class DayTab extends TabPlane {
|
||||
*/
|
||||
constructor(chooser) {
|
||||
super(chooser);
|
||||
this.#date = document.getElementById(this.prefix + "-date");
|
||||
this.#dateError = document.getElementById(this.prefix + "-date-error");
|
||||
this.#date = document.getElementById(`${this.prefix}-date`);
|
||||
this.#dateError = document.getElementById(`${this.prefix}-date-error`);
|
||||
if (this.#date !== null) {
|
||||
this.#date.onchange = () => {
|
||||
if (this.#validateDate()) {
|
||||
@ -337,11 +329,11 @@ class CustomTab extends TabPlane {
|
||||
*/
|
||||
constructor(chooser) {
|
||||
super(chooser);
|
||||
this.#start = document.getElementById(this.prefix + "-start");
|
||||
this.#startError = document.getElementById(this.prefix + "-start-error");
|
||||
this.#end = document.getElementById(this.prefix + "-end");
|
||||
this.#endError = document.getElementById(this.prefix + "-end-error");
|
||||
this.#conform = document.getElementById(this.prefix + "-confirm");
|
||||
this.#start = document.getElementById(`${this.prefix}-start`);
|
||||
this.#startError = document.getElementById(`${this.prefix}-start-error`);
|
||||
this.#end = document.getElementById(`${this.prefix}-end`);
|
||||
this.#endError = document.getElementById(`${this.prefix}-end-error`);
|
||||
this.#conform = document.getElementById(`${this.prefix}-confirm`);
|
||||
if (this.#start !== null) {
|
||||
this.#start.onchange = () => {
|
||||
if (this.#validateStart()) {
|
||||
@ -359,7 +351,7 @@ class CustomTab extends TabPlane {
|
||||
isValid = this.#validateEnd() && isValid;
|
||||
if (isValid) {
|
||||
window.location = chooser.modal.dataset.urlTemplate
|
||||
.replaceAll("PERIOD", this.#start.value + "-" + this.#end.value);
|
||||
.replaceAll("PERIOD", `${this.#start.value}-${this.#end.value}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user