Compare commits
421 Commits
Author | SHA1 | Date | |
---|---|---|---|
621020b0f0 | |||
6ad36cfaa3 | |||
20b0412091 | |||
3ca246d3e0 | |||
85d1b13ccd | |||
3bada28b8f | |||
8f2cef8d81 | |||
e62316c477 | |||
24ddb0c278 | |||
536f3390aa | |||
fadd8e73b6 | |||
12ccf658bf | |||
e30d1257e5 | |||
404b902d88 | |||
a560ff175a | |||
4be1ead6b5 | |||
700e4f822a | |||
c21ed59dfe | |||
c4a8326bfc | |||
371c80f668 | |||
40be3fb664 | |||
1e56403b35 | |||
650c26036a | |||
b19a6e5ffe | |||
1224d6f83e | |||
3a8618f7c3 | |||
5d87205659 | |||
04de4f5c5e | |||
f8ea863b80 | |||
5ae0d03b32 | |||
a9a3ad5871 | |||
5edc95afce | |||
943ace6fc7 | |||
a63bc977e9 | |||
dabe6ddbca | |||
f47e9b3150 | |||
bb5383febe | |||
87f9063ceb | |||
51f0185bcf | |||
7ca08d6cc8 | |||
c8e9e562be | |||
ba43bd7e90 | |||
4e550413ba | |||
59a3cbb472 | |||
d1b64d069e | |||
d823d3254f | |||
5e9a2fb0c3 | |||
3f2e659ba5 | |||
9f7bb6b9de | |||
6857164702 | |||
6bac76be64 | |||
370d2668e5 | |||
5e3e695e62 | |||
510d369e9c | |||
b65cae9252 | |||
285c12406b | |||
df240472a4 | |||
1218b224fc | |||
79689ac0e5 | |||
1660e66766 | |||
12d00c9c7d | |||
428018e4a9 | |||
a8f318b0bb | |||
a3507494e5 | |||
3aa6c8d6f6 | |||
052b62cdd4 | |||
3728a4037d | |||
6eee17d44f | |||
e5cc2b5a2f | |||
ac3b5523b1 | |||
5af6fd9619 | |||
71a20cba29 | |||
4a4cf1ea40 | |||
e9824808ec | |||
c984d2d596 | |||
720e77c814 | |||
0f0412827d | |||
3a0e978f76 | |||
8c10d42d7b | |||
04ec51afbe | |||
fe7a8842ce | |||
66daa5c42c | |||
27fb44937d | |||
7026ed3a65 | |||
fdd3e93778 | |||
def7559457 | |||
7905820d68 | |||
7ae332c975 | |||
86c5b91697 | |||
9168840e64 | |||
21b9cfa8b8 | |||
b0b3b3acb1 | |||
cb1d254cf0 | |||
eb9ad57e72 | |||
ec26f8ef4d | |||
7ed29115ed | |||
95955197ac | |||
d5a0f79e4b | |||
6aa655aa64 | |||
6e532af26e | |||
fa1818d124 | |||
f21ecc2aa9 | |||
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 | |||
884e37fe1b | |||
cc6a73211e | |||
2299b86d0f | |||
6d293a1aac | |||
a2311aee24 | |||
5571c0d01f | |||
98e1bad413 | |||
7ff52d99e6 | |||
cc440a4110 | |||
f5149a0c37 | |||
ca928636fd | |||
4a8297d594 | |||
915e4408e1 | |||
fd9eac06f6 | |||
403942dfc0 | |||
35dc513760 | |||
01861f0b6a | |||
8c10f1e96a | |||
5f7fc0b8e8 | |||
700c179774 | |||
cabe02f7d0 | |||
5ceb9f2e83 | |||
fe1c7669b6 | |||
4eac10981f | |||
c869bccc04 | |||
61c111db69 | |||
34f63c1cdf | |||
a643d9e811 | |||
2239ddfad1 | |||
12fbe36b9a | |||
46e34bb89a | |||
c9453d3023 | |||
fc766724c4 | |||
38c394c0af | |||
67e2b06d37 | |||
be10a8d99e | |||
fbeec600b7 | |||
1a54592d4c | |||
94a527caf2 | |||
0a1bbbdd47 | |||
82b63e4bd4 | |||
e1d1aff0c1 | |||
2e5f9ee01f | |||
f901a0020f | |||
fc2be75c3b | |||
96c131940b | |||
b9435a255b | |||
56045f0faf | |||
08d1e60238 | |||
d88b3ac770 | |||
40e329d37f | |||
23a0721d8d | |||
2b2c665eb6 | |||
954173a2c2 | |||
91e6dc6668 | |||
e9d8a8fcd8 | |||
4c84686395 | |||
61fd1849ed | |||
a67158f8f6 | |||
5c6bfd8b49 | |||
d9ecf51c6d | |||
5d31eb9172 | |||
fadce244c5 | |||
cbe7c6ca6d | |||
b03938fb2e | |||
8061a23fdc | |||
cd8a480cd0 | |||
b8b87714eb | |||
bf2f96621d | |||
2d771f04be | |||
3a12472d4b | |||
d5a686a5d8 | |||
690f89e29a | |||
82a6a53dc4 | |||
cdd31b1047 | |||
5bad949cfa | |||
3826646d06 | |||
74071e8997 | |||
3ce34803f3 | |||
232f73172f | |||
ff1bb7142b | |||
7155bf635a | |||
c306ff8009 | |||
b344abce06 | |||
b3c666c872 | |||
6a671cac84 | |||
fe87c3a7de | |||
2013f8cbd9 | |||
2325842471 | |||
c80e58b049 | |||
be0ae5eba4 | |||
2b84f64554 | |||
0a658a76e8 | |||
50dc79d865 | |||
8e5377a416 | |||
4299fd6fbd | |||
1d6a53f7cd | |||
bb2993b0c0 | |||
f6946c1165 | |||
8e219d8006 | |||
53565eb9e6 | |||
965e78d8ad | |||
74b81d3e23 | |||
a0fba6387f | |||
d28bdf2064 | |||
edf0c00e34 | |||
107d161379 | |||
f2c184f769 | |||
b45986ecfc | |||
a2c2452ec5 | |||
5194258b48 | |||
3fe7eb41ac | |||
7fb9e2f0a1 | |||
1d443f7b76 | |||
6ad4fba9cd | |||
3dda6531b5 |
3
.gitignore
vendored
3
.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.
|
||||
@ -38,3 +38,4 @@ excludes
|
||||
*.mo
|
||||
zh_Hans
|
||||
test_temp.py
|
||||
dummy.js
|
||||
|
40
.readthedocs.yaml
Normal file
40
.readthedocs.yaml
Normal file
@ -0,0 +1,40 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/5
|
||||
|
||||
# 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.
|
||||
|
||||
# .readthedocs.yaml
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Set the version of Python and other tools you might need
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.11"
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
|
||||
# If using Sphinx, optionally build your docs in additional formats such as PDF
|
||||
formats: all
|
||||
|
||||
# Optionally declare the Python requirements required to build your docs
|
||||
python:
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
24
MANIFEST.in
24
MANIFEST.in
@ -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.
|
||||
@ -15,14 +15,14 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
include src/accounting/translations/*
|
||||
include src/accounting/translations/*/LC_MESSAGES/*
|
||||
include docs/*
|
||||
include docs/source/*
|
||||
include docs/source/_static/*
|
||||
include docs/source/_templates/*
|
||||
include tests/*
|
||||
include tests/test_site/*
|
||||
include tests/test_site/templates/*
|
||||
include tests/test_site/translations/*
|
||||
include tests/test_site/translations/*/LC_MESSAGES/*
|
||||
recursive-include src/accounting/static *
|
||||
exclude src/accounting/static/js/dummy.js
|
||||
recursive-include src/accounting/templates *
|
||||
recursive-include src/accounting/translations *
|
||||
recursive-include src/accounting/data *
|
||||
recursive-include docs *
|
||||
recursive-exclude docs/build *
|
||||
recursive-include tests *
|
||||
exclude tests/test_temp.py
|
||||
recursive-exclude tests *.pyc
|
||||
recursive-exclude tests/instance *
|
||||
|
181
README.rst
181
README.rst
@ -1,24 +1,167 @@
|
||||
=====================
|
||||
Mia! Accounting Flask
|
||||
=====================
|
||||
===============
|
||||
Mia! Accounting
|
||||
===============
|
||||
|
||||
|
||||
Description
|
||||
===========
|
||||
|
||||
This is the Mia! Accounting Flask project. It is an accounting
|
||||
module for the Flask_ applications.
|
||||
*Mia! Accounting* is an accounting module for Flask_ applications.
|
||||
It is designed both for mobile and desktop environments. It
|
||||
implements `double-entry bookkeeping`_. It generates the following
|
||||
accounting reports:
|
||||
|
||||
* Trial balance
|
||||
* Income statement
|
||||
* Balance sheet
|
||||
|
||||
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
|
||||
receivables.
|
||||
|
||||
|
||||
Install
|
||||
=======
|
||||
Live Demonstration and Test Site
|
||||
================================
|
||||
|
||||
Install the latest source from the
|
||||
`Mia! Accounting Flask repository`_.
|
||||
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
|
||||
same code as the `test site`_ in the `source distribution`_. It is
|
||||
the simplest website that works with *Mia! Accounting*. It is also
|
||||
used in the automatic tests.
|
||||
|
||||
If you do not have a running Flask application or do not know how to
|
||||
start one, you may start with the test site.
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
Install *Mia! Accounting* with ``pip``:
|
||||
|
||||
::
|
||||
|
||||
pip install git+https://gitea.imacat.idv.tw/imacat/mia-accounting-flask.git
|
||||
pip install mia-accounting
|
||||
|
||||
You may also download from the `PyPI project page`_ or the
|
||||
`release page`_ on the `Git repository`_.
|
||||
|
||||
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
You need a running Flask application with database user login.
|
||||
The primary key of the user data model must be integer. You also
|
||||
need at least one user.
|
||||
|
||||
The following front-end JavaScript libraries must be loaded. You may
|
||||
download it locally or use CDN_.
|
||||
|
||||
* Bootstrap_ 5.2.3 or above
|
||||
* FontAwesome_ 6.2.1 or above
|
||||
* `Decimal.js`_ 6.4.3 or above
|
||||
* `Tempus-Dominus`_ 6.4.3 or above
|
||||
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
You need to pass the Flask *app* and an implementation of
|
||||
`UserUtilityInterface`_ to the `init_app`_ function.
|
||||
``UserUtilityInterface`` contains everything *Mia! Accounting* needs.
|
||||
|
||||
The following is an example configuration for *Mia! Accounting*.
|
||||
|
||||
::
|
||||
|
||||
from flask import Response, redirect
|
||||
from .auth import current_user()
|
||||
from .modules import User
|
||||
|
||||
def create_app(test_config=None) -> Flask:
|
||||
app: Flask = Flask(__name__)
|
||||
|
||||
... (Configuration of SQLAlchemy, CSRF, Babel_JS, ... etc) ...
|
||||
|
||||
import accounting
|
||||
|
||||
class UserUtils(accounting.UserUtilityInterface[User]):
|
||||
|
||||
def can_view(self) -> bool:
|
||||
return True
|
||||
|
||||
def can_edit(self) -> bool:
|
||||
return "editor" in current_user().roles
|
||||
|
||||
def can_admin(self) -> bool:
|
||||
return current_user().is_admin
|
||||
|
||||
def unauthorized(self) -> Response:
|
||||
return redirect("/login")
|
||||
|
||||
@property
|
||||
def cls(self) -> t.Type[User]:
|
||||
return User
|
||||
|
||||
@property
|
||||
def pk_column(self) -> Column:
|
||||
return User.id
|
||||
|
||||
@property
|
||||
def current_user(self) -> User | None:
|
||||
return current_user()
|
||||
|
||||
def get_by_username(self, username: str) -> User | None:
|
||||
return User.query.filter(User.username == username).first()
|
||||
|
||||
def get_pk(self, user: User) -> int:
|
||||
return user.id
|
||||
|
||||
accounting.init_app(app, UserUtils())
|
||||
|
||||
... (Any other configuration) ...
|
||||
|
||||
return app
|
||||
|
||||
|
||||
Database Initialization
|
||||
=======================
|
||||
|
||||
After the configuration, run the ``accounting-init-db`` console
|
||||
command to initialize the accounting database. You need to specify
|
||||
the username of a user as the data creator.
|
||||
|
||||
::
|
||||
|
||||
% flask --app myapp accounting-init-db -u username
|
||||
|
||||
|
||||
Navigation Menu
|
||||
===============
|
||||
|
||||
Include the navigation menu in the `Bootstrap navigation bar`_ in your
|
||||
base template:
|
||||
|
||||
::
|
||||
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
|
||||
<div class="container-fluid">
|
||||
...
|
||||
<div id="collapsible-navbar" class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
...
|
||||
{% include "accounting/include/nav.html" %}
|
||||
...
|
||||
</ul>
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
Check your Flask application and see how it works.
|
||||
|
||||
|
||||
Documentation
|
||||
=============
|
||||
|
||||
Refer to the `documentation on Read the Docs`_.
|
||||
|
||||
|
||||
Copyright
|
||||
@ -46,5 +189,21 @@ Authors
|
||||
| imacat@mail.imacat.idv.tw
|
||||
| 2023/1/27
|
||||
|
||||
|
||||
.. _Flask: https://flask.palletsprojects.com
|
||||
.. _Mia! Accounting Flask repository: https://gitea.imacat.idv.tw/imacat/mia-accounting-flask
|
||||
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
|
||||
.. _live demonstration: https://accounting.imacat.idv.tw
|
||||
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
|
||||
.. _source distribution: https://pypi.org/project/mia-accounting/#files
|
||||
.. _PyPI project page: https://pypi.org/project/mia-accounting
|
||||
.. _release page: https://github.com/imacat/mia-accounting/releases
|
||||
.. _Git repository: https://github.com/imacat/mia-accounting
|
||||
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
|
||||
.. _Bootstrap: https://getbootstrap.com
|
||||
.. _FontAwesome: https://fontawesome.com
|
||||
.. _Decimal.js: https://mikemcl.github.io/decimal.js
|
||||
.. _Tempus-Dominus: https://getdatepicker.com
|
||||
.. _UserUtilityInterface: https://mia-accounting.readthedocs.io/en/latest/accounting.utils.html#accounting.utils.user.UserUtilityInterface
|
||||
.. _init_app: https://mia-accounting.readthedocs.io/en/latest/accounting.html#accounting.init_app
|
||||
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
|
||||
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io
|
||||
|
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:
|
45
docs/source/accounting.journal_entry.utils.rst
Normal file
45
docs/source/accounting.journal_entry.utils.rst
Normal file
@ -0,0 +1,45 @@
|
||||
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.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:
|
@ -60,6 +60,22 @@ accounting.report.reports.trial\_balance module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.reports.unapplied module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.reports.unapplied
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.reports.unapplied\_accounts module
|
||||
----------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.reports.unapplied_accounts
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
|
@ -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
|
||||
-------------------------------------------
|
||||
|
||||
@ -60,6 +52,14 @@ accounting.report.utils.report\_type module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.unapplied module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.utils.unapplied
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.urls module
|
||||
-----------------------------------
|
||||
|
||||
|
@ -10,13 +10,31 @@ Subpackages
|
||||
accounting.account
|
||||
accounting.base_account
|
||||
accounting.currency
|
||||
accounting.journal_entry
|
||||
accounting.option
|
||||
accounting.report
|
||||
accounting.transaction
|
||||
accounting.unmatched_offset
|
||||
accounting.utils
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.commands module
|
||||
--------------------------
|
||||
|
||||
.. automodule:: accounting.commands
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.forms module
|
||||
-----------------------
|
||||
|
||||
.. automodule:: accounting.forms
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.locale module
|
||||
------------------------
|
||||
|
||||
|
@ -1,61 +0,0 @@
|
||||
accounting.transaction package
|
||||
==============================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.transaction.converters module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.converters
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.forms module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.forms
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.operators module
|
||||
---------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.operators
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.summary\_editor module
|
||||
---------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.summary_editor
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.template\_filters module
|
||||
-----------------------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.template_filters
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.transaction.views module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: accounting.transaction.views
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.transaction
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
29
docs/source/accounting.unmatched_offset.rst
Normal file
29
docs/source/accounting.unmatched_offset.rst
Normal file
@ -0,0 +1,29 @@
|
||||
accounting.unmatched\_offset package
|
||||
====================================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.unmatched\_offset.queries module
|
||||
-------------------------------------------
|
||||
|
||||
.. automodule:: accounting.unmatched_offset.queries
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.unmatched\_offset.views module
|
||||
-----------------------------------------
|
||||
|
||||
.. automodule:: accounting.unmatched_offset.views
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.unmatched_offset
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
@ -4,6 +4,22 @@ accounting.utils package
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.utils.cast module
|
||||
----------------------------
|
||||
|
||||
.. automodule:: accounting.utils.cast
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.current\_account module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.current_account
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.flash\_errors module
|
||||
-------------------------------------
|
||||
|
||||
@ -12,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
|
||||
---------------------------------
|
||||
|
||||
@ -20,6 +44,30 @@ accounting.utils.next\_uri module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.offset\_alias module
|
||||
-------------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.offset_alias
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.offset\_matcher module
|
||||
---------------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.offset_matcher
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.options module
|
||||
-------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.options
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.pagination module
|
||||
----------------------------------
|
||||
|
||||
@ -60,10 +108,10 @@ accounting.utils.strip\_text module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.txn\_types module
|
||||
----------------------------------
|
||||
accounting.utils.unapplied module
|
||||
---------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.txn_types
|
||||
.. automodule:: accounting.utils.unapplied
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
@ -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 = '1.3.2'
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
61
docs/source/examples.rst
Normal file
61
docs/source/examples.rst
Normal file
@ -0,0 +1,61 @@
|
||||
Examples
|
||||
========
|
||||
|
||||
|
||||
.. _example-userutils:
|
||||
|
||||
An Example Configuration
|
||||
------------------------
|
||||
|
||||
The following is an example configuration for *Mia! Accounting*.
|
||||
|
||||
::
|
||||
|
||||
from flask import Response, redirect
|
||||
from .auth import current_user()
|
||||
from .modules import User
|
||||
|
||||
def create_app(test_config=None) -> Flask:
|
||||
app: Flask = Flask(__name__)
|
||||
|
||||
... (Configuration of SQLAlchemy, CSRF, Babel_JS, ... etc) ...
|
||||
|
||||
import accounting
|
||||
|
||||
class UserUtils(accounting.UserUtilityInterface[User]):
|
||||
|
||||
def can_view(self) -> bool:
|
||||
return True
|
||||
|
||||
def can_edit(self) -> bool:
|
||||
return "editor" in current_user().roles
|
||||
|
||||
def can_admin(self) -> bool:
|
||||
return current_user().is_admin
|
||||
|
||||
def unauthorized(self) -> Response:
|
||||
return redirect("/login")
|
||||
|
||||
@property
|
||||
def cls(self) -> t.Type[User]:
|
||||
return User
|
||||
|
||||
@property
|
||||
def pk_column(self) -> Column:
|
||||
return User.id
|
||||
|
||||
@property
|
||||
def current_user(self) -> User | None:
|
||||
return current_user()
|
||||
|
||||
def get_by_username(self, username: str) -> User | None:
|
||||
return User.query.filter(User.username == username).first()
|
||||
|
||||
def get_pk(self, user: User) -> int:
|
||||
return user.id
|
||||
|
||||
accounting.init_app(app, UserUtils())
|
||||
|
||||
... (Any other configuration) ...
|
||||
|
||||
return app
|
57
docs/source/history.rst
Normal file
57
docs/source/history.rst
Normal file
@ -0,0 +1,57 @@
|
||||
History
|
||||
=======
|
||||
|
||||
I created my own private accounting application in Perl_/mod_perl_ in
|
||||
2007, as part of my personal website. The first revision was made
|
||||
using Perl/Mojolicious_ in 2019, with the aim of making it
|
||||
mobile-friendly using Bootstrap_, and with modern back-end and
|
||||
front-end technologies such as jQuery.
|
||||
|
||||
The second revision was done in Python_/Django_ in 2020, as I was
|
||||
looking to change my career from PHP_/Laravel_ to Python, but lacked
|
||||
experience with large Python projects. I needed something in my
|
||||
portfolio and decided to work on the somewhat outdated Mojolicious
|
||||
project.
|
||||
|
||||
Despite having no prior experience with Django, I spent two months
|
||||
working late nights to create the `Mia! Accounting Django`_
|
||||
application. It took me another 1.5 months to make it an independent
|
||||
module, which I later released as an open source project on PyPI.
|
||||
|
||||
The application worked nicely for my household bookkeeping for two
|
||||
years. However, new demands arose over time, especially with tracking
|
||||
payables and receivables. This was critical `during the pandemic`_ as
|
||||
more payments were made online with credit cards.
|
||||
|
||||
The biggest issue I encountered was with
|
||||
`Django's MTV architectural pattern`_. Django takes over the control
|
||||
flow. I had to override several parts of the `class-based views`_ for
|
||||
different but yet simple control flow logic. In the end, it became
|
||||
very difficult to track whether things went wrong because I overrode
|
||||
something or because it just wouldn't work with the basic assumption
|
||||
of the class-based views. By the time I realized it, it was too late
|
||||
for me to drop Django's MTV and rewrite everything from class-based
|
||||
views to function-based views.
|
||||
|
||||
Therefore, I decided to turn to microframeworks_ like Flask_. After
|
||||
working with modularized Flask and FastAPI_ applications for two
|
||||
years, I returned to the project and wrote its third revision using
|
||||
Flask in 2023.
|
||||
|
||||
|
||||
.. _Perl: https://www.perl.org
|
||||
.. _mod_perl: https://perl.apache.org
|
||||
.. _Mojolicious: https://mojolicious.org
|
||||
.. _Bootstrap: https://getbootstrap.com
|
||||
.. _jQuery: https://jquery.com
|
||||
.. _Python: https://www.python.org
|
||||
.. _Django: https://www.djangoproject.com
|
||||
.. _PHP: https://www.php.net
|
||||
.. _Laravel: https://laravel.com
|
||||
.. _Mia! Accounting Django: https://github.com/imacat/mia-accounting-django
|
||||
.. _during the pandemic: https://en.wikipedia.org/wiki/COVID-19_pandemic
|
||||
.. _FastAPI: https://fastapi.tiangolo.com
|
||||
.. _Django's MTV architectural pattern: https://docs.djangoproject.com/en/dev/faq/general/#django-appears-to-be-a-mvc-framework-but-you-call-the-controller-the-view-and-the-view-the-template-how-come-you-don-t-use-the-standard-names
|
||||
.. _class-based views: https://docs.djangoproject.com/en/4.2/topics/class-based-views/
|
||||
.. _microframeworks: https://en.wikipedia.org/wiki/Microframework
|
||||
.. _Flask: https://flask.palletsprojects.com
|
@ -1,15 +1,20 @@
|
||||
.. 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
|
||||
:caption: Contents:
|
||||
|
||||
intro
|
||||
accounting
|
||||
examples
|
||||
history
|
||||
|
||||
|
||||
|
||||
Indices and tables
|
||||
|
126
docs/source/intro.rst
Normal file
126
docs/source/intro.rst
Normal file
@ -0,0 +1,126 @@
|
||||
Introduction
|
||||
============
|
||||
|
||||
*Mia! Accounting* is an accounting module for Flask_ applications.
|
||||
It is designed both for mobile and desktop environments. It
|
||||
implements `double-entry bookkeeping`_. It generates the following
|
||||
accounting reports:
|
||||
|
||||
* Trial balance
|
||||
* Income statement
|
||||
* Balance sheet
|
||||
|
||||
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
|
||||
receivables.
|
||||
|
||||
|
||||
Live Demonstration and Test Site
|
||||
--------------------------------
|
||||
|
||||
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
|
||||
same code as the `test site`_ in the `source distribution`_. It is
|
||||
the simplest website that works with *Mia! Accounting*. It is also
|
||||
used in the automatic tests.
|
||||
|
||||
If you do not have a running Flask application or do not know how to
|
||||
start one, you may start with the test site.
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
Install *Mia! Accounting* with ``pip``:
|
||||
|
||||
::
|
||||
|
||||
pip install mia-accounting
|
||||
|
||||
You may also download from the `PyPI project page`_ or the
|
||||
`release page`_ on the `Git repository`_.
|
||||
|
||||
|
||||
Prerequisites
|
||||
-------------
|
||||
|
||||
You need a running Flask application with database user login.
|
||||
The primary key of the user data model must be integer. You also
|
||||
need at least one user.
|
||||
|
||||
The following front-end JavaScript libraries must be loaded. You may
|
||||
download it locally or use CDN_.
|
||||
|
||||
* Bootstrap_ 5.2.3 or above
|
||||
* FontAwesome_ 6.2.1 or above
|
||||
* `Decimal.js`_ 6.4.3 or above
|
||||
* `Tempus-Dominus`_ 6.4.3 or above
|
||||
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
You need to pass the Flask *app* and an implementation of
|
||||
:py:class:`accounting.utils.user.UserUtilityInterface` to the
|
||||
:py:func:`accounting.init_app` function. ``UserUtilityInterface``
|
||||
contains everything *Mia! Accounting* needs.
|
||||
|
||||
See an example in :ref:`example-userutils`.
|
||||
|
||||
|
||||
Database Initialization
|
||||
-----------------------
|
||||
|
||||
After the configuration, run the ``accounting-init-db`` console
|
||||
command to initialize the accounting database. You need to specify
|
||||
the username of a user as the data creator.
|
||||
|
||||
::
|
||||
|
||||
% flask --app myapp accounting-init-db -u username
|
||||
|
||||
|
||||
Navigation Menu
|
||||
---------------
|
||||
|
||||
Include the navigation menu in the `Bootstrap navigation bar`_ in your
|
||||
base template:
|
||||
|
||||
::
|
||||
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
|
||||
<div class="container-fluid">
|
||||
...
|
||||
<div id="collapsible-navbar" class="collapse navbar-collapse">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
...
|
||||
{% include "accounting/include/nav.html" %}
|
||||
...
|
||||
</ul>
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
Check your Flask application and see how it works.
|
||||
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
Refer to the `documentation on Read the Docs`_.
|
||||
|
||||
|
||||
.. _Flask: https://flask.palletsprojects.com
|
||||
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
|
||||
.. _live demonstration: https://accounting.imacat.idv.tw
|
||||
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
|
||||
.. _source distribution: https://pypi.org/project/mia-accounting/#files
|
||||
.. _PyPI project page: https://pypi.org/project/mia-accounting
|
||||
.. _release page: https://github.com/imacat/mia-accounting/releases
|
||||
.. _Git repository: https://github.com/imacat/mia-accounting
|
||||
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
|
||||
.. _Bootstrap: https://getbootstrap.com
|
||||
.. _FontAwesome: https://fontawesome.com
|
||||
.. _Decimal.js: https://mikemcl.github.io/decimal.js
|
||||
.. _Tempus-Dominus: https://getdatepicker.com
|
||||
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
|
||||
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io
|
@ -1,7 +1,7 @@
|
||||
# The Mia! Flask Project.
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
||||
|
||||
# Copyright (c) 2022 imacat.
|
||||
# Copyright (c) 2022-2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@ -15,6 +15,51 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
[project]
|
||||
name = "mia-accounting"
|
||||
version = "1.3.2"
|
||||
description = "A Flask accounting module."
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{name = "imacat", email = "imacat@mail.imacat.idv.tw"},
|
||||
]
|
||||
keywords = ["mia", "accounting", "flask"]
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Framework :: Flask",
|
||||
"Topic :: Office/Business :: Financial :: Accounting",
|
||||
]
|
||||
dependencies = [
|
||||
"flask",
|
||||
"Flask-SQLAlchemy",
|
||||
"Flask-WTF",
|
||||
"Flask-Babel >= 3",
|
||||
"Flask-Babel-JS",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"unittest",
|
||||
"httpx",
|
||||
"OpenCC",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Documentation" = "https://mia-accounting.readthedocs.io"
|
||||
"Repository" = "https://github.com/imacat/mia-accounting"
|
||||
"Bug Tracker" = "https://github.com/imacat/mia-accounting/issues"
|
||||
"Demonstration" = "https://accounting.imacat.idv.tw"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=42"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.exclude-package-data]
|
||||
"*" = [
|
||||
"babel.cfg",
|
||||
"*.pot",
|
||||
"*.po",
|
||||
]
|
||||
|
56
setup.cfg
56
setup.cfg
@ -1,56 +0,0 @@
|
||||
# The Mia! Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
||||
|
||||
# Copyright (c) 2022-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.
|
||||
|
||||
[metadata]
|
||||
name = mia-accounting-flask
|
||||
version = 0.5.0
|
||||
author = imacat
|
||||
author_email = imacat@mail.imacat.idv.tw
|
||||
description = The Mia! Accounting Flask project.
|
||||
long_description = file: README.rst
|
||||
long_description_content_type = text/x-rst
|
||||
url = https://github.com/imacat/mia-accounting-flask
|
||||
project_urls =
|
||||
Bug Tracker = https://github.com/imacat/mia-accounting-flask/issues
|
||||
classifiers =
|
||||
Programming Language :: Python :: 3
|
||||
License :: OSI Approved :: Apache Software License
|
||||
Operating System :: OS Independent
|
||||
Framework :: Flask
|
||||
Topic :: Office/Business :: Financial :: Accounting
|
||||
|
||||
[options]
|
||||
package_dir =
|
||||
= src
|
||||
python_requires = >=3.11
|
||||
install_requires =
|
||||
flask
|
||||
Flask-SQLAlchemy
|
||||
Flask-WTF
|
||||
Flask-Babel >= 3
|
||||
Flask-Babel-JS
|
||||
tests_require =
|
||||
unittest
|
||||
httpx
|
||||
OpenCC
|
||||
|
||||
[options.package_data]
|
||||
accounting =
|
||||
static/**
|
||||
templates/**
|
||||
translations/*/LC_MESSAGES/*.mo
|
||||
data/**
|
@ -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.
|
||||
@ -17,13 +17,12 @@
|
||||
"""The accounting application.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
from accounting.utils.user import AbstractUserUtils
|
||||
from accounting.utils.user import UserUtilityInterface
|
||||
|
||||
db: SQLAlchemy = SQLAlchemy()
|
||||
"""The database instance."""
|
||||
@ -31,19 +30,13 @@ data_dir: Path = Path(__file__).parent / "data"
|
||||
"""The data directory."""
|
||||
|
||||
|
||||
def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
url_prefix: str = "/accounting",
|
||||
can_view_func: t.Callable[[], bool] | None = None,
|
||||
can_edit_func: t.Callable[[], bool] | None = None) -> None:
|
||||
def init_app(app: Flask, user_utils: UserUtilityInterface,
|
||||
url_prefix: str = "/accounting") -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param app: The Flask application.
|
||||
:param user_utils: The user utilities.
|
||||
:param url_prefix: The URL prefix of the accounting application.
|
||||
:param can_view_func: A callback that returns whether the current user can
|
||||
view the accounting data.
|
||||
:param can_edit_func: A callback that returns whether the current user can
|
||||
edit the accounting data.
|
||||
:return: None.
|
||||
"""
|
||||
# The database instance must be set before loading everything
|
||||
@ -54,7 +47,6 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
init_user_utils(user_utils)
|
||||
|
||||
bp: Blueprint = Blueprint("accounting", __name__,
|
||||
url_prefix=url_prefix,
|
||||
template_folder="templates",
|
||||
static_folder="static")
|
||||
|
||||
@ -69,11 +61,14 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
bp.add_app_template_global(default_currency_code,
|
||||
"accounting_default_currency_code")
|
||||
|
||||
from .commands import init_db_command
|
||||
app.cli.add_command(init_db_command)
|
||||
|
||||
from . import locale
|
||||
locale.init_app(app, bp)
|
||||
|
||||
from .utils import permission
|
||||
permission.init_app(bp, can_view_func, can_edit_func)
|
||||
permission.init_app(bp, user_utils)
|
||||
|
||||
from .utils import next_uri
|
||||
next_uri.init_app(bp)
|
||||
@ -87,10 +82,16 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
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)
|
||||
|
||||
from . import unmatched_offset
|
||||
unmatched_offset.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.
|
||||
@ -19,6 +19,8 @@
|
||||
"""
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from .commands import init_accounts_command
|
||||
|
||||
|
||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
@ -32,6 +34,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
|
||||
from .views import bp as account_bp
|
||||
bp.register_blueprint(account_bp, url_prefix="/accounts")
|
||||
|
||||
from .commands import init_accounts_command
|
||||
app.cli.add_command(init_accounts_command)
|
||||
|
@ -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.
|
||||
@ -17,45 +17,21 @@
|
||||
"""The console commands for the account management.
|
||||
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import typing as t
|
||||
from secrets import randbelow
|
||||
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import BaseAccount, Account, AccountL10n
|
||||
from accounting.utils.user import has_user, get_user_pk
|
||||
from accounting.utils.user import get_user_pk
|
||||
import sqlalchemy as sa
|
||||
|
||||
AccountData = tuple[int, str, int, str, str, str, bool]
|
||||
"""The format of the account data, as a list of (ID, base account code, number,
|
||||
English, Traditional Chinese, Simplified Chinese, is-offset-needed) tuples."""
|
||||
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
|
||||
|
||||
|
||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||
value: str) -> str:
|
||||
"""Validates the username for the click console command.
|
||||
|
||||
:param ctx: The console command context.
|
||||
:param param: The console command option.
|
||||
:param value: The username.
|
||||
:raise click.BadParameter: When validation fails.
|
||||
:return: The username.
|
||||
"""
|
||||
value = value.strip()
|
||||
if value == "":
|
||||
raise click.BadParameter("Username empty.")
|
||||
if not has_user(value):
|
||||
raise click.BadParameter(f"User {value} does not exist.")
|
||||
return value
|
||||
|
||||
|
||||
@click.command("accounting-init-accounts")
|
||||
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||
help="The username.", callback=__validate_username,
|
||||
default=lambda: os.getlogin())
|
||||
@with_appcontext
|
||||
def init_accounts_command(username: str) -> None:
|
||||
"""Initializes the accounts."""
|
||||
creator_pk: int = get_user_pk(username)
|
||||
@ -64,8 +40,6 @@ def init_accounts_command(username: str) -> None:
|
||||
.filter(db.func.length(BaseAccount.code) == 4)\
|
||||
.order_by(BaseAccount.code).all()
|
||||
if len(bases) == 0:
|
||||
click.echo("Please initialize the base accounts with "
|
||||
"\"flask accounting-init-base\" first.")
|
||||
raise click.Abort
|
||||
|
||||
existing: list[Account] = Account.query.all()
|
||||
@ -74,7 +48,6 @@ def init_accounts_command(username: str) -> None:
|
||||
bases_to_add: list[BaseAccount] = [x for x in bases
|
||||
if x.code not in existing_base_code]
|
||||
if len(bases_to_add) == 0:
|
||||
click.echo("No more account to import.")
|
||||
return
|
||||
|
||||
existing_id: set[int] = {x.id for x in existing}
|
||||
@ -90,38 +63,45 @@ def init_accounts_command(username: str) -> None:
|
||||
existing_id.add(new_id)
|
||||
return new_id
|
||||
|
||||
data: list[AccountData] = []
|
||||
data: list[dict[str, t.Any]] = []
|
||||
l10n_data: list[dict[str, t.Any]] = []
|
||||
for base in bases_to_add:
|
||||
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
||||
is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \
|
||||
else False
|
||||
data.append((get_new_id(), base.code, 1, base.title_l10n,
|
||||
l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed))
|
||||
__add_accounting_accounts(data, creator_pk)
|
||||
click.echo(F"{len(data)} added. Accounting accounts initialized.")
|
||||
account_id: int = get_new_id()
|
||||
data.append({"id": account_id,
|
||||
"base_code": base.code,
|
||||
"no": 1,
|
||||
"title_l10n": base.title_l10n,
|
||||
"is_need_offset": __is_need_offset(base.code),
|
||||
"created_by_id": creator_pk,
|
||||
"updated_by_id": creator_pk})
|
||||
for locale in {"zh_Hant", "zh_Hans"}:
|
||||
l10n_data.append({"account_id": account_id,
|
||||
"locale": locale,
|
||||
"title": l10n[locale]})
|
||||
db.session.execute(sa.insert(Account), data)
|
||||
db.session.execute(sa.insert(AccountL10n), l10n_data)
|
||||
|
||||
|
||||
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
|
||||
-> None:
|
||||
"""Adds the accounts.
|
||||
def __is_need_offset(base_code: str) -> bool:
|
||||
"""Checks that whether journal entry line items in the account need offset.
|
||||
|
||||
:param data: A list of (base code, number, title) tuples.
|
||||
:param creator_pk: The primary key of the creator.
|
||||
:return: None.
|
||||
:param base_code: The code of the base account.
|
||||
:return: True if journal entry line items in the account need offset, or
|
||||
False otherwise.
|
||||
"""
|
||||
accounts: list[Account] = [Account(id=x[0],
|
||||
base_code=x[1],
|
||||
no=x[2],
|
||||
title_l10n=x[3],
|
||||
is_offset_needed=x[6],
|
||||
created_by_id=creator_pk,
|
||||
updated_by_id=creator_pk)
|
||||
for x in data]
|
||||
l10n: list[AccountL10n] = [AccountL10n(account_id=x[0],
|
||||
locale=y[0],
|
||||
title=y[1])
|
||||
for x in data
|
||||
for y in (("zh_Hant", x[4]), ("zh_Hans", x[5]))]
|
||||
db.session.bulk_save_objects(accounts)
|
||||
db.session.bulk_save_objects(l10n)
|
||||
db.session.commit()
|
||||
# Assets
|
||||
if base_code[0] == "1":
|
||||
if base_code[:3] in {"113", "114", "118", "184", "186"}:
|
||||
return True
|
||||
if base_code in {"1286", "1411", "1421", "1431", "1441", "1511",
|
||||
"1521", "1581", "1611", "1851"}:
|
||||
return True
|
||||
return False
|
||||
# Liabilities
|
||||
if base_code[0] == "2":
|
||||
if base_code in {"2111", "2114", "2284", "2293", "2861"}:
|
||||
return False
|
||||
return True
|
||||
# Only assets and liabilities need offset
|
||||
return False
|
||||
|
@ -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.
|
||||
@ -53,6 +53,20 @@ class BaseAccountAvailable:
|
||||
"The base account is not available."))
|
||||
|
||||
|
||||
class NoOffsetNominalAccount:
|
||||
"""The validator to check nominal account is not to be offset."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
|
||||
assert isinstance(form, AccountForm)
|
||||
if not field.data:
|
||||
return
|
||||
if form.base_code.data is None:
|
||||
return
|
||||
if form.base_code.data[0] not in {"1", "2", "3"}:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"A nominal account does not need offset."))
|
||||
|
||||
|
||||
class AccountForm(FlaskForm):
|
||||
"""The form to create or edit an account."""
|
||||
base_code = StringField(
|
||||
@ -66,8 +80,9 @@ class AccountForm(FlaskForm):
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
|
||||
"""The title."""
|
||||
is_offset_needed = BooleanField()
|
||||
"""Whether the the entries of this account need offset."""
|
||||
is_need_offset = BooleanField(
|
||||
validators=[NoOffsetNominalAccount()])
|
||||
"""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.
|
||||
@ -87,7 +102,10 @@ class AccountForm(FlaskForm):
|
||||
obj.base_code = self.base_code.data
|
||||
obj.no = count + 1
|
||||
obj.title = self.title.data
|
||||
obj.is_offset_needed = self.is_offset_needed.data
|
||||
if self.base_code.data[0] in {"1", "2", "3"}:
|
||||
obj.is_need_offset = self.is_need_offset.data
|
||||
else:
|
||||
obj.is_need_offset = False
|
||||
if is_new:
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.created_by_id = current_user_pk
|
||||
|
@ -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,15 +40,15 @@ 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"):
|
||||
sub_conditions.append(Account.is_offset_needed)
|
||||
if k in gettext("Needs Offset"):
|
||||
sub_conditions.append(Account.is_need_offset)
|
||||
conditions.append(sa.or_(*sub_conditions))
|
||||
|
||||
return Account.query.filter(*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.
|
||||
@ -27,6 +27,7 @@ from werkzeug.datastructures import ImmutableMultiDict
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Account, BaseAccount
|
||||
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.pagination import Pagination
|
||||
@ -52,7 +53,7 @@ def list_accounts() -> str:
|
||||
list=pagination.list, pagination=pagination)
|
||||
|
||||
|
||||
@bp.get("/create", endpoint="create")
|
||||
@bp.get("create", endpoint="create")
|
||||
@has_permission(can_edit)
|
||||
def show_add_account_form() -> str:
|
||||
"""Shows the form to add an account.
|
||||
@ -69,7 +70,7 @@ def show_add_account_form() -> str:
|
||||
form=form)
|
||||
|
||||
|
||||
@bp.post("/store", endpoint="store")
|
||||
@bp.post("store", endpoint="store")
|
||||
@has_permission(can_edit)
|
||||
def add_account() -> redirect:
|
||||
"""Adds an account.
|
||||
@ -86,11 +87,11 @@ def add_account() -> redirect:
|
||||
form.populate_obj(account)
|
||||
db.session.add(account)
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The account is added successfully"), "success")
|
||||
flash(s(lazy_gettext("The account is added successfully")), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(account)))
|
||||
|
||||
|
||||
@bp.get("/<account:account>", endpoint="detail")
|
||||
@bp.get("<account:account>", endpoint="detail")
|
||||
@has_permission(can_view)
|
||||
def show_account_detail(account: Account) -> str:
|
||||
"""Shows the account detail.
|
||||
@ -101,7 +102,7 @@ def show_account_detail(account: Account) -> str:
|
||||
return render_template("accounting/account/detail.html", obj=account)
|
||||
|
||||
|
||||
@bp.get("/<account:account>/edit", endpoint="edit")
|
||||
@bp.get("<account:account>/edit", endpoint="edit")
|
||||
@has_permission(can_edit)
|
||||
def show_account_edit_form(account: Account) -> str:
|
||||
"""Shows the form to edit an account.
|
||||
@ -120,7 +121,7 @@ def show_account_edit_form(account: Account) -> str:
|
||||
account=account, form=form)
|
||||
|
||||
|
||||
@bp.post("/<account:account>/update", endpoint="update")
|
||||
@bp.post("<account:account>/update", endpoint="update")
|
||||
@has_permission(can_edit)
|
||||
def update_account(account: Account) -> redirect:
|
||||
"""Updates an account.
|
||||
@ -138,16 +139,16 @@ def update_account(account: Account) -> redirect:
|
||||
with db.session.no_autoflush:
|
||||
form.populate_obj(account)
|
||||
if not account.is_modified:
|
||||
flash(lazy_gettext("The account was not modified."), "success")
|
||||
flash(s(lazy_gettext("The account was not modified.")), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(account)))
|
||||
account.updated_by_id = get_current_user_pk()
|
||||
account.updated_at = sa.func.now()
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The account is updated successfully."), "success")
|
||||
flash(s(lazy_gettext("The account is updated successfully.")), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(account)))
|
||||
|
||||
|
||||
@bp.post("/<account:account>/delete", endpoint="delete")
|
||||
@bp.post("<account:account>/delete", endpoint="delete")
|
||||
@has_permission(can_edit)
|
||||
def delete_account(account: Account) -> redirect:
|
||||
"""Deletes an account.
|
||||
@ -156,14 +157,17 @@ 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()
|
||||
flash(lazy_gettext("The account is deleted successfully."), "success")
|
||||
flash(s(lazy_gettext("The account is deleted successfully.")), "success")
|
||||
return redirect(or_next(__get_list_uri()))
|
||||
|
||||
|
||||
@bp.get("/bases/<baseAccount:base>", endpoint="order")
|
||||
@bp.get("bases/<baseAccount:base>", endpoint="order")
|
||||
@has_permission(can_view)
|
||||
def show_account_order(base: BaseAccount) -> str:
|
||||
"""Shows the order of the accounts under a same base account.
|
||||
@ -174,7 +178,7 @@ def show_account_order(base: BaseAccount) -> str:
|
||||
return render_template("accounting/account/order.html", base=base)
|
||||
|
||||
|
||||
@bp.post("/bases/<baseAccount:base>", endpoint="sort")
|
||||
@bp.post("bases/<baseAccount:base>", endpoint="sort")
|
||||
@has_permission(can_edit)
|
||||
def sort_accounts(base: BaseAccount) -> redirect:
|
||||
"""Reorders the accounts under a base account.
|
||||
@ -186,10 +190,10 @@ def sort_accounts(base: BaseAccount) -> redirect:
|
||||
form: AccountReorderForm = AccountReorderForm(base)
|
||||
form.save_order()
|
||||
if not form.is_modified:
|
||||
flash(lazy_gettext("The order was not modified."), "success")
|
||||
flash(s(lazy_gettext("The order was not modified.")), "success")
|
||||
return redirect(or_next(__get_list_uri()))
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The order is updated successfully."), "success")
|
||||
flash(s(lazy_gettext("The order is updated successfully.")), "success")
|
||||
return redirect(or_next(__get_list_uri()))
|
||||
|
||||
|
||||
|
@ -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.
|
||||
@ -19,6 +19,8 @@
|
||||
"""
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from .commands import init_base_accounts_command
|
||||
|
||||
|
||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
@ -32,6 +34,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
|
||||
from .views import bp as base_account_bp
|
||||
bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")
|
||||
|
||||
from .commands import init_base_accounts_command
|
||||
app.cli.add_command(init_base_accounts_command)
|
||||
|
@ -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.
|
||||
@ -19,21 +19,17 @@
|
||||
"""
|
||||
import csv
|
||||
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import data_dir
|
||||
from accounting import db
|
||||
from accounting.models import BaseAccount, BaseAccountL10n
|
||||
|
||||
|
||||
@click.command("accounting-init-base")
|
||||
@with_appcontext
|
||||
def init_base_accounts_command() -> None:
|
||||
"""Initializes the base accounts."""
|
||||
if BaseAccount.query.first() is not None:
|
||||
click.echo("Base accounts already exist.")
|
||||
raise click.Abort
|
||||
return
|
||||
|
||||
with open(data_dir / "base_accounts.csv") as fp:
|
||||
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
||||
@ -45,7 +41,5 @@ def init_base_accounts_command() -> None:
|
||||
"locale": y,
|
||||
"title": x[f"l10n-{y}"]}
|
||||
for x in data for y in locales]
|
||||
db.session.bulk_insert_mappings(BaseAccount, account_data)
|
||||
db.session.bulk_insert_mappings(BaseAccountL10n, l10n_data)
|
||||
db.session.commit()
|
||||
click.echo("Base accounts initialized.")
|
||||
db.session.execute(sa.insert(BaseAccount), account_data)
|
||||
db.session.execute(sa.insert(BaseAccountL10n), l10n_data)
|
||||
|
@ -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.
|
||||
@ -22,6 +22,7 @@ from flask import Blueprint, render_template
|
||||
from accounting.models import BaseAccount
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import has_permission, can_view
|
||||
from .queries import get_base_account_query
|
||||
|
||||
bp: Blueprint = Blueprint("base-account", __name__)
|
||||
"""The view blueprint for the base account management."""
|
||||
@ -34,14 +35,13 @@ def list_accounts() -> str:
|
||||
|
||||
:return: The account list.
|
||||
"""
|
||||
from .queries import get_base_account_query
|
||||
accounts: list[BaseAccount] = get_base_account_query()
|
||||
pagination: Pagination = Pagination[BaseAccount](accounts)
|
||||
return render_template("accounting/base-account/list.html",
|
||||
list=pagination.list, pagination=pagination)
|
||||
|
||||
|
||||
@bp.get("/<baseAccount:account>", endpoint="detail")
|
||||
@bp.get("<baseAccount:account>", endpoint="detail")
|
||||
@has_permission(can_view)
|
||||
def show_account_detail(account: BaseAccount) -> str:
|
||||
"""Shows the account detail.
|
||||
|
62
src/accounting/commands.py
Normal file
62
src/accounting/commands.py
Normal file
@ -0,0 +1,62 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/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 console commands.
|
||||
|
||||
"""
|
||||
import os
|
||||
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from accounting import db
|
||||
from accounting.account import init_accounts_command
|
||||
from accounting.base_account import init_base_accounts_command
|
||||
from accounting.currency import init_currencies_command
|
||||
from accounting.utils.user import has_user
|
||||
|
||||
|
||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||
value: str) -> str:
|
||||
"""Validates the username for the click console command.
|
||||
|
||||
:param ctx: The console command context.
|
||||
:param param: The console command option.
|
||||
:param value: The username.
|
||||
:raise click.BadParameter: When validation fails.
|
||||
:return: The username.
|
||||
"""
|
||||
value = value.strip()
|
||||
if value == "":
|
||||
raise click.BadParameter("Username empty.")
|
||||
if not has_user(value):
|
||||
raise click.BadParameter(f"User {value} does not exist.")
|
||||
return value
|
||||
|
||||
|
||||
@click.command("accounting-init-db")
|
||||
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||
help="The username.", callback=__validate_username,
|
||||
default=lambda: os.getlogin())
|
||||
@with_appcontext
|
||||
def init_db_command(username: str) -> None:
|
||||
"""Initializes the accounting database."""
|
||||
db.create_all()
|
||||
init_base_accounts_command()
|
||||
init_accounts_command(username)
|
||||
init_currencies_command(username)
|
||||
db.session.commit()
|
||||
click.echo("Accounting database initialized.")
|
@ -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.
|
||||
@ -19,6 +19,8 @@
|
||||
"""
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from .commands import init_currencies_command
|
||||
|
||||
|
||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
@ -33,6 +35,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
from .views import bp as currency_bp, api_bp as currency_api_bp
|
||||
bp.register_blueprint(currency_bp, url_prefix="/currencies")
|
||||
bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
|
||||
|
||||
from .commands import init_currencies_command
|
||||
app.cli.add_command(init_currencies_command)
|
||||
|
@ -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.
|
||||
@ -18,42 +18,15 @@
|
||||
|
||||
"""
|
||||
import csv
|
||||
import os
|
||||
import typing as t
|
||||
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db, data_dir
|
||||
from accounting.models import Currency, CurrencyL10n
|
||||
from accounting.utils.user import has_user, get_user_pk
|
||||
|
||||
CurrencyData = tuple[str, str, str, str]
|
||||
from accounting.utils.user import get_user_pk
|
||||
|
||||
|
||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||
value: str) -> str:
|
||||
"""Validates the username for the click console command.
|
||||
|
||||
:param ctx: The console command context.
|
||||
:param param: The console command option.
|
||||
:param value: The username.
|
||||
:raise click.BadParameter: When validation fails.
|
||||
:return: The username.
|
||||
"""
|
||||
value = value.strip()
|
||||
if value == "":
|
||||
raise click.BadParameter("Username empty.")
|
||||
if not has_user(value):
|
||||
raise click.BadParameter(f"User {value} does not exist.")
|
||||
return value
|
||||
|
||||
|
||||
@click.command("accounting-init-currencies")
|
||||
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||
help="The username.", callback=__validate_username,
|
||||
default=lambda: os.getlogin())
|
||||
@with_appcontext
|
||||
def init_currencies_command(username: str) -> None:
|
||||
"""Initializes the currencies."""
|
||||
existing_codes: set[str] = {x.code for x in Currency.query.all()}
|
||||
@ -63,7 +36,6 @@ def init_currencies_command(username: str) -> None:
|
||||
to_add: list[dict[str, str]] = [x for x in data
|
||||
if x["code"] not in existing_codes]
|
||||
if len(to_add) == 0:
|
||||
click.echo("No more currency to add.")
|
||||
return
|
||||
|
||||
creator_pk: int = get_user_pk(username)
|
||||
@ -77,8 +49,5 @@ def init_currencies_command(username: str) -> None:
|
||||
"locale": y,
|
||||
"name": x[f"l10n-{y}"]}
|
||||
for x in to_add for y in locales]
|
||||
db.session.bulk_insert_mappings(Currency, currency_data)
|
||||
db.session.bulk_insert_mappings(CurrencyL10n, l10n_data)
|
||||
db.session.commit()
|
||||
|
||||
click.echo(F"{len(to_add)} added. Currencies initialized.")
|
||||
db.session.execute(sa.insert(Currency), currency_data)
|
||||
db.session.execute(sa.insert(CurrencyL10n), l10n_data)
|
||||
|
@ -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.
|
||||
@ -17,8 +17,6 @@
|
||||
"""The forms for the currency management.
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, ValidationError
|
||||
from wtforms.validators import DataRequired, Regexp, NoneOf
|
||||
@ -30,22 +28,24 @@ from accounting.utils.strip_text import strip_text
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
|
||||
|
||||
class CodeUnique:
|
||||
"""The validator to check if the code is unique."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
assert isinstance(form, CurrencyForm)
|
||||
if field.data == "":
|
||||
return
|
||||
if form.obj_code is not None and form.obj_code == field.data:
|
||||
return
|
||||
if db.session.get(Currency, field.data) is not None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"Code conflicts with another currency."))
|
||||
|
||||
|
||||
class CurrencyForm(FlaskForm):
|
||||
"""The form to create or edit a currency."""
|
||||
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
|
||||
"""The reserved codes that are not available."""
|
||||
|
||||
class CodeUnique:
|
||||
"""The validator to check if the code is unique."""
|
||||
def __call__(self, form: CurrencyForm, field: StringField) -> None:
|
||||
if field.data == "":
|
||||
return
|
||||
if form.obj_code is not None and form.obj_code == field.data:
|
||||
return
|
||||
if db.session.get(Currency, field.data) is not None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"Code conflicts with another currency."))
|
||||
|
||||
code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(lazy_gettext("Please fill in the code.")),
|
||||
|
@ -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.
|
||||
@ -27,12 +27,14 @@ from werkzeug.datastructures import ImmutableMultiDict
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency
|
||||
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.pagination import Pagination
|
||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .forms import CurrencyForm
|
||||
from .queries import get_currency_query
|
||||
|
||||
bp: Blueprint = Blueprint("currency", __name__)
|
||||
"""The view blueprint for the currency management."""
|
||||
@ -47,14 +49,13 @@ def list_currencies() -> str:
|
||||
|
||||
:return: The currency list.
|
||||
"""
|
||||
from .queries import get_currency_query
|
||||
currencies: list[Currency] = get_currency_query()
|
||||
pagination: Pagination = Pagination[Currency](currencies)
|
||||
return render_template("accounting/currency/list.html",
|
||||
list=pagination.list, pagination=pagination)
|
||||
|
||||
|
||||
@bp.get("/create", endpoint="create")
|
||||
@bp.get("create", endpoint="create")
|
||||
@has_permission(can_edit)
|
||||
def show_add_currency_form() -> str:
|
||||
"""Shows the form to add a currency.
|
||||
@ -71,7 +72,7 @@ def show_add_currency_form() -> str:
|
||||
form=form)
|
||||
|
||||
|
||||
@bp.post("/store", endpoint="store")
|
||||
@bp.post("store", endpoint="store")
|
||||
@has_permission(can_edit)
|
||||
def add_currency() -> redirect:
|
||||
"""Adds a currency.
|
||||
@ -88,11 +89,11 @@ def add_currency() -> redirect:
|
||||
form.populate_obj(currency)
|
||||
db.session.add(currency)
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The currency is added successfully"), "success")
|
||||
flash(s(lazy_gettext("The currency is added successfully.")), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||
|
||||
|
||||
@bp.get("/<currency:currency>", endpoint="detail")
|
||||
@bp.get("<currency:currency>", endpoint="detail")
|
||||
@has_permission(can_view)
|
||||
def show_currency_detail(currency: Currency) -> str:
|
||||
"""Shows the currency detail.
|
||||
@ -103,7 +104,7 @@ def show_currency_detail(currency: Currency) -> str:
|
||||
return render_template("accounting/currency/detail.html", obj=currency)
|
||||
|
||||
|
||||
@bp.get("/<currency:currency>/edit", endpoint="edit")
|
||||
@bp.get("<currency:currency>/edit", endpoint="edit")
|
||||
@has_permission(can_edit)
|
||||
def show_currency_edit_form(currency: Currency) -> str:
|
||||
"""Shows the form to edit a currency.
|
||||
@ -122,7 +123,7 @@ def show_currency_edit_form(currency: Currency) -> str:
|
||||
currency=currency, form=form)
|
||||
|
||||
|
||||
@bp.post("/<currency:currency>/update", endpoint="update")
|
||||
@bp.post("<currency:currency>/update", endpoint="update")
|
||||
@has_permission(can_edit)
|
||||
def update_currency(currency: Currency) -> redirect:
|
||||
"""Updates a currency.
|
||||
@ -141,16 +142,16 @@ def update_currency(currency: Currency) -> redirect:
|
||||
with db.session.no_autoflush:
|
||||
form.populate_obj(currency)
|
||||
if not currency.is_modified:
|
||||
flash(lazy_gettext("The currency was not modified."), "success")
|
||||
flash(s(lazy_gettext("The currency was not modified.")), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||
currency.updated_by_id = get_current_user_pk()
|
||||
currency.updated_at = sa.func.now()
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The currency is updated successfully."), "success")
|
||||
flash(s(lazy_gettext("The currency is updated successfully.")), "success")
|
||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||
|
||||
|
||||
@bp.post("/<currency:currency>/delete", endpoint="delete")
|
||||
@bp.post("<currency:currency>/delete", endpoint="delete")
|
||||
@has_permission(can_edit)
|
||||
def delete_currency(currency: Currency) -> redirect:
|
||||
"""Deletes a currency.
|
||||
@ -159,13 +160,16 @@ 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(lazy_gettext("The currency is deleted successfully."), "success")
|
||||
flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
|
||||
return redirect(or_next(url_for("accounting.currency.list")))
|
||||
|
||||
|
||||
@api_bp.get("/exists-code", endpoint="exists")
|
||||
@api_bp.get("exists-code", endpoint="exists")
|
||||
@has_permission(can_edit)
|
||||
def exists_code() -> dict[str, bool]:
|
||||
"""Validates whether a currency code exists.
|
||||
@ -182,4 +186,3 @@ def __get_detail_uri(currency: Currency) -> str:
|
||||
:return: The detail URI of the currency.
|
||||
"""
|
||||
return url_for("accounting.currency.detail", currency=currency)
|
||||
|
||||
|
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")
|
101
src/accounting/journal_entry/converters.py
Normal file
101
src/accounting/journal_entry/converters.py
Normal file
@ -0,0 +1,101 @@
|
||||
# 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 werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import JournalEntry
|
||||
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 = db.session.get(JournalEntry, value)
|
||||
if journal_entry is None:
|
||||
abort(404)
|
||||
return journal_entry
|
||||
|
||||
def to_url(self, value: JournalEntry) -> str:
|
||||
"""Converts a journal entry to its ID.
|
||||
|
||||
:param value: The journal entry.
|
||||
:return: The ID.
|
||||
"""
|
||||
return str(value.id)
|
||||
|
||||
|
||||
class JournalEntryTypeConverter(BaseConverter):
|
||||
"""The journal entry converter to convert the journal entry type ID from
|
||||
and to the corresponding journal entry type in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> JournalEntryType:
|
||||
"""Converts a journal entry ID to a journal entry.
|
||||
|
||||
:param value: The journal entry ID.
|
||||
:return: The corresponding journal entry type.
|
||||
"""
|
||||
type_dict: dict[str, JournalEntryType] \
|
||||
= {x.value: x for x in JournalEntryType}
|
||||
journal_entry_type: JournalEntryType | None = type_dict.get(value)
|
||||
if journal_entry_type is None:
|
||||
abort(404)
|
||||
return journal_entry_type
|
||||
|
||||
def to_url(self, value: JournalEntryType) -> str:
|
||||
"""Converts a journal entry type to its ID.
|
||||
|
||||
:param value: The journal entry type.
|
||||
:return: The ID.
|
||||
"""
|
||||
return str(value.value)
|
||||
|
||||
|
||||
class DateConverter(BaseConverter):
|
||||
"""The date converter to convert the ISO date from and to the
|
||||
corresponding date in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> date:
|
||||
"""Converts an ISO date to a date.
|
||||
|
||||
:param value: The ISO date.
|
||||
:return: The corresponding date.
|
||||
"""
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
abort(404)
|
||||
|
||||
def to_url(self, value: date) -> str:
|
||||
"""Converts a date to its ISO date.
|
||||
|
||||
:param value: The date.
|
||||
:return: The ISO date.
|
||||
"""
|
||||
return value.isoformat()
|
22
src/accounting/journal_entry/forms/__init__.py
Normal file
22
src/accounting/journal_entry/forms/__init__.py
Normal file
@ -0,0 +1,22 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The forms for the journal entry management.
|
||||
|
||||
"""
|
||||
from .reorder import sort_journal_entries_in, JournalEntryReorderForm
|
||||
from .journal_entry import JournalEntryForm, CashReceiptJournalEntryForm, \
|
||||
CashDisbursementJournalEntryForm, TransferJournalEntryForm
|
293
src/accounting/journal_entry/forms/currency.py
Normal file
293
src/accounting/journal_entry/forms/currency.py
Normal file
@ -0,0 +1,293 @@
|
||||
# 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 currency sub-forms for the journal entry management.
|
||||
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask_babel import LazyString
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, ValidationError, FieldList, IntegerField, \
|
||||
BooleanField, FormField
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
from accounting import db
|
||||
from accounting.forms import CurrencyExists
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import JournalEntryLineItem
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.offset_alias import offset_alias
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
|
||||
|
||||
CURRENCY_REQUIRED: DataRequired = DataRequired(
|
||||
lazy_gettext("Please select the currency."))
|
||||
"""The validator to check if the currency code is empty."""
|
||||
|
||||
|
||||
class SameCurrencyAsOriginalLineItems:
|
||||
"""The validator to check if the currency is the same as the
|
||||
original line items."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
assert isinstance(form, CurrencyForm)
|
||||
if field.data is None:
|
||||
return
|
||||
original_line_item_id: set[int] \
|
||||
= {x.original_line_item_id.data
|
||||
for x in form.line_items
|
||||
if x.original_line_item_id.data is not None}
|
||||
if len(original_line_item_id) == 0:
|
||||
return
|
||||
original_line_item_currency_codes: set[str] = set(db.session.scalars(
|
||||
sa.select(JournalEntryLineItem.currency_code)
|
||||
.filter(JournalEntryLineItem.id.in_(original_line_item_id))).all())
|
||||
for currency_code in original_line_item_currency_codes:
|
||||
if field.data != currency_code:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The currency must be the same as the"
|
||||
" original line item."))
|
||||
|
||||
|
||||
class KeepCurrencyWhenHavingOffset:
|
||||
"""The validator to check if the currency is the same when there is
|
||||
offset."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
assert isinstance(form, CurrencyForm)
|
||||
if field.data is None:
|
||||
return
|
||||
offset: sa.Alias = offset_alias()
|
||||
original_line_items: list[JournalEntryLineItem]\
|
||||
= JournalEntryLineItem.query\
|
||||
.join(offset, be(JournalEntryLineItem.id
|
||||
== offset.c.original_line_item_id),
|
||||
isouter=True)\
|
||||
.filter(JournalEntryLineItem.id
|
||||
.in_({x.id.data for x in form.line_items
|
||||
if x.id.data is not None}))\
|
||||
.group_by(JournalEntryLineItem.id,
|
||||
JournalEntryLineItem.currency_code)\
|
||||
.having(sa.func.count(offset.c.id) > 0).all()
|
||||
for original_line_item in original_line_items:
|
||||
if original_line_item.currency_code != field.data:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The currency must not be changed when there is offset."))
|
||||
|
||||
|
||||
class NeedSomeLineItems:
|
||||
"""The validator to check if there is any line item sub-form."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: FieldList) -> None:
|
||||
if len(field) == 0:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"Please add some line items."))
|
||||
|
||||
|
||||
class IsBalanced:
|
||||
"""The validator to check that the total amount of the debit and credit
|
||||
line items are equal."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: BooleanField) -> None:
|
||||
assert isinstance(form, TransferCurrencyForm)
|
||||
if len(form.debit) == 0 or len(form.credit) == 0:
|
||||
return
|
||||
if form.debit_total != form.credit_total:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The totals of the debit and credit amounts do not match."))
|
||||
|
||||
|
||||
class CurrencyForm(FlaskForm):
|
||||
"""The form to create or edit a currency in a journal entry."""
|
||||
no = IntegerField()
|
||||
"""The order in the journal entry."""
|
||||
code = StringField()
|
||||
"""The currency code."""
|
||||
whole_form = BooleanField()
|
||||
"""The pseudo field for the whole form validators."""
|
||||
|
||||
@property
|
||||
def line_items(self) -> list[LineItemForm]:
|
||||
"""Returns the line item sub-forms.
|
||||
|
||||
:return: The line item sub-forms.
|
||||
"""
|
||||
line_item_forms: list[LineItemForm] = []
|
||||
if isinstance(self, CashReceiptCurrencyForm):
|
||||
line_item_forms.extend([x.form for x in self.credit])
|
||||
elif isinstance(self, CashDisbursementCurrencyForm):
|
||||
line_item_forms.extend([x.form for x in self.debit])
|
||||
elif isinstance(self, TransferCurrencyForm):
|
||||
line_item_forms.extend([x.form for x in self.debit])
|
||||
line_item_forms.extend([x.form for x in self.credit])
|
||||
return line_item_forms
|
||||
|
||||
@property
|
||||
def is_code_locked(self) -> bool:
|
||||
"""Returns whether the currency code should not be changed.
|
||||
|
||||
:return: True if the currency code should not be changed, or False
|
||||
otherwise
|
||||
"""
|
||||
line_item_forms: list[LineItemForm] = self.line_items
|
||||
original_line_item_id: set[int] \
|
||||
= {x.original_line_item_id.data for x in line_item_forms
|
||||
if x.original_line_item_id.data is not None}
|
||||
if len(original_line_item_id) > 0:
|
||||
return True
|
||||
line_item_id: set[int] = {x.id.data for x in line_item_forms
|
||||
if x.id.data is not None}
|
||||
select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
|
||||
.filter(JournalEntryLineItem.original_line_item_id
|
||||
.in_(line_item_id))
|
||||
return db.session.scalar(select) > 0
|
||||
|
||||
|
||||
class CashReceiptCurrencyForm(CurrencyForm):
|
||||
"""The form to create or edit a currency in a
|
||||
cash receipt journal entry."""
|
||||
no = IntegerField()
|
||||
"""The order in the journal entry."""
|
||||
code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[CURRENCY_REQUIRED,
|
||||
CurrencyExists(),
|
||||
SameCurrencyAsOriginalLineItems(),
|
||||
KeepCurrencyWhenHavingOffset()])
|
||||
"""The currency code."""
|
||||
credit = FieldList(FormField(CreditLineItemForm),
|
||||
validators=[NeedSomeLineItems()])
|
||||
"""The credit line items."""
|
||||
whole_form = BooleanField()
|
||||
"""The pseudo field for the whole form validators."""
|
||||
|
||||
@property
|
||||
def credit_total(self) -> Decimal:
|
||||
"""Returns the total amount of the credit line items.
|
||||
|
||||
:return: The total amount of the credit line items.
|
||||
"""
|
||||
return sum([x.amount.data for x in self.credit
|
||||
if x.amount.data is not None])
|
||||
|
||||
@property
|
||||
def credit_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the credit line item errors, without the errors in their
|
||||
sub-forms.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return [x for x in self.credit.errors
|
||||
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||
|
||||
|
||||
class CashDisbursementCurrencyForm(CurrencyForm):
|
||||
"""The form to create or edit a currency in a
|
||||
cash disbursement journal entry."""
|
||||
no = IntegerField()
|
||||
"""The order in the journal entry."""
|
||||
code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[CURRENCY_REQUIRED,
|
||||
CurrencyExists(),
|
||||
SameCurrencyAsOriginalLineItems(),
|
||||
KeepCurrencyWhenHavingOffset()])
|
||||
"""The currency code."""
|
||||
debit = FieldList(FormField(DebitLineItemForm),
|
||||
validators=[NeedSomeLineItems()])
|
||||
"""The debit line items."""
|
||||
whole_form = BooleanField()
|
||||
"""The pseudo field for the whole form validators."""
|
||||
|
||||
@property
|
||||
def debit_total(self) -> Decimal:
|
||||
"""Returns the total amount of the debit line items.
|
||||
|
||||
:return: The total amount of the debit line items.
|
||||
"""
|
||||
return sum([x.amount.data for x in self.debit
|
||||
if x.amount.data is not None])
|
||||
|
||||
@property
|
||||
def debit_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the debit line item errors, without the errors in their
|
||||
sub-forms.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return [x for x in self.debit.errors
|
||||
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||
|
||||
|
||||
class TransferCurrencyForm(CurrencyForm):
|
||||
"""The form to create or edit a currency in a transfer journal entry."""
|
||||
no = IntegerField()
|
||||
"""The order in the journal entry."""
|
||||
code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[CURRENCY_REQUIRED,
|
||||
CurrencyExists(),
|
||||
SameCurrencyAsOriginalLineItems(),
|
||||
KeepCurrencyWhenHavingOffset()])
|
||||
"""The currency code."""
|
||||
debit = FieldList(FormField(DebitLineItemForm),
|
||||
validators=[NeedSomeLineItems()])
|
||||
"""The debit line items."""
|
||||
credit = FieldList(FormField(CreditLineItemForm),
|
||||
validators=[NeedSomeLineItems()])
|
||||
"""The credit line items."""
|
||||
whole_form = BooleanField(validators=[IsBalanced()])
|
||||
"""The pseudo field for the whole form validators."""
|
||||
|
||||
@property
|
||||
def debit_total(self) -> Decimal:
|
||||
"""Returns the total amount of the debit line items.
|
||||
|
||||
:return: The total amount of the debit line items.
|
||||
"""
|
||||
return sum([x.amount.data for x in self.debit
|
||||
if x.amount.data is not None])
|
||||
|
||||
@property
|
||||
def credit_total(self) -> Decimal:
|
||||
"""Returns the total amount of the credit line items.
|
||||
|
||||
:return: The total amount of the credit line items.
|
||||
"""
|
||||
return sum([x.amount.data for x in self.credit
|
||||
if x.amount.data is not None])
|
||||
|
||||
@property
|
||||
def debit_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the debit line item errors, without the errors in their
|
||||
sub-forms.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return [x for x in self.debit.errors
|
||||
if isinstance(x, str) or isinstance(x, LazyString)]
|
||||
|
||||
@property
|
||||
def credit_errors(self) -> list[str | LazyString]:
|
||||
"""Returns the credit line item errors, without the errors in their
|
||||
sub-forms.
|
||||
|
||||
:return:
|
||||
"""
|
||||
return [x for x in self.credit.errors
|
||||
if isinstance(x, str) or isinstance(x, LazyString)]
|
593
src/accounting/journal_entry/forms/journal_entry.py
Normal file
593
src/accounting/journal_entry/forms/journal_entry.py
Normal file
@ -0,0 +1,593 @@
|
||||
# The Mia! Accounting 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
|
496
src/accounting/journal_entry/forms/line_item.py
Normal file
496
src/accounting/journal_entry/forms/line_item.py
Normal file
@ -0,0 +1,496 @@
|
||||
# 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, JournalEntry, 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.get(JournalEntryLineItem, form.id.data)
|
||||
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.join(JournalEntry)\
|
||||
.filter(JournalEntryLineItem.original_line_item_id
|
||||
== self.id.data)\
|
||||
.order_by(JournalEntry.date, JournalEntry.no,
|
||||
JournalEntryLineItem.no)\
|
||||
.options(selectinload(JournalEntryLineItem.journal_entry),
|
||||
selectinload(JournalEntryLineItem.account)).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)
|
19
src/accounting/journal_entry/utils/__init__.py
Normal file
19
src/accounting/journal_entry/utils/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The utilities for the journal entry management.
|
||||
|
||||
"""
|
49
src/accounting/journal_entry/utils/account_option.py
Normal file
49
src/accounting/journal_entry/utils/account_option.py
Normal file
@ -0,0 +1,49 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The account option for the journal entry management.
|
||||
|
||||
"""
|
||||
from accounting.models import Account
|
||||
|
||||
|
||||
class AccountOption:
|
||||
"""An account option."""
|
||||
|
||||
def __init__(self, account: Account):
|
||||
"""Constructs an account option.
|
||||
|
||||
:param account: The account.
|
||||
"""
|
||||
self.id: str = account.id
|
||||
"""The account ID."""
|
||||
self.code: str = account.code
|
||||
"""The account code."""
|
||||
self.query_values: list[str] = account.query_values
|
||||
"""The values to be queried."""
|
||||
self.__str: str = str(account)
|
||||
"""The string representation of the account option."""
|
||||
self.is_in_use: bool = False
|
||||
"""True if this account is in use, or False otherwise."""
|
||||
self.is_need_offset: bool = account.is_need_offset
|
||||
"""True if this account needs offset, or False otherwise."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the string representation of the account option.
|
||||
|
||||
:return: The string representation of the account option.
|
||||
"""
|
||||
return self.__str
|
@ -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,43 +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("_%—_%"))\
|
||||
.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:
|
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 accounting.utils.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.
|
||||
@ -24,8 +24,8 @@ import typing as t
|
||||
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
|
||||
@ -52,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:
|
||||
@ -60,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
|
||||
|
||||
@ -113,8 +113,8 @@ class Account(db.Model):
|
||||
"""The account number under the base account."""
|
||||
title_l10n = db.Column("title", db.String, nullable=False)
|
||||
"""The title."""
|
||||
is_offset_needed = db.Column(db.Boolean, nullable=False, default=False)
|
||||
"""Whether the entries of this account need offset."""
|
||||
is_need_offset = db.Column(db.Boolean, nullable=False, default=False)
|
||||
"""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."""
|
||||
@ -138,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,"""
|
||||
@ -153,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:
|
||||
@ -169,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
|
||||
|
||||
@ -187,15 +188,90 @@ 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:
|
||||
"""Returns whether the account is a real account.
|
||||
|
||||
:return: True if the account is a real account, or False otherwise.
|
||||
"""
|
||||
return self.base_code[0] in {"1", "2", "3"}
|
||||
|
||||
@property
|
||||
def is_nominal(self) -> bool:
|
||||
"""Returns whether the account is a nominal account.
|
||||
|
||||
:return: True if the account is a nominal account, or False otherwise.
|
||||
"""
|
||||
return not self.is_real
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
"""Returns the number of items in the account.
|
||||
|
||||
:return: The number of items in the account.
|
||||
"""
|
||||
if not hasattr(self, "__count"):
|
||||
setattr(self, "__count", 0)
|
||||
return getattr(self, "__count")
|
||||
|
||||
@count.setter
|
||||
def count(self, count: int) -> None:
|
||||
"""Sets the number of items in the account.
|
||||
|
||||
:param count: The number of items in the account.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__count", count)
|
||||
|
||||
@property
|
||||
def query_values(self) -> list[str]:
|
||||
"""Returns the values to be queried.
|
||||
|
||||
:return: The values to be queried.
|
||||
"""
|
||||
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
||||
|
||||
@property
|
||||
def is_modified(self) -> bool:
|
||||
"""Returns whether a product account was modified.
|
||||
|
||||
:return: True if modified, or False otherwise.
|
||||
"""
|
||||
if db.session.is_modified(self):
|
||||
return True
|
||||
for l10n in self.l10n:
|
||||
if db.session.is_modified(l10n):
|
||||
return True
|
||||
return False
|
||||
|
||||
@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.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
AccountL10n.query.filter(AccountL10n.account == self).delete()
|
||||
cls: t.Type[t.Self] = self.__class__
|
||||
cls.query.filter(cls.id == self.id).delete()
|
||||
|
||||
@classmethod
|
||||
def find_by_code(cls, code: str) -> t.Self | None:
|
||||
@ -211,13 +287,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"),
|
||||
@ -232,12 +310,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"),
|
||||
@ -251,14 +331,6 @@ class Account(db.Model):
|
||||
cls.base_code != "3353")\
|
||||
.order_by(cls.base_code, cls.no).all()
|
||||
|
||||
@property
|
||||
def query_values(self) -> list[str]:
|
||||
"""Returns the values to be queried.
|
||||
|
||||
:return: The values to be queried.
|
||||
"""
|
||||
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
||||
|
||||
@classmethod
|
||||
def cash(cls) -> t.Self:
|
||||
"""Returns the cash account.
|
||||
@ -275,28 +347,6 @@ class Account(db.Model):
|
||||
"""
|
||||
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
|
||||
|
||||
@property
|
||||
def is_modified(self) -> bool:
|
||||
"""Returns whether a product account was modified.
|
||||
|
||||
:return: True if modified, or False otherwise.
|
||||
"""
|
||||
if db.session.is_modified(self):
|
||||
return True
|
||||
for l10n in self.l10n:
|
||||
if db.session.is_modified(l10n):
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Deletes this account.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
AccountL10n.query.filter(AccountL10n.account == self).delete()
|
||||
cls: t.Type[t.Self] = self.__class__
|
||||
cls.query.filter(cls.id == self.id).delete()
|
||||
|
||||
|
||||
class AccountL10n(db.Model):
|
||||
"""A localized account title."""
|
||||
@ -346,15 +396,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:
|
||||
@ -362,11 +413,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
|
||||
|
||||
@ -380,15 +431,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:
|
||||
@ -403,6 +454,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.
|
||||
|
||||
@ -430,23 +492,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:
|
||||
@ -458,28 +520,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"))
|
||||
@ -506,46 +568,50 @@ 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,
|
||||
debit=[y for y in by_currency[x]
|
||||
if y.is_debit],
|
||||
credit=[y for y in by_currency[x]
|
||||
if not y.is_debit])
|
||||
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]
|
||||
if not y.is_debit])
|
||||
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:
|
||||
@ -555,10 +621,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:
|
||||
@ -568,70 +634,85 @@ class Transaction(db.Model):
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def can_delete(self) -> bool:
|
||||
"""Returns whether the journal entry can be deleted.
|
||||
|
||||
:return: True if the journal entry can be deleted, or False otherwise.
|
||||
"""
|
||||
for line_item in self.line_items:
|
||||
if len(line_item.offsets) > 0:
|
||||
return False
|
||||
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,
|
||||
onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
"""The transaction ID."""
|
||||
transaction = db.relationship(Transaction, back_populates="entries")
|
||||
"""The transaction."""
|
||||
"""The line item ID."""
|
||||
journal_entry_id = db.Column(db.Integer,
|
||||
db.ForeignKey(JournalEntry.id,
|
||||
onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
"""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."""
|
||||
offset_original_id = db.Column(db.Integer,
|
||||
db.ForeignKey(id, onupdate="CASCADE"),
|
||||
nullable=True)
|
||||
"""The ID of the original entry to offset."""
|
||||
offset_original = db.relationship("JournalEntry", back_populates="offsets",
|
||||
remote_side=id, passive_deletes=True)
|
||||
"""The original entry to offset."""
|
||||
offsets = db.relationship("JournalEntry", back_populates="offset_original")
|
||||
"""The offset entries."""
|
||||
"""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 line item."""
|
||||
original_line_item = db.relationship("JournalEntryLineItem",
|
||||
remote_side=id, passive_deletes=True)
|
||||
"""The original line item."""
|
||||
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."""
|
||||
|
||||
@property
|
||||
def eid(self) -> int | None:
|
||||
"""Returns the journal entry ID. This is the alternative name of the
|
||||
ID field, to work with WTForms.
|
||||
def __str__(self) -> str:
|
||||
"""Returns the string representation of the line item.
|
||||
|
||||
:return: The journal entry ID.
|
||||
:return: The string representation of the line item.
|
||||
"""
|
||||
return self.id
|
||||
if not hasattr(self, "__str"):
|
||||
from accounting.template_filters import format_date, format_amount
|
||||
setattr(self, "__str",
|
||||
gettext("%(date)s %(description)s %(amount)s",
|
||||
date=format_date(self.journal_entry.date),
|
||||
description="" if self.description is None
|
||||
else self.description,
|
||||
amount=format_amount(self.amount)))
|
||||
return getattr(self, "__str")
|
||||
|
||||
@property
|
||||
def account_code(self) -> str:
|
||||
@ -641,11 +722,25 @@ class JournalEntry(db.Model):
|
||||
"""
|
||||
return self.account.code
|
||||
|
||||
@property
|
||||
def is_need_offset(self) -> bool:
|
||||
"""Returns whether the line item needs offset.
|
||||
|
||||
:return: True if the line item needs offset, or False otherwise.
|
||||
"""
|
||||
if not self.account.is_need_offset:
|
||||
return False
|
||||
if self.account.base_code[0] == "1" and not self.is_debit:
|
||||
return False
|
||||
if self.account.base_code[0] == "2" and self.is_debit:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
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
|
||||
|
||||
@ -653,6 +748,108 @@ 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
|
||||
|
||||
@property
|
||||
def net_balance(self) -> Decimal:
|
||||
"""Returns the net balance.
|
||||
|
||||
:return: The net balance.
|
||||
"""
|
||||
if not hasattr(self, "__net_balance"):
|
||||
setattr(self, "__net_balance", self.amount + sum(
|
||||
[x.amount if x.is_debit == self.is_debit else -x.amount
|
||||
for x in self.offsets]))
|
||||
return getattr(self, "__net_balance")
|
||||
|
||||
@net_balance.setter
|
||||
def net_balance(self, net_balance: Decimal) -> None:
|
||||
"""Sets the net balance.
|
||||
|
||||
:param net_balance: The net balance.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__net_balance", net_balance)
|
||||
|
||||
@property
|
||||
def offsets(self) -> list[t.Self]:
|
||||
"""Returns the offset items.
|
||||
|
||||
:return: The offset items.
|
||||
"""
|
||||
if not hasattr(self, "__offsets"):
|
||||
cls: t.Type[t.Self] = self.__class__
|
||||
offsets: list[t.Self] = cls.query.join(JournalEntry)\
|
||||
.filter(JournalEntryLineItem.original_line_item_id == self.id)\
|
||||
.order_by(JournalEntry.date, JournalEntry.no,
|
||||
cls.is_debit, cls.no).all()
|
||||
setattr(self, "__offsets", offsets)
|
||||
return getattr(self, "__offsets")
|
||||
|
||||
@property
|
||||
def match(self) -> t.Self | None:
|
||||
"""Returns the match of the line item.
|
||||
|
||||
:return: The match of the line item.
|
||||
"""
|
||||
if not hasattr(self, "__match"):
|
||||
setattr(self, "__match", None)
|
||||
return getattr(self, "__match")
|
||||
|
||||
@match.setter
|
||||
def match(self, match: t.Self) -> None:
|
||||
"""Sets the match of the line item.
|
||||
|
||||
:param match: The matcho of the line item.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__match", match)
|
||||
|
||||
@property
|
||||
def query_values(self) -> list[str]:
|
||||
"""Returns the values to be queried.
|
||||
|
||||
:return: The values to be queried.
|
||||
"""
|
||||
def format_amount(value: Decimal) -> str:
|
||||
whole: int = int(value)
|
||||
frac: Decimal = (value - whole).normalize()
|
||||
return str(whole) + str(abs(frac))[1:]
|
||||
|
||||
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,19 +17,21 @@
|
||||
"""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
|
||||
from .converters import PeriodConverter, CurrentAccountConverter, \
|
||||
NeedOffsetAccountConverter
|
||||
app.url_map.converters["period"] = PeriodConverter
|
||||
app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter
|
||||
app.url_map.converters["currentAccount"] = CurrentAccountConverter
|
||||
app.url_map.converters["needOffsetAccount"] = NeedOffsetAccountConverter
|
||||
|
||||
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,13 +23,13 @@ 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):
|
||||
"""The supplier converter to convert the period specification from and to
|
||||
the corresponding period in the routes."""
|
||||
"""The converter to convert the period specification from and to the
|
||||
corresponding period in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> Period:
|
||||
"""Converts a period specification to a period.
|
||||
@ -51,26 +51,52 @@ class PeriodConverter(BaseConverter):
|
||||
return value.spec
|
||||
|
||||
|
||||
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."""
|
||||
class CurrentAccountConverter(BaseConverter):
|
||||
"""The converter to convert the current account code from and to the
|
||||
corresponding 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.
|
||||
:return: Its code.
|
||||
"""
|
||||
return value.code
|
||||
|
||||
|
||||
class NeedOffsetAccountConverter(BaseConverter):
|
||||
"""The converter to convert the unapplied original line item account code
|
||||
from and to the corresponding account in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> Account:
|
||||
"""Converts an account code to an account.
|
||||
|
||||
:param value: The account code.
|
||||
:return: The corresponding account.
|
||||
"""
|
||||
account: Account | None = Account.find_by_code(value)
|
||||
if account is None:
|
||||
abort(404)
|
||||
if not account.is_need_offset:
|
||||
abort(404)
|
||||
return account
|
||||
|
||||
def to_url(self, value: Account) -> 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:
|
||||
@ -188,20 +189,20 @@ class AccountCollector:
|
||||
:return: None.
|
||||
"""
|
||||
self.__add_owner_s_equity(Account.NET_CHANGE_CODE,
|
||||
self.__query_currency_period(),
|
||||
self.__query_current_period(),
|
||||
self.__period)
|
||||
|
||||
def __query_currency_period(self) -> Decimal | None:
|
||||
def __query_current_period(self) -> Decimal | None:
|
||||
"""Queries and returns the net income or loss for current period.
|
||||
|
||||
: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
|
||||
@ -213,12 +214,12 @@ class AccountCollector:
|
||||
:return: The balance.
|
||||
"""
|
||||
conditions.extend([sa.not_(Account.base_code.startswith(x))
|
||||
for x in {"1", "2"}])
|
||||
for x in {"1", "2", "3"}])
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(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,38 +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
|
||||
@ -67,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.
|
||||
@ -92,147 +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(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,
|
||||
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
|
||||
if x.income is not None])
|
||||
entry.expense = sum([x.expense for x in self.entries
|
||||
if x.expense is not None])
|
||||
entry.balance = entry.income - entry.expense
|
||||
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])
|
||||
line_item.expense = sum([x.expense for x in self.line_items
|
||||
if x.expense is not None])
|
||||
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
|
||||
@ -248,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]
|
||||
|
||||
|
||||
@ -256,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."""
|
||||
@ -333,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(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))
|
||||
@ -362,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.
|
||||
|
||||
@ -372,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.
|
||||
@ -401,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,
|
||||
@ -426,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)
|
||||
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,60 @@ 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 currency: The currency.
|
||||
:param account: The account.
|
||||
: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 +105,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 +113,20 @@ 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 pagination: The pagination.
|
||||
: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 +137,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 +149,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,27 +176,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,
|
||||
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.
|
||||
@ -200,17 +206,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)
|
||||
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
|
||||
@ -36,25 +37,26 @@ from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.urls import ledger_url
|
||||
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
|
||||
@ -64,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.
|
||||
@ -91,123 +93,131 @@ 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.base_code[0] not in {"1", "2", "3"}:
|
||||
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(JournalEntry.currency_code == self.__currency.code,
|
||||
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,
|
||||
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
|
||||
if x.debit is not None])
|
||||
entry.credit = sum([x.credit for x in self.entries
|
||||
if x.credit is not None])
|
||||
entry.balance = entry.debit - entry.credit
|
||||
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])
|
||||
line_item.credit = sum([x.credit for x in self.line_items
|
||||
if x.credit is not None])
|
||||
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.
|
||||
"""
|
||||
if self.__account.is_nominal:
|
||||
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
|
||||
@ -223,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]
|
||||
|
||||
|
||||
@ -234,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."""
|
||||
@ -256,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."""
|
||||
@ -302,9 +312,10 @@ class PageParams(BasePageParams):
|
||||
|
||||
:return: The account options.
|
||||
"""
|
||||
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
||||
.filter(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))
|
||||
@ -327,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.
|
||||
@ -351,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,
|
||||
@ -375,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)
|
||||
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,29 +26,30 @@ 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
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.pagination import Pagination
|
||||
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:
|
||||
@ -56,22 +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.filter(*conditions)\
|
||||
.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:
|
||||
@ -85,14 +92,14 @@ 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"):
|
||||
conditions.append(Account.is_offset_needed)
|
||||
if k in gettext("Needs Offset"):
|
||||
conditions.append(Account.is_need_offset)
|
||||
return sa.select(Account.id).filter(sa.or_(*conditions))
|
||||
|
||||
@staticmethod
|
||||
@ -103,57 +110,74 @@ 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(
|
||||
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))
|
||||
try:
|
||||
journal_entry_date = datetime.strptime(k, "%Y/%m/%d")
|
||||
conditions.append(sa.and_(
|
||||
sa.extract("year", JournalEntry.date)
|
||||
== journal_entry_date.year,
|
||||
sa.extract("month", JournalEntry.date)
|
||||
== journal_entry_date.month,
|
||||
sa.extract("day", JournalEntry.date)
|
||||
== journal_entry_date.day))
|
||||
except ValueError:
|
||||
pass
|
||||
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:
|
||||
@ -161,7 +185,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:
|
||||
@ -177,8 +201,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.
|
||||
@ -186,16 +211,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)
|
||||
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] \
|
||||
|
185
src/accounting/report/reports/unapplied.py
Normal file
185
src/accounting/report/reports/unapplied.py
Normal file
@ -0,0 +1,185 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
||||
|
||||
# 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 unapplied original line items.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Account, 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 BaseCSVRow, csv_download
|
||||
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.unapplied import get_accounts_with_unapplied
|
||||
from accounting.report.utils.urls import unapplied_url
|
||||
from accounting.utils.offset_matcher import OffsetMatcher
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import can_edit
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, journal_entry_date: str | date, currency: str,
|
||||
description: str | None, amount: str | Decimal,
|
||||
net_balance: str | Decimal):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param journal_entry_date: The journal entry date.
|
||||
:param currency: The currency.
|
||||
:param description: The description.
|
||||
:param amount: The amount.
|
||||
:param net_balance: The net balance.
|
||||
"""
|
||||
self.date: str | date = journal_entry_date
|
||||
"""The date."""
|
||||
self.currency: str = currency
|
||||
"""The currency."""
|
||||
self.description: str | None = description
|
||||
"""The description."""
|
||||
self.amount: str | Decimal = amount
|
||||
"""The amount."""
|
||||
self.net_balance: str | Decimal = net_balance
|
||||
"""The net balance."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | date | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.date, self.currency, self.description, self.amount,
|
||||
self.net_balance]
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, account: Account,
|
||||
is_mark_matches: bool,
|
||||
pagination: Pagination[JournalEntryLineItem],
|
||||
line_items: list[JournalEntryLineItem]):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param account: The account.
|
||||
:param is_mark_matches: Whether to mark the matched offsets.
|
||||
:param pagination: The pagination.
|
||||
:param line_items: The line items.
|
||||
"""
|
||||
self.account: Account = account
|
||||
"""The account."""
|
||||
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
||||
"""The pagination."""
|
||||
self.line_items: list[JournalEntryLineItem] = line_items
|
||||
"""The line items."""
|
||||
self.is_mark_matches: bool = is_mark_matches
|
||||
"""Whether to mark the matched offsets."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return len(self.line_items) > 0
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.UNAPPLIED,
|
||||
account=self.account)
|
||||
|
||||
@property
|
||||
def account_options(self) -> list[OptionLink]:
|
||||
"""Returns the account options.
|
||||
|
||||
:return: The account options.
|
||||
"""
|
||||
options: list[OptionLink] = [OptionLink(gettext("Accounts"),
|
||||
unapplied_url(None),
|
||||
False)]
|
||||
options.extend([OptionLink(str(x),
|
||||
unapplied_url(x),
|
||||
x.id == self.account.id)
|
||||
for x in get_accounts_with_unapplied()])
|
||||
return options
|
||||
|
||||
|
||||
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
|
||||
"""Composes and returns the CSV rows from the line items.
|
||||
|
||||
:param line_items: The line items.
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
|
||||
gettext("Description"), gettext("Amount"),
|
||||
gettext("Net Balance"))]
|
||||
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
|
||||
x.description, x.amount, x.net_balance)
|
||||
for x in line_items])
|
||||
return rows
|
||||
|
||||
|
||||
class UnappliedOriginalLineItems(BaseReport):
|
||||
"""The unapplied original line items."""
|
||||
|
||||
def __init__(self, account: Account):
|
||||
"""Constructs the unapplied original line items.
|
||||
|
||||
:param account: The account.
|
||||
"""
|
||||
self.__account: Account = account
|
||||
"""The account."""
|
||||
offset_matcher: OffsetMatcher = OffsetMatcher(self.__account)
|
||||
self.__line_items: list[JournalEntryLineItem] \
|
||||
= offset_matcher.unapplied
|
||||
"""The line items."""
|
||||
self.__is_mark_matches: bool \
|
||||
= can_edit() and len(offset_matcher.unmatched_offsets) > 0
|
||||
"""Whether to mark the matched offsets."""
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = f"unapplied-{self.__account.code}.csv"
|
||||
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[JournalEntryLineItem] \
|
||||
= Pagination[JournalEntryLineItem](self.__line_items,
|
||||
is_reversed=True)
|
||||
params: PageParams = PageParams(account=self.__account,
|
||||
is_mark_matches=self.__is_mark_matches,
|
||||
pagination=pagination,
|
||||
line_items=pagination.list)
|
||||
return render_template("accounting/report/unapplied.html",
|
||||
report=params)
|
137
src/accounting/report/reports/unapplied_accounts.py
Normal file
137
src/accounting/report/reports/unapplied_accounts.py
Normal file
@ -0,0 +1,137 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
||||
|
||||
# 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 accounts with unapplied original line items.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Account
|
||||
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
|
||||
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.unapplied import get_accounts_with_unapplied
|
||||
from accounting.report.utils.urls import unapplied_url
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, account: str, count: int | str):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param account: The account.
|
||||
:param count: The number of unapplied original line items.
|
||||
"""
|
||||
self.account: str = account
|
||||
"""The currency."""
|
||||
self.count: int | str = count
|
||||
"""The number of unapplied original line items."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | date | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.account, self.count]
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, accounts: list[Account]):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param accounts: The accounts.
|
||||
"""
|
||||
self.accounts: list[Account] = accounts
|
||||
"""The accounts."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return len(self.accounts) > 0
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.UNAPPLIED)
|
||||
|
||||
@property
|
||||
def account_options(self) -> list[OptionLink]:
|
||||
"""Returns the account options.
|
||||
|
||||
:return: The account options.
|
||||
"""
|
||||
options: list[OptionLink] = [OptionLink(gettext("Accounts"),
|
||||
unapplied_url(None),
|
||||
True)]
|
||||
options.extend([OptionLink(str(x),
|
||||
unapplied_url(x),
|
||||
False)
|
||||
for x in self.accounts])
|
||||
return options
|
||||
|
||||
|
||||
def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
|
||||
"""Composes and returns the CSV rows from the line items.
|
||||
|
||||
:param accounts: The accounts.
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
|
||||
rows.extend([CSVRow(str(x).title(), x.count)
|
||||
for x in accounts])
|
||||
return rows
|
||||
|
||||
|
||||
class AccountsWithUnappliedOriginalLineItems(BaseReport):
|
||||
"""The accounts with unapplied original line items."""
|
||||
|
||||
def __init__(self):
|
||||
"""Constructs the outstanding balances."""
|
||||
self.__accounts: list[Account] = get_accounts_with_unapplied()
|
||||
"""The accounts."""
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = f"unapplied-accounts.csv"
|
||||
return csv_download(filename, get_csv_rows(self.__accounts))
|
||||
|
||||
def html(self) -> str:
|
||||
"""Composes and returns the report as HTML.
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
return render_template("accounting/report/unapplied-accounts.html",
|
||||
report=PageParams(accounts=self.__accounts))
|
@ -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.
|
||||
@ -22,6 +22,7 @@ from abc import ABC, abstractmethod
|
||||
from datetime import timedelta, date
|
||||
from decimal import Decimal
|
||||
from io import StringIO
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Response
|
||||
|
||||
@ -53,7 +54,7 @@ def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
|
||||
fp.seek(0)
|
||||
response: Response = Response(fp.read(), mimetype="text/csv")
|
||||
response.headers["Content-Disposition"] \
|
||||
= f"attachment; filename={filename}"
|
||||
= f"attachment; filename={quote(filename)}"
|
||||
return response
|
||||
|
||||
|
||||
|
@ -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.
|
||||
@ -27,10 +27,15 @@ class OptionLink:
|
||||
"""Constructs an option link.
|
||||
|
||||
:param title: The title.
|
||||
:param url: The URI.
|
||||
:param url: The URL.
|
||||
:param is_active: True if active, or False otherwise
|
||||
:param fa_icon: The font-awesome icon, if any.
|
||||
"""
|
||||
self.title: str = title
|
||||
"""The title."""
|
||||
self.url: str = url
|
||||
"""The URL."""
|
||||
self.is_active: bool = is_active
|
||||
"""True if active, or False otherwise."""
|
||||
self.fa_icon: str | None = fa_icon
|
||||
"""The font-awesome icon, if any."""
|
||||
|
@ -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,11 +30,11 @@ 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, \
|
||||
trial_balance_url, income_statement_url, balance_sheet_url
|
||||
trial_balance_url, income_statement_url, balance_sheet_url, unapplied_url
|
||||
|
||||
|
||||
class ReportChooser:
|
||||
@ -68,12 +68,13 @@ 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)
|
||||
self.__reports.append(self.__unapplied)
|
||||
for report in self.__reports:
|
||||
if report.is_active:
|
||||
self.current_report = report.title
|
||||
@ -81,14 +82,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 +110,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:
|
||||
@ -151,6 +152,23 @@ class ReportChooser:
|
||||
self.__active_report == ReportType.BALANCE_SHEET,
|
||||
fa_icon="fa-solid fa-scale-balanced")
|
||||
|
||||
@property
|
||||
def __unapplied(self) -> OptionLink:
|
||||
"""Returns the unapplied original line items.
|
||||
|
||||
:return: The unapplied original line items.
|
||||
"""
|
||||
account: Account = self.__account
|
||||
if not account.is_need_offset:
|
||||
return OptionLink(gettext("Unapplied Original Line Items"),
|
||||
unapplied_url(None),
|
||||
self.__active_report == ReportType.UNAPPLIED,
|
||||
fa_icon="fa-solid fa-link-slash")
|
||||
return OptionLink(gettext("Unapplied Original Line Items"),
|
||||
unapplied_url(account),
|
||||
self.__active_report == ReportType.UNAPPLIED,
|
||||
fa_icon="fa-solid fa-link-slash")
|
||||
|
||||
def __iter__(self) -> t.Iterator[OptionLink]:
|
||||
"""Returns the iteration of the reports.
|
||||
|
||||
|
@ -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.
|
||||
@ -34,5 +34,7 @@ class ReportType(Enum):
|
||||
"""The income statement."""
|
||||
BALANCE_SHEET: str = "balance-sheet"
|
||||
"""The balance sheet."""
|
||||
UNAPPLIED: str = "unapplied"
|
||||
"""The unapplied original line items."""
|
||||
SEARCH: str = "search"
|
||||
"""The search."""
|
||||
|
67
src/accounting/report/utils/unapplied.py
Normal file
67
src/accounting/report/utils/unapplied.py
Normal file
@ -0,0 +1,67 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
||||
|
||||
# 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 unapplied original line item utilities.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.offset_alias import offset_alias
|
||||
|
||||
|
||||
def get_accounts_with_unapplied() -> list[Account]:
|
||||
"""Returns the accounts with unapplied original line items.
|
||||
|
||||
:return: The accounts with unapplied original line items.
|
||||
"""
|
||||
offset: sa.Alias = offset_alias()
|
||||
net_balance: sa.Label \
|
||||
= (JournalEntryLineItem.amount
|
||||
+ sa.func.sum(sa.case(
|
||||
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
|
||||
offset.c.amount),
|
||||
else_=-offset.c.amount))).label("net_balance")
|
||||
select_unapplied: sa.Select \
|
||||
= sa.select(JournalEntryLineItem.id)\
|
||||
.join(Account)\
|
||||
.join(offset, be(JournalEntryLineItem.id
|
||||
== offset.c.original_line_item_id),
|
||||
isouter=True)\
|
||||
.filter(Account.is_need_offset,
|
||||
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||
sa.not_(JournalEntryLineItem.is_debit)),
|
||||
sa.and_(Account.base_code.startswith("1"),
|
||||
JournalEntryLineItem.is_debit)))\
|
||||
.group_by(JournalEntryLineItem.id)\
|
||||
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
|
||||
|
||||
count_func: sa.Label \
|
||||
= sa.func.count(JournalEntryLineItem.id).label("count")
|
||||
select: sa.Select = sa.select(Account.id, count_func)\
|
||||
.join(JournalEntryLineItem, isouter=True)\
|
||||
.filter(JournalEntryLineItem.id.in_(select_unapplied))\
|
||||
.group_by(Account.id)\
|
||||
.having(count_func > 0)
|
||||
counts: dict[int, int] \
|
||||
= {x.id: x.count for x in db.session.execute(select)}
|
||||
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
|
||||
.order_by(Account.base_code, Account.no).all()
|
||||
for account in accounts:
|
||||
account.count = counts[account.id]
|
||||
return accounts
|
@ -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) \
|
||||
@ -46,15 +47,16 @@ def ledger_url(currency: Currency, account: Account, period: Period) \
|
||||
:param period: The period.
|
||||
:return: The URL of the ledger.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.ledger-default",
|
||||
currency=currency, account=account)
|
||||
return url_for("accounting.report.ledger",
|
||||
if currency.code == default_currency_code() \
|
||||
and account.code == Account.CASH_CODE \
|
||||
and period.is_default:
|
||||
return url_for("accounting-report.ledger-default")
|
||||
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 +66,10 @@ 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")
|
||||
if period.is_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.default")
|
||||
return url_for("accounting-report.income-expenses",
|
||||
currency=currency, account=account,
|
||||
period=period)
|
||||
|
||||
@ -82,10 +81,9 @@ def trial_balance_url(currency: Currency, period: Period) -> str:
|
||||
:param period: The period.
|
||||
:return: The URL of the trial balance.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.trial-balance-default",
|
||||
currency=currency)
|
||||
return url_for("accounting.report.trial-balance",
|
||||
if currency.code == default_currency_code() and period.is_default:
|
||||
return url_for("accounting-report.trial-balance-default")
|
||||
return url_for("accounting-report.trial-balance",
|
||||
currency=currency, period=period)
|
||||
|
||||
|
||||
@ -96,10 +94,9 @@ def income_statement_url(currency: Currency, period: Period) -> str:
|
||||
:param period: The period.
|
||||
:return: The URL of the income statement.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.income-statement-default",
|
||||
currency=currency)
|
||||
return url_for("accounting.report.income-statement",
|
||||
if currency.code == default_currency_code() and period.is_default:
|
||||
return url_for("accounting-report.income-statement-default")
|
||||
return url_for("accounting-report.income-statement",
|
||||
currency=currency, period=period)
|
||||
|
||||
|
||||
@ -110,8 +107,19 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
|
||||
:param period: The period.
|
||||
:return: The URL of the balance sheet.
|
||||
"""
|
||||
if period.is_default:
|
||||
return url_for("accounting.report.balance-sheet-default",
|
||||
currency=currency)
|
||||
return url_for("accounting.report.balance-sheet",
|
||||
if currency.code == default_currency_code() and period.is_default:
|
||||
return url_for("accounting-report.balance-sheet-default")
|
||||
return url_for("accounting-report.balance-sheet",
|
||||
currency=currency, period=period)
|
||||
|
||||
|
||||
def unapplied_url(account: Account | None) -> str:
|
||||
"""Returns the URL of the unapplied original line items.
|
||||
|
||||
:param account: The account, or None to list the accounts with unapplied
|
||||
original line items.
|
||||
:return: The URL of the unapplied original line items.
|
||||
"""
|
||||
if account is None:
|
||||
return url_for("accounting-report.unapplied-default")
|
||||
return url_for("accounting-report.unapplied", account=account)
|
||||
|
@ -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,16 @@ 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 .reports.unapplied import UnappliedOriginalLineItems
|
||||
from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems
|
||||
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")
|
||||
|
||||
@ -41,10 +44,7 @@ def get_default_report() -> str | Response:
|
||||
|
||||
:return: The income and expenses log in the default period.
|
||||
"""
|
||||
return __get_income_expenses(
|
||||
db.session.get(Currency, default_currency_code()),
|
||||
default_ie_account(),
|
||||
get_period())
|
||||
return get_default_income_expenses()
|
||||
|
||||
|
||||
@bp.get("journal", endpoint="journal-default")
|
||||
@ -80,17 +80,15 @@ def __get_journal(period: Period) -> str | Response:
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("ledger/<currency:currency>/<account:account>",
|
||||
endpoint="ledger-default")
|
||||
@bp.get("ledger", endpoint="ledger-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_ledger(currency: Currency, account: Account) -> str | Response:
|
||||
"""Returns the ledger in the default period.
|
||||
def get_default_ledger() -> str | Response:
|
||||
"""Returns the ledger in the default currency, cash, and default period.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:return: The ledger in the default period.
|
||||
:return: The ledger in the default currency, cash, and default period.
|
||||
"""
|
||||
return __get_ledger(currency, account, get_period())
|
||||
return __get_ledger(db.session.get(Currency, default_currency_code()),
|
||||
Account.cash(), get_period())
|
||||
|
||||
|
||||
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
|
||||
@ -123,26 +121,23 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
|
||||
endpoint="income-expenses-default")
|
||||
@bp.get("income-expenses", endpoint="income-expenses-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_income_expenses(currency: Currency,
|
||||
account: IncomeExpensesAccount) \
|
||||
-> str | Response:
|
||||
def get_default_income_expenses() -> str | Response:
|
||||
"""Returns the income and expenses log in the default period.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:return: The income and expenses log in the default period.
|
||||
"""
|
||||
return __get_income_expenses(currency, account, get_period())
|
||||
return __get_income_expenses(
|
||||
db.session.get(Currency, default_currency_code()),
|
||||
options.default_ie_account,
|
||||
get_period())
|
||||
|
||||
|
||||
@bp.get(
|
||||
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
|
||||
endpoint="income-expenses")
|
||||
@bp.get("income-expenses/<currency:currency>/<currentAccount: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 +149,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.
|
||||
|
||||
@ -169,16 +164,15 @@ def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("trial-balance/<currency:currency>",
|
||||
endpoint="trial-balance-default")
|
||||
@bp.get("trial-balance", endpoint="trial-balance-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_trial_balance(currency: Currency) -> str | Response:
|
||||
def get_default_trial_balance() -> str | Response:
|
||||
"""Returns the trial balance in the default period.
|
||||
|
||||
:param currency: The currency.
|
||||
:return: The trial balance in the default period.
|
||||
"""
|
||||
return __get_trial_balance(currency, get_period())
|
||||
return __get_trial_balance(
|
||||
db.session.get(Currency, default_currency_code()), get_period())
|
||||
|
||||
|
||||
@bp.get("trial-balance/<currency:currency>/<period:period>",
|
||||
@ -207,16 +201,15 @@ def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("income-statement/<currency:currency>",
|
||||
endpoint="income-statement-default")
|
||||
@bp.get("income-statement", endpoint="income-statement-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_income_statement(currency: Currency) -> str | Response:
|
||||
def get_default_income_statement() -> str | Response:
|
||||
"""Returns the income statement in the default period.
|
||||
|
||||
:param currency: The currency.
|
||||
:return: The income statement in the default period.
|
||||
"""
|
||||
return __get_income_statement(currency, get_period())
|
||||
return __get_income_statement(
|
||||
db.session.get(Currency, default_currency_code()), get_period())
|
||||
|
||||
|
||||
@bp.get("income-statement/<currency:currency>/<period:period>",
|
||||
@ -246,16 +239,15 @@ def __get_income_statement(currency: Currency, period: Period) \
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("balance-sheet/<currency:currency>",
|
||||
endpoint="balance-sheet-default")
|
||||
@bp.get("balance-sheet", endpoint="balance-sheet-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_balance_sheet(currency: Currency) -> str | Response:
|
||||
def get_default_balance_sheet() -> str | Response:
|
||||
"""Returns the balance sheet in the default period.
|
||||
|
||||
:param currency: The currency.
|
||||
:return: The balance sheet in the default period.
|
||||
"""
|
||||
return __get_balance_sheet(currency, get_period())
|
||||
return __get_balance_sheet(
|
||||
db.session.get(Currency, default_currency_code()), get_period())
|
||||
|
||||
|
||||
@bp.get("balance-sheet/<currency:currency>/<period:period>",
|
||||
@ -286,6 +278,34 @@ def __get_balance_sheet(currency: Currency, period: Period) \
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("unapplied", endpoint="unapplied-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_unapplied() -> str | Response:
|
||||
"""Returns the accounts with unapplied original line items.
|
||||
|
||||
:return: The accounts with unapplied original line items.
|
||||
"""
|
||||
report: AccountsWithUnappliedOriginalLineItems \
|
||||
= AccountsWithUnappliedOriginalLineItems()
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("unapplied/<needOffsetAccount:account>", endpoint="unapplied")
|
||||
@has_permission(can_view)
|
||||
def get_unapplied(account: Account) -> str | Response:
|
||||
"""Returns the unapplied original line items.
|
||||
|
||||
:param account: The Account.
|
||||
:return: The unapplied original line items.
|
||||
"""
|
||||
report: UnappliedOriginalLineItems = UnappliedOriginalLineItems(account)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("search", endpoint="search")
|
||||
@has_permission(can_view)
|
||||
def search() -> str | Response:
|
||||
|
@ -1,4 +1,4 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
/* The Mia! Accounting Project
|
||||
* style.css: The style sheet for the accounting application.
|
||||
*/
|
||||
|
||||
@ -31,6 +31,9 @@
|
||||
color: #141619;
|
||||
background-color: #D3D3D4;
|
||||
}
|
||||
.form-control.accounting-disabled {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
/** The toolbar */
|
||||
.accounting-toolbar {
|
||||
@ -73,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;
|
||||
@ -113,46 +116,70 @@
|
||||
border-bottom: thick double slategray;
|
||||
}
|
||||
|
||||
/* Links between objects */
|
||||
.accounting-original-line-item {
|
||||
border-top: thin solid darkslategray;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
.accounting-original-line-item a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.accounting-original-line-item a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
.accounting-offset-line-items {
|
||||
border-top: thin solid darkslategray;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
.accounting-offset-line-items ul li {
|
||||
list-style: none;
|
||||
}
|
||||
.accounting-offset-line-items ul li a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.accounting-offset-line-items ul li a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/** The option selector */
|
||||
.accounting-selector-list {
|
||||
height: 20rem;
|
||||
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-stripped .list-group-item-success:nth-child(2n+1) {
|
||||
background-color: #c7dbd2;
|
||||
}
|
||||
.accounting-list-group-hover .list-group-item:hover {
|
||||
background-color: #ececec;
|
||||
}
|
||||
.accounting-transaction-entry {
|
||||
.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-line-item-editor-original-line-item-content {
|
||||
width: calc(100% - 3rem);
|
||||
}
|
||||
|
||||
/* The report table */
|
||||
.accounting-report-table-header, .accounting-report-table-footer {
|
||||
@ -182,21 +209,39 @@ a.accounting-report-table-row {
|
||||
.accounting-report-table-body .accounting-amount {
|
||||
font-style: italic;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) {
|
||||
background-color: #f2f2f2;
|
||||
background-color: #ecedee;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.075);
|
||||
background-color: #e5e6e7;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row-danger {
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row-danger:nth-child(2n+1) {
|
||||
background-color: #eccccf;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row-danger:hover {
|
||||
background-color: #e5c7ca;
|
||||
}
|
||||
.accounting-journal-table .accounting-report-table-row {
|
||||
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
|
||||
}
|
||||
.accounting-ledger-table .accounting-report-table-row {
|
||||
.accounting-ledger-real-table .accounting-report-table-row {
|
||||
grid-template-columns: 1fr 4fr 1fr 1fr 1fr;
|
||||
}
|
||||
.accounting-ledger-table .accounting-report-table-footer .accounting-report-table-row {
|
||||
.accounting-ledger-real-table .accounting-report-table-footer .accounting-report-table-row {
|
||||
grid-template-columns: 5fr 1fr 1fr 1fr;
|
||||
}
|
||||
.accounting-ledger-nominal-table .accounting-report-table-row {
|
||||
grid-template-columns: 1fr 4fr 1fr 1fr;
|
||||
}
|
||||
.accounting-ledger-nominal-table .accounting-report-table-footer .accounting-report-table-row {
|
||||
grid-template-columns: 5fr 1fr 1fr;
|
||||
}
|
||||
.accounting-income-expenses-table .accounting-report-table-row {
|
||||
grid-template-columns: 1fr 2fr 4fr 1fr 1fr 1fr;
|
||||
}
|
||||
@ -276,12 +321,56 @@ a.accounting-report-table-row {
|
||||
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
|
||||
font-style: italic;
|
||||
}
|
||||
.accounting-unapplied-table .accounting-report-table-row {
|
||||
grid-template-columns: 1fr 1fr 5fr 1fr 1fr;
|
||||
}
|
||||
.accounting-unapplied-account-table .accounting-report-table-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.accounting-unapplied-account-table .accounting-report-table-header .accounting-report-table-row {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The accounting report */
|
||||
.accounting-mobile-journal-credit {
|
||||
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 unmatched offsets */
|
||||
.accounting-unmatched-offset-pair-list {
|
||||
height: 20rem;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
/* The Material Design text field (floating form control in Bootstrap) */
|
||||
.accounting-material-text-field {
|
||||
position: relative;
|
||||
@ -306,7 +395,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
|
||||
*/
|
||||
|
||||
@ -24,161 +24,404 @@
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initializeBaseAccountSelector();
|
||||
document.getElementById("accounting-base-code")
|
||||
.onchange = validateBase;
|
||||
document.getElementById("accounting-title")
|
||||
.onchange = validateTitle;
|
||||
document.getElementById("accounting-form")
|
||||
.onsubmit = validateForm;
|
||||
AccountForm.initialize();
|
||||
});
|
||||
|
||||
/**
|
||||
* Initializes the base account selector.
|
||||
* The account form.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function initializeBaseAccountSelector() {
|
||||
const selector = document.getElementById("accounting-base-selector-modal");
|
||||
const base = document.getElementById("accounting-base");
|
||||
const baseCode = document.getElementById("accounting-base-code");
|
||||
const baseContent = document.getElementById("accounting-base-content");
|
||||
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
||||
const btnClear = document.getElementById("accounting-btn-clear-base");
|
||||
selector.addEventListener("show.bs.modal", () => {
|
||||
base.classList.add("accounting-not-empty");
|
||||
for (const option of options) {
|
||||
option.classList.remove("active");
|
||||
}
|
||||
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
|
||||
if (selected !== null) {
|
||||
selected.classList.add("active");
|
||||
}
|
||||
});
|
||||
selector.addEventListener("hidden.bs.modal", () => {
|
||||
if (baseCode.value === "") {
|
||||
base.classList.remove("accounting-not-empty");
|
||||
}
|
||||
});
|
||||
for (const option of options) {
|
||||
option.onclick = () => {
|
||||
baseCode.value = option.dataset.code;
|
||||
baseContent.innerText = option.dataset.content;
|
||||
btnClear.classList.add("btn-danger");
|
||||
btnClear.classList.remove("btn-secondary")
|
||||
btnClear.disabled = false;
|
||||
validateBase();
|
||||
bootstrap.Modal.getInstance(selector).hide();
|
||||
class AccountForm {
|
||||
|
||||
/**
|
||||
* The base account selector
|
||||
* @type {BaseAccountSelector}
|
||||
*/
|
||||
#baseAccountSelector;
|
||||
|
||||
/**
|
||||
* The form element
|
||||
* @type {HTMLFormElement}
|
||||
*/
|
||||
#formElement;
|
||||
|
||||
/**
|
||||
* The control of the base account
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#baseControl;
|
||||
|
||||
/**
|
||||
* The input of the base account
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#baseCode;
|
||||
|
||||
/**
|
||||
* The base account
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#base;
|
||||
|
||||
/**
|
||||
* The error message for the base account
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#baseError;
|
||||
|
||||
/**
|
||||
* The title
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#title;
|
||||
|
||||
/**
|
||||
* The error message of the title
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#titleError;
|
||||
|
||||
/**
|
||||
* The control of the is-need-offset option
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#isNeedOffsetControl;
|
||||
|
||||
/**
|
||||
* The is-need-offset option
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#isNeedOffset;
|
||||
|
||||
/**
|
||||
* Constructs the account form.
|
||||
*
|
||||
*/
|
||||
constructor() {
|
||||
this.#baseAccountSelector = new BaseAccountSelector(this);
|
||||
this.#formElement = document.getElementById("accounting-form");
|
||||
this.#baseControl = document.getElementById("accounting-base-control");
|
||||
this.#baseCode = document.getElementById("accounting-base-code");
|
||||
this.#base = document.getElementById("accounting-base");
|
||||
this.#baseError = document.getElementById("accounting-base-error");
|
||||
this.#title = document.getElementById("accounting-title");
|
||||
this.#titleError = document.getElementById("accounting-title-error");
|
||||
this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
|
||||
this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
|
||||
this.#formElement.onsubmit = () => {
|
||||
return this.#validate();
|
||||
};
|
||||
this.#baseControl.onclick = () => {
|
||||
this.#baseControl.classList.add("accounting-not-empty");
|
||||
this.#baseAccountSelector.onOpen();
|
||||
};
|
||||
}
|
||||
btnClear.onclick = () => {
|
||||
baseCode.value = "";
|
||||
baseContent.innerText = "";
|
||||
btnClear.classList.add("btn-secondary")
|
||||
btnClear.classList.remove("btn-danger");
|
||||
btnClear.disabled = true;
|
||||
validateBase();
|
||||
bootstrap.Modal.getInstance(selector).hide();
|
||||
}
|
||||
initializeBaseAccountQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the query on the base account options.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function initializeBaseAccountQuery() {
|
||||
const query = document.getElementById("accounting-base-selector-query");
|
||||
const optionList = document.getElementById("accounting-base-option-list");
|
||||
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
||||
const queryNoResult = document.getElementById("accounting-base-option-no-result");
|
||||
query.addEventListener("input", () => {
|
||||
if (query.value === "") {
|
||||
for (const option of options) {
|
||||
option.classList.remove("d-none");
|
||||
}
|
||||
optionList.classList.remove("d-none");
|
||||
queryNoResult.classList.add("d-none");
|
||||
return
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
onBaseAccountSelectorClosed() {
|
||||
if (this.#baseCode.value === "") {
|
||||
this.#baseControl.classList.remove("accounting-not-empty");
|
||||
}
|
||||
let hasAnyMatched = false;
|
||||
for (const option of options) {
|
||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||
let isMatched = false;
|
||||
for (const queryValue of queryValues) {
|
||||
if (queryValue.includes(query.value)) {
|
||||
isMatched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isMatched) {
|
||||
option.classList.remove("d-none");
|
||||
hasAnyMatched = true;
|
||||
} else {
|
||||
option.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
if (!hasAnyMatched) {
|
||||
optionList.classList.add("d-none");
|
||||
queryNoResult.classList.remove("d-none");
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the selected base account.
|
||||
*
|
||||
* @param account {BaseAccountOption} the selected base account
|
||||
*/
|
||||
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 {
|
||||
optionList.classList.remove("d-none");
|
||||
queryNoResult.classList.add("d-none");
|
||||
this.#isNeedOffsetControl.classList.add("d-none");
|
||||
this.#isNeedOffset.disabled = true;
|
||||
this.#isNeedOffset.checked = false;
|
||||
}
|
||||
});
|
||||
this.#validateBase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the base account.
|
||||
*
|
||||
*/
|
||||
clearBaseAccount() {
|
||||
this.#baseCode.value = "";
|
||||
this.#base.innerText = "";
|
||||
this.#validateBase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validate() {
|
||||
let isValid = true;
|
||||
isValid = this.#validateBase() && isValid;
|
||||
isValid = this.#validateTitle() && isValid;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the base account.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateBase() {
|
||||
if (this.#baseCode.value === "") {
|
||||
this.#baseControl.classList.add("is-invalid");
|
||||
this.#baseError.innerText = A_("Please select the base account.");
|
||||
return false;
|
||||
}
|
||||
this.#baseControl.classList.remove("is-invalid");
|
||||
this.#baseError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the title.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateTitle() {
|
||||
this.#title.value = this.#title.value.trim();
|
||||
if (this.#title.value === "") {
|
||||
this.#title.classList.add("is-invalid");
|
||||
this.#titleError.innerText = A_("Please fill in the title.");
|
||||
return false;
|
||||
}
|
||||
this.#title.classList.remove("is-invalid");
|
||||
this.#titleError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account form
|
||||
* @type {AccountForm} the form
|
||||
*/
|
||||
static #form;
|
||||
|
||||
static initialize() {
|
||||
this.#form = new AccountForm();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form.
|
||||
* The base account selector.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateForm() {
|
||||
let isValid = true;
|
||||
isValid = validateBase() && isValid;
|
||||
isValid = validateTitle() && isValid;
|
||||
return isValid;
|
||||
class BaseAccountSelector {
|
||||
|
||||
/**
|
||||
* The account form
|
||||
* @type {AccountForm}
|
||||
*/
|
||||
form;
|
||||
|
||||
/**
|
||||
* The selector modal
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#modal;
|
||||
|
||||
/**
|
||||
* The query input
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#query;
|
||||
|
||||
/**
|
||||
* The error message when the query has no result
|
||||
* @type {HTMLParagraphElement}
|
||||
*/
|
||||
#queryNoResult;
|
||||
|
||||
/**
|
||||
* The option list
|
||||
* @type {HTMLUListElement}
|
||||
*/
|
||||
#optionList;
|
||||
|
||||
/**
|
||||
* The options
|
||||
* @type {BaseAccountOption[]}
|
||||
*/
|
||||
#options;
|
||||
|
||||
/**
|
||||
* The button to clear the base account value
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#clearButton;
|
||||
|
||||
/**
|
||||
* Constructs the base account selector.
|
||||
*
|
||||
* @param form {AccountForm} the form
|
||||
*/
|
||||
constructor(form) {
|
||||
this.form = form;
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the options.
|
||||
*
|
||||
*/
|
||||
#filterOptions() {
|
||||
let isAnyMatched = false;
|
||||
for (const option of this.#options) {
|
||||
if (option.isMatched(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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback when the base account selector is shown.
|
||||
*
|
||||
*/
|
||||
onOpen() {
|
||||
this.#query.value = "";
|
||||
this.#filterOptions();
|
||||
for (const option of this.#options) {
|
||||
option.setActive(option.code === this.form.baseCode);
|
||||
}
|
||||
if (this.form.baseCode === 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the base account.
|
||||
* A base account option.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateBase() {
|
||||
const field = document.getElementById("accounting-base-code");
|
||||
const error = document.getElementById("accounting-base-code-error");
|
||||
const displayField = document.getElementById("accounting-base");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
displayField.classList.add("is-invalid");
|
||||
error.innerText = A_("Please select the base account.");
|
||||
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;
|
||||
}
|
||||
displayField.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the title.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateTitle() {
|
||||
const field = document.getElementById("accounting-title");
|
||||
const error = document.getElementById("accounting-title-error");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the title.");
|
||||
return false;
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
@ -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,250 +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";
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
AccountSelector.initialize();
|
||||
});
|
||||
|
||||
/**
|
||||
* The account selector.
|
||||
*
|
||||
*/
|
||||
class AccountSelector {
|
||||
|
||||
/**
|
||||
* The entry type
|
||||
* @type {string}
|
||||
*/
|
||||
#entryType;
|
||||
|
||||
/**
|
||||
* The prefix of the HTML ID and class
|
||||
* @type {string}
|
||||
*/
|
||||
#prefix;
|
||||
|
||||
/**
|
||||
* Constructs an account selector.
|
||||
*
|
||||
* @param modal {HTMLFormElement} the account selector modal
|
||||
*/
|
||||
constructor(modal) {
|
||||
this.#entryType = modal.dataset.entryType;
|
||||
this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
|
||||
this.#init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the account selector.
|
||||
*
|
||||
*/
|
||||
#init() {
|
||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||
const more = document.getElementById(this.#prefix + "-more");
|
||||
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
|
||||
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||
more.onclick = () => {
|
||||
more.classList.add("d-none");
|
||||
this.#filterAccountOptions();
|
||||
};
|
||||
this.#initializeAccountQuery();
|
||||
btnClear.onclick = () => {
|
||||
formAccountControl.classList.remove("accounting-not-empty");
|
||||
formAccount.innerText = "";
|
||||
formAccount.dataset.code = "";
|
||||
formAccount.dataset.text = "";
|
||||
validateJournalEntryAccount();
|
||||
};
|
||||
for (const option of options) {
|
||||
option.onclick = () => {
|
||||
formAccountControl.classList.add("accounting-not-empty");
|
||||
formAccount.innerText = option.dataset.content;
|
||||
formAccount.dataset.code = option.dataset.code;
|
||||
formAccount.dataset.text = option.dataset.content;
|
||||
validateJournalEntryAccount();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the query on the account options.
|
||||
*
|
||||
*/
|
||||
#initializeAccountQuery() {
|
||||
const query = document.getElementById(this.#prefix + "-query");
|
||||
query.addEventListener("input", () => {
|
||||
this.#filterAccountOptions();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the account options.
|
||||
*
|
||||
*/
|
||||
#filterAccountOptions() {
|
||||
const query = document.getElementById(this.#prefix + "-query");
|
||||
const optionList = document.getElementById(this.#prefix + "-option-list");
|
||||
if (optionList === null) {
|
||||
console.log(this.#prefix + "-option-list");
|
||||
}
|
||||
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||
const more = document.getElementById(this.#prefix + "-more");
|
||||
const queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
||||
const codesInUse = this.#getAccountCodeUsedInForm();
|
||||
let shouldAnyShow = false;
|
||||
for (const option of options) {
|
||||
const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
|
||||
if (shouldShow) {
|
||||
option.classList.remove("d-none");
|
||||
shouldAnyShow = true;
|
||||
} else {
|
||||
option.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
if (!shouldAnyShow && more.classList.contains("d-none")) {
|
||||
optionList.classList.add("d-none");
|
||||
queryNoResult.classList.remove("d-none");
|
||||
} else {
|
||||
optionList.classList.remove("d-none");
|
||||
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
|
||||
*/
|
||||
#getAccountCodeUsedInForm() {
|
||||
const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code"));
|
||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||
const inUse = [formAccount.dataset.code];
|
||||
for (const accountCode of accountCodes) {
|
||||
inUse.push(accountCode.value);
|
||||
}
|
||||
return inUse
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether an account option should show.
|
||||
*
|
||||
* @param option {HTMLLIElement} the account option
|
||||
* @param more {HTMLLIElement} the more account element
|
||||
* @param inUse {string[]} the account codes that are used in the form
|
||||
* @param query {HTMLInputElement} the query element, if any
|
||||
* @return {boolean} true if the account option should show, or false otherwise
|
||||
*/
|
||||
#shouldAccountOptionShow(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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the account selector when it is shown.
|
||||
*
|
||||
*/
|
||||
initShow() {
|
||||
const formAccount = document.getElementById("accounting-entry-form-account");
|
||||
const query = document.getElementById(this.#prefix + "-query")
|
||||
const more = document.getElementById(this.#prefix + "-more");
|
||||
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
|
||||
query.value = "";
|
||||
more.classList.remove("d-none");
|
||||
this.#filterAccountOptions();
|
||||
for (const option of options) {
|
||||
if (option.dataset.code === formAccount.dataset.code) {
|
||||
option.classList.add("active");
|
||||
} else {
|
||||
option.classList.remove("active");
|
||||
}
|
||||
}
|
||||
if (formAccount.dataset.code === "") {
|
||||
btnClear.classList.add("btn-secondary");
|
||||
btnClear.classList.remove("btn-danger");
|
||||
btnClear.disabled = true;
|
||||
} else {
|
||||
btnClear.classList.add("btn-danger");
|
||||
btnClear.classList.remove("btn-secondary");
|
||||
btnClear.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account selectors.
|
||||
* @type {{debit: AccountSelector, credit: AccountSelector}}
|
||||
*/
|
||||
static #selectors = {}
|
||||
|
||||
/**
|
||||
* Initializes the account selectors.
|
||||
*
|
||||
*/
|
||||
static initialize() {
|
||||
const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal"));
|
||||
for (const modal of modals) {
|
||||
const selector = new AccountSelector(modal);
|
||||
this.#selectors[selector.#entryType] = selector;
|
||||
}
|
||||
this.#initializeTransactionForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the transaction form.
|
||||
*
|
||||
*/
|
||||
static #initializeTransactionForm() {
|
||||
const entryForm = document.getElementById("accounting-entry-form");
|
||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||
formAccountControl.onclick = () => this.#selectors[entryForm.dataset.entryType].initShow();
|
||||
}
|
||||
/**
|
||||
* Initializes the account selector for the journal entry form.
|
||||
*x
|
||||
*/
|
||||
static initializeJournalEntryForm() {
|
||||
const entryForm = document.getElementById("accounting-entry-form");
|
||||
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
|
||||
formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
/* The Mia! Accounting Project
|
||||
* currency-form.js: The JavaScript for the currency form
|
||||
*/
|
||||
|
||||
@ -24,152 +24,151 @@
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("accounting-code")
|
||||
.onchange = validateCode;
|
||||
document.getElementById("accounting-name")
|
||||
.onchange = validateName;
|
||||
document.getElementById("accounting-form")
|
||||
.onsubmit = validateForm;
|
||||
CurrencyForm.initialize();
|
||||
});
|
||||
|
||||
/**
|
||||
* The asynchronous validation result
|
||||
* @type {object}
|
||||
* @private
|
||||
*/
|
||||
let isAsyncValid = {};
|
||||
|
||||
/**
|
||||
* Validates the form.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateForm() {
|
||||
isAsyncValid = {
|
||||
"code": false,
|
||||
"_sync": false,
|
||||
};
|
||||
let isValid = true;
|
||||
isValid = validateCode() && isValid;
|
||||
isValid = validateName() && isValid;
|
||||
isAsyncValid["_sync"] = isValid;
|
||||
submitFormIfAllAsyncValid();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the form if the whole form passed the asynchronous
|
||||
* validations.
|
||||
* The currency form.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function submitFormIfAllAsyncValid() {
|
||||
let isValid = true;
|
||||
for (const key of Object.keys(isAsyncValid)) {
|
||||
isValid = isAsyncValid[key] && isValid;
|
||||
}
|
||||
if (isValid) {
|
||||
document.getElementById("accounting-form").submit()
|
||||
}
|
||||
}
|
||||
class CurrencyForm {
|
||||
|
||||
/**
|
||||
* Validates the code.
|
||||
*
|
||||
* @param changeEvent {Event} the change event, if invoked from onchange
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateCode(changeEvent = null) {
|
||||
const key = "code";
|
||||
const isSubmission = changeEvent === null;
|
||||
let hasAsyncValidation = false;
|
||||
const field = document.getElementById("accounting-code");
|
||||
const error = document.getElementById("accounting-code-error");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the code.");
|
||||
return false;
|
||||
}
|
||||
const blocklist = JSON.parse(field.dataset.blocklist);
|
||||
if (blocklist.includes(field.value)) {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("This code is not available.");
|
||||
return false;
|
||||
}
|
||||
if (!field.value.match(/^[A-Z]{3}$/)) {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
|
||||
return false;
|
||||
}
|
||||
const original = field.dataset.original;
|
||||
if (original === "" || field.value !== original) {
|
||||
hasAsyncValidation = true;
|
||||
validateAsyncCodeIsDuplicated(isSubmission, key);
|
||||
}
|
||||
if (!hasAsyncValidation) {
|
||||
isAsyncValid[key] = true;
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* The form.
|
||||
* @type {HTMLFormElement}
|
||||
*/
|
||||
#formElement;
|
||||
|
||||
/**
|
||||
* Validates asynchronously whether the code is duplicated.
|
||||
* The boolean validation result is stored in isAsyncValid[key].
|
||||
*
|
||||
* @param isSubmission {boolean} whether this is invoked from a form submission
|
||||
* @param key {string} the key to store the result in isAsyncValid
|
||||
* @private
|
||||
*/
|
||||
function validateAsyncCodeIsDuplicated(isSubmission, key) {
|
||||
const field = document.getElementById("accounting-code");
|
||||
const error = document.getElementById("accounting-code-error");
|
||||
const url = field.dataset.existsUrl;
|
||||
const onLoad = function () {
|
||||
if (this.status === 200) {
|
||||
const result = JSON.parse(this.responseText);
|
||||
if (result["exists"]) {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Code conflicts with another currency.");
|
||||
if (isSubmission) {
|
||||
isAsyncValid[key] = false;
|
||||
/**
|
||||
* The code
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#code;
|
||||
|
||||
/**
|
||||
* The error message of the code
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#codeError;
|
||||
|
||||
/**
|
||||
* The name
|
||||
* @type {HTMLInputElement}
|
||||
*/
|
||||
#name;
|
||||
|
||||
/**
|
||||
* The error message of the name
|
||||
* @type {HTMLDivElement}
|
||||
*/
|
||||
#nameError;
|
||||
|
||||
/**
|
||||
* Constructs the currency form.
|
||||
*
|
||||
*/
|
||||
constructor() {
|
||||
this.#formElement = document.getElementById("accounting-form");
|
||||
this.#code = document.getElementById("accounting-code");
|
||||
this.#codeError = document.getElementById("accounting-code-error");
|
||||
this.#name = document.getElementById("accounting-name");
|
||||
this.#nameError = document.getElementById("accounting-name-error");
|
||||
this.#code.onchange = () => {
|
||||
this.#validateCode().then();
|
||||
};
|
||||
this.#name.onchange = () => {
|
||||
this.#validateName();
|
||||
};
|
||||
this.#formElement.onsubmit = () => {
|
||||
this.#validate().then((isValid) => {
|
||||
if (isValid) {
|
||||
this.#formElement.submit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
if (isSubmission) {
|
||||
isAsyncValid[key] = true;
|
||||
submitFormIfAllAsyncValid();
|
||||
});
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the form.
|
||||
*
|
||||
* @returns {Promise<boolean>} true if valid, or false otherwise
|
||||
*/
|
||||
async #validate() {
|
||||
let isValid = true;
|
||||
isValid = await this.#validateCode() && isValid;
|
||||
isValid = this.#validateName() && isValid;
|
||||
return isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the code.
|
||||
*
|
||||
* @param changeEvent {Event} the change event, if invoked from onchange
|
||||
* @returns {Promise<boolean>} true if valid, or false otherwise
|
||||
*/
|
||||
async #validateCode(changeEvent = null) {
|
||||
this.#code.value = this.#code.value.trim();
|
||||
if (this.#code.value === "") {
|
||||
this.#code.classList.add("is-invalid");
|
||||
this.#codeError.innerText = A_("Please fill in the code.");
|
||||
return false;
|
||||
}
|
||||
const blocklist = JSON.parse(this.#code.dataset.blocklist);
|
||||
if (blocklist.includes(this.#code.value)) {
|
||||
this.#code.classList.add("is-invalid");
|
||||
this.#codeError.innerText = A_("This code is not available.");
|
||||
return false;
|
||||
}
|
||||
if (!this.#code.value.match(/^[A-Z]{3}$/)) {
|
||||
this.#code.classList.add("is-invalid");
|
||||
this.#codeError.innerText = A_("Code can only be composed of 3 upper-cased letters.");
|
||||
return false;
|
||||
}
|
||||
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 data = await response.json();
|
||||
if (data["exists"]) {
|
||||
this.#code.classList.add("is-invalid");
|
||||
this.#codeError.innerText = A_("Code conflicts with another currency.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
const request = new XMLHttpRequest();
|
||||
request.onload = onLoad;
|
||||
request.open("GET", url + "?q=" + encodeURIComponent(field.value));
|
||||
request.send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the name.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
* @private
|
||||
*/
|
||||
function validateName() {
|
||||
const field = document.getElementById("accounting-name");
|
||||
const error = document.getElementById("accounting-name-error");
|
||||
field.value = field.value.trim();
|
||||
if (field.value === "") {
|
||||
field.classList.add("is-invalid");
|
||||
error.innerText = A_("Please fill in the name.");
|
||||
return false;
|
||||
this.#code.classList.remove("is-invalid");
|
||||
this.#codeError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the name.
|
||||
*
|
||||
* @returns {boolean} true if valid, or false otherwise
|
||||
*/
|
||||
#validateName() {
|
||||
this.#name.value = this.#name.value.trim();
|
||||
if (this.#name.value === "") {
|
||||
this.#name.classList.add("is-invalid");
|
||||
this.#nameError.innerText = A_("Please fill in the name.");
|
||||
return false;
|
||||
}
|
||||
this.#name.classList.remove("is-invalid");
|
||||
this.#nameError.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The form
|
||||
* @type {CurrencyForm}
|
||||
*/
|
||||
static #form;
|
||||
|
||||
/**
|
||||
* Initializes the currency form.
|
||||
*
|
||||
*/
|
||||
static initialize() {
|
||||
this.#form = new CurrencyForm();
|
||||
}
|
||||
field.classList.remove("is-invalid");
|
||||
error.innerText = "";
|
||||
return true;
|
||||
}
|
||||
|
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.#isShowMore) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
1156
src/accounting/static/js/journal-entry-form.js
Normal file
1156
src/accounting/static/js/journal-entry-form.js
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user