Compare commits
370 Commits
v0.9.0
...
84bd01087c
| Author | SHA1 | Date | |
|---|---|---|---|
| 84bd01087c | |||
| c10bf81d24 | |||
| acf2a0fa87 | |||
| 090acbd66b | |||
| 220dbaa683 | |||
| 800832d15e | |||
| 454ff8bb5f | |||
| 9b002cd9a9 | |||
| c1d5b46145 | |||
| 46c6767a90 | |||
| 381298adb1 | |||
| bfa42000d8 | |||
| 0d02f41417 | |||
| a26f1942f8 | |||
| 0a7bcdd9ec | |||
| b6ea944eb5 | |||
| be9f4f3d83 | |||
| dc42a05959 | |||
| 9c6cc1f3eb | |||
| e6d25882fc | |||
| 970c2e9946 | |||
| 356950e2c7 | |||
| cca3f89bf1 | |||
| 674b0de3b2 | |||
| 29dfc6c5a4 | |||
| aa3bc1ed69 | |||
| 1289d7cba6 | |||
| d62e295dc6 | |||
| 693c5890ca | |||
| 3adcaa61d3 | |||
| aea9dcae79 | |||
| 40278eaf06 | |||
| e00c14f277 | |||
| f20c462685 | |||
| 80ae4bd91c | |||
| 6ee3ee76ea | |||
| 2bfcc8b889 | |||
| 99564c02d0 | |||
| 25d9904180 | |||
| 1cf83adf87 | |||
| 8e3d1f11b5 | |||
| 0ab14aa34d | |||
| e0ed81ad1f | |||
| ece7481e9e | |||
| 50d4526e0b | |||
| 3f0a0b4227 | |||
| dcc9626b23 | |||
| 79eb077129 | |||
| d5719ad223 | |||
| eb3fa8f414 | |||
| 937908717b | |||
| 0104fa4c21 | |||
| 14365ca255 | |||
| cd86651606 | |||
| 9147744ff7 | |||
| 1a212a5330 | |||
| 0614457b7b | |||
| 545f49043b | |||
| cac0d66ca1 | |||
| 5ffd37c859 | |||
| 9ae8c1bce9 | |||
| ec0ff3e2e6 | |||
| 40a8080751 | |||
| 736a4086ee | |||
| 6723077b72 | |||
| 0ae00bce79 | |||
| 356d2010cc | |||
| 501c4b1d22 | |||
| 64b9c8c11f | |||
| 9072de82d4 | |||
| 30fd9c2164 | |||
| 7cb01b4cee | |||
| 9a4e04c41f | |||
| a9c4fa9de0 | |||
| 3a676e0b5a | |||
| 9cc7b64bb3 | |||
| 352867797d | |||
| 09a344d749 | |||
| 818c357613 | |||
| 822c8fc49b | |||
| 3b8a2e3bb1 | |||
| 9e4927ee0b | |||
| 3b030c577c | |||
| 60b33f2a3b | |||
| 08fdf59844 | |||
| b397515457 | |||
| abe90d3483 | |||
| 65e7dcdf6d | |||
| 74e414badf | |||
| 69175979ff | |||
| 2f69e0f215 | |||
| 961385c389 | |||
| a691cfd2da | |||
| 482a0faa23 | |||
| 0ecf7b6617 | |||
| 4408bbfc82 | |||
| 433110f486 | |||
| 0b1dd4f4fc | |||
| 46bd27e126 | |||
| b718d19450 | |||
| 2969e83afe | |||
| a732656746 | |||
| 1daed940b6 | |||
| f29cb00aec | |||
| 693f07a49c | |||
| 8c899776f2 | |||
| f9aa226bf9 | |||
| c9bb4197be | |||
| 9ae8d587d8 | |||
| 158058dcfb | |||
| 0bc9947234 | |||
| 8c58a9083a | |||
| f45663754c | |||
| cda9e4e3c6 | |||
| ee5b447c23 | |||
| 25bfcf4aa4 | |||
| 5956d2cd4c | |||
| 833285d924 | |||
| dee4f5e83f | |||
| f0d1cae32d | |||
| 5dc71697b3 | |||
| 1bb1e03c08 | |||
| 914ff92e0f | |||
| 8a1cf463b1 | |||
| d4cf224d6b | |||
| 8d412ec00a | |||
| 2986c518ce | |||
| f1351243a6 | |||
| 969e8c76a6 | |||
| 10f5e75752 | |||
| 169b3c292a | |||
| 3eb3aef2f2 | |||
| 6c455a615c | |||
| 4f3339bf68 | |||
| b5aa7e923f | |||
| 359c335662 | |||
| c11ae23885 | |||
| e083b11394 | |||
| 167990fc4c | |||
| d5c1be3d80 | |||
| f6567794e0 | |||
| ded85d88f7 | |||
| 6d780e9296 | |||
| 86637267d3 | |||
| 71e97721aa | |||
| 5815608288 | |||
| 5f75d93c6a | |||
| 118c4b458e | |||
| 3f7e4c0dda | |||
| eed4c923f6 | |||
| 09dd5ae541 | |||
| 172a12b134 | |||
| f3c558f48a | |||
| 988757d30e | |||
| 50cea90d1b | |||
| 71dfb6f003 | |||
| be628b4aa1 | |||
| 5d444adec4 | |||
| 014d67f7b8 | |||
| 26b4d4388f | |||
| 6e2e92d0fb | |||
| b1f87cb707 | |||
| 928dea8312 | |||
| b8cec8a2af | |||
| b6ae946f32 | |||
| a9acc18a6f | |||
| 5468010c87 | |||
| b505e380df | |||
| 412da170e1 | |||
| fa237795cf | |||
| e2f854b5cc | |||
| f8895e3bff | |||
| 84ad065782 | |||
| 260e3cbe82 | |||
| cd039520b6 | |||
| 05e652aa62 | |||
| 5c9bf0638c | |||
| bbc78433fd | |||
| 7bcc2b28b2 | |||
| c1d9ca284c | |||
| 165e28441a | |||
| 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 |
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
|
||||||
|
|
||||||
# Copyright (c) 2022 imacat.
|
# Copyright (c) 2022 imacat.
|
||||||
@@ -25,6 +25,8 @@ venv
|
|||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
|
.claude
|
||||||
|
.codex
|
||||||
|
|
||||||
instance
|
instance
|
||||||
flask_session
|
flask_session
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 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: .
|
||||||
|
- requirements: docs/requirements.txt
|
||||||
+10
-13
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
||||||
|
|
||||||
# Copyright (c) 2022-2023 imacat.
|
# Copyright (c) 2022-2023 imacat.
|
||||||
@@ -15,17 +15,14 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
recursive-include src/accounting/static *
|
||||||
exclude src/accounting/static/js/dummy.js
|
exclude src/accounting/static/js/dummy.js
|
||||||
include src/accounting/translations/*
|
recursive-include src/accounting/templates *
|
||||||
include src/accounting/translations/*/LC_MESSAGES/*
|
recursive-include src/accounting/translations *
|
||||||
include docs/*
|
recursive-include src/accounting/data *
|
||||||
include docs/source/*
|
recursive-include docs *
|
||||||
include docs/source/_static/*
|
recursive-exclude docs/build *
|
||||||
include docs/source/_templates/*
|
recursive-include tests *
|
||||||
include tests/*
|
|
||||||
exclude tests/test_temp.py
|
exclude tests/test_temp.py
|
||||||
include tests/test_site/*
|
recursive-exclude tests *.pyc
|
||||||
include tests/test_site/static/*
|
recursive-exclude tests/instance *
|
||||||
include tests/test_site/templates/*
|
|
||||||
include tests/test_site/translations/*
|
|
||||||
include tests/test_site/translations/*/LC_MESSAGES/*
|
|
||||||
|
|||||||
+56
-12
@@ -1,30 +1,65 @@
|
|||||||
=====================
|
===============
|
||||||
Mia! Accounting Flask
|
Mia! Accounting
|
||||||
=====================
|
===============
|
||||||
|
|
||||||
|
|
||||||
Description
|
Description
|
||||||
===========
|
===========
|
||||||
|
|
||||||
This is the Mia! Accounting Flask project. It is an accounting
|
*Mia! Accounting* is an accounting module for Flask_ applications.
|
||||||
module for the 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
|
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
|
||||||
`Mia! Accounting Flask repository`_.
|
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`_.
|
||||||
|
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
=============
|
||||||
|
|
||||||
|
Refer to the `documentation on Read the Docs`_.
|
||||||
|
|
||||||
|
|
||||||
|
Change Log
|
||||||
|
==========
|
||||||
|
|
||||||
|
Refer to the `change log`_.
|
||||||
|
|
||||||
|
|
||||||
Copyright
|
Copyright
|
||||||
=========
|
=========
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
Copyright (c) 2023-2024 imacat.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -46,5 +81,14 @@ Authors
|
|||||||
| imacat@mail.imacat.idv.tw
|
| imacat@mail.imacat.idv.tw
|
||||||
| 2023/1/27
|
| 2023/1/27
|
||||||
|
|
||||||
|
|
||||||
.. _Flask: https://flask.palletsprojects.com
|
.. _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
|
||||||
|
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io
|
||||||
|
.. _change log: https://mia-accounting.readthedocs.io/en/latest/changelog.html
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
sphinx_rtd_theme
|
||||||
@@ -20,14 +20,6 @@ accounting.journal\_entry.utils.description\_editor module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.journal\_entry.utils.offset\_alias module
|
|
||||||
----------------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.utils.offset_alias
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.journal\_entry.utils.operators module
|
accounting.journal\_entry.utils.operators module
|
||||||
------------------------------------------------
|
------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,38 @@ accounting.report.reports.trial\_balance module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
: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:
|
||||||
|
|
||||||
|
accounting.report.reports.unmatched module
|
||||||
|
------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.unmatched
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.reports.unmatched\_accounts module
|
||||||
|
----------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.reports.unmatched_accounts
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
Module contents
|
Module contents
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ accounting.report.utils.csv\_export module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.utils.offset\_matcher module
|
||||||
|
----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.offset_matcher
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.report.utils.option\_link module
|
accounting.report.utils.option\_link module
|
||||||
-------------------------------------------
|
-------------------------------------------
|
||||||
|
|
||||||
@@ -52,6 +60,22 @@ accounting.report.utils.report\_type module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.utils.unapplied module
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.unapplied
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.report.utils.unmatched module
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.report.utils.unmatched
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.report.utils.urls module
|
accounting.report.utils.urls module
|
||||||
-----------------------------------
|
-----------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ Subpackages
|
|||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
accounting.commands module
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.commands
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.forms module
|
accounting.forms module
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ accounting.utils.next\_uri module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.offset\_alias module
|
||||||
|
-------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.offset_alias
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.utils.options module
|
accounting.utils.options module
|
||||||
-------------------------------
|
-------------------------------
|
||||||
|
|
||||||
@@ -92,6 +100,14 @@ accounting.utils.strip\_text module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.title\_case module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.title_case
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.utils.user module
|
accounting.utils.user module
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
Change Log
|
||||||
|
==========
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.6.1
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Released 2024/12/3
|
||||||
|
|
||||||
|
Fix test cases for compatibility with httpx 0.28.0.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.6.0
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Released 2024/6/4
|
||||||
|
|
||||||
|
* Updated Python version to 3.12.
|
||||||
|
* Revised the calculation of "today" to use the client's timezone instead of
|
||||||
|
the server's timezone.
|
||||||
|
* Updated the Bootstrap, FontAwesome, and Tempus-Dominus versions in the test
|
||||||
|
site.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.11
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Released 2023/12/26
|
||||||
|
|
||||||
|
Bug fix.
|
||||||
|
|
||||||
|
* Refined to enable the selection of the 3351-001 Accumulated Profit or Loss
|
||||||
|
account.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.10
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Released 2023/11/28
|
||||||
|
|
||||||
|
Bug fix.
|
||||||
|
|
||||||
|
* Fixed the form validator to enable the selection of Accumulated Profit or
|
||||||
|
Loss accounts other than 3351-001.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.9
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/11/28
|
||||||
|
|
||||||
|
Bug fix.
|
||||||
|
|
||||||
|
* Refined to enable the selection of Accumulated Profit or Loss accounts other
|
||||||
|
than 3351-001, facilitating the consolidation of existing balances.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.8
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/10/24
|
||||||
|
|
||||||
|
Bug fix.
|
||||||
|
|
||||||
|
* Fixed an icon in the detail of the cash receipt journal entry.
|
||||||
|
|
||||||
|
Released at Jaipur, India on vacation.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.7
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/7/29
|
||||||
|
|
||||||
|
Revised account title capitalization to capitalize account titles
|
||||||
|
upon initialization of base accounts, rather than when displaying
|
||||||
|
the accounts. This prevents the system from incorrectly
|
||||||
|
capitalizing titles of user-added accounts.
|
||||||
|
|
||||||
|
For existing installation, run the ``accounting-titleize`` console
|
||||||
|
command to capitalize the existing account titles that were already
|
||||||
|
initialized.
|
||||||
|
|
||||||
|
Other fixes:
|
||||||
|
|
||||||
|
* Added missing documentation to the global variables, class
|
||||||
|
properties, and object properties.
|
||||||
|
* Various minor fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.6
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/5/23
|
||||||
|
|
||||||
|
Bug fixes.
|
||||||
|
|
||||||
|
* Fixed the return URI of the creation forms to decode the next URI.
|
||||||
|
* Fixed the unmatched offset list to use the encoded next URI.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.5
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/5/23
|
||||||
|
|
||||||
|
Security fixes.
|
||||||
|
|
||||||
|
* Revised the next URI utilities to encode and decode the next URI
|
||||||
|
preventing tampering with the next URI.
|
||||||
|
* Added the integrity value of the CDN stylesheet links.
|
||||||
|
* Various fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.4
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/5/18
|
||||||
|
|
||||||
|
Security fixes.
|
||||||
|
|
||||||
|
* Added safeguard to the next URI utilities, to prevent Cross-Site
|
||||||
|
Scripting (XSS) attacks.
|
||||||
|
* Applied the safe next URI utilities to the test site.
|
||||||
|
* Added the ``SameSite`` and ``Secure`` flags to the session cookie
|
||||||
|
of the test site.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.3
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/30
|
||||||
|
|
||||||
|
* Fixed the error of the net balance in the unmatched offset list.
|
||||||
|
* Revised the original line item editor not to override the existing
|
||||||
|
amount when the existing amount is less or equal to the net
|
||||||
|
balance.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.2
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/30
|
||||||
|
|
||||||
|
* Fixed the error of the net balance in the unmatched offset list.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.1
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/30
|
||||||
|
|
||||||
|
* Fixed the error calling the old ``setEnableDescriptionAccount``
|
||||||
|
method in the ``saveOriginalLineItem`` method of the JavaScript
|
||||||
|
``JournalEntryLineItemEditor`` class.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.5.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/23
|
||||||
|
|
||||||
|
* Updated to require ``SQLAlchemy >= 2``.
|
||||||
|
* Added the change log.
|
||||||
|
* Added the ``VERSION`` constant to the ``accounting`` module for
|
||||||
|
the package version, and revised ``pyproject.toml`` and ``conf.py``
|
||||||
|
to read the version from it.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.4.1
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/22
|
||||||
|
|
||||||
|
* Updated to allow editing the description of the journal entry line
|
||||||
|
item with offsets or are offsetting to original line items.
|
||||||
|
* Updated not to override the existing description of a journal entry
|
||||||
|
line item after choosing the original line item to offset to.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.4.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/18
|
||||||
|
|
||||||
|
* Rewrote the unapplied original line items and unmatched offsets.
|
||||||
|
|
||||||
|
* The unapplied original line items and unmatched offsets are both
|
||||||
|
in the report submodule. They can be filtered with currency and
|
||||||
|
period now.
|
||||||
|
* Show the unapplied original line items and unmatched offsets
|
||||||
|
together, and added the accumulated balance in the unmatched
|
||||||
|
offset list, for ease of reference.
|
||||||
|
|
||||||
|
* Removed the account code from the journal entry detail and journal
|
||||||
|
entry form for mobile devices.
|
||||||
|
* Made the account options in the reports to be scrollable.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.3.3
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/13
|
||||||
|
|
||||||
|
Changed the sample data generation in the test site live demonstration
|
||||||
|
from pre-recorded data to real-time generation, to avoid the problem
|
||||||
|
with the start of months and weeks changed with the date of the
|
||||||
|
import.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.3.2
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/12
|
||||||
|
|
||||||
|
Added the sample data generation and database reset on the test site
|
||||||
|
for live demonstration.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.3.1
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/11
|
||||||
|
|
||||||
|
* Fixed the permission of the navigation menu of the unmatched offsets.
|
||||||
|
* Revised the test site to be more accessible as the live demonstration.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.3.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/11
|
||||||
|
|
||||||
|
Added the ``accounting-init-db`` console command to replace all the
|
||||||
|
other console commands to initialize the accounting database. The
|
||||||
|
test site does not work with previous versions (<1.3.0).
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.2.1
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/9
|
||||||
|
|
||||||
|
Fixed the search result to allow full ``year/month/day``
|
||||||
|
specification.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.2.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/9
|
||||||
|
|
||||||
|
* Simplified the URL of the default reports.
|
||||||
|
* Fixed the crash with malformed Chinese translation.
|
||||||
|
* Fixed the crash when downloading CSV data with non-US-ASCII
|
||||||
|
filenames.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.1.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/9
|
||||||
|
|
||||||
|
* Added the unapplied original line item list, to track unpaid
|
||||||
|
payables, unreceived receivables, assets, prepaids, refundable
|
||||||
|
deposits, etc.
|
||||||
|
* Added the offset matcher to match unapplied original line items
|
||||||
|
with unmatched offsets.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.0.1
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/6
|
||||||
|
|
||||||
|
Documentation fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 1.0.0
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Released 2023/4/6
|
||||||
|
|
||||||
|
The first formal release in Flask.
|
||||||
|
|
||||||
|
Added the documentation.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.11.1 (Pre-release)
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
Released 2023/4/5
|
||||||
|
|
||||||
|
Removed the zero balances from the trial balance, the income
|
||||||
|
statement, and the balance sheet.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.11.0 (Pre-release)
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
Released 2023/4/5
|
||||||
|
|
||||||
|
* Renamed the project from ``mia-accounting-flask`` to
|
||||||
|
``mia-accounting``.
|
||||||
|
* Updated the URL of the reports, as the default views of the
|
||||||
|
accounting application.
|
||||||
|
* Updated ``README``.
|
||||||
|
* Various fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.10.0 (Pre-release)
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
Released 2023/4/3
|
||||||
|
|
||||||
|
* Added the unauthorized method to the ``UserUtilityInterface``
|
||||||
|
interface to allow fine control to how to handle the case when the
|
||||||
|
user has not logged in.
|
||||||
|
* Revised the JavaScript description editor to respect the account
|
||||||
|
that the user has confirmed or specifically selected.
|
||||||
|
* Various fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.9.1 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/24
|
||||||
|
|
||||||
|
* A distinguishable look in the option detail than the option form.
|
||||||
|
* A better look in the new journal entry forms when there is no line
|
||||||
|
item yet.
|
||||||
|
* Fixed the search in the original entry selector in the journal
|
||||||
|
entry form to always do a partial match, to fix the problem that
|
||||||
|
there is no match when typing is not finished yet.
|
||||||
|
* Fixed the search in the original entry selector to search the net
|
||||||
|
balance correctly.
|
||||||
|
* Replaced the ``editor`` and ``editor2`` accounts with the ``admin``
|
||||||
|
and ``editor`` accounts.
|
||||||
|
* Various fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.9.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/23
|
||||||
|
|
||||||
|
Moved the settings from the ``.env`` file to the option table in the
|
||||||
|
database that can be set and updated on the web interface. Added the
|
||||||
|
settings page to show and update the settings.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.8.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/22
|
||||||
|
|
||||||
|
* Added the recurring transactions to the description editor.
|
||||||
|
* Added prevention to delete database objects that are essential or
|
||||||
|
referenced by others with foreign keys.
|
||||||
|
* Various fixes on the visual layout.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.7.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/21
|
||||||
|
|
||||||
|
* Renamed "transaction" to "journal entry", and "journal entry" to
|
||||||
|
"journal entry line item".
|
||||||
|
* Renamed ``summary`` to ``description``.
|
||||||
|
* Updated tempus-dominus from version 6.2.10 to 6.4.3.
|
||||||
|
* Fixed titles and capitalization.
|
||||||
|
* Fixed to search case-insensitively.
|
||||||
|
* Added favicon to the test site.
|
||||||
|
* Fixed the navigation menu when there is no matching endpoint.
|
||||||
|
* Various fixes.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.6.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/18
|
||||||
|
|
||||||
|
* Added offset tracking to the journal entries in the payable and
|
||||||
|
receivable accounts.
|
||||||
|
* Renamed the ``is_offset_needed`` column to ``is_need_offset`` in
|
||||||
|
the ``Account`` data model.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.5.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/10
|
||||||
|
|
||||||
|
Added the accounting reports.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.4.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/3/1
|
||||||
|
|
||||||
|
Added the transaction summary helper.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.3.1 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/2/28
|
||||||
|
|
||||||
|
* Fixed the error that cannot select any account when adding new
|
||||||
|
transactions.
|
||||||
|
* Fixed the database error when adding new transactions.
|
||||||
|
* Added the button to convert a cash income or cash expense
|
||||||
|
transaction to a transfer transaction.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.3.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/2/27
|
||||||
|
|
||||||
|
Added the transaction management.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.2.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/2/7
|
||||||
|
|
||||||
|
* Added the currency management.
|
||||||
|
* Changed the ``can_edit`` permission to at least require the user to
|
||||||
|
log in first.
|
||||||
|
* Changed the type hint of the ``current_user`` pseudo property of
|
||||||
|
the ``AbstractUserUtils`` class to return ``None`` when the user
|
||||||
|
has not logged in.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.1.1 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/2/3
|
||||||
|
|
||||||
|
Finalized the account management, with tests and reordering.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.1.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/2/3
|
||||||
|
|
||||||
|
Added the account management, and updated the API to initialize the
|
||||||
|
accounting application.
|
||||||
|
|
||||||
|
|
||||||
|
Version 0.0.0 (Pre-release)
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Released 2023/2/3
|
||||||
|
|
||||||
|
Initial release with main account list, localization, pagination,
|
||||||
|
query, permission, Sphinx documentation, and a test case based on a
|
||||||
|
test demonstration site.
|
||||||
+3
-2
@@ -6,14 +6,15 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath('../../src/'))
|
sys.path.insert(0, os.path.abspath('../../src/'))
|
||||||
|
import accounting
|
||||||
|
|
||||||
# -- Project information -----------------------------------------------------
|
# -- Project information -----------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||||
|
|
||||||
project = 'Mia! Accounting Flask'
|
project = 'Mia! Accounting'
|
||||||
copyright = '2023, imacat'
|
copyright = '2023, imacat'
|
||||||
author = 'imacat'
|
author = 'imacat'
|
||||||
release = '0.9.0'
|
release = accounting.VERSION
|
||||||
|
|
||||||
# -- General configuration ---------------------------------------------------
|
# -- General configuration ---------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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 Base, 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 base(self) -> type[DeclarativeBase]:
|
||||||
|
return Base
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cls(self) -> 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 db.session.scalar(
|
||||||
|
sa.select(User).where(User.username == username))
|
||||||
|
|
||||||
|
def get_pk(self, user: User) -> int:
|
||||||
|
return user.id
|
||||||
|
|
||||||
|
accounting.init_app(app, UserUtils())
|
||||||
|
|
||||||
|
... (Any other configuration) ...
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -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,21 @@
|
|||||||
.. 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.
|
sphinx-quickstart on Fri Jan 27 12:20:04 2023.
|
||||||
You can adapt this file completely to your liking, but it should at least
|
You can adapt this file completely to your liking, but it should at least
|
||||||
contain the root `toctree` directive.
|
contain the root `toctree` directive.
|
||||||
|
|
||||||
Welcome to Mia! Accounting Flask's documentation!
|
Welcome to Mia! Accounting's documentation!
|
||||||
=================================================
|
===========================================
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
:caption: Contents:
|
:caption: Contents:
|
||||||
|
|
||||||
|
intro
|
||||||
|
accounting
|
||||||
|
examples
|
||||||
|
history
|
||||||
|
changelog
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Indices and tables
|
Indices and tables
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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.4.0 or above
|
||||||
|
* `decimal.js`_ 10.4.3 or above, or `decimal.js-light`_ 2.5.1 or above.
|
||||||
|
* `Tempus-Dominus`_ 6.7.7 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.
|
||||||
|
|
||||||
|
|
||||||
|
.. _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
|
||||||
|
.. _decimal.js-light: https://mikemcl.github.io/decimal.js-light
|
||||||
|
.. _Tempus-Dominus: https://getdatepicker.com
|
||||||
|
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
|
||||||
+51
-2
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
||||||
|
|
||||||
# Copyright (c) 2022 imacat.
|
# Copyright (c) 2022-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -15,6 +15,55 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "mia-accounting"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = "A Flask accounting module."
|
||||||
|
readme = "README.rst"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
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",
|
||||||
|
"SQLAlchemy >= 2",
|
||||||
|
"Flask-SQLAlchemy-Lite",
|
||||||
|
"Flask-WTF",
|
||||||
|
"Flask-Babel >= 3",
|
||||||
|
"Flask-Babel-JS",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
devel = [
|
||||||
|
"httpx >= 0.28.0",
|
||||||
|
"OpenCC",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
"Documentation" = "https://mia-accounting.readthedocs.io"
|
||||||
|
"Change Log" = "https://mia-accounting.readthedocs.io/en/latest/changelog.html"
|
||||||
|
"Repository" = "https://github.com/imacat/mia-accounting"
|
||||||
|
"Bug Tracker" = "https://github.com/imacat/mia-accounting/issues"
|
||||||
|
"Demonstration" = "https://accounting.imacat.idv.tw"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=42"]
|
requires = ["setuptools>=42"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.dynamic]
|
||||||
|
version = {attr = "accounting.VERSION"}
|
||||||
|
|
||||||
|
[tool.setuptools.exclude-package-data]
|
||||||
|
"*" = [
|
||||||
|
"babel.cfg",
|
||||||
|
"*.pot",
|
||||||
|
"*.po",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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.9.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,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -20,10 +20,12 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, Blueprint
|
from flask import Flask, Blueprint
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy_lite import SQLAlchemy
|
||||||
|
|
||||||
from accounting.utils.user import UserUtilityInterface
|
from .utils.user import UserUtilityInterface
|
||||||
|
|
||||||
|
VERSION: str = "1.6.1"
|
||||||
|
"""The package version."""
|
||||||
db: SQLAlchemy = SQLAlchemy()
|
db: SQLAlchemy = SQLAlchemy()
|
||||||
"""The database instance."""
|
"""The database instance."""
|
||||||
data_dir: Path = Path(__file__).parent / "data"
|
data_dir: Path = Path(__file__).parent / "data"
|
||||||
@@ -47,7 +49,6 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
|
|||||||
init_user_utils(user_utils)
|
init_user_utils(user_utils)
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("accounting", __name__,
|
bp: Blueprint = Blueprint("accounting", __name__,
|
||||||
url_prefix=url_prefix,
|
|
||||||
template_folder="templates",
|
template_folder="templates",
|
||||||
static_folder="static")
|
static_folder="static")
|
||||||
|
|
||||||
@@ -62,6 +63,10 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
|
|||||||
bp.add_app_template_global(default_currency_code,
|
bp.add_app_template_global(default_currency_code,
|
||||||
"accounting_default_currency_code")
|
"accounting_default_currency_code")
|
||||||
|
|
||||||
|
from .commands import init_db_command, titleize_command
|
||||||
|
app.cli.add_command(init_db_command)
|
||||||
|
app.cli.add_command(titleize_command)
|
||||||
|
|
||||||
from . import locale
|
from . import locale
|
||||||
locale.init_app(app, bp)
|
locale.init_app(app, bp)
|
||||||
|
|
||||||
@@ -84,9 +89,9 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
|
|||||||
journal_entry.init_app(app, bp)
|
journal_entry.init_app(app, bp)
|
||||||
|
|
||||||
from . import report
|
from . import report
|
||||||
report.init_app(app, bp)
|
report.init_app(app, url_prefix)
|
||||||
|
|
||||||
from . import option
|
from . import option
|
||||||
option.init_app(bp)
|
option.init_app(bp)
|
||||||
|
|
||||||
app.register_blueprint(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
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
"""
|
"""
|
||||||
from flask import Flask, Blueprint
|
from flask import Flask, Blueprint
|
||||||
|
|
||||||
|
from .commands import init_accounts_command
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||||
"""Initialize the application.
|
"""Initialize the application.
|
||||||
@@ -32,6 +34,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
|
|||||||
|
|
||||||
from .views import bp as account_bp
|
from .views import bp as account_bp
|
||||||
bp.register_blueprint(account_bp, url_prefix="/accounts")
|
bp.register_blueprint(account_bp, url_prefix="/accounts")
|
||||||
|
|
||||||
from .commands import init_accounts_command
|
|
||||||
app.cli.add_command(init_accounts_command)
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,63 +17,38 @@
|
|||||||
"""The console commands for the account management.
|
"""The console commands for the account management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
from secrets import randbelow
|
from secrets import randbelow
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from flask.cli import with_appcontext
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from accounting import db
|
from .. import db
|
||||||
from accounting.models import BaseAccount, Account, AccountL10n
|
from ..models import BaseAccount, Account, AccountL10n
|
||||||
from accounting.utils.user import has_user, get_user_pk
|
from ..utils.user import get_user_pk
|
||||||
|
|
||||||
AccountData = tuple[int, str, int, str, str, str, bool]
|
type AccountData = tuple[int, str, int, str, str, str, bool]
|
||||||
"""The format of the account data, as a list of (ID, base account code, number,
|
"""The format of the account data, as a list of (ID, base account code, number,
|
||||||
English, Traditional Chinese, Simplified Chinese, is-need-offset) 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:
|
def init_accounts_command(username: str) -> None:
|
||||||
"""Initializes the accounts."""
|
"""Initializes the accounts."""
|
||||||
creator_pk: int = get_user_pk(username)
|
creator_pk: int = get_user_pk(username)
|
||||||
|
|
||||||
bases: list[BaseAccount] = BaseAccount.query\
|
bases: list[BaseAccount] = db.session.scalars(
|
||||||
.filter(db.func.length(BaseAccount.code) == 4)\
|
sa.select(BaseAccount).where(sa.func.length(BaseAccount.code) == 4)
|
||||||
.order_by(BaseAccount.code).all()
|
.order_by(BaseAccount.code)).unique().all()
|
||||||
if len(bases) == 0:
|
if len(bases) == 0:
|
||||||
click.echo("Please initialize the base accounts with "
|
|
||||||
"\"flask accounting-init-base\" first.")
|
|
||||||
raise click.Abort
|
raise click.Abort
|
||||||
|
|
||||||
existing: list[Account] = Account.query.all()
|
existing: list[Account] = \
|
||||||
|
db.session.scalars(sa.select(Account)).unique().all()
|
||||||
|
|
||||||
existing_base_code: set[str] = {x.base_code for x in existing}
|
existing_base_code: set[str] = {x.base_code for x in existing}
|
||||||
bases_to_add: list[BaseAccount] = [x for x in bases
|
bases_to_add: list[BaseAccount] = [x for x in bases
|
||||||
if x.code not in existing_base_code]
|
if x.code not in existing_base_code]
|
||||||
if len(bases_to_add) == 0:
|
if len(bases_to_add) == 0:
|
||||||
click.echo("No more account to import.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
existing_id: set[int] = {x.id for x in existing}
|
existing_id: set[int] = {x.id for x in existing}
|
||||||
@@ -89,14 +64,24 @@ def init_accounts_command(username: str) -> None:
|
|||||||
existing_id.add(new_id)
|
existing_id.add(new_id)
|
||||||
return new_id
|
return new_id
|
||||||
|
|
||||||
data: list[AccountData] = []
|
data: list[dict[str, Any]] = []
|
||||||
|
l10n_data: list[dict[str, Any]] = []
|
||||||
for base in bases_to_add:
|
for base in bases_to_add:
|
||||||
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
||||||
is_need_offset: bool = __is_need_offset(base.code)
|
account_id: int = get_new_id()
|
||||||
data.append((get_new_id(), base.code, 1, base.title_l10n,
|
data.append({"id": account_id,
|
||||||
l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset))
|
"base_code": base.code,
|
||||||
__add_accounting_accounts(data, creator_pk)
|
"no": 1,
|
||||||
click.echo(F"{len(data)} added. Accounting accounts initialized.")
|
"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 __is_need_offset(base_code: str) -> bool:
|
def __is_need_offset(base_code: str) -> bool:
|
||||||
@@ -108,42 +93,16 @@ def __is_need_offset(base_code: str) -> bool:
|
|||||||
"""
|
"""
|
||||||
# Assets
|
# Assets
|
||||||
if base_code[0] == "1":
|
if base_code[0] == "1":
|
||||||
if base_code[:3] in {"113", "114", "118", "184"}:
|
if base_code[:3] in {"113", "114", "118", "184", "186"}:
|
||||||
return True
|
return True
|
||||||
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
|
if base_code in {"1286", "1411", "1421", "1431", "1441", "1511",
|
||||||
"1581", "1611", "1851", ""}:
|
"1521", "1581", "1611", "1851"}:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
# Liabilities
|
# Liabilities
|
||||||
if base_code[0] == "2":
|
if base_code[0] == "2":
|
||||||
if base_code in {"2111", "2114", "2284", "2293"}:
|
if base_code in {"2111", "2114", "2284", "2293", "2861"}:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
# Only assets and liabilities need offset
|
# Only assets and liabilities need offset
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
|
|
||||||
-> None:
|
|
||||||
"""Adds the accounts.
|
|
||||||
|
|
||||||
:param data: A list of (base code, number, title) tuples.
|
|
||||||
:param creator_pk: The primary key of the creator.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
accounts: list[Account] = [Account(id=x[0],
|
|
||||||
base_code=x[1],
|
|
||||||
no=x[2],
|
|
||||||
title_l10n=x[3],
|
|
||||||
is_need_offset=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()
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
from flask import abort
|
from flask import abort
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from accounting.models import Account
|
from ..models import Account
|
||||||
|
|
||||||
|
|
||||||
class AccountConverter(BaseConverter):
|
class AccountConverter(BaseConverter):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -23,12 +23,12 @@ from flask_wtf import FlaskForm
|
|||||||
from wtforms import StringField, BooleanField
|
from wtforms import StringField, BooleanField
|
||||||
from wtforms.validators import DataRequired, ValidationError
|
from wtforms.validators import DataRequired, ValidationError
|
||||||
|
|
||||||
from accounting import db
|
from .. import db
|
||||||
from accounting.locale import lazy_gettext
|
from ..locale import lazy_gettext
|
||||||
from accounting.models import BaseAccount, Account
|
from ..models import BaseAccount, Account
|
||||||
from accounting.utils.random_id import new_id
|
from ..utils.random_id import new_id
|
||||||
from accounting.utils.strip_text import strip_text
|
from ..utils.strip_text import strip_text
|
||||||
from accounting.utils.user import get_current_user_pk
|
from ..utils.user import get_current_user_pk
|
||||||
|
|
||||||
|
|
||||||
class BaseAccountExists:
|
class BaseAccountExists:
|
||||||
@@ -97,8 +97,9 @@ class AccountForm(FlaskForm):
|
|||||||
if obj.base_code is not None:
|
if obj.base_code is not None:
|
||||||
sort_accounts_in(obj.base_code, obj.id)
|
sort_accounts_in(obj.base_code, obj.id)
|
||||||
sort_accounts_in(self.base_code.data, obj.id)
|
sort_accounts_in(self.base_code.data, obj.id)
|
||||||
count: int = Account.query\
|
count: int = db.session.scalar(
|
||||||
.filter(Account.base_code == self.base_code.data).count()
|
sa.select(sa.func.count(Account.id))
|
||||||
|
.where(Account.base_code == self.base_code.data))
|
||||||
obj.base_code = self.base_code.data
|
obj.base_code = self.base_code.data
|
||||||
obj.no = count + 1
|
obj.no = count + 1
|
||||||
obj.title = self.title.data
|
obj.title = self.title.data
|
||||||
@@ -137,9 +138,10 @@ class AccountForm(FlaskForm):
|
|||||||
|
|
||||||
:return: The selectable base accounts.
|
:return: The selectable base accounts.
|
||||||
"""
|
"""
|
||||||
return BaseAccount.query\
|
return db.session.scalars(
|
||||||
.filter(sa.func.char_length(BaseAccount.code) == 4)\
|
sa.select(BaseAccount)
|
||||||
.order_by(BaseAccount.code).all()
|
.where(sa.func.char_length(BaseAccount.code) == 4)
|
||||||
|
.order_by(BaseAccount.code)).unique()
|
||||||
|
|
||||||
|
|
||||||
def sort_accounts_in(base_code: str, exclude: int) -> None:
|
def sort_accounts_in(base_code: str, exclude: int) -> None:
|
||||||
@@ -150,10 +152,10 @@ def sort_accounts_in(base_code: str, exclude: int) -> None:
|
|||||||
:param exclude: The account ID to exclude.
|
:param exclude: The account ID to exclude.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
accounts: list[Account] = Account.query\
|
accounts: list[Account] = db.session.scalars(
|
||||||
.filter(Account.base_code == base_code,
|
sa.select(Account)
|
||||||
Account.id != exclude)\
|
.where(Account.base_code == base_code, Account.id != exclude)
|
||||||
.order_by(Account.no).all()
|
.order_by(Account.no)).unique().all()
|
||||||
for i in range(len(accounts)):
|
for i in range(len(accounts)):
|
||||||
if accounts[i].no != i + 1:
|
if accounts[i].no != i + 1:
|
||||||
accounts[i].no = i + 1
|
accounts[i].no = i + 1
|
||||||
@@ -168,7 +170,9 @@ class AccountReorderForm:
|
|||||||
:param base: The base account.
|
:param base: The base account.
|
||||||
"""
|
"""
|
||||||
self.base: BaseAccount = base
|
self.base: BaseAccount = base
|
||||||
|
"""The base account."""
|
||||||
self.is_modified: bool = False
|
self.is_modified: bool = False
|
||||||
|
"""Whether the order is modified."""
|
||||||
|
|
||||||
def save_order(self) -> None:
|
def save_order(self) -> None:
|
||||||
"""Saves the order of the account.
|
"""Saves the order of the account.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -20,9 +20,10 @@
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from accounting.locale import gettext
|
from .. import db
|
||||||
from accounting.models import Account, AccountL10n
|
from ..locale import gettext
|
||||||
from accounting.utils.query import parse_query_keywords
|
from ..models import Account, AccountL10n
|
||||||
|
from ..utils.query import parse_query_keywords
|
||||||
|
|
||||||
|
|
||||||
def get_account_query() -> list[Account]:
|
def get_account_query() -> list[Account]:
|
||||||
@@ -32,17 +33,20 @@ def get_account_query() -> list[Account]:
|
|||||||
"""
|
"""
|
||||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||||
if len(keywords) == 0:
|
if len(keywords) == 0:
|
||||||
return Account.query.order_by(Account.base_code, Account.no).all()
|
return db.session.scalars(
|
||||||
code: sa.BinaryExpression = Account.base_code + "-" \
|
sa.select(Account)
|
||||||
|
.order_by(Account.base_code, Account.no)).unique().all()
|
||||||
|
code: sa.ColumnElement[str] = Account.base_code + "-" \
|
||||||
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
|
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
|
||||||
sa.func.char_length(sa.cast(Account.no,
|
sa.func.char_length(sa.cast(Account.no,
|
||||||
sa.String)) + 1)
|
sa.String)) + 1)
|
||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.ColumnElement[bool]] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
l10n: list[AccountL10n] = AccountL10n.query\
|
l10n: list[AccountL10n] = db.session.scalars(
|
||||||
.filter(AccountL10n.title.icontains(k)).all()
|
sa.select(AccountL10n)
|
||||||
l10n_matches: set[str] = {x.account_id for x in l10n}
|
.where(AccountL10n.title.icontains(k))).all()
|
||||||
sub_conditions: list[sa.BinaryExpression] \
|
l10n_matches: set[int] = {x.account_id for x in l10n}
|
||||||
|
sub_conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [Account.base_code.contains(k),
|
= [Account.base_code.contains(k),
|
||||||
Account.title_l10n.icontains(k),
|
Account.title_l10n.icontains(k),
|
||||||
code.contains(k),
|
code.contains(k),
|
||||||
@@ -51,5 +55,6 @@ def get_account_query() -> list[Account]:
|
|||||||
sub_conditions.append(Account.is_need_offset)
|
sub_conditions.append(Account.is_need_offset)
|
||||||
conditions.append(sa.or_(*sub_conditions))
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
|
|
||||||
return Account.query.filter(*conditions)\
|
return db.session.scalars(
|
||||||
.order_by(Account.base_code, Account.no).all()
|
sa.select(Account).where(*conditions)
|
||||||
|
.order_by(Account.base_code, Account.no)).unique().all()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -21,20 +21,20 @@ from urllib.parse import parse_qsl, urlencode
|
|||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import Blueprint, render_template, session, redirect, flash, \
|
from flask import Blueprint, render_template, session, redirect, flash, \
|
||||||
url_for, request
|
url_for, request, Response
|
||||||
from werkzeug.datastructures import ImmutableMultiDict
|
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
|
|
||||||
from accounting.utils.permission import can_view, has_permission, can_edit
|
|
||||||
from accounting.utils.user import get_current_user_pk
|
|
||||||
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
|
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
|
||||||
from .queries import get_account_query
|
from .queries import get_account_query
|
||||||
|
from .. import db
|
||||||
|
from ..locale import lazy_gettext
|
||||||
|
from ..models import Account, BaseAccount
|
||||||
|
from ..utils.cast import s
|
||||||
|
from ..utils.flash_errors import flash_form_errors
|
||||||
|
from ..utils.next_uri import inherit_next, or_next
|
||||||
|
from ..utils.pagination import Pagination
|
||||||
|
from ..utils.permission import can_view, has_permission, can_edit
|
||||||
|
from ..utils.user import get_current_user_pk
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("account", __name__)
|
bp: Blueprint = Blueprint("account", __name__)
|
||||||
"""The view blueprint for the account management."""
|
"""The view blueprint for the account management."""
|
||||||
@@ -47,13 +47,13 @@ def list_accounts() -> str:
|
|||||||
|
|
||||||
:return: The account list.
|
:return: The account list.
|
||||||
"""
|
"""
|
||||||
accounts: list[BaseAccount] = get_account_query()
|
accounts: list[Account] = get_account_query()
|
||||||
pagination: Pagination = Pagination[BaseAccount](accounts)
|
pagination: Pagination = Pagination[Account](accounts)
|
||||||
return render_template("accounting/account/list.html",
|
return render_template("accounting/account/list.html",
|
||||||
list=pagination.list, pagination=pagination)
|
list=pagination.list, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/create", endpoint="create")
|
@bp.get("create", endpoint="create")
|
||||||
@has_permission(can_edit)
|
@has_permission(can_edit)
|
||||||
def show_add_account_form() -> str:
|
def show_add_account_form() -> str:
|
||||||
"""Shows the form to add an account.
|
"""Shows the form to add an account.
|
||||||
@@ -70,9 +70,9 @@ def show_add_account_form() -> str:
|
|||||||
form=form)
|
form=form)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/store", endpoint="store")
|
@bp.post("store", endpoint="store")
|
||||||
@has_permission(can_edit)
|
@has_permission(can_edit)
|
||||||
def add_account() -> redirect:
|
def add_account() -> Response:
|
||||||
"""Adds an account.
|
"""Adds an account.
|
||||||
|
|
||||||
:return: The redirection to the account detail on success, or the account
|
:return: The redirection to the account detail on success, or the account
|
||||||
@@ -91,7 +91,7 @@ def add_account() -> redirect:
|
|||||||
return redirect(inherit_next(__get_detail_uri(account)))
|
return redirect(inherit_next(__get_detail_uri(account)))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<account:account>", endpoint="detail")
|
@bp.get("<account:account>", endpoint="detail")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def show_account_detail(account: Account) -> str:
|
def show_account_detail(account: Account) -> str:
|
||||||
"""Shows the account detail.
|
"""Shows the account detail.
|
||||||
@@ -102,7 +102,7 @@ def show_account_detail(account: Account) -> str:
|
|||||||
return render_template("accounting/account/detail.html", obj=account)
|
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)
|
@has_permission(can_edit)
|
||||||
def show_account_edit_form(account: Account) -> str:
|
def show_account_edit_form(account: Account) -> str:
|
||||||
"""Shows the form to edit an account.
|
"""Shows the form to edit an account.
|
||||||
@@ -121,9 +121,9 @@ def show_account_edit_form(account: Account) -> str:
|
|||||||
account=account, form=form)
|
account=account, form=form)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<account:account>/update", endpoint="update")
|
@bp.post("<account:account>/update", endpoint="update")
|
||||||
@has_permission(can_edit)
|
@has_permission(can_edit)
|
||||||
def update_account(account: Account) -> redirect:
|
def update_account(account: Account) -> Response:
|
||||||
"""Updates an account.
|
"""Updates an account.
|
||||||
|
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
@@ -148,9 +148,9 @@ def update_account(account: Account) -> redirect:
|
|||||||
return redirect(inherit_next(__get_detail_uri(account)))
|
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)
|
@has_permission(can_edit)
|
||||||
def delete_account(account: Account) -> redirect:
|
def delete_account(account: Account) -> Response:
|
||||||
"""Deletes an account.
|
"""Deletes an account.
|
||||||
|
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
@@ -167,7 +167,7 @@ def delete_account(account: Account) -> redirect:
|
|||||||
return redirect(or_next(__get_list_uri()))
|
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)
|
@has_permission(can_view)
|
||||||
def show_account_order(base: BaseAccount) -> str:
|
def show_account_order(base: BaseAccount) -> str:
|
||||||
"""Shows the order of the accounts under a same base account.
|
"""Shows the order of the accounts under a same base account.
|
||||||
@@ -178,9 +178,9 @@ def show_account_order(base: BaseAccount) -> str:
|
|||||||
return render_template("accounting/account/order.html", base=base)
|
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)
|
@has_permission(can_edit)
|
||||||
def sort_accounts(base: BaseAccount) -> redirect:
|
def sort_accounts(base: BaseAccount) -> Response:
|
||||||
"""Reorders the accounts under a base account.
|
"""Reorders the accounts under a base account.
|
||||||
|
|
||||||
:param base: The base account.
|
:param base: The base account.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
"""
|
"""
|
||||||
from flask import Flask, Blueprint
|
from flask import Flask, Blueprint
|
||||||
|
|
||||||
|
from .commands import init_base_accounts_command
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||||
"""Initialize the application.
|
"""Initialize the application.
|
||||||
@@ -32,6 +34,3 @@ def init_app(app: Flask, bp: Blueprint) -> None:
|
|||||||
|
|
||||||
from .views import bp as base_account_bp
|
from .views import bp as base_account_bp
|
||||||
bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")
|
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,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -19,33 +19,27 @@
|
|||||||
"""
|
"""
|
||||||
import csv
|
import csv
|
||||||
|
|
||||||
import click
|
import sqlalchemy as sa
|
||||||
from flask.cli import with_appcontext
|
|
||||||
|
|
||||||
from accounting import data_dir
|
from .. import db, data_dir
|
||||||
from accounting import db
|
from ..models import BaseAccount, BaseAccountL10n
|
||||||
from accounting.models import BaseAccount, BaseAccountL10n
|
from ..utils.title_case import title_case
|
||||||
|
|
||||||
|
|
||||||
@click.command("accounting-init-base")
|
|
||||||
@with_appcontext
|
|
||||||
def init_base_accounts_command() -> None:
|
def init_base_accounts_command() -> None:
|
||||||
"""Initializes the base accounts."""
|
"""Initializes the base accounts."""
|
||||||
if BaseAccount.query.first() is not None:
|
if db.session.scalar(sa.select(BaseAccount)) is not None:
|
||||||
click.echo("Base accounts already exist.")
|
return
|
||||||
raise click.Abort
|
|
||||||
|
|
||||||
with open(data_dir / "base_accounts.csv") as fp:
|
with open(data_dir / "base_accounts.csv") as fp:
|
||||||
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
||||||
account_data: list[dict[str, str]] = [{"code": x["code"],
|
account_data: list[dict[str, str]] = \
|
||||||
"title_l10n": x["title"]}
|
[{"code": x["code"], "title_l10n": title_case(x["title"])}
|
||||||
for x in data]
|
for x in data]
|
||||||
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
|
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
|
||||||
l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
|
l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
|
||||||
"locale": y,
|
"locale": y,
|
||||||
"title": x[f"l10n-{y}"]}
|
"title": x[f"l10n-{y}"]}
|
||||||
for x in data for y in locales]
|
for x in data for y in locales]
|
||||||
db.session.bulk_insert_mappings(BaseAccount, account_data)
|
db.session.execute(sa.insert(BaseAccount), account_data)
|
||||||
db.session.bulk_insert_mappings(BaseAccountL10n, l10n_data)
|
db.session.execute(sa.insert(BaseAccountL10n), l10n_data)
|
||||||
db.session.commit()
|
|
||||||
click.echo("Base accounts initialized.")
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
from flask import abort
|
from flask import abort
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from accounting import db
|
from .. import db
|
||||||
from accounting.models import BaseAccount
|
from ..models import BaseAccount
|
||||||
|
|
||||||
|
|
||||||
class BaseAccountConverter(BaseConverter):
|
class BaseAccountConverter(BaseConverter):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -20,8 +20,9 @@
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from accounting.models import BaseAccount, BaseAccountL10n
|
from .. import db
|
||||||
from accounting.utils.query import parse_query_keywords
|
from ..models import BaseAccount, BaseAccountL10n
|
||||||
|
from ..utils.query import parse_query_keywords
|
||||||
|
|
||||||
|
|
||||||
def get_base_account_query() -> list[BaseAccount]:
|
def get_base_account_query() -> list[BaseAccount]:
|
||||||
@@ -31,14 +32,17 @@ def get_base_account_query() -> list[BaseAccount]:
|
|||||||
"""
|
"""
|
||||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||||
if len(keywords) == 0:
|
if len(keywords) == 0:
|
||||||
return BaseAccount.query.order_by(BaseAccount.code).all()
|
return db.session.scalars(
|
||||||
conditions: list[sa.BinaryExpression] = []
|
sa.select(BaseAccount).order_by(BaseAccount.code)).unique().all()
|
||||||
|
conditions: list[sa.ColumnElement[bool]] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
|
l10n: list[BaseAccountL10n] = db.session.scalars(
|
||||||
.filter(BaseAccountL10n.title.icontains(k)).all()
|
sa.select(BaseAccountL10n)
|
||||||
|
.where((BaseAccountL10n.title.icontains(k)))).all()
|
||||||
l10n_matches: set[str] = {x.account_code for x in l10n}
|
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||||
conditions.append(sa.or_(BaseAccount.code.contains(k),
|
conditions.append(sa.or_(BaseAccount.code.contains(k),
|
||||||
BaseAccount.title_l10n.icontains(k),
|
BaseAccount.title_l10n.icontains(k),
|
||||||
BaseAccount.code.in_(l10n_matches)))
|
BaseAccount.code.in_(l10n_matches)))
|
||||||
return BaseAccount.query.filter(*conditions)\
|
return db.session.scalars(
|
||||||
.order_by(BaseAccount.code).all()
|
sa.select(BaseAccount).where(*conditions)
|
||||||
|
.order_by(BaseAccount.code)).unique().all()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -19,9 +19,10 @@
|
|||||||
"""
|
"""
|
||||||
from flask import Blueprint, render_template
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
from accounting.models import BaseAccount
|
from .queries import get_base_account_query
|
||||||
from accounting.utils.pagination import Pagination
|
from ..models import BaseAccount
|
||||||
from accounting.utils.permission import has_permission, can_view
|
from ..utils.pagination import Pagination
|
||||||
|
from ..utils.permission import has_permission, can_view
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("base-account", __name__)
|
bp: Blueprint = Blueprint("base-account", __name__)
|
||||||
"""The view blueprint for the base account management."""
|
"""The view blueprint for the base account management."""
|
||||||
@@ -34,14 +35,13 @@ def list_accounts() -> str:
|
|||||||
|
|
||||||
:return: The account list.
|
:return: The account list.
|
||||||
"""
|
"""
|
||||||
from .queries import get_base_account_query
|
|
||||||
accounts: list[BaseAccount] = get_base_account_query()
|
accounts: list[BaseAccount] = get_base_account_query()
|
||||||
pagination: Pagination = Pagination[BaseAccount](accounts)
|
pagination: Pagination = Pagination[BaseAccount](accounts)
|
||||||
return render_template("accounting/base-account/list.html",
|
return render_template("accounting/base-account/list.html",
|
||||||
list=pagination.list, pagination=pagination)
|
list=pagination.list, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<baseAccount:account>", endpoint="detail")
|
@bp.get("<baseAccount:account>", endpoint="detail")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def show_account_detail(account: BaseAccount) -> str:
|
def show_account_detail(account: BaseAccount) -> str:
|
||||||
"""Shows the account detail.
|
"""Shows the account detail.
|
||||||
@@ -50,4 +50,3 @@ def show_account_detail(account: BaseAccount) -> str:
|
|||||||
:return: The detail.
|
:return: The detail.
|
||||||
"""
|
"""
|
||||||
return render_template("accounting/base-account/detail.html", obj=account)
|
return render_template("accounting/base-account/detail.html", obj=account)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
|
||||||
|
|
||||||
|
# Copyright (c) 2023-2026 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
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .account import init_accounts_command
|
||||||
|
from .base_account import init_base_accounts_command
|
||||||
|
from .currency import init_currencies_command
|
||||||
|
from .models import BaseAccount, Account
|
||||||
|
from .utils.title_case import title_case
|
||||||
|
from .utils.user import base_cls, has_user, 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-db")
|
||||||
|
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||||
|
help="The username.", callback=__validate_username,
|
||||||
|
default=lambda: os.getlogin())
|
||||||
|
@click.option("--skip-accounts", is_flag=True, default=False,
|
||||||
|
help="Skip initializing accounts.")
|
||||||
|
@click.option("--skip-currencies", is_flag=True, default=False,
|
||||||
|
help="Skip initializing currencies.")
|
||||||
|
@with_appcontext
|
||||||
|
def init_db_command(username: str, skip_accounts: bool,
|
||||||
|
skip_currencies: bool) -> None:
|
||||||
|
"""Initializes the accounting database."""
|
||||||
|
base_cls.metadata.create_all(db.engine)
|
||||||
|
init_base_accounts_command()
|
||||||
|
if not skip_accounts:
|
||||||
|
init_accounts_command(username)
|
||||||
|
print("OK 1")
|
||||||
|
if not skip_currencies:
|
||||||
|
init_currencies_command(username)
|
||||||
|
print("OK 2")
|
||||||
|
db.session.commit()
|
||||||
|
click.echo("Accounting database initialized.")
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("accounting-titleize")
|
||||||
|
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||||
|
help="The username.", callback=__validate_username,
|
||||||
|
default=lambda: os.getlogin())
|
||||||
|
@with_appcontext
|
||||||
|
def titleize_command(username: str) -> None:
|
||||||
|
"""Capitalize the account titles."""
|
||||||
|
updater_pk: int = get_user_pk(username)
|
||||||
|
updated: int = 0
|
||||||
|
for base in db.session.scalars(sa.select(BaseAccount)).unique():
|
||||||
|
new_title: str = title_case(base.title_l10n)
|
||||||
|
if base.title_l10n != new_title:
|
||||||
|
base.title_l10n = new_title
|
||||||
|
updated = updated + 1
|
||||||
|
for account in db.session.scalars(sa.select(Account)).unique():
|
||||||
|
if account.title_l10n.lower() == account.base.title_l10n.lower():
|
||||||
|
new_title: str = title_case(account.title_l10n)
|
||||||
|
if account.title_l10n != new_title:
|
||||||
|
account.title_l10n = new_title
|
||||||
|
account.updated_at = sa.func.now()
|
||||||
|
account.updated_by_id = updater_pk
|
||||||
|
updated = updated + 1
|
||||||
|
if updated == 0:
|
||||||
|
click.echo("All account titles were already capitalized.")
|
||||||
|
return
|
||||||
|
db.session.commit()
|
||||||
|
click.echo(f"{updated} account titles capitalized.")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@@ -19,6 +19,8 @@
|
|||||||
"""
|
"""
|
||||||
from flask import Flask, Blueprint
|
from flask import Flask, Blueprint
|
||||||
|
|
||||||
|
from .commands import init_currencies_command
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||||
"""Initialize the application.
|
"""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
|
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_bp, url_prefix="/currencies")
|
||||||
bp.register_blueprint(currency_api_bp, url_prefix="/api/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,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -18,67 +18,37 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import csv
|
import csv
|
||||||
import os
|
from typing import Any
|
||||||
import typing as t
|
|
||||||
|
|
||||||
import click
|
import sqlalchemy as sa
|
||||||
from flask.cli import with_appcontext
|
|
||||||
|
|
||||||
from accounting import db, data_dir
|
from .. import db, data_dir
|
||||||
from accounting.models import Currency, CurrencyL10n
|
from ..models import Currency, CurrencyL10n
|
||||||
from accounting.utils.user import has_user, get_user_pk
|
from ..utils.user import get_user_pk
|
||||||
|
|
||||||
CurrencyData = tuple[str, str, str, str]
|
|
||||||
|
|
||||||
|
|
||||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
|
||||||
value: str) -> str:
|
|
||||||
"""Validates the username for the click console command.
|
|
||||||
|
|
||||||
:param ctx: The console command context.
|
|
||||||
:param param: The console command option.
|
|
||||||
:param value: The username.
|
|
||||||
:raise click.BadParameter: When validation fails.
|
|
||||||
:return: The username.
|
|
||||||
"""
|
|
||||||
value = value.strip()
|
|
||||||
if value == "":
|
|
||||||
raise click.BadParameter("Username empty.")
|
|
||||||
if not has_user(value):
|
|
||||||
raise click.BadParameter(f"User {value} does not exist.")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@click.command("accounting-init-currencies")
|
|
||||||
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
|
||||||
help="The username.", callback=__validate_username,
|
|
||||||
default=lambda: os.getlogin())
|
|
||||||
@with_appcontext
|
|
||||||
def init_currencies_command(username: str) -> None:
|
def init_currencies_command(username: str) -> None:
|
||||||
"""Initializes the currencies."""
|
"""Initializes the currencies."""
|
||||||
existing_codes: set[str] = {x.code for x in Currency.query.all()}
|
existing_codes: set[str] = \
|
||||||
|
{x.code for x in db.session.scalars(sa.select(Currency)).unique()}
|
||||||
|
|
||||||
with open(data_dir / "currencies.csv") as fp:
|
with open(data_dir / "currencies.csv") as fp:
|
||||||
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
||||||
to_add: list[dict[str, str]] = [x for x in data
|
to_add: list[dict[str, str]] = [x for x in data
|
||||||
if x["code"] not in existing_codes]
|
if x["code"] not in existing_codes]
|
||||||
if len(to_add) == 0:
|
if len(to_add) == 0:
|
||||||
click.echo("No more currency to add.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
creator_pk: int = get_user_pk(username)
|
creator_pk: int = get_user_pk(username)
|
||||||
currency_data: list[dict[str, t.Any]] = [{"code": x["code"],
|
currency_data: list[dict[str, Any]] = [{"code": x["code"],
|
||||||
"name_l10n": x["name"],
|
"name_l10n": x["name"],
|
||||||
"created_by_id": creator_pk,
|
"created_by_id": creator_pk,
|
||||||
"updated_by_id": creator_pk}
|
"updated_by_id": creator_pk}
|
||||||
for x in to_add]
|
for x in to_add]
|
||||||
locales: list[str] = [x[5:] for x in to_add[0] if x.startswith("l10n-")]
|
locales: list[str] = [x[5:] for x in to_add[0] if x.startswith("l10n-")]
|
||||||
l10n_data: list[dict[str, str]] = [{"currency_code": x["code"],
|
l10n_data: list[dict[str, str]] = [{"currency_code": x["code"],
|
||||||
"locale": y,
|
"locale": y,
|
||||||
"name": x[f"l10n-{y}"]}
|
"name": x[f"l10n-{y}"]}
|
||||||
for x in to_add for y in locales]
|
for x in to_add for y in locales]
|
||||||
db.session.bulk_insert_mappings(Currency, currency_data)
|
db.session.execute(sa.insert(Currency), currency_data)
|
||||||
db.session.bulk_insert_mappings(CurrencyL10n, l10n_data)
|
db.session.execute(sa.insert(CurrencyL10n), l10n_data)
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
click.echo(F"{len(to_add)} added. Currencies initialized.")
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
from flask import abort
|
from flask import abort
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from accounting import db
|
from .. import db
|
||||||
from accounting.models import Currency
|
from ..models import Currency
|
||||||
|
|
||||||
|
|
||||||
class CurrencyConverter(BaseConverter):
|
class CurrencyConverter(BaseConverter):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -21,11 +21,11 @@ from flask_wtf import FlaskForm
|
|||||||
from wtforms import StringField, ValidationError
|
from wtforms import StringField, ValidationError
|
||||||
from wtforms.validators import DataRequired, Regexp, NoneOf
|
from wtforms.validators import DataRequired, Regexp, NoneOf
|
||||||
|
|
||||||
from accounting import db
|
from .. import db
|
||||||
from accounting.locale import lazy_gettext
|
from ..locale import lazy_gettext
|
||||||
from accounting.models import Currency
|
from ..models import Currency
|
||||||
from accounting.utils.strip_text import strip_text
|
from ..utils.strip_text import strip_text
|
||||||
from accounting.utils.user import get_current_user_pk
|
from ..utils.user import get_current_user_pk
|
||||||
|
|
||||||
|
|
||||||
class CodeUnique:
|
class CodeUnique:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -20,8 +20,9 @@
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from accounting.models import Currency, CurrencyL10n
|
from .. import db
|
||||||
from accounting.utils.query import parse_query_keywords
|
from ..models import Currency, CurrencyL10n
|
||||||
|
from ..utils.query import parse_query_keywords
|
||||||
|
|
||||||
|
|
||||||
def get_currency_query() -> list[Currency]:
|
def get_currency_query() -> list[Currency]:
|
||||||
@@ -31,14 +32,17 @@ def get_currency_query() -> list[Currency]:
|
|||||||
"""
|
"""
|
||||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||||
if len(keywords) == 0:
|
if len(keywords) == 0:
|
||||||
return Currency.query.order_by(Currency.code).all()
|
return db.session.scalars(
|
||||||
conditions: list[sa.BinaryExpression] = []
|
sa.select(Currency).order_by(Currency.code)).unique().all()
|
||||||
|
conditions: list[sa.ColumnElement[bool]] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
l10n: list[CurrencyL10n] = db.session.scalars(
|
||||||
.filter(CurrencyL10n.name.icontains(k)).all()
|
sa.select(CurrencyL10n)
|
||||||
|
.where(CurrencyL10n.name.icontains(k))).all()
|
||||||
l10n_matches: set[str] = {x.account_code for x in l10n}
|
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||||
conditions.append(sa.or_(Currency.code.icontains(k),
|
conditions.append(sa.or_(Currency.code.icontains(k),
|
||||||
Currency.name_l10n.icontains(k),
|
Currency.name_l10n.icontains(k),
|
||||||
Currency.code.in_(l10n_matches)))
|
Currency.code.in_(l10n_matches)))
|
||||||
return Currency.query.filter(*conditions)\
|
return db.session.scalars(
|
||||||
.order_by(Currency.code).all()
|
sa.select(Currency).where(*conditions)
|
||||||
|
.order_by(Currency.code)).unique().all()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -21,19 +21,20 @@ from urllib.parse import urlencode, parse_qsl
|
|||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import Blueprint, render_template, redirect, session, request, \
|
from flask import Blueprint, render_template, redirect, session, request, \
|
||||||
flash, url_for
|
flash, url_for, Response
|
||||||
from werkzeug.datastructures import ImmutableMultiDict
|
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 .forms import CurrencyForm
|
||||||
|
from .queries import get_currency_query
|
||||||
|
from .. import db
|
||||||
|
from ..locale import lazy_gettext
|
||||||
|
from ..models import Currency
|
||||||
|
from ..utils.cast import s
|
||||||
|
from ..utils.flash_errors import flash_form_errors
|
||||||
|
from ..utils.next_uri import inherit_next, or_next
|
||||||
|
from ..utils.pagination import Pagination
|
||||||
|
from ..utils.permission import has_permission, can_view, can_edit
|
||||||
|
from ..utils.user import get_current_user_pk
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("currency", __name__)
|
bp: Blueprint = Blueprint("currency", __name__)
|
||||||
"""The view blueprint for the currency management."""
|
"""The view blueprint for the currency management."""
|
||||||
@@ -48,14 +49,13 @@ def list_currencies() -> str:
|
|||||||
|
|
||||||
:return: The currency list.
|
:return: The currency list.
|
||||||
"""
|
"""
|
||||||
from .queries import get_currency_query
|
|
||||||
currencies: list[Currency] = get_currency_query()
|
currencies: list[Currency] = get_currency_query()
|
||||||
pagination: Pagination = Pagination[Currency](currencies)
|
pagination: Pagination = Pagination[Currency](currencies)
|
||||||
return render_template("accounting/currency/list.html",
|
return render_template("accounting/currency/list.html",
|
||||||
list=pagination.list, pagination=pagination)
|
list=pagination.list, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/create", endpoint="create")
|
@bp.get("create", endpoint="create")
|
||||||
@has_permission(can_edit)
|
@has_permission(can_edit)
|
||||||
def show_add_currency_form() -> str:
|
def show_add_currency_form() -> str:
|
||||||
"""Shows the form to add a currency.
|
"""Shows the form to add a currency.
|
||||||
@@ -72,9 +72,9 @@ def show_add_currency_form() -> str:
|
|||||||
form=form)
|
form=form)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/store", endpoint="store")
|
@bp.post("store", endpoint="store")
|
||||||
@has_permission(can_edit)
|
@has_permission(can_edit)
|
||||||
def add_currency() -> redirect:
|
def add_currency() -> Response:
|
||||||
"""Adds a currency.
|
"""Adds a currency.
|
||||||
|
|
||||||
:return: The redirection to the currency detail on success, or the currency
|
:return: The redirection to the currency detail on success, or the currency
|
||||||
@@ -93,7 +93,7 @@ def add_currency() -> redirect:
|
|||||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
return redirect(inherit_next(__get_detail_uri(currency)))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<currency:currency>", endpoint="detail")
|
@bp.get("<currency:currency>", endpoint="detail")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def show_currency_detail(currency: Currency) -> str:
|
def show_currency_detail(currency: Currency) -> str:
|
||||||
"""Shows the currency detail.
|
"""Shows the currency detail.
|
||||||
@@ -104,7 +104,7 @@ def show_currency_detail(currency: Currency) -> str:
|
|||||||
return render_template("accounting/currency/detail.html", obj=currency)
|
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)
|
@has_permission(can_edit)
|
||||||
def show_currency_edit_form(currency: Currency) -> str:
|
def show_currency_edit_form(currency: Currency) -> str:
|
||||||
"""Shows the form to edit a currency.
|
"""Shows the form to edit a currency.
|
||||||
@@ -123,9 +123,9 @@ def show_currency_edit_form(currency: Currency) -> str:
|
|||||||
currency=currency, form=form)
|
currency=currency, form=form)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<currency:currency>/update", endpoint="update")
|
@bp.post("<currency:currency>/update", endpoint="update")
|
||||||
@has_permission(can_edit)
|
@has_permission(can_edit)
|
||||||
def update_currency(currency: Currency) -> redirect:
|
def update_currency(currency: Currency) -> Response:
|
||||||
"""Updates a currency.
|
"""Updates a currency.
|
||||||
|
|
||||||
:param currency: The currency.
|
:param currency: The currency.
|
||||||
@@ -151,9 +151,9 @@ def update_currency(currency: Currency) -> redirect:
|
|||||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
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)
|
@has_permission(can_edit)
|
||||||
def delete_currency(currency: Currency) -> redirect:
|
def delete_currency(currency: Currency) -> Response:
|
||||||
"""Deletes a currency.
|
"""Deletes a currency.
|
||||||
|
|
||||||
:param currency: The currency.
|
:param currency: The currency.
|
||||||
@@ -169,7 +169,7 @@ def delete_currency(currency: Currency) -> redirect:
|
|||||||
return redirect(or_next(url_for("accounting.currency.list")))
|
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)
|
@has_permission(can_edit)
|
||||||
def exists_code() -> dict[str, bool]:
|
def exists_code() -> dict[str, bool]:
|
||||||
"""Validates whether a currency code exists.
|
"""Validates whether a currency code exists.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -24,10 +24,9 @@ from flask_wtf import FlaskForm
|
|||||||
from wtforms import StringField, ValidationError
|
from wtforms import StringField, ValidationError
|
||||||
from wtforms.validators import DataRequired
|
from wtforms.validators import DataRequired
|
||||||
|
|
||||||
from accounting import db
|
from . import db
|
||||||
from accounting.locale import lazy_gettext
|
from .locale import lazy_gettext
|
||||||
from accounting.models import Currency, Account
|
from .models import Currency, Account
|
||||||
|
|
||||||
|
|
||||||
ACCOUNT_REQUIRED: DataRequired = DataRequired(
|
ACCOUNT_REQUIRED: DataRequired = DataRequired(
|
||||||
lazy_gettext("Please select the account."))
|
lazy_gettext("Please select the account."))
|
||||||
@@ -65,12 +64,12 @@ class IsDebitAccount:
|
|||||||
:param message: The error message.
|
:param message: The error message.
|
||||||
"""
|
"""
|
||||||
self.__message: str | LazyString = message
|
self.__message: str | LazyString = message
|
||||||
|
"""The error message."""
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
if field.data is None:
|
if field.data is None:
|
||||||
return
|
return
|
||||||
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
|
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
|
||||||
and not field.data.startswith("3351-") \
|
|
||||||
and not field.data.startswith("3353-"):
|
and not field.data.startswith("3353-"):
|
||||||
return
|
return
|
||||||
raise ValidationError(self.__message)
|
raise ValidationError(self.__message)
|
||||||
@@ -85,12 +84,12 @@ class IsCreditAccount:
|
|||||||
:param message: The error message.
|
:param message: The error message.
|
||||||
"""
|
"""
|
||||||
self.__message: str | LazyString = message
|
self.__message: str | LazyString = message
|
||||||
|
"""The error message."""
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
if field.data is None:
|
if field.data is None:
|
||||||
return
|
return
|
||||||
if re.match(r"^(?:[123489]|7[1234])", field.data) \
|
if re.match(r"^(?:[123489]|7[1234])", field.data) \
|
||||||
and not field.data.startswith("3351-") \
|
|
||||||
and not field.data.startswith("3353-"):
|
and not field.data.startswith("3353-"):
|
||||||
return
|
return
|
||||||
raise ValidationError(self.__message)
|
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
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,14 +17,14 @@
|
|||||||
"""The path converters for the journal entry management.
|
"""The path converters for the journal entry management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
import datetime as dt
|
||||||
|
|
||||||
from flask import abort
|
from flask import abort
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from accounting.models import JournalEntry, JournalEntryLineItem
|
from .. import db
|
||||||
from accounting.utils.journal_entry_types import JournalEntryType
|
from ..models import JournalEntry
|
||||||
|
from ..utils.journal_entry_types import JournalEntryType
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryConverter(BaseConverter):
|
class JournalEntryConverter(BaseConverter):
|
||||||
@@ -37,13 +37,8 @@ class JournalEntryConverter(BaseConverter):
|
|||||||
:param value: The journal entry ID.
|
:param value: The journal entry ID.
|
||||||
:return: The corresponding journal entry.
|
:return: The corresponding journal entry.
|
||||||
"""
|
"""
|
||||||
journal_entry: JournalEntry | None = JournalEntry.query\
|
journal_entry: JournalEntry | None \
|
||||||
.join(JournalEntryLineItem)\
|
= db.session.get(JournalEntry, value)
|
||||||
.filter(JournalEntry.id == value)\
|
|
||||||
.options(selectinload(JournalEntry.line_items)
|
|
||||||
.selectinload(JournalEntryLineItem.offsets)
|
|
||||||
.selectinload(JournalEntryLineItem.journal_entry))\
|
|
||||||
.first()
|
|
||||||
if journal_entry is None:
|
if journal_entry is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
return journal_entry
|
return journal_entry
|
||||||
@@ -87,18 +82,18 @@ class DateConverter(BaseConverter):
|
|||||||
"""The date converter to convert the ISO date from and to the
|
"""The date converter to convert the ISO date from and to the
|
||||||
corresponding date in the routes."""
|
corresponding date in the routes."""
|
||||||
|
|
||||||
def to_python(self, value: str) -> date:
|
def to_python(self, value: str) -> dt.date:
|
||||||
"""Converts an ISO date to a date.
|
"""Converts an ISO date to a date.
|
||||||
|
|
||||||
:param value: The ISO date.
|
:param value: The ISO date.
|
||||||
:return: The corresponding date.
|
:return: The corresponding date.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return date.fromisoformat(value)
|
return dt.date.fromisoformat(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
def to_url(self, value: date) -> str:
|
def to_url(self, value: dt.date) -> str:
|
||||||
"""Converts a date to its ISO date.
|
"""Converts a date to its ISO date.
|
||||||
|
|
||||||
:param value: The date.
|
:param value: The date.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -26,15 +26,13 @@ from wtforms import StringField, ValidationError, FieldList, IntegerField, \
|
|||||||
BooleanField, FormField
|
BooleanField, FormField
|
||||||
from wtforms.validators import DataRequired
|
from wtforms.validators import DataRequired
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.forms import CurrencyExists
|
|
||||||
from accounting.journal_entry.utils.offset_alias import offset_alias
|
|
||||||
from accounting.locale import lazy_gettext
|
|
||||||
from accounting.models import JournalEntryLineItem
|
|
||||||
from accounting.utils.cast import be
|
|
||||||
from accounting.utils.strip_text import strip_text
|
|
||||||
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
|
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
|
||||||
|
from ... import db
|
||||||
|
from ...forms import CurrencyExists
|
||||||
|
from ...locale import lazy_gettext
|
||||||
|
from ...models import JournalEntryLineItem
|
||||||
|
from ...utils.offset_alias import offset_alias
|
||||||
|
from ...utils.strip_text import strip_text
|
||||||
|
|
||||||
CURRENCY_REQUIRED: DataRequired = DataRequired(
|
CURRENCY_REQUIRED: DataRequired = DataRequired(
|
||||||
lazy_gettext("Please select the currency."))
|
lazy_gettext("Please select the currency."))
|
||||||
@@ -57,7 +55,7 @@ class SameCurrencyAsOriginalLineItems:
|
|||||||
return
|
return
|
||||||
original_line_item_currency_codes: set[str] = set(db.session.scalars(
|
original_line_item_currency_codes: set[str] = set(db.session.scalars(
|
||||||
sa.select(JournalEntryLineItem.currency_code)
|
sa.select(JournalEntryLineItem.currency_code)
|
||||||
.filter(JournalEntryLineItem.id.in_(original_line_item_id))).all())
|
.where(JournalEntryLineItem.id.in_(original_line_item_id))).all())
|
||||||
for currency_code in original_line_item_currency_codes:
|
for currency_code in original_line_item_currency_codes:
|
||||||
if field.data != currency_code:
|
if field.data != currency_code:
|
||||||
raise ValidationError(lazy_gettext(
|
raise ValidationError(lazy_gettext(
|
||||||
@@ -74,17 +72,17 @@ class KeepCurrencyWhenHavingOffset:
|
|||||||
if field.data is None:
|
if field.data is None:
|
||||||
return
|
return
|
||||||
offset: sa.Alias = offset_alias()
|
offset: sa.Alias = offset_alias()
|
||||||
original_line_items: list[JournalEntryLineItem]\
|
original_line_items: list[JournalEntryLineItem] = db.session.scalars(
|
||||||
= JournalEntryLineItem.query\
|
sa.select(JournalEntryLineItem)
|
||||||
.join(offset, be(JournalEntryLineItem.id
|
.join(offset,
|
||||||
== offset.c.original_line_item_id),
|
JournalEntryLineItem.id == offset.c.original_line_item_id,
|
||||||
isouter=True)\
|
isouter=True)
|
||||||
.filter(JournalEntryLineItem.id
|
.where(JournalEntryLineItem.id
|
||||||
.in_({x.id.data for x in form.line_items
|
.in_({x.id.data for x in form.line_items
|
||||||
if x.id.data is not None}))\
|
if x.id.data is not None}))
|
||||||
.group_by(JournalEntryLineItem.id,
|
.group_by(JournalEntryLineItem.id,
|
||||||
JournalEntryLineItem.currency_code)\
|
JournalEntryLineItem.currency_code)
|
||||||
.having(sa.func.count(offset.c.id) > 0).all()
|
.having(sa.func.count(offset.c.id) > 0)).unique().all()
|
||||||
for original_line_item in original_line_items:
|
for original_line_item in original_line_items:
|
||||||
if original_line_item.currency_code != field.data:
|
if original_line_item.currency_code != field.data:
|
||||||
raise ValidationError(lazy_gettext(
|
raise ValidationError(lazy_gettext(
|
||||||
@@ -154,8 +152,8 @@ class CurrencyForm(FlaskForm):
|
|||||||
line_item_id: set[int] = {x.id.data for x in line_item_forms
|
line_item_id: set[int] = {x.id.data for x in line_item_forms
|
||||||
if x.id.data is not None}
|
if x.id.data is not None}
|
||||||
select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
|
select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
|
||||||
.filter(JournalEntryLineItem.original_line_item_id
|
.where(JournalEntryLineItem.original_line_item_id
|
||||||
.in_(line_item_id))
|
.in_(line_item_id))
|
||||||
return db.session.scalar(select) > 0
|
return db.session.scalar(select) > 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import typing as t
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -28,21 +27,20 @@ from wtforms import DateField, FieldList, FormField, TextAreaField, \
|
|||||||
BooleanField
|
BooleanField
|
||||||
from wtforms.validators import DataRequired, ValidationError
|
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, \
|
from .currency import CurrencyForm, CashReceiptCurrencyForm, \
|
||||||
CashDisbursementCurrencyForm, TransferCurrencyForm
|
CashDisbursementCurrencyForm, TransferCurrencyForm
|
||||||
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
|
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
|
||||||
from .reorder import sort_journal_entries_in
|
from .reorder import sort_journal_entries_in
|
||||||
|
from ..utils.account_option import AccountOption
|
||||||
|
from ..utils.description_editor import DescriptionEditor
|
||||||
|
from ..utils.original_line_items import get_selectable_original_line_items
|
||||||
|
from ... import db
|
||||||
|
from ...locale import lazy_gettext
|
||||||
|
from ...models import JournalEntry, Account, JournalEntryLineItem, \
|
||||||
|
JournalEntryCurrency
|
||||||
|
from ...utils.random_id import new_id
|
||||||
|
from ...utils.strip_text import strip_multiline_text
|
||||||
|
from ...utils.user import get_current_user_pk
|
||||||
|
|
||||||
DATE_REQUIRED: DataRequired = DataRequired(
|
DATE_REQUIRED: DataRequired = DataRequired(
|
||||||
lazy_gettext("Please fill in the date."))
|
lazy_gettext("Please fill in the date."))
|
||||||
@@ -123,7 +121,7 @@ class JournalEntryForm(FlaskForm):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.is_modified: bool = False
|
self.is_modified: bool = False
|
||||||
"""Whether the journal entry is modified during populate_obj()."""
|
"""Whether the journal entry is modified during populate_obj()."""
|
||||||
self.collector: t.Type[LineItemCollector] = LineItemCollector
|
self.collector: type[LineItemCollector] = LineItemCollector
|
||||||
"""The line item collector. The default is the base abstract
|
"""The line item collector. The default is the base abstract
|
||||||
collector only to provide the correct type. The subclass forms should
|
collector only to provide the correct type. The subclass forms should
|
||||||
provide their own collectors."""
|
provide their own collectors."""
|
||||||
@@ -151,19 +149,19 @@ class JournalEntryForm(FlaskForm):
|
|||||||
is_new: bool = obj.id is None
|
is_new: bool = obj.id is None
|
||||||
if is_new:
|
if is_new:
|
||||||
obj.id = new_id(JournalEntry)
|
obj.id = new_id(JournalEntry)
|
||||||
self.date: DateField
|
|
||||||
self.__set_date(obj, self.date.data)
|
self.__set_date(obj, self.date.data)
|
||||||
obj.note = self.note.data
|
obj.note = self.note.data
|
||||||
|
|
||||||
collector_cls: t.Type[LineItemCollector] = self.collector
|
collector_cls: type[LineItemCollector] = self.collector
|
||||||
collector: collector_cls = collector_cls(self, obj)
|
collector: collector_cls = collector_cls(self, obj)
|
||||||
collector.collect()
|
collector.collect()
|
||||||
|
|
||||||
to_delete: set[int] = {x.id for x in obj.line_items
|
to_delete: set[int] = {x.id for x in obj.line_items
|
||||||
if x.id not in collector.to_keep}
|
if x.id not in collector.to_keep}
|
||||||
if len(to_delete) > 0:
|
if len(to_delete) > 0:
|
||||||
JournalEntryLineItem.query\
|
db.session.execute(
|
||||||
.filter(JournalEntryLineItem.id.in_(to_delete)).delete()
|
sa.delete(JournalEntryLineItem)
|
||||||
|
.where(JournalEntryLineItem.id.in_(to_delete)))
|
||||||
self.is_modified = True
|
self.is_modified = True
|
||||||
|
|
||||||
if is_new or db.session.is_modified(obj):
|
if is_new or db.session.is_modified(obj):
|
||||||
@@ -198,7 +196,7 @@ class JournalEntryForm(FlaskForm):
|
|||||||
if self.max_date is not None and new_date == self.max_date:
|
if self.max_date is not None and new_date == self.max_date:
|
||||||
db_min_no: int | None = db.session.scalar(
|
db_min_no: int | None = db.session.scalar(
|
||||||
sa.select(sa.func.min(JournalEntry.no))
|
sa.select(sa.func.min(JournalEntry.no))
|
||||||
.filter(JournalEntry.date == new_date))
|
.where(JournalEntry.date == new_date))
|
||||||
if db_min_no is None:
|
if db_min_no is None:
|
||||||
obj.date = new_date
|
obj.date = new_date
|
||||||
obj.no = 1
|
obj.no = 1
|
||||||
@@ -208,8 +206,9 @@ class JournalEntryForm(FlaskForm):
|
|||||||
sort_journal_entries_in(new_date)
|
sort_journal_entries_in(new_date)
|
||||||
else:
|
else:
|
||||||
sort_journal_entries_in(new_date, obj.id)
|
sort_journal_entries_in(new_date, obj.id)
|
||||||
count: int = JournalEntry.query\
|
count: int = db.session.scalar(
|
||||||
.filter(JournalEntry.date == new_date).count()
|
sa.select(sa.func.count(JournalEntry.id))
|
||||||
|
.where(JournalEntry.date == new_date))
|
||||||
obj.date = new_date
|
obj.date = new_date
|
||||||
obj.no = count + 1
|
obj.no = count + 1
|
||||||
|
|
||||||
@@ -224,7 +223,7 @@ class JournalEntryForm(FlaskForm):
|
|||||||
if not (x.code[0] == "2" and x.is_need_offset)]
|
if not (x.code[0] == "2" and x.is_need_offset)]
|
||||||
in_use: set[int] = set(db.session.scalars(
|
in_use: set[int] = set(db.session.scalars(
|
||||||
sa.select(JournalEntryLineItem.account_id)
|
sa.select(JournalEntryLineItem.account_id)
|
||||||
.filter(JournalEntryLineItem.is_debit)
|
.where(JournalEntryLineItem.is_debit)
|
||||||
.group_by(JournalEntryLineItem.account_id)).all())
|
.group_by(JournalEntryLineItem.account_id)).all())
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
account.is_in_use = account.id in in_use
|
account.is_in_use = account.id in in_use
|
||||||
@@ -241,7 +240,7 @@ class JournalEntryForm(FlaskForm):
|
|||||||
if not (x.code[0] == "1" and x.is_need_offset)]
|
if not (x.code[0] == "1" and x.is_need_offset)]
|
||||||
in_use: set[int] = set(db.session.scalars(
|
in_use: set[int] = set(db.session.scalars(
|
||||||
sa.select(JournalEntryLineItem.account_id)
|
sa.select(JournalEntryLineItem.account_id)
|
||||||
.filter(sa.not_(JournalEntryLineItem.is_debit))
|
.where(sa.not_(JournalEntryLineItem.is_debit))
|
||||||
.group_by(JournalEntryLineItem.account_id)).all())
|
.group_by(JournalEntryLineItem.account_id)).all())
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
account.is_in_use = account.id in in_use
|
account.is_in_use = account.id in in_use
|
||||||
@@ -291,7 +290,7 @@ class JournalEntryForm(FlaskForm):
|
|||||||
return None
|
return None
|
||||||
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
|
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
|
||||||
.join(JournalEntryLineItem)\
|
.join(JournalEntryLineItem)\
|
||||||
.filter(JournalEntryLineItem.id.in_(original_line_item_id))
|
.where(JournalEntryLineItem.id.in_(original_line_item_id))
|
||||||
return db.session.scalar(select)
|
return db.session.scalar(select)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -304,16 +303,12 @@ class JournalEntryForm(FlaskForm):
|
|||||||
if x.id.data is not None}
|
if x.id.data is not None}
|
||||||
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
|
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
|
||||||
.join(JournalEntryLineItem)\
|
.join(JournalEntryLineItem)\
|
||||||
.filter(JournalEntryLineItem.original_line_item_id
|
.where(JournalEntryLineItem.original_line_item_id
|
||||||
.in_(line_item_id))
|
.in_(line_item_id))
|
||||||
return db.session.scalar(select)
|
return db.session.scalar(select)
|
||||||
|
|
||||||
|
|
||||||
T = t.TypeVar("T", bound=JournalEntryForm)
|
class LineItemCollector[T: JournalEntryForm](ABC):
|
||||||
"""A journal entry form variant."""
|
|
||||||
|
|
||||||
|
|
||||||
class LineItemCollector(t.Generic[T], ABC):
|
|
||||||
"""The line item collector."""
|
"""The line item collector."""
|
||||||
|
|
||||||
def __init__(self, form: T, obj: JournalEntry):
|
def __init__(self, form: T, obj: JournalEntry):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"""The line item sub-forms for the journal entry management.
|
"""The line item sub-forms for the journal entry management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
import datetime as dt
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -27,16 +27,15 @@ from sqlalchemy.orm import selectinload
|
|||||||
from wtforms import StringField, ValidationError, DecimalField, IntegerField
|
from wtforms import StringField, ValidationError, DecimalField, IntegerField
|
||||||
from wtforms.validators import Optional
|
from wtforms.validators import Optional
|
||||||
|
|
||||||
from accounting import db
|
from ... import db
|
||||||
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
|
from ...forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
|
||||||
IsCreditAccount
|
IsCreditAccount
|
||||||
from accounting.locale import lazy_gettext
|
from ...locale import lazy_gettext
|
||||||
from accounting.models import Account, JournalEntryLineItem
|
from ...models import Account, JournalEntry, JournalEntryLineItem
|
||||||
from accounting.template_filters import format_amount
|
from ...template_filters import format_amount
|
||||||
from accounting.utils.cast import be
|
from ...utils.random_id import new_id
|
||||||
from accounting.utils.random_id import new_id
|
from ...utils.strip_text import strip_text
|
||||||
from accounting.utils.strip_text import strip_text
|
from ...utils.user import get_current_user_pk
|
||||||
from accounting.utils.user import get_current_user_pk
|
|
||||||
|
|
||||||
|
|
||||||
class OriginalLineItemExists:
|
class OriginalLineItemExists:
|
||||||
@@ -127,10 +126,8 @@ class KeepAccountWhenHavingOffset:
|
|||||||
assert isinstance(form, LineItemForm)
|
assert isinstance(form, LineItemForm)
|
||||||
if field.data is None or form.id.data is None:
|
if field.data is None or form.id.data is None:
|
||||||
return
|
return
|
||||||
line_item: JournalEntryLineItem | None = db.session\
|
line_item: JournalEntryLineItem | None \
|
||||||
.query(JournalEntryLineItem)\
|
= db.session.get(JournalEntryLineItem, form.id.data)
|
||||||
.filter(JournalEntryLineItem.id == form.id.data)\
|
|
||||||
.options(selectinload(JournalEntryLineItem.offsets)).first()
|
|
||||||
if line_item is None or len(line_item.offsets) == 0:
|
if line_item is None or len(line_item.offsets) == 0:
|
||||||
return
|
return
|
||||||
if field.data != line_item.account_code:
|
if field.data != line_item.account_code:
|
||||||
@@ -200,14 +197,14 @@ class NotExceedingOriginalLineItemNetBalance:
|
|||||||
existing_line_item_id \
|
existing_line_item_id \
|
||||||
= {x.id for x in form.journal_entry_form.obj.line_items}
|
= {x.id for x in form.journal_entry_form.obj.line_items}
|
||||||
offset_total_func: sa.Function = sa.func.sum(sa.case(
|
offset_total_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(be(JournalEntryLineItem.is_debit == is_debit),
|
(JournalEntryLineItem.is_debit == is_debit,
|
||||||
JournalEntryLineItem.amount),
|
JournalEntryLineItem.amount),
|
||||||
else_=-JournalEntryLineItem.amount))
|
else_=-JournalEntryLineItem.amount))
|
||||||
offset_total_but_form: Decimal | None = db.session.scalar(
|
offset_total_but_form: Decimal | None = db.session.scalar(
|
||||||
sa.select(offset_total_func)
|
sa.select(offset_total_func)
|
||||||
.filter(be(JournalEntryLineItem.original_line_item_id
|
.where(JournalEntryLineItem.original_line_item_id
|
||||||
== original_line_item.id),
|
== original_line_item.id,
|
||||||
JournalEntryLineItem.id.not_in(existing_line_item_id)))
|
JournalEntryLineItem.id.not_in(existing_line_item_id)))
|
||||||
if offset_total_but_form is None:
|
if offset_total_but_form is None:
|
||||||
offset_total_but_form = Decimal("0")
|
offset_total_but_form = Decimal("0")
|
||||||
offset_total_on_form: Decimal = sum(
|
offset_total_on_form: Decimal = sum(
|
||||||
@@ -234,8 +231,7 @@ class NotLessThanOffsetTotal:
|
|||||||
(JournalEntryLineItem.is_debit != is_debit,
|
(JournalEntryLineItem.is_debit != is_debit,
|
||||||
JournalEntryLineItem.amount),
|
JournalEntryLineItem.amount),
|
||||||
else_=-JournalEntryLineItem.amount)))\
|
else_=-JournalEntryLineItem.amount)))\
|
||||||
.filter(be(JournalEntryLineItem.original_line_item_id
|
.where(JournalEntryLineItem.original_line_item_id == form.id.data)
|
||||||
== form.id.data))
|
|
||||||
offset_total: Decimal | None = db.session.scalar(select_offset_total)
|
offset_total: Decimal | None = db.session.scalar(select_offset_total)
|
||||||
if offset_total is not None and field.data < offset_total:
|
if offset_total is not None and field.data < offset_total:
|
||||||
raise ValidationError(lazy_gettext(
|
raise ValidationError(lazy_gettext(
|
||||||
@@ -269,6 +265,19 @@ class LineItemForm(FlaskForm):
|
|||||||
self.journal_entry_form: JournalEntryForm | None = None
|
self.journal_entry_form: JournalEntryForm | None = None
|
||||||
"""The source journal entry form."""
|
"""The source journal entry form."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_title(self) -> str:
|
||||||
|
"""Returns the title of the account.
|
||||||
|
|
||||||
|
:return: The title 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 account.title
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def account_text(self) -> str:
|
def account_text(self) -> str:
|
||||||
"""Returns the text representation of the account.
|
"""Returns the text representation of the account.
|
||||||
@@ -298,7 +307,7 @@ class LineItemForm(FlaskForm):
|
|||||||
return getattr(self, "____original_line_item")
|
return getattr(self, "____original_line_item")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def original_line_item_date(self) -> date | None:
|
def original_line_item_date(self) -> dt.date | None:
|
||||||
"""Returns the text representation of the original line item.
|
"""Returns the text representation of the original line item.
|
||||||
|
|
||||||
:return: The text representation of the original line item.
|
:return: The text representation of the original line item.
|
||||||
@@ -344,14 +353,14 @@ class LineItemForm(FlaskForm):
|
|||||||
def get_offsets() -> list[JournalEntryLineItem]:
|
def get_offsets() -> list[JournalEntryLineItem]:
|
||||||
if not self.is_need_offset or self.id.data is None:
|
if not self.is_need_offset or self.id.data is None:
|
||||||
return []
|
return []
|
||||||
return JournalEntryLineItem.query\
|
return db.session.scalars(
|
||||||
.filter(JournalEntryLineItem.original_line_item_id
|
sa.select(JournalEntryLineItem).join(JournalEntry)
|
||||||
== self.id.data)\
|
.where(JournalEntryLineItem.original_line_item_id
|
||||||
|
== self.id.data)
|
||||||
|
.order_by(JournalEntry.date, JournalEntry.no,
|
||||||
|
JournalEntryLineItem.no)
|
||||||
.options(selectinload(JournalEntryLineItem.journal_entry),
|
.options(selectinload(JournalEntryLineItem.journal_entry),
|
||||||
selectinload(JournalEntryLineItem.account),
|
selectinload(JournalEntryLineItem.account))).all()
|
||||||
selectinload(JournalEntryLineItem.offsets)
|
|
||||||
.selectinload(
|
|
||||||
JournalEntryLineItem.journal_entry)).all()
|
|
||||||
setattr(self, "__offsets", get_offsets())
|
setattr(self, "__offsets", get_offsets())
|
||||||
return getattr(self, "__offsets")
|
return getattr(self, "__offsets")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,31 +17,29 @@
|
|||||||
"""The reorder forms for the journal entry management.
|
"""The reorder forms for the journal entry management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
import datetime as dt
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from accounting import db
|
from ... import db
|
||||||
from accounting.models import JournalEntry
|
from ...models import JournalEntry
|
||||||
|
|
||||||
|
|
||||||
def sort_journal_entries_in(journal_entry_date: date,
|
def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
|
||||||
exclude: int | None = None) -> None:
|
|
||||||
"""Sorts the journal entries under a date after changing the date or
|
"""Sorts the journal entries under a date after changing the date or
|
||||||
deleting a journal entry.
|
deleting a journal entry.
|
||||||
|
|
||||||
:param journal_entry_date: The date of the journal entry.
|
:param date: The date of the journal entry.
|
||||||
:param exclude: The journal entry ID to exclude.
|
:param exclude: The journal entry ID to exclude.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.ColumnElement[bool]] = [JournalEntry.date == date]
|
||||||
= [JournalEntry.date == journal_entry_date]
|
|
||||||
if exclude is not None:
|
if exclude is not None:
|
||||||
conditions.append(JournalEntry.id != exclude)
|
conditions.append(JournalEntry.id != exclude)
|
||||||
journal_entries: list[JournalEntry] = JournalEntry.query\
|
journal_entries: list[JournalEntry] = db.session.scalars(
|
||||||
.filter(*conditions)\
|
sa.select(JournalEntry).where(*conditions)
|
||||||
.order_by(JournalEntry.no).all()
|
.order_by(JournalEntry.no)).all()
|
||||||
for i in range(len(journal_entries)):
|
for i in range(len(journal_entries)):
|
||||||
if journal_entries[i].no != i + 1:
|
if journal_entries[i].no != i + 1:
|
||||||
journal_entries[i].no = i + 1
|
journal_entries[i].no = i + 1
|
||||||
@@ -50,21 +48,24 @@ def sort_journal_entries_in(journal_entry_date: date,
|
|||||||
class JournalEntryReorderForm:
|
class JournalEntryReorderForm:
|
||||||
"""The form to reorder the journal entries."""
|
"""The form to reorder the journal entries."""
|
||||||
|
|
||||||
def __init__(self, journal_entry_date: date):
|
def __init__(self, date: dt.date):
|
||||||
"""Constructs the form to reorder the journal entries in a day.
|
"""Constructs the form to reorder the journal entries in a day.
|
||||||
|
|
||||||
:param journal_entry_date: The date.
|
:param date: The date.
|
||||||
"""
|
"""
|
||||||
self.date: date = journal_entry_date
|
self.date: dt.date = date
|
||||||
|
"""The date."""
|
||||||
self.is_modified: bool = False
|
self.is_modified: bool = False
|
||||||
|
"""Whether the order is modified."""
|
||||||
|
|
||||||
def save_order(self) -> None:
|
def save_order(self) -> None:
|
||||||
"""Saves the order of the account.
|
"""Saves the order of the account.
|
||||||
|
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
journal_entries: list[JournalEntry] = JournalEntry.query\
|
journal_entries: list[JournalEntry] = db.session.scalars(
|
||||||
.filter(JournalEntry.date == self.date).all()
|
sa.select(JournalEntry)
|
||||||
|
.where(JournalEntry.date == self.date)).all()
|
||||||
|
|
||||||
# Collects the specified order.
|
# Collects the specified order.
|
||||||
orders: dict[JournalEntry, int] = {}
|
orders: dict[JournalEntry, int] = {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# 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/10
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"""The account option for the journal entry management.
|
"""The account option for the journal entry management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from accounting.models import Account
|
from ...models import Account
|
||||||
|
|
||||||
|
|
||||||
class AccountOption:
|
class AccountOption:
|
||||||
@@ -28,10 +28,12 @@ class AccountOption:
|
|||||||
|
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
"""
|
"""
|
||||||
self.id: str = account.id
|
self.id: int = account.id
|
||||||
"""The account ID."""
|
"""The account ID."""
|
||||||
self.code: str = account.code
|
self.code: str = account.code
|
||||||
"""The account code."""
|
"""The account code."""
|
||||||
|
self.title: str = account.title
|
||||||
|
"""The account title."""
|
||||||
self.query_values: list[str] = account.query_values
|
self.query_values: list[str] = account.query_values
|
||||||
"""The values to be queried."""
|
"""The values to be queried."""
|
||||||
self.__str: str = str(account)
|
self.__str: str = str(account)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -18,13 +18,13 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
import typing as t
|
from typing import Literal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from accounting import db
|
from ... import db
|
||||||
from accounting.models import Account, JournalEntryLineItem
|
from ...models import Account, JournalEntryLineItem
|
||||||
from accounting.utils.options import options, Recurring
|
from ...utils.options import options, Recurring
|
||||||
|
|
||||||
|
|
||||||
class DescriptionAccount:
|
class DescriptionAccount:
|
||||||
@@ -36,12 +36,14 @@ class DescriptionAccount:
|
|||||||
:param account: The account.
|
:param account: The account.
|
||||||
:param freq: The frequency of the tag with the account.
|
:param freq: The frequency of the tag with the account.
|
||||||
"""
|
"""
|
||||||
self.account: Account = account
|
self.__account: Account = account
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.id: int = account.id
|
self.id: int = account.id
|
||||||
"""The account ID."""
|
"""The account ID."""
|
||||||
self.code: str = account.code
|
self.code: str = account.code
|
||||||
"""The 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
|
self.freq: int = freq
|
||||||
"""The frequency of the tag with the account."""
|
"""The frequency of the tag with the account."""
|
||||||
|
|
||||||
@@ -50,7 +52,15 @@ class DescriptionAccount:
|
|||||||
|
|
||||||
:return: The string representation of the account.
|
:return: The string representation of the account.
|
||||||
"""
|
"""
|
||||||
return str(self.account)
|
return str(self.__account)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self) -> str:
|
||||||
|
"""Returns the account title.
|
||||||
|
|
||||||
|
:return: The account title.
|
||||||
|
"""
|
||||||
|
return self.__account.title
|
||||||
|
|
||||||
def add_freq(self, freq: int) -> None:
|
def add_freq(self, freq: int) -> None:
|
||||||
"""Adds the frequency of an account.
|
"""Adds the frequency of an account.
|
||||||
@@ -114,12 +124,12 @@ class DescriptionTag:
|
|||||||
class DescriptionType:
|
class DescriptionType:
|
||||||
"""A description type"""
|
"""A description type"""
|
||||||
|
|
||||||
def __init__(self, type_id: t.Literal["general", "travel", "bus"]):
|
def __init__(self, type_id: Literal["general", "travel", "bus"]):
|
||||||
"""Constructs a description type.
|
"""Constructs a description type.
|
||||||
|
|
||||||
:param type_id: The type ID, either "general", "travel", or "bus".
|
:param type_id: The type ID, either "general", "travel", or "bus".
|
||||||
"""
|
"""
|
||||||
self.id: t.Literal["general", "travel", "bus"] = type_id
|
self.id: Literal["general", "travel", "bus"] = type_id
|
||||||
"""The type ID."""
|
"""The type ID."""
|
||||||
self.__tag_dict: dict[str, DescriptionTag] = {}
|
self.__tag_dict: dict[str, DescriptionTag] = {}
|
||||||
"""A dictionary from the tag name to their corresponding tag."""
|
"""A dictionary from the tag name to their corresponding tag."""
|
||||||
@@ -156,8 +166,11 @@ class DescriptionRecurring:
|
|||||||
:param account: The account.
|
:param account: The account.
|
||||||
"""
|
"""
|
||||||
self.name: str = name
|
self.name: str = name
|
||||||
|
"""The name."""
|
||||||
self.account: DescriptionAccount = DescriptionAccount(account, 0)
|
self.account: DescriptionAccount = DescriptionAccount(account, 0)
|
||||||
|
"""The account."""
|
||||||
self.description_template: str = description_template
|
self.description_template: str = description_template
|
||||||
|
"""The description template."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def account_codes(self) -> list[str]:
|
def account_codes(self) -> list[str]:
|
||||||
@@ -171,12 +184,12 @@ class DescriptionRecurring:
|
|||||||
class DescriptionDebitCredit:
|
class DescriptionDebitCredit:
|
||||||
"""The description on debit or credit."""
|
"""The description on debit or credit."""
|
||||||
|
|
||||||
def __init__(self, debit_credit: t.Literal["debit", "credit"]):
|
def __init__(self, debit_credit: Literal["debit", "credit"]):
|
||||||
"""Constructs the description on debit or credit.
|
"""Constructs the description on debit or credit.
|
||||||
|
|
||||||
:param debit_credit: Either "debit" or "credit".
|
:param debit_credit: Either "debit" or "credit".
|
||||||
"""
|
"""
|
||||||
self.debit_credit: t.Literal["debit", "credit"] = debit_credit
|
self.debit_credit: Literal["debit", "credit"] = debit_credit
|
||||||
"""Either debit or credit."""
|
"""Either debit or credit."""
|
||||||
self.general: DescriptionType = DescriptionType("general")
|
self.general: DescriptionType = DescriptionType("general")
|
||||||
"""The general tags."""
|
"""The general tags."""
|
||||||
@@ -184,14 +197,14 @@ class DescriptionDebitCredit:
|
|||||||
"""The travel tags."""
|
"""The travel tags."""
|
||||||
self.bus: DescriptionType = DescriptionType("bus")
|
self.bus: DescriptionType = DescriptionType("bus")
|
||||||
"""The bus tags."""
|
"""The bus tags."""
|
||||||
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
|
self.__type_dict: dict[Literal["general", "travel", "bus"],
|
||||||
DescriptionType] \
|
DescriptionType] \
|
||||||
= {x.id: x for x in {self.general, self.travel, self.bus}}
|
= {x.id: x for x in {self.general, self.travel, self.bus}}
|
||||||
"""A dictionary from the type ID to the corresponding tags."""
|
"""A dictionary from the type ID to the corresponding tags."""
|
||||||
self.recurring: list[DescriptionRecurring] = []
|
self.recurring: list[DescriptionRecurring] = []
|
||||||
"""The recurring transactions."""
|
"""The recurring transactions."""
|
||||||
|
|
||||||
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"],
|
def add_tag(self, tag_type: Literal["general", "travel", "bus"],
|
||||||
name: str, account: Account, freq: int) -> None:
|
name: str, account: Account, freq: int) -> None:
|
||||||
"""Adds a tag.
|
"""Adds a tag.
|
||||||
|
|
||||||
@@ -259,16 +272,18 @@ class DescriptionEditor:
|
|||||||
select: sa.Select = sa.Select(debit_credit, tag_type, tag,
|
select: sa.Select = sa.Select(debit_credit, tag_type, tag,
|
||||||
JournalEntryLineItem.account_id,
|
JournalEntryLineItem.account_id,
|
||||||
sa.func.count().label("freq"))\
|
sa.func.count().label("freq"))\
|
||||||
.filter(JournalEntryLineItem.description.is_not(None),
|
.where(JournalEntryLineItem.description.is_not(None),
|
||||||
JournalEntryLineItem.description.like("_%—_%"),
|
JournalEntryLineItem.description.like("_%—_%"),
|
||||||
JournalEntryLineItem.original_line_item_id.is_(None))\
|
JournalEntryLineItem.original_line_item_id.is_(None))\
|
||||||
.group_by(debit_credit, tag_type, tag,
|
.group_by(debit_credit, tag_type, tag,
|
||||||
JournalEntryLineItem.account_id)
|
JournalEntryLineItem.account_id)
|
||||||
result: list[sa.Row] = db.session.execute(select).all()
|
result: list[sa.Row] = db.session.execute(select).all()
|
||||||
accounts: dict[int, Account] \
|
accounts: dict[int, Account] \
|
||||||
= {x.id: x for x in Account.query
|
= {x.id: x for x in db.session.scalars(
|
||||||
.filter(Account.id.in_({x.account_id for x in result})).all()}
|
sa.select(Account)
|
||||||
debit_credit_dict: dict[t.Literal["debit", "credit"],
|
.where(Account.id.in_({x.account_id for x in result})))
|
||||||
|
.unique()}
|
||||||
|
debit_credit_dict: dict[Literal["debit", "credit"],
|
||||||
DescriptionDebitCredit] \
|
DescriptionDebitCredit] \
|
||||||
= {x.debit_credit: x for x in {self.debit, self.credit}}
|
= {x.debit_credit: x for x in {self.debit, self.credit}}
|
||||||
for row in result:
|
for row in result:
|
||||||
@@ -302,20 +317,21 @@ class DescriptionEditor:
|
|||||||
if len(codes) == 0:
|
if len(codes) == 0:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def get_condition(code0: str) -> sa.BinaryExpression:
|
def get_condition(code0: str) -> sa.ColumnElement[bool]:
|
||||||
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
|
m: re.Match[str] | None = re.match(r"^(\d{4})-(\d{3})$", code0)
|
||||||
assert m is not None,\
|
assert m is not None, \
|
||||||
f"Malformed account code \"{code0}\" for regular transactions."
|
f"Malformed account code \"{code0}\" for regular transactions."
|
||||||
return sa.and_(Account.base_code == m.group(1),
|
return sa.and_(Account.base_code == m.group(1),
|
||||||
Account.no == int(m.group(2)))
|
Account.no == int(m.group(2)))
|
||||||
|
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [get_condition(x) for x in codes]
|
= [get_condition(x) for x in codes]
|
||||||
accounts: dict[str, Account] \
|
accounts: dict[str, Account] \
|
||||||
= {x.code: x for x in
|
= {x.code: x for x in
|
||||||
Account.query.filter(sa.or_(*conditions)).all()}
|
db.session.scalars(
|
||||||
|
sa.select(Account).where(sa.or_(*conditions))).unique()}
|
||||||
for code in codes:
|
for code in codes:
|
||||||
assert code in accounts,\
|
assert code in accounts, \
|
||||||
f"Unknown account \"{code}\" for regular transactions."
|
f"Unknown account \"{code}\" for regular transactions."
|
||||||
return accounts
|
return accounts
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,19 +17,17 @@
|
|||||||
"""The operators for different journal entry types.
|
"""The operators for different journal entry types.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from flask import render_template, request, abort
|
from flask import render_template, request, abort
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
|
|
||||||
from accounting.models import JournalEntry
|
from ..forms import JournalEntryForm, CashReceiptJournalEntryForm, \
|
||||||
from accounting.template_globals import default_currency_code
|
CashDisbursementJournalEntryForm, TransferJournalEntryForm
|
||||||
from accounting.utils.journal_entry_types import JournalEntryType
|
from ..forms.line_item import LineItemForm
|
||||||
from accounting.journal_entry.forms import JournalEntryForm, \
|
from ...models import JournalEntry
|
||||||
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
|
from ...template_globals import default_currency_code
|
||||||
TransferJournalEntryForm
|
from ...utils.journal_entry_types import JournalEntryType
|
||||||
from accounting.journal_entry.forms.line_item import LineItemForm
|
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryOperator(ABC):
|
class JournalEntryOperator(ABC):
|
||||||
@@ -39,7 +37,7 @@ class JournalEntryOperator(ABC):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def form(self) -> t.Type[JournalEntryForm]:
|
def form(self) -> type[JournalEntryForm]:
|
||||||
"""Returns the form class.
|
"""Returns the form class.
|
||||||
|
|
||||||
:return: The form class.
|
:return: The form class.
|
||||||
@@ -100,7 +98,7 @@ class CashReceiptJournalEntry(JournalEntryOperator):
|
|||||||
"""The order when checking the journal entry operator."""
|
"""The order when checking the journal entry operator."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def form(self) -> t.Type[JournalEntryForm]:
|
def form(self) -> type[JournalEntryForm]:
|
||||||
"""Returns the form class.
|
"""Returns the form class.
|
||||||
|
|
||||||
:return: The form class.
|
:return: The form class.
|
||||||
@@ -170,7 +168,7 @@ class CashDisbursementJournalEntry(JournalEntryOperator):
|
|||||||
"""The order when checking the journal entry operator."""
|
"""The order when checking the journal entry operator."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def form(self) -> t.Type[JournalEntryForm]:
|
def form(self) -> type[JournalEntryForm]:
|
||||||
"""Returns the form class.
|
"""Returns the form class.
|
||||||
|
|
||||||
:return: The form class.
|
:return: The form class.
|
||||||
@@ -243,7 +241,7 @@ class TransferJournalEntry(JournalEntryOperator):
|
|||||||
"""The order when checking the journal entry operator."""
|
"""The order when checking the journal entry operator."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def form(self) -> t.Type[JournalEntryForm]:
|
def form(self) -> type[JournalEntryForm]:
|
||||||
"""Returns the form class.
|
"""Returns the form class.
|
||||||
|
|
||||||
:return: The form class.
|
:return: The form class.
|
||||||
@@ -334,3 +332,4 @@ def get_journal_entry_op(journal_entry: JournalEntry,
|
|||||||
key=lambda x: x.CHECK_ORDER):
|
key=lambda x: x.CHECK_ORDER):
|
||||||
if journal_entry_type.is_my_type(journal_entry):
|
if journal_entry_type.is_my_type(journal_entry):
|
||||||
return journal_entry_type
|
return journal_entry_type
|
||||||
|
assert False
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -22,10 +22,9 @@ from decimal import Decimal
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from accounting import db
|
from ... import db
|
||||||
from accounting.models import Account, JournalEntry, JournalEntryLineItem
|
from ...models import Account, JournalEntry, JournalEntryLineItem
|
||||||
from accounting.utils.cast import be
|
from ...utils.offset_alias import offset_alias
|
||||||
from .offset_alias import offset_alias
|
|
||||||
|
|
||||||
|
|
||||||
def get_selectable_original_line_items(
|
def get_selectable_original_line_items(
|
||||||
@@ -45,11 +44,10 @@ def get_selectable_original_line_items(
|
|||||||
offset: sa.Alias = offset_alias()
|
offset: sa.Alias = offset_alias()
|
||||||
net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case(
|
net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case(
|
||||||
(offset.c.id.in_(line_item_id_on_form), 0),
|
(offset.c.id.in_(line_item_id_on_form), 0),
|
||||||
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
|
(offset.c.is_debit == JournalEntryLineItem.is_debit, offset.c.amount),
|
||||||
offset.c.amount),
|
|
||||||
else_=-offset.c.amount))).label("net_balance")
|
else_=-offset.c.amount))).label("net_balance")
|
||||||
conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
|
conditions: list[sa.ColumnElement[bool]] = [Account.is_need_offset]
|
||||||
sub_conditions: list[sa.BinaryExpression] = []
|
sub_conditions: list[sa.ColumnElement[bool]] = []
|
||||||
if is_payable:
|
if is_payable:
|
||||||
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
|
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
|
||||||
sa.not_(JournalEntryLineItem.is_debit)))
|
sa.not_(JournalEntryLineItem.is_debit)))
|
||||||
@@ -60,23 +58,25 @@ def get_selectable_original_line_items(
|
|||||||
select_net_balances: sa.Select \
|
select_net_balances: sa.Select \
|
||||||
= sa.select(JournalEntryLineItem.id, net_balance)\
|
= sa.select(JournalEntryLineItem.id, net_balance)\
|
||||||
.join(Account)\
|
.join(Account)\
|
||||||
.join(offset, be(JournalEntryLineItem.id
|
.join(offset,
|
||||||
== offset.c.original_line_item_id),
|
JournalEntryLineItem.id == offset.c.original_line_item_id,
|
||||||
isouter=True)\
|
isouter=True)\
|
||||||
.filter(*conditions)\
|
.where(*conditions)\
|
||||||
.group_by(JournalEntryLineItem.id)\
|
.group_by(JournalEntryLineItem.id)\
|
||||||
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
|
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
|
||||||
net_balances: dict[int, Decimal] \
|
net_balances: dict[int, Decimal] \
|
||||||
= {x.id: x.net_balance
|
= {x.id: x.net_balance
|
||||||
for x in db.session.execute(select_net_balances).all()}
|
for x in db.session.execute(select_net_balances)}
|
||||||
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\
|
line_items: list[JournalEntryLineItem] = db.session.scalars(
|
||||||
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\
|
sa.select(JournalEntryLineItem)
|
||||||
.join(JournalEntry)\
|
.where(JournalEntryLineItem.id.in_({x for x in net_balances}))
|
||||||
.order_by(JournalEntry.date, JournalEntryLineItem.is_debit,
|
.join(JournalEntry)
|
||||||
JournalEntryLineItem.no)\
|
.order_by(JournalEntry.date, JournalEntry.no,
|
||||||
|
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
|
||||||
.options(selectinload(JournalEntryLineItem.currency),
|
.options(selectinload(JournalEntryLineItem.currency),
|
||||||
selectinload(JournalEntryLineItem.account),
|
selectinload(JournalEntryLineItem.account),
|
||||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
selectinload(JournalEntryLineItem.journal_entry))).all()
|
||||||
|
line_items.reverse()
|
||||||
for line_item in line_items:
|
for line_item in line_items:
|
||||||
line_item.net_balance = line_item.amount \
|
line_item.net_balance = line_item.amount \
|
||||||
if net_balances[line_item.id] is None \
|
if net_balances[line_item.id] is None \
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,28 +17,29 @@
|
|||||||
"""The views for the journal entry management.
|
"""The views for the journal entry management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
import datetime as dt
|
||||||
from urllib.parse import parse_qsl, urlencode
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import Blueprint, render_template, session, redirect, request, \
|
from flask import Blueprint, render_template, session, redirect, request, \
|
||||||
flash, url_for
|
flash, url_for, Response
|
||||||
from werkzeug.datastructures import ImmutableMultiDict
|
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 .forms import sort_journal_entries_in, JournalEntryReorderForm
|
||||||
from .template_filters import with_type, to_transfer, format_amount_input, \
|
from .template_filters import with_type, to_transfer, format_amount_input, \
|
||||||
text2html
|
text2html
|
||||||
from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
|
from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
|
||||||
get_journal_entry_op
|
get_journal_entry_op
|
||||||
|
from .. import db
|
||||||
|
from ..locale import lazy_gettext
|
||||||
|
from ..models import JournalEntry
|
||||||
|
from ..utils.cast import s
|
||||||
|
from ..utils.flash_errors import flash_form_errors
|
||||||
|
from ..utils.journal_entry_types import JournalEntryType
|
||||||
|
from ..utils.next_uri import inherit_next, or_next
|
||||||
|
from ..utils.permission import has_permission, can_view, can_edit
|
||||||
|
from ..utils.timezone import get_tz_today
|
||||||
|
from ..utils.user import get_current_user_pk
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("journal-entry", __name__)
|
bp: Blueprint = Blueprint("journal-entry", __name__)
|
||||||
"""The view blueprint for the journal entry management."""
|
"""The view blueprint for the journal entry management."""
|
||||||
@@ -49,7 +50,7 @@ bp.add_app_template_filter(format_amount_input,
|
|||||||
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
|
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/create/<journalEntryType:journal_entry_type>", endpoint="create")
|
@bp.get("create/<journalEntryType:journal_entry_type>", endpoint="create")
|
||||||
@has_permission(can_edit)
|
@has_permission(can_edit)
|
||||||
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
|
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
|
||||||
"""Shows the form to add a journal entry.
|
"""Shows the form to add a journal entry.
|
||||||
@@ -67,13 +68,13 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
|
|||||||
form.validate()
|
form.validate()
|
||||||
else:
|
else:
|
||||||
form = journal_entry_op.form()
|
form = journal_entry_op.form()
|
||||||
form.date.data = date.today()
|
form.date.data = get_tz_today()
|
||||||
return journal_entry_op.render_create_template(form)
|
return journal_entry_op.render_create_template(form)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/store/<journalEntryType:journal_entry_type>", endpoint="store")
|
@bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store")
|
||||||
@has_permission(can_edit)
|
@has_permission(can_edit)
|
||||||
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
|
def add_journal_entry(journal_entry_type: JournalEntryType) -> Response:
|
||||||
"""Adds a journal entry.
|
"""Adds a journal entry.
|
||||||
|
|
||||||
:param journal_entry_type: The journal entry type.
|
:param journal_entry_type: The journal entry type.
|
||||||
@@ -98,7 +99,7 @@ def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
|
|||||||
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<journalEntry:journal_entry>", endpoint="detail")
|
@bp.get("<journalEntry:journal_entry>", endpoint="detail")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
|
def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
|
||||||
"""Shows the journal entry detail.
|
"""Shows the journal entry detail.
|
||||||
@@ -111,7 +112,7 @@ def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
|
|||||||
return journal_entry_op.render_detail_template(journal_entry)
|
return journal_entry_op.render_detail_template(journal_entry)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<journalEntry:journal_entry>/edit", endpoint="edit")
|
@bp.get("<journalEntry:journal_entry>/edit", endpoint="edit")
|
||||||
@has_permission(can_edit)
|
@has_permission(can_edit)
|
||||||
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
|
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
|
||||||
"""Shows the form to edit a journal entry.
|
"""Shows the form to edit a journal entry.
|
||||||
@@ -133,9 +134,9 @@ def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
|
|||||||
return journal_entry_op.render_edit_template(journal_entry, form)
|
return journal_entry_op.render_edit_template(journal_entry, form)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<journalEntry:journal_entry>/update", endpoint="update")
|
@bp.post("<journalEntry:journal_entry>/update", endpoint="update")
|
||||||
@has_permission(can_edit)
|
@has_permission(can_edit)
|
||||||
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
|
def update_journal_entry(journal_entry: JournalEntry) -> Response:
|
||||||
"""Updates a journal entry.
|
"""Updates a journal entry.
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
:param journal_entry: The journal entry.
|
||||||
@@ -166,9 +167,9 @@ def update_journal_entry(journal_entry: JournalEntry) -> redirect:
|
|||||||
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<journalEntry:journal_entry>/delete", endpoint="delete")
|
@bp.post("<journalEntry:journal_entry>/delete", endpoint="delete")
|
||||||
@has_permission(can_edit)
|
@has_permission(can_edit)
|
||||||
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
|
def delete_journal_entry(journal_entry: JournalEntry) -> Response:
|
||||||
"""Deletes a journal entry.
|
"""Deletes a journal entry.
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
:param journal_entry: The journal entry.
|
||||||
@@ -186,31 +187,31 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
|
|||||||
return redirect(or_next(__get_default_page_uri()))
|
return redirect(or_next(__get_default_page_uri()))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/dates/<date:journal_entry_date>", endpoint="order")
|
@bp.get("dates/<date:date>", endpoint="order")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def show_journal_entry_order(journal_entry_date: date) -> str:
|
def show_journal_entry_order(date: dt.date) -> str:
|
||||||
"""Shows the order of the journal entries in a same date.
|
"""Shows the order of the journal entries in a same date.
|
||||||
|
|
||||||
:param journal_entry_date: The date.
|
:param date: The date.
|
||||||
:return: The order of the journal entries in the date.
|
:return: The order of the journal entries in the date.
|
||||||
"""
|
"""
|
||||||
journal_entries: list[JournalEntry] = JournalEntry.query \
|
journal_entries: list[JournalEntry] = db.session.scalars(
|
||||||
.filter(JournalEntry.date == journal_entry_date) \
|
sa.select(JournalEntry).where(JournalEntry.date == date)
|
||||||
.order_by(JournalEntry.no).all()
|
.order_by(JournalEntry.no)).all()
|
||||||
return render_template("accounting/journal-entry/order.html",
|
return render_template("accounting/journal-entry/order.html",
|
||||||
date=journal_entry_date, list=journal_entries)
|
date=date, list=journal_entries)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/dates/<date:journal_entry_date>", endpoint="sort")
|
@bp.post("dates/<date:date>", endpoint="sort")
|
||||||
@has_permission(can_edit)
|
@has_permission(can_edit)
|
||||||
def sort_journal_entries(journal_entry_date: date) -> redirect:
|
def sort_journal_entries(date: dt.date) -> Response:
|
||||||
"""Reorders the journal entries in a date.
|
"""Reorders the journal entries in a date.
|
||||||
|
|
||||||
:param journal_entry_date: The date.
|
:param date: The date.
|
||||||
:return: The redirection to the incoming account or the account list. The
|
:return: The redirection to the incoming account or the account list. The
|
||||||
reordering operation does not fail.
|
reordering operation does not fail.
|
||||||
"""
|
"""
|
||||||
form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date)
|
form: JournalEntryReorderForm = JournalEntryReorderForm(date)
|
||||||
form.save_order()
|
form.save_order()
|
||||||
if not form.is_modified:
|
if not form.is_modified:
|
||||||
flash(s(lazy_gettext("The order was not modified.")), "success")
|
flash(s(lazy_gettext("The order was not modified.")), "success")
|
||||||
@@ -235,4 +236,4 @@ def __get_default_page_uri() -> str:
|
|||||||
|
|
||||||
:return: The URI for the default page.
|
:return: The URI for the default page.
|
||||||
"""
|
"""
|
||||||
return url_for("accounting.report.default")
|
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
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@@ -25,8 +25,10 @@ from flask_babel import LazyString, Domain
|
|||||||
from flask_babel_js import JAVASCRIPT, c2js
|
from flask_babel_js import JAVASCRIPT, c2js
|
||||||
|
|
||||||
translation_dir: Path = Path(__file__).parent / "translations"
|
translation_dir: Path = Path(__file__).parent / "translations"
|
||||||
|
"""The directory of the translation files."""
|
||||||
domain: Domain = Domain(translation_directories=[translation_dir],
|
domain: Domain = Domain(translation_directories=[translation_dir],
|
||||||
domain="accounting")
|
domain="accounting")
|
||||||
|
"""The message domain."""
|
||||||
|
|
||||||
|
|
||||||
def gettext(string, **variables) -> str:
|
def gettext(string, **variables) -> str:
|
||||||
@@ -120,6 +122,5 @@ def init_app(app: Flask, bp: Blueprint) -> None:
|
|||||||
:param bp: The blueprint of the accounting application.
|
:param bp: The blueprint of the accounting application.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
bp.add_url_rule("/_jstrans.js", "babel_catalog",
|
bp.add_url_rule("/_jstrans.js", "babel_catalog", __babel_js_catalog_view)
|
||||||
__babel_js_catalog_view)
|
|
||||||
app.jinja_env.globals["A_"] = domain.gettext
|
app.jinja_env.globals["A_"] = domain.gettext
|
||||||
|
|||||||
+366
-265
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -19,33 +19,33 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
import re
|
import re
|
||||||
import typing as t
|
|
||||||
from datetime import date
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from babel import Locale
|
from babel import Locale
|
||||||
from flask_babel import get_locale, get_babel
|
from flask_babel import get_locale, get_babel
|
||||||
from sqlalchemy import text
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from accounting import db
|
from . import db
|
||||||
from accounting.locale import gettext
|
from .locale import gettext
|
||||||
from accounting.utils.user import user_cls, user_pk_column
|
from .utils.user import base_cls, user_cls, user_pk_column
|
||||||
|
|
||||||
|
|
||||||
class BaseAccount(db.Model):
|
class BaseAccount(base_cls):
|
||||||
"""A base account."""
|
"""A base account."""
|
||||||
__tablename__ = "accounting_base_accounts"
|
__tablename__ = "accounting_base_accounts"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
code = db.Column(db.String, nullable=False, primary_key=True)
|
code: Mapped[str] = mapped_column(primary_key=True)
|
||||||
"""The code."""
|
"""The account code."""
|
||||||
title_l10n = db.Column("title", db.String, nullable=False)
|
title_l10n: Mapped[str] = mapped_column("title")
|
||||||
"""The title."""
|
"""The title."""
|
||||||
l10n = db.relationship("BaseAccountL10n", back_populates="account",
|
l10n: Mapped[list[BaseAccountL10n]] \
|
||||||
lazy=False)
|
= relationship(back_populates="account", lazy=False)
|
||||||
"""The localized titles."""
|
"""The localized titles."""
|
||||||
accounts = db.relationship("Account", back_populates="base")
|
accounts: Mapped[list[Account]] = relationship(back_populates="base")
|
||||||
"""The descendant accounts under the base account."""
|
"""The descendant accounts under the base account."""
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
@@ -53,7 +53,7 @@ class BaseAccount(db.Model):
|
|||||||
|
|
||||||
:return: The string representation of the base account.
|
:return: The string representation of the base account.
|
||||||
"""
|
"""
|
||||||
return f"{self.code} {self.title.title()}"
|
return f"{self.code} {self.title}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
@@ -78,69 +78,64 @@ class BaseAccount(db.Model):
|
|||||||
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
||||||
|
|
||||||
|
|
||||||
class BaseAccountL10n(db.Model):
|
class BaseAccountL10n(base_cls):
|
||||||
"""A localized base account title."""
|
"""A localized base account title."""
|
||||||
__tablename__ = "accounting_base_accounts_l10n"
|
__tablename__ = "accounting_base_accounts_l10n"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
account_code = db.Column(db.String,
|
account_code: Mapped[str] \
|
||||||
db.ForeignKey(BaseAccount.code,
|
= mapped_column(sa.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
||||||
onupdate="CASCADE",
|
ondelete="CASCADE"),
|
||||||
ondelete="CASCADE"),
|
primary_key=True)
|
||||||
nullable=False, primary_key=True)
|
"""The account code."""
|
||||||
"""The code of the account."""
|
account: Mapped[BaseAccount] = relationship(back_populates="l10n")
|
||||||
account = db.relationship(BaseAccount, back_populates="l10n")
|
|
||||||
"""The account."""
|
"""The account."""
|
||||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
locale: Mapped[str] = mapped_column(primary_key=True)
|
||||||
"""The locale."""
|
"""The locale."""
|
||||||
title = db.Column(db.String, nullable=False)
|
title: Mapped[str]
|
||||||
"""The localized title."""
|
"""The localized title."""
|
||||||
|
|
||||||
|
|
||||||
class Account(db.Model):
|
class Account(base_cls):
|
||||||
"""An account."""
|
"""An account."""
|
||||||
__tablename__ = "accounting_accounts"
|
__tablename__ = "accounting_accounts"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
|
||||||
autoincrement=False)
|
|
||||||
"""The account ID."""
|
"""The account ID."""
|
||||||
base_code = db.Column(db.String,
|
base_code: Mapped[str] \
|
||||||
db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
= mapped_column(sa.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
||||||
ondelete="CASCADE"),
|
ondelete="CASCADE"))
|
||||||
nullable=False)
|
|
||||||
"""The code of the base account."""
|
"""The code of the base account."""
|
||||||
base = db.relationship(BaseAccount, back_populates="accounts")
|
base: Mapped[BaseAccount] = relationship(back_populates="accounts")
|
||||||
"""The base account."""
|
"""The base account."""
|
||||||
no = db.Column(db.Integer, nullable=False, default=text("1"))
|
no: Mapped[int] = mapped_column(default=sa.text("1"))
|
||||||
"""The account number under the base account."""
|
"""The account number under the base account."""
|
||||||
title_l10n = db.Column("title", db.String, nullable=False)
|
title_l10n: Mapped[str] = mapped_column("title")
|
||||||
"""The title."""
|
"""The title."""
|
||||||
is_need_offset = db.Column(db.Boolean, nullable=False, default=False)
|
is_need_offset: Mapped[bool] = mapped_column(default=False)
|
||||||
"""Whether the journal entry line items of this account need offset."""
|
"""Whether the journal entry line items of this account need offset."""
|
||||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
created_at: Mapped[dt.datetime] \
|
||||||
server_default=db.func.now())
|
= mapped_column(sa.DateTime(timezone=True),
|
||||||
"""The time of creation."""
|
server_default=sa.func.now())
|
||||||
created_by_id = db.Column(db.Integer,
|
"""The date and time when this record was created."""
|
||||||
db.ForeignKey(user_pk_column,
|
created_by_id: Mapped[int] \
|
||||||
onupdate="CASCADE"),
|
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
nullable=False)
|
"""The ID of the user who created the record."""
|
||||||
"""The ID of the creator."""
|
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
|
||||||
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
"""The user who created the record."""
|
||||||
"""The creator."""
|
updated_at: Mapped[dt.datetime] \
|
||||||
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
= mapped_column(sa.DateTime(timezone=True),
|
||||||
server_default=db.func.now())
|
server_default=sa.func.now())
|
||||||
"""The time of last update."""
|
"""The date and time when this record was last updated."""
|
||||||
updated_by_id = db.Column(db.Integer,
|
updated_by_id: Mapped[int] \
|
||||||
db.ForeignKey(user_pk_column,
|
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
onupdate="CASCADE"),
|
"""The ID of the last user who updated the record."""
|
||||||
nullable=False)
|
updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
|
||||||
"""The ID of the updator."""
|
"""The last user who updated the record."""
|
||||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
l10n: Mapped[list[AccountL10n]] \
|
||||||
"""The updator."""
|
= relationship(back_populates="account", lazy=False)
|
||||||
l10n = db.relationship("AccountL10n", back_populates="account",
|
|
||||||
lazy=False)
|
|
||||||
"""The localized titles."""
|
"""The localized titles."""
|
||||||
line_items = db.relationship("JournalEntryLineItem",
|
line_items: Mapped[list[JournalEntryLineItem]] \
|
||||||
back_populates="account")
|
= relationship(back_populates="account")
|
||||||
"""The journal entry line items."""
|
"""The journal entry line items."""
|
||||||
|
|
||||||
CASH_CODE: str = "1111-001"
|
CASH_CODE: str = "1111-001"
|
||||||
@@ -155,7 +150,7 @@ class Account(db.Model):
|
|||||||
|
|
||||||
:return: The string representation of this account.
|
:return: The string representation of this account.
|
||||||
"""
|
"""
|
||||||
return f"{self.base_code}-{self.no:03d} {self.title.title()}"
|
return f"{self.base_code}-{self.no:03d} {self.title}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def code(self) -> str:
|
def code(self) -> str:
|
||||||
@@ -186,6 +181,8 @@ class Account(db.Model):
|
|||||||
:param value: The new title.
|
:param value: The new title.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
|
if self.title == value:
|
||||||
|
return
|
||||||
if self.title_l10n is None:
|
if self.title_l10n is None:
|
||||||
self.title_l10n = value
|
self.title_l10n = value
|
||||||
return
|
return
|
||||||
@@ -215,6 +212,25 @@ class Account(db.Model):
|
|||||||
"""
|
"""
|
||||||
return not self.is_real
|
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, value: int) -> None:
|
||||||
|
"""Sets the number of items in the account.
|
||||||
|
|
||||||
|
:param value: The number of items in the account.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__count", value)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def query_values(self) -> list[str]:
|
def query_values(self) -> list[str]:
|
||||||
"""Returns the values to be queried.
|
"""Returns the values to be queried.
|
||||||
@@ -251,135 +267,142 @@ class Account(db.Model):
|
|||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
AccountL10n.query.filter(AccountL10n.account == self).delete()
|
db.session.execute(sa.delete(AccountL10n)
|
||||||
cls: t.Type[t.Self] = self.__class__
|
.where(AccountL10n.account == self))
|
||||||
cls.query.filter(cls.id == self.id).delete()
|
cls: type[Self] = self.__class__
|
||||||
|
db.session.execute(sa.delete(cls).where(cls.id == self.id))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_by_code(cls, code: str) -> t.Self | None:
|
def find_by_code(cls, code: str) -> Self | None:
|
||||||
"""Finds an account by its code.
|
"""Finds an account by its code.
|
||||||
|
|
||||||
:param code: The code.
|
:param code: The code.
|
||||||
:return: The account, or None if this account does not exist.
|
:return: The account, or None if this account does not exist.
|
||||||
"""
|
"""
|
||||||
m = re.match(r"^([1-9]{4})-(\d{3})$", code)
|
m: re.Match[str] | None = re.match(r"^([1-9]{4})-(\d{3})$", code)
|
||||||
if m is None:
|
if m is None:
|
||||||
return None
|
return None
|
||||||
return cls.query.filter(cls.base_code == m.group(1),
|
return db.session.scalar(
|
||||||
cls.no == int(m.group(2))).first()
|
sa.select(cls).where(cls.base_code == m.group(1),
|
||||||
|
cls.no == int(m.group(2))))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def selectable_debit(cls) -> list[t.Self]:
|
def selectable_debit(cls) -> list[Self]:
|
||||||
"""Returns the selectable debit accounts.
|
"""Returns the selectable debit accounts.
|
||||||
Payable line items can not start from debit.
|
Payable line items can not start from debit.
|
||||||
|
|
||||||
:return: The selectable debit accounts.
|
:return: The selectable debit accounts.
|
||||||
"""
|
"""
|
||||||
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
return db.session.scalars(
|
||||||
sa.and_(cls.base_code.startswith("2"),
|
sa.select(cls)
|
||||||
sa.not_(cls.is_need_offset)),
|
.where(sa.or_(cls.base_code.startswith("1"),
|
||||||
cls.base_code.startswith("3"),
|
sa.and_(cls.base_code.startswith("2"),
|
||||||
cls.base_code.startswith("5"),
|
sa.not_(cls.is_need_offset)),
|
||||||
cls.base_code.startswith("6"),
|
cls.base_code.startswith("3"),
|
||||||
cls.base_code.startswith("75"),
|
cls.base_code.startswith("5"),
|
||||||
cls.base_code.startswith("76"),
|
cls.base_code.startswith("6"),
|
||||||
cls.base_code.startswith("77"),
|
cls.base_code.startswith("75"),
|
||||||
cls.base_code.startswith("78"),
|
cls.base_code.startswith("76"),
|
||||||
cls.base_code.startswith("8"),
|
cls.base_code.startswith("77"),
|
||||||
cls.base_code.startswith("9")),
|
cls.base_code.startswith("78"),
|
||||||
cls.base_code != "3351",
|
cls.base_code.startswith("8"),
|
||||||
cls.base_code != "3353")\
|
cls.base_code.startswith("9")),
|
||||||
.order_by(cls.base_code, cls.no).all()
|
cls.base_code != "3353")
|
||||||
|
.order_by(cls.base_code, cls.no)).unique().all()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def selectable_credit(cls) -> list[t.Self]:
|
def selectable_credit(cls) -> list[Self]:
|
||||||
"""Returns the selectable debit accounts.
|
"""Returns the selectable debit accounts.
|
||||||
Receivable line items can not start from credit.
|
Receivable line items can not start from credit.
|
||||||
|
|
||||||
:return: The selectable debit accounts.
|
:return: The selectable debit accounts.
|
||||||
"""
|
"""
|
||||||
return cls.query.filter(sa.or_(sa.and_(cls.base_code.startswith("1"),
|
return db.session.scalars(
|
||||||
sa.not_(cls.is_need_offset)),
|
sa.select(cls)
|
||||||
cls.base_code.startswith("2"),
|
.where(sa.or_(sa.and_(cls.base_code.startswith("1"),
|
||||||
cls.base_code.startswith("3"),
|
sa.not_(cls.is_need_offset)),
|
||||||
cls.base_code.startswith("4"),
|
cls.base_code.startswith("2"),
|
||||||
cls.base_code.startswith("71"),
|
cls.base_code.startswith("3"),
|
||||||
cls.base_code.startswith("72"),
|
cls.base_code.startswith("4"),
|
||||||
cls.base_code.startswith("73"),
|
cls.base_code.startswith("71"),
|
||||||
cls.base_code.startswith("74"),
|
cls.base_code.startswith("72"),
|
||||||
cls.base_code.startswith("8"),
|
cls.base_code.startswith("73"),
|
||||||
cls.base_code.startswith("9")),
|
cls.base_code.startswith("74"),
|
||||||
cls.base_code != "3351",
|
cls.base_code.startswith("8"),
|
||||||
cls.base_code != "3353")\
|
cls.base_code.startswith("9")),
|
||||||
.order_by(cls.base_code, cls.no).all()
|
cls.base_code != "3353")
|
||||||
|
.order_by(cls.base_code, cls.no)).unique().all()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def cash(cls) -> t.Self:
|
def cash(cls) -> Self:
|
||||||
"""Returns the cash account.
|
"""Returns the cash account.
|
||||||
|
|
||||||
:return: The cash account
|
:return: The cash account
|
||||||
"""
|
"""
|
||||||
return cls.find_by_code(cls.CASH_CODE)
|
account: Self | None = cls.find_by_code(cls.CASH_CODE)
|
||||||
|
assert account is not None
|
||||||
|
return account
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def accumulated_change(cls) -> t.Self:
|
def accumulated_change(cls) -> Self:
|
||||||
"""Returns the accumulated-change account.
|
"""Returns the accumulated-change account.
|
||||||
|
|
||||||
:return: The accumulated-change account
|
:return: The accumulated-change account
|
||||||
"""
|
"""
|
||||||
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
|
account: Self | None = cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
|
||||||
|
assert account is not None
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
class AccountL10n(db.Model):
|
class AccountL10n(base_cls):
|
||||||
"""A localized account title."""
|
"""A localized account title."""
|
||||||
__tablename__ = "accounting_accounts_l10n"
|
__tablename__ = "accounting_accounts_l10n"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
account_id = db.Column(db.Integer,
|
account_id: Mapped[int] \
|
||||||
db.ForeignKey(Account.id, onupdate="CASCADE",
|
= mapped_column(sa.ForeignKey(Account.id, onupdate="CASCADE",
|
||||||
ondelete="CASCADE"),
|
ondelete="CASCADE"),
|
||||||
nullable=False, primary_key=True)
|
primary_key=True)
|
||||||
"""The account ID."""
|
"""The account ID."""
|
||||||
account = db.relationship(Account, back_populates="l10n")
|
account: Mapped[Account] = relationship(back_populates="l10n")
|
||||||
"""The account."""
|
"""The account."""
|
||||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
locale: Mapped[str] = mapped_column(primary_key=True)
|
||||||
"""The locale."""
|
"""The locale."""
|
||||||
title = db.Column(db.String, nullable=False)
|
title: Mapped[str]
|
||||||
"""The localized title."""
|
"""The localized title."""
|
||||||
|
|
||||||
|
|
||||||
class Currency(db.Model):
|
class Currency(base_cls):
|
||||||
"""A currency."""
|
"""A currency."""
|
||||||
__tablename__ = "accounting_currencies"
|
__tablename__ = "accounting_currencies"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
code = db.Column(db.String, nullable=False, primary_key=True)
|
code: Mapped[str] = mapped_column(primary_key=True)
|
||||||
"""The code."""
|
"""The currency code."""
|
||||||
name_l10n = db.Column("name", db.String, nullable=False)
|
name_l10n: Mapped[str] = mapped_column("name")
|
||||||
"""The name."""
|
"""The currency name."""
|
||||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
created_at: Mapped[dt.datetime] \
|
||||||
server_default=db.func.now())
|
= mapped_column(sa.DateTime(timezone=True),
|
||||||
"""The time of creation."""
|
server_default=sa.func.now())
|
||||||
created_by_id = db.Column(db.Integer,
|
"""The date and time when this record was created."""
|
||||||
db.ForeignKey(user_pk_column,
|
created_by_id: Mapped[int] \
|
||||||
onupdate="CASCADE"),
|
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
nullable=False)
|
"""The ID of the user who created the record."""
|
||||||
"""The ID of the creator."""
|
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
|
||||||
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
"""The user who created the record."""
|
||||||
"""The creator."""
|
updated_at: Mapped[dt.datetime] \
|
||||||
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
= mapped_column(sa.DateTime(timezone=True),
|
||||||
server_default=db.func.now())
|
server_default=sa.func.now())
|
||||||
"""The time of last update."""
|
"""The date and time when this record was last updated."""
|
||||||
updated_by_id = db.Column(db.Integer,
|
updated_by_id: Mapped[int] \
|
||||||
db.ForeignKey(user_pk_column,
|
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
onupdate="CASCADE"),
|
"""The ID of the last user who updated the record."""
|
||||||
nullable=False)
|
updated_by: Mapped[user_cls] \
|
||||||
"""The ID of the updator."""
|
= relationship(foreign_keys=updated_by_id)
|
||||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
"""The last user who updated the record."""
|
||||||
"""The updator."""
|
l10n: Mapped[list[CurrencyL10n]] \
|
||||||
l10n = db.relationship("CurrencyL10n", back_populates="currency",
|
= relationship(back_populates="currency", lazy=False)
|
||||||
lazy=False)
|
|
||||||
"""The localized names."""
|
"""The localized names."""
|
||||||
line_items = db.relationship("JournalEntryLineItem",
|
line_items: Mapped[list[JournalEntryLineItem]] \
|
||||||
back_populates="currency")
|
= relationship(back_populates="currency")
|
||||||
"""The journal entry line items."""
|
"""The journal entry line items."""
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
@@ -410,6 +433,8 @@ class Currency(db.Model):
|
|||||||
:param value: The new name.
|
:param value: The new name.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
|
if self.name == value:
|
||||||
|
return
|
||||||
if self.name_l10n is None:
|
if self.name_l10n is None:
|
||||||
self.name_l10n = value
|
self.name_l10n = value
|
||||||
return
|
return
|
||||||
@@ -442,7 +467,7 @@ class Currency(db.Model):
|
|||||||
|
|
||||||
:return: True if the currency can be deleted, or False otherwise.
|
:return: True if the currency can be deleted, or False otherwise.
|
||||||
"""
|
"""
|
||||||
from accounting.template_globals import default_currency_code
|
from .template_globals import default_currency_code
|
||||||
if self.code == default_currency_code():
|
if self.code == default_currency_code():
|
||||||
return False
|
return False
|
||||||
return len(self.line_items) == 0
|
return len(self.line_items) == 0
|
||||||
@@ -452,25 +477,26 @@ class Currency(db.Model):
|
|||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
|
db.session.execute(
|
||||||
cls: t.Type[t.Self] = self.__class__
|
sa.delete(CurrencyL10n)
|
||||||
cls.query.filter(cls.code == self.code).delete()
|
.where(CurrencyL10n.currency_code == self.code))
|
||||||
|
db.session.delete(self)
|
||||||
|
|
||||||
|
|
||||||
class CurrencyL10n(db.Model):
|
class CurrencyL10n(base_cls):
|
||||||
"""A localized currency name."""
|
"""A localized currency name."""
|
||||||
__tablename__ = "accounting_currencies_l10n"
|
__tablename__ = "accounting_currencies_l10n"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
currency_code = db.Column(db.String,
|
currency_code: Mapped[str] \
|
||||||
db.ForeignKey(Currency.code, onupdate="CASCADE",
|
= mapped_column(sa.ForeignKey(Currency.code, onupdate="CASCADE",
|
||||||
ondelete="CASCADE"),
|
ondelete="CASCADE"),
|
||||||
nullable=False, primary_key=True)
|
primary_key=True)
|
||||||
"""The currency code."""
|
"""The currency code."""
|
||||||
currency = db.relationship(Currency, back_populates="l10n")
|
currency: Mapped[Currency] = relationship(back_populates="l10n")
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
locale: Mapped[str] = mapped_column(primary_key=True)
|
||||||
"""The locale."""
|
"""The locale."""
|
||||||
name = db.Column(db.String, nullable=False)
|
name: Mapped[str]
|
||||||
"""The localized name."""
|
"""The localized name."""
|
||||||
|
|
||||||
|
|
||||||
@@ -517,41 +543,38 @@ class JournalEntryCurrency:
|
|||||||
return sum([x.amount for x in self.credit])
|
return sum([x.amount for x in self.credit])
|
||||||
|
|
||||||
|
|
||||||
class JournalEntry(db.Model):
|
class JournalEntry(base_cls):
|
||||||
"""A journal entry."""
|
"""A journal entry."""
|
||||||
__tablename__ = "accounting_journal_entries"
|
__tablename__ = "accounting_journal_entries"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
|
||||||
autoincrement=False)
|
|
||||||
"""The journal entry ID."""
|
"""The journal entry ID."""
|
||||||
date = db.Column(db.Date, nullable=False)
|
date: Mapped[dt.date]
|
||||||
"""The date."""
|
"""The date."""
|
||||||
no = db.Column(db.Integer, nullable=False, default=text("1"))
|
no: Mapped[int] = mapped_column(default=sa.text("1"))
|
||||||
"""The account number under the date."""
|
"""The journal entry number under the date."""
|
||||||
note = db.Column(db.String)
|
note: Mapped[str | None]
|
||||||
"""The note."""
|
"""The note."""
|
||||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
created_at: Mapped[dt.datetime] \
|
||||||
server_default=db.func.now())
|
= mapped_column(sa.DateTime(timezone=True),
|
||||||
"""The time of creation."""
|
server_default=sa.func.now())
|
||||||
created_by_id = db.Column(db.Integer,
|
"""The date and time when this record was created."""
|
||||||
db.ForeignKey(user_pk_column,
|
created_by_id: Mapped[int] \
|
||||||
onupdate="CASCADE"),
|
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
nullable=False)
|
"""The ID of the user who created the record."""
|
||||||
"""The ID of the creator."""
|
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
|
||||||
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
"""The user who created the record."""
|
||||||
"""The creator."""
|
updated_at: Mapped[dt.datetime] \
|
||||||
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
= mapped_column(sa.DateTime(timezone=True),
|
||||||
server_default=db.func.now())
|
server_default=sa.func.now())
|
||||||
"""The time of last update."""
|
"""The date and time when this record was last updated."""
|
||||||
updated_by_id = db.Column(db.Integer,
|
updated_by_id: Mapped[int] \
|
||||||
db.ForeignKey(user_pk_column,
|
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
onupdate="CASCADE"),
|
"""The ID of the last user who updated the record."""
|
||||||
nullable=False)
|
updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
|
||||||
"""The ID of the updator."""
|
"""The last user who updated the record."""
|
||||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
line_items: Mapped[list[JournalEntryLineItem]] \
|
||||||
"""The updator."""
|
= relationship(back_populates="journal_entry")
|
||||||
line_items = db.relationship("JournalEntryLineItem",
|
|
||||||
back_populates="journal_entry")
|
|
||||||
"""The line items."""
|
"""The line items."""
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
@@ -632,57 +655,49 @@ class JournalEntry(db.Model):
|
|||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
JournalEntryLineItem.query\
|
db.session.execute(
|
||||||
.filter(JournalEntryLineItem.journal_entry_id == self.id).delete()
|
sa.delete(JournalEntryLineItem)
|
||||||
|
.where(JournalEntryLineItem.journal_entry_id == self.id))
|
||||||
db.session.delete(self)
|
db.session.delete(self)
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryLineItem(db.Model):
|
class JournalEntryLineItem(base_cls):
|
||||||
"""A line item in the journal entry."""
|
"""A line item in the journal entry."""
|
||||||
__tablename__ = "accounting_journal_entry_line_items"
|
__tablename__ = "accounting_journal_entry_line_items"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
|
||||||
autoincrement=False)
|
|
||||||
"""The line item ID."""
|
"""The line item ID."""
|
||||||
journal_entry_id = db.Column(db.Integer,
|
journal_entry_id: Mapped[int] \
|
||||||
db.ForeignKey(JournalEntry.id,
|
= mapped_column(sa.ForeignKey(JournalEntry.id, onupdate="CASCADE",
|
||||||
onupdate="CASCADE",
|
ondelete="CASCADE"))
|
||||||
ondelete="CASCADE"),
|
|
||||||
nullable=False)
|
|
||||||
"""The journal entry ID."""
|
"""The journal entry ID."""
|
||||||
journal_entry = db.relationship(JournalEntry, back_populates="line_items")
|
journal_entry: Mapped[JournalEntry] \
|
||||||
|
= relationship(back_populates="line_items")
|
||||||
"""The journal entry."""
|
"""The journal entry."""
|
||||||
is_debit = db.Column(db.Boolean, nullable=False)
|
is_debit: Mapped[bool]
|
||||||
"""True for a debit line item, or False for a credit line item."""
|
"""True for a debit line item, or False for a credit line item."""
|
||||||
no = db.Column(db.Integer, nullable=False)
|
no: Mapped[int]
|
||||||
"""The line item number under the journal entry and debit or credit."""
|
"""The line item number under the journal entry and debit or credit."""
|
||||||
original_line_item_id = db.Column(db.Integer,
|
original_line_item_id: Mapped[int | None] \
|
||||||
db.ForeignKey(id, onupdate="CASCADE"),
|
= mapped_column(sa.ForeignKey(id, onupdate="CASCADE"))
|
||||||
nullable=True)
|
|
||||||
"""The ID of the original line item."""
|
"""The ID of the original line item."""
|
||||||
original_line_item = db.relationship("JournalEntryLineItem",
|
original_line_item: Mapped[JournalEntryLineItem | None] \
|
||||||
back_populates="offsets",
|
= relationship(remote_side=id, passive_deletes=True)
|
||||||
remote_side=id, passive_deletes=True)
|
|
||||||
"""The original line item."""
|
"""The original line item."""
|
||||||
offsets = db.relationship("JournalEntryLineItem",
|
currency_code: Mapped[str] \
|
||||||
back_populates="original_line_item")
|
= mapped_column(sa.ForeignKey(Currency.code, onupdate="CASCADE"))
|
||||||
"""The offset items."""
|
|
||||||
currency_code = db.Column(db.String,
|
|
||||||
db.ForeignKey(Currency.code, onupdate="CASCADE"),
|
|
||||||
nullable=False)
|
|
||||||
"""The currency code."""
|
"""The currency code."""
|
||||||
currency = db.relationship(Currency, back_populates="line_items")
|
currency: Mapped[Currency] = relationship(back_populates="line_items")
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
account_id = db.Column(db.Integer,
|
account_id: Mapped[int] \
|
||||||
db.ForeignKey(Account.id,
|
= mapped_column(sa.ForeignKey(Account.id, onupdate="CASCADE"))
|
||||||
onupdate="CASCADE"),
|
|
||||||
nullable=False)
|
|
||||||
"""The account ID."""
|
"""The account ID."""
|
||||||
account = db.relationship(Account, back_populates="line_items", lazy=False)
|
account: Mapped[Account] \
|
||||||
|
= relationship(back_populates="line_items", lazy=False)
|
||||||
"""The account."""
|
"""The account."""
|
||||||
description = db.Column(db.String, nullable=True)
|
description: Mapped[str | None]
|
||||||
"""The description."""
|
"""The description."""
|
||||||
amount = db.Column(db.Numeric(14, 2), nullable=False)
|
amount: Mapped[Decimal] = mapped_column(sa.Numeric(14, 2))
|
||||||
"""The amount."""
|
"""The amount."""
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
@@ -691,7 +706,7 @@ class JournalEntryLineItem(db.Model):
|
|||||||
:return: The string representation of the line item.
|
:return: The string representation of the line item.
|
||||||
"""
|
"""
|
||||||
if not hasattr(self, "__str"):
|
if not hasattr(self, "__str"):
|
||||||
from accounting.template_filters import format_date, format_amount
|
from .template_filters import format_date, format_amount
|
||||||
setattr(self, "__str",
|
setattr(self, "__str",
|
||||||
gettext("%(date)s %(description)s %(amount)s",
|
gettext("%(date)s %(description)s %(amount)s",
|
||||||
date=format_date(self.journal_entry.date),
|
date=format_date(self.journal_entry.date),
|
||||||
@@ -708,14 +723,6 @@ class JournalEntryLineItem(db.Model):
|
|||||||
"""
|
"""
|
||||||
return self.account.code
|
return self.account.code
|
||||||
|
|
||||||
@property
|
|
||||||
def debit(self) -> Decimal | None:
|
|
||||||
"""Returns the debit amount.
|
|
||||||
|
|
||||||
:return: The debit amount, or None if this is not a debit line item.
|
|
||||||
"""
|
|
||||||
return self.amount if self.is_debit else None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_need_offset(self) -> bool:
|
def is_need_offset(self) -> bool:
|
||||||
"""Returns whether the line item needs offset.
|
"""Returns whether the line item needs offset.
|
||||||
@@ -730,13 +737,43 @@ class JournalEntryLineItem(db.Model):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def debit(self) -> Decimal | None:
|
||||||
|
"""Returns the debit amount.
|
||||||
|
|
||||||
|
:return: The debit amount, or None if this is not a debit line item.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__debit"):
|
||||||
|
setattr(self, "__debit", self.amount if self.is_debit else None)
|
||||||
|
return getattr(self, "__debit")
|
||||||
|
|
||||||
|
@debit.setter
|
||||||
|
def debit(self, value: Decimal | None) -> None:
|
||||||
|
"""Sets the debit amount.
|
||||||
|
|
||||||
|
:param value: The debit amount.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__debit", value)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def credit(self) -> Decimal | None:
|
def credit(self) -> Decimal | None:
|
||||||
"""Returns the credit amount.
|
"""Returns the credit amount.
|
||||||
|
|
||||||
:return: The credit amount, or None if this is not a credit line item.
|
:return: The credit amount, or None if this is not a credit line item.
|
||||||
"""
|
"""
|
||||||
return None if self.is_debit else self.amount
|
if not hasattr(self, "__credit"):
|
||||||
|
setattr(self, "__credit", None if self.is_debit else self.amount)
|
||||||
|
return getattr(self, "__credit")
|
||||||
|
|
||||||
|
@credit.setter
|
||||||
|
def credit(self, value: Decimal | None) -> None:
|
||||||
|
"""Sets the credit amount.
|
||||||
|
|
||||||
|
:param value: The credit amount.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__credit", value)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def net_balance(self) -> Decimal:
|
def net_balance(self) -> Decimal:
|
||||||
@@ -751,16 +788,89 @@ class JournalEntryLineItem(db.Model):
|
|||||||
return getattr(self, "__net_balance")
|
return getattr(self, "__net_balance")
|
||||||
|
|
||||||
@net_balance.setter
|
@net_balance.setter
|
||||||
def net_balance(self, net_balance: Decimal) -> None:
|
def net_balance(self, value: Decimal) -> None:
|
||||||
"""Sets the net balance.
|
"""Sets the net balance.
|
||||||
|
|
||||||
:param net_balance: The net balance.
|
:param value: The net balance.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
setattr(self, "__net_balance", net_balance)
|
setattr(self, "__net_balance", value)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def query_values(self) -> tuple[list[str], list[str]]:
|
def balance(self) -> Decimal:
|
||||||
|
"""Returns the balance.
|
||||||
|
|
||||||
|
:return: The balance.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__balance"):
|
||||||
|
setattr(self, "__balance", Decimal("0"))
|
||||||
|
return getattr(self, "__balance")
|
||||||
|
|
||||||
|
@balance.setter
|
||||||
|
def balance(self, value: Decimal) -> None:
|
||||||
|
"""Sets the balance.
|
||||||
|
|
||||||
|
:param value: The balance.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__balance", value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def offsets(self) -> list[Self]:
|
||||||
|
"""Returns the offset items.
|
||||||
|
|
||||||
|
:return: The offset items.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__offsets"):
|
||||||
|
cls: type[Self] = self.__class__
|
||||||
|
offsets: list[Self] = db.session.scalars(
|
||||||
|
sa.select(cls).join(JournalEntry)
|
||||||
|
.where(cls.original_line_item_id == self.id)
|
||||||
|
.order_by(JournalEntry.date, JournalEntry.no,
|
||||||
|
cls.is_debit, cls.no)).unique().all()
|
||||||
|
setattr(self, "__offsets", offsets)
|
||||||
|
return getattr(self, "__offsets")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_offset(self) -> bool:
|
||||||
|
"""Returns whether the line item is an offset.
|
||||||
|
|
||||||
|
:return: True if the line item is an offset, or False otherwise.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "__is_offset"):
|
||||||
|
setattr(self, "__is_offset", False)
|
||||||
|
return getattr(self, "__is_offset")
|
||||||
|
|
||||||
|
@is_offset.setter
|
||||||
|
def is_offset(self, value: bool) -> None:
|
||||||
|
"""Sets whether the line item is an offset.
|
||||||
|
|
||||||
|
:param value: True if the line item is an offset, or False otherwise.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__is_offset", value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def match(self) -> 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, value: Self) -> None:
|
||||||
|
"""Sets the match of the line item.
|
||||||
|
|
||||||
|
:param value: The matcho of the line item.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
setattr(self, "__match", value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def query_values(self) -> list[str]:
|
||||||
"""Returns the values to be queried.
|
"""Returns the values to be queried.
|
||||||
|
|
||||||
:return: The values to be queried.
|
:return: The values to be queried.
|
||||||
@@ -770,46 +880,37 @@ class JournalEntryLineItem(db.Model):
|
|||||||
frac: Decimal = (value - whole).normalize()
|
frac: Decimal = (value - whole).normalize()
|
||||||
return str(whole) + str(abs(frac))[1:]
|
return str(whole) + str(abs(frac))[1:]
|
||||||
|
|
||||||
journal_entry_day: date = self.journal_entry.date
|
return ["{}/{}/{}".format(self.journal_entry.date.year,
|
||||||
description: str = "" if self.description is None else self.description
|
self.journal_entry.date.month,
|
||||||
return ([description],
|
self.journal_entry.date.day),
|
||||||
[str(journal_entry_day.year),
|
"" if self.description is None else self.description,
|
||||||
"{}/{}".format(journal_entry_day.year,
|
str(self.account),
|
||||||
journal_entry_day.month),
|
format_amount(self.amount)]
|
||||||
"{}/{}".format(journal_entry_day.month,
|
|
||||||
journal_entry_day.day),
|
|
||||||
"{}/{}/{}".format(journal_entry_day.year,
|
|
||||||
journal_entry_day.month,
|
|
||||||
journal_entry_day.day),
|
|
||||||
format_amount(self.amount),
|
|
||||||
format_amount(self.net_balance)])
|
|
||||||
|
|
||||||
|
|
||||||
class Option(db.Model):
|
class Option(base_cls):
|
||||||
"""An option."""
|
"""An option."""
|
||||||
__tablename__ = "accounting_options"
|
__tablename__ = "accounting_options"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
name = db.Column(db.String, nullable=False, primary_key=True)
|
name: Mapped[str] = mapped_column(primary_key=True)
|
||||||
"""The name."""
|
"""The name."""
|
||||||
value = db.Column(db.Text, nullable=False)
|
value: Mapped[str] = mapped_column(sa.Text)
|
||||||
"""The option value."""
|
"""The option value."""
|
||||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
created_at: Mapped[dt.datetime] \
|
||||||
server_default=db.func.now())
|
= mapped_column(sa.DateTime(timezone=True),
|
||||||
"""The time of creation."""
|
server_default=sa.func.now())
|
||||||
created_by_id = db.Column(db.Integer,
|
"""The date and time when this record was created."""
|
||||||
db.ForeignKey(user_pk_column,
|
created_by_id: Mapped[int] \
|
||||||
onupdate="CASCADE"),
|
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
nullable=False)
|
"""The ID of the user who created the record."""
|
||||||
"""The ID of the creator."""
|
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
|
||||||
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
"""The user who created the record."""
|
||||||
"""The creator."""
|
updated_at: Mapped[dt.datetime] \
|
||||||
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
= mapped_column(sa.DateTime(timezone=True),
|
||||||
server_default=db.func.now())
|
server_default=sa.func.now())
|
||||||
"""The time of last update."""
|
"""The date and time when this record was last updated."""
|
||||||
updated_by_id = db.Column(db.Integer,
|
updated_by_id: Mapped[int] \
|
||||||
db.ForeignKey(user_pk_column,
|
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||||
onupdate="CASCADE"),
|
"""The ID of the last user who updated the record."""
|
||||||
nullable=False)
|
updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
|
||||||
"""The ID of the updator."""
|
"""The last user who updated the record."""
|
||||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
|
||||||
"""The updator."""
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -23,13 +23,13 @@ from flask_wtf import FlaskForm
|
|||||||
from wtforms import StringField, FieldList, FormField, IntegerField
|
from wtforms import StringField, FieldList, FormField, IntegerField
|
||||||
from wtforms.validators import DataRequired, ValidationError
|
from wtforms.validators import DataRequired, ValidationError
|
||||||
|
|
||||||
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
|
from ..forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
|
||||||
IsDebitAccount, IsCreditAccount
|
IsDebitAccount, IsCreditAccount
|
||||||
from accounting.locale import lazy_gettext
|
from ..locale import lazy_gettext
|
||||||
from accounting.models import Account
|
from ..models import Account
|
||||||
from accounting.utils.current_account import CurrentAccount
|
from ..utils.current_account import CurrentAccount
|
||||||
from accounting.utils.options import Options
|
from ..utils.options import Options
|
||||||
from accounting.utils.strip_text import strip_text
|
from ..utils.strip_text import strip_text
|
||||||
|
|
||||||
|
|
||||||
class CurrentAccountExists:
|
class CurrentAccountExists:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -20,16 +20,16 @@
|
|||||||
from urllib.parse import parse_qsl, urlencode
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, session, request, \
|
from flask import Blueprint, render_template, redirect, session, request, \
|
||||||
flash, url_for
|
flash, url_for, Response
|
||||||
from werkzeug.datastructures import ImmutableMultiDict
|
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
|
from .forms import OptionForm
|
||||||
|
from ..locale import lazy_gettext
|
||||||
|
from ..utils.cast import s
|
||||||
|
from ..utils.flash_errors import flash_form_errors
|
||||||
|
from ..utils.next_uri import inherit_next
|
||||||
|
from ..utils.options import options
|
||||||
|
from ..utils.permission import has_permission, can_admin
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("option", __name__)
|
bp: Blueprint = Blueprint("option", __name__)
|
||||||
"""The view blueprint for the currency management."""
|
"""The view blueprint for the currency management."""
|
||||||
@@ -64,7 +64,7 @@ def show_option_form() -> str:
|
|||||||
|
|
||||||
@bp.post("update", endpoint="update")
|
@bp.post("update", endpoint="update")
|
||||||
@has_permission(can_admin)
|
@has_permission(can_admin)
|
||||||
def update_options() -> redirect:
|
def update_options() -> Response:
|
||||||
"""Updates the options.
|
"""Updates the options.
|
||||||
|
|
||||||
:return: The redirection to the option form.
|
:return: The redirection to the option form.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@@ -17,19 +17,21 @@
|
|||||||
"""The report management.
|
"""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.
|
"""Initialize the application.
|
||||||
|
|
||||||
:param app: The Flask 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.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from .converters import PeriodConverter, IncomeExpensesAccountConverter
|
from .converters import PeriodConverter, CurrentAccountConverter, \
|
||||||
|
NeedOffsetAccountConverter
|
||||||
app.url_map.converters["period"] = PeriodConverter
|
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
|
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,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -22,14 +22,14 @@ import re
|
|||||||
from flask import abort
|
from flask import abort
|
||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from accounting.models import Account
|
|
||||||
from accounting.utils.current_account import CurrentAccount
|
|
||||||
from .period import Period, get_period
|
from .period import Period, get_period
|
||||||
|
from ..models import Account
|
||||||
|
from ..utils.current_account import CurrentAccount
|
||||||
|
|
||||||
|
|
||||||
class PeriodConverter(BaseConverter):
|
class PeriodConverter(BaseConverter):
|
||||||
"""The supplier converter to convert the period specification from and to
|
"""The converter to convert the period specification from and to the
|
||||||
the corresponding period in the routes."""
|
corresponding period in the routes."""
|
||||||
|
|
||||||
def to_python(self, value: str) -> Period:
|
def to_python(self, value: str) -> Period:
|
||||||
"""Converts a period specification to a period.
|
"""Converts a period specification to a period.
|
||||||
@@ -51,9 +51,9 @@ class PeriodConverter(BaseConverter):
|
|||||||
return value.spec
|
return value.spec
|
||||||
|
|
||||||
|
|
||||||
class IncomeExpensesAccountConverter(BaseConverter):
|
class CurrentAccountConverter(BaseConverter):
|
||||||
"""The supplier converter to convert the income and expenses log pseudo
|
"""The converter to convert the current account code from and to the
|
||||||
account code from and to the corresponding pseudo account in the routes."""
|
corresponding account in the routes."""
|
||||||
|
|
||||||
def to_python(self, value: str) -> CurrentAccount:
|
def to_python(self, value: str) -> CurrentAccount:
|
||||||
"""Converts an account code to an account.
|
"""Converts an account code to an account.
|
||||||
@@ -77,3 +77,29 @@ class IncomeExpensesAccountConverter(BaseConverter):
|
|||||||
:return: Its code.
|
:return: Its code.
|
||||||
"""
|
"""
|
||||||
return value.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.
|
||||||
|
:return: Its code.
|
||||||
|
"""
|
||||||
|
return value.code
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -20,25 +20,29 @@ This file is largely taken from the NanoParma ERP project, first written in
|
|||||||
2021/9/16 by imacat (imacat@nanoparma.com).
|
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
import datetime as dt
|
||||||
from datetime import date
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from accounting.models import JournalEntry
|
|
||||||
from .period import Period
|
from .period import Period
|
||||||
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
||||||
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
|
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
|
||||||
|
from ... import db
|
||||||
|
from ...models import JournalEntry
|
||||||
|
from ...utils.timezone import get_tz_today
|
||||||
|
|
||||||
|
|
||||||
class PeriodChooser:
|
class PeriodChooser:
|
||||||
"""The period chooser."""
|
"""The period chooser."""
|
||||||
|
|
||||||
def __init__(self, get_url: t.Callable[[Period], str]):
|
def __init__(self, get_url: Callable[[Period], str]):
|
||||||
"""Constructs a period chooser.
|
"""Constructs a period chooser.
|
||||||
|
|
||||||
:param get_url: The callback to return the URL of the current report in
|
:param get_url: The callback to return the URL of the current report in
|
||||||
a period.
|
a period.
|
||||||
"""
|
"""
|
||||||
self.__get_url: t.Callable[[Period], str] = get_url
|
self.__get_url: Callable[[Period], str] = get_url
|
||||||
"""The callback to return the URL of the current report in a period."""
|
"""The callback to return the URL of the current report in a period."""
|
||||||
|
|
||||||
# Shortcut periods
|
# Shortcut periods
|
||||||
@@ -61,12 +65,12 @@ class PeriodChooser:
|
|||||||
self.url_template: str = get_url(TemplatePeriod())
|
self.url_template: str = get_url(TemplatePeriod())
|
||||||
"""The URL template."""
|
"""The URL template."""
|
||||||
|
|
||||||
first: JournalEntry | None \
|
first: JournalEntry | None = db.session.scalar(
|
||||||
= JournalEntry.query.order_by(JournalEntry.date).first()
|
sa.select(JournalEntry).order_by(JournalEntry.date))
|
||||||
start: date | None = None if first is None else first.date
|
start: dt.date | None = None if first is None else first.date
|
||||||
|
|
||||||
# Attributes
|
# Attributes
|
||||||
self.data_start: date | None = start
|
self.data_start: dt.date | None = start
|
||||||
"""The start of the data."""
|
"""The start of the data."""
|
||||||
self.has_data: bool = start is not None
|
self.has_data: bool = start is not None
|
||||||
"""Whether there is any data."""
|
"""Whether there is any data."""
|
||||||
@@ -80,8 +84,8 @@ class PeriodChooser:
|
|||||||
"""The available years."""
|
"""The available years."""
|
||||||
|
|
||||||
if self.has_data:
|
if self.has_data:
|
||||||
today: date = date.today()
|
today: dt.date = get_tz_today()
|
||||||
self.has_last_month = start < date(today.year, today.month, 1)
|
self.has_last_month = start < dt.date(today.year, today.month, 1)
|
||||||
self.has_last_year = start.year < today.year
|
self.has_last_year = start.year < today.year
|
||||||
self.has_yesterday = start < today
|
self.has_yesterday = start < today
|
||||||
if start.year < today.year - 1:
|
if start.year < today.year - 1:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,12 +17,12 @@
|
|||||||
"""The period description composer.
|
"""The period description composer.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from datetime import date, timedelta
|
import datetime as dt
|
||||||
|
|
||||||
from accounting.locale import gettext
|
from ...locale import gettext
|
||||||
|
|
||||||
|
|
||||||
def get_desc(start: date | None, end: date | None) -> str:
|
def get_desc(start: dt.date | None, end: dt.date | None) -> str:
|
||||||
"""Returns the period description.
|
"""Returns the period description.
|
||||||
|
|
||||||
:param start: The start of the period.
|
:param start: The start of the period.
|
||||||
@@ -46,7 +46,7 @@ def get_desc(start: date | None, end: date | None) -> str:
|
|||||||
return __get_day_desc(start, end)
|
return __get_day_desc(start, end)
|
||||||
|
|
||||||
|
|
||||||
def __get_since_desc(start: date) -> str:
|
def __get_since_desc(start: dt.date) -> str:
|
||||||
"""Returns the description without the end day.
|
"""Returns the description without the end day.
|
||||||
|
|
||||||
:param start: The start of the period.
|
:param start: The start of the period.
|
||||||
@@ -67,7 +67,7 @@ def __get_since_desc(start: date) -> str:
|
|||||||
return gettext("since %(start)s", start=get_start_desc())
|
return gettext("since %(start)s", start=get_start_desc())
|
||||||
|
|
||||||
|
|
||||||
def __get_until_desc(end: date) -> str:
|
def __get_until_desc(end: dt.date) -> str:
|
||||||
"""Returns the description without the start day.
|
"""Returns the description without the start day.
|
||||||
|
|
||||||
:param end: The end of the period.
|
:param end: The end of the period.
|
||||||
@@ -81,14 +81,14 @@ def __get_until_desc(end: date) -> str:
|
|||||||
"""
|
"""
|
||||||
if end.month == 12 and end.day == 31:
|
if end.month == 12 and end.day == 31:
|
||||||
return str(end.year)
|
return str(end.year)
|
||||||
if (end + timedelta(days=1)).day == 1:
|
if (end + dt.timedelta(days=1)).day == 1:
|
||||||
return __format_month(end)
|
return __format_month(end)
|
||||||
return __format_day(end)
|
return __format_day(end)
|
||||||
|
|
||||||
return gettext("until %(end)s", end=get_end_desc())
|
return gettext("until %(end)s", end=get_end_desc())
|
||||||
|
|
||||||
|
|
||||||
def __get_year_desc(start: date, end: date) -> str:
|
def __get_year_desc(start: dt.date, end: dt.date) -> str:
|
||||||
"""Returns the description as a year range.
|
"""Returns the description as a year range.
|
||||||
|
|
||||||
:param start: The start of the period.
|
:param start: The start of the period.
|
||||||
@@ -105,7 +105,7 @@ def __get_year_desc(start: date, end: date) -> str:
|
|||||||
return __get_from_to_desc(start_text, str(end.year))
|
return __get_from_to_desc(start_text, str(end.year))
|
||||||
|
|
||||||
|
|
||||||
def __get_month_desc(start: date, end: date) -> str:
|
def __get_month_desc(start: dt.date, end: dt.date) -> str:
|
||||||
"""Returns the description as a month range.
|
"""Returns the description as a month range.
|
||||||
|
|
||||||
:param start: The start of the period.
|
:param start: The start of the period.
|
||||||
@@ -113,7 +113,7 @@ def __get_month_desc(start: date, end: date) -> str:
|
|||||||
:return: The description as a month range.
|
:return: The description as a month range.
|
||||||
:raise ValueError: The period is not a month range.
|
:raise ValueError: The period is not a month range.
|
||||||
"""
|
"""
|
||||||
if start.day != 1 or (end + timedelta(days=1)).day != 1:
|
if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
start_text: str = __format_month(start)
|
start_text: str = __format_month(start)
|
||||||
if start.year == end.year and start.month == end.month:
|
if start.year == end.year and start.month == end.month:
|
||||||
@@ -123,7 +123,7 @@ def __get_month_desc(start: date, end: date) -> str:
|
|||||||
return __get_from_to_desc(start_text, __format_month(end))
|
return __get_from_to_desc(start_text, __format_month(end))
|
||||||
|
|
||||||
|
|
||||||
def __get_day_desc(start: date, end: date) -> str:
|
def __get_day_desc(start: dt.date, end: dt.date) -> str:
|
||||||
"""Returns the description as a day range.
|
"""Returns the description as a day range.
|
||||||
|
|
||||||
:param start: The start of the period.
|
:param start: The start of the period.
|
||||||
@@ -142,7 +142,7 @@ def __get_day_desc(start: date, end: date) -> str:
|
|||||||
return __get_from_to_desc(start_text, __format_day(end))
|
return __get_from_to_desc(start_text, __format_day(end))
|
||||||
|
|
||||||
|
|
||||||
def __format_month(month: date) -> str:
|
def __format_month(month: dt.date) -> str:
|
||||||
"""Formats a month.
|
"""Formats a month.
|
||||||
|
|
||||||
:param month: The month.
|
:param month: The month.
|
||||||
@@ -151,7 +151,7 @@ def __format_month(month: date) -> str:
|
|||||||
return f"{month.year}/{month.month}"
|
return f"{month.year}/{month.month}"
|
||||||
|
|
||||||
|
|
||||||
def __format_day(day: date) -> str:
|
def __format_day(day: dt.date) -> str:
|
||||||
"""Formats a day.
|
"""Formats a day.
|
||||||
|
|
||||||
:param day: The day.
|
:param day: The day.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@@ -18,14 +18,14 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import calendar
|
import calendar
|
||||||
from datetime import date
|
import datetime as dt
|
||||||
|
|
||||||
|
|
||||||
def month_end(day: date) -> date:
|
def month_end(day: dt.date) -> dt.date:
|
||||||
"""Returns the end day of month for a date.
|
"""Returns the end day of month for a date.
|
||||||
|
|
||||||
:param day: The date.
|
:param day: The date.
|
||||||
:return: The end day of the month of that day.
|
:return: The end day of the month of that day.
|
||||||
"""
|
"""
|
||||||
last_day: int = calendar.monthrange(day.year, day.month)[1]
|
last_day: int = calendar.monthrange(day.year, day.month)[1]
|
||||||
return date(day.year, day.month, last_day)
|
return dt.date(day.year, day.month, last_day)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -18,9 +18,9 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import calendar
|
import calendar
|
||||||
|
import datetime as dt
|
||||||
import re
|
import re
|
||||||
import typing as t
|
from collections.abc import Callable
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from .period import Period
|
from .period import Period
|
||||||
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
||||||
@@ -39,7 +39,7 @@ def get_period(spec: str | None = None) -> Period:
|
|||||||
"""
|
"""
|
||||||
if spec is None:
|
if spec is None:
|
||||||
return ThisMonth()
|
return ThisMonth()
|
||||||
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = {
|
named_periods: dict[str, type[Callable[[], Period]]] = {
|
||||||
"this-month": lambda: ThisMonth(),
|
"this-month": lambda: ThisMonth(),
|
||||||
"last-month": lambda: LastMonth(),
|
"last-month": lambda: LastMonth(),
|
||||||
"since-last-month": lambda: SinceLastMonth(),
|
"since-last-month": lambda: SinceLastMonth(),
|
||||||
@@ -57,7 +57,7 @@ def get_period(spec: str | None = None) -> Period:
|
|||||||
return Period(start, end)
|
return Period(start, end)
|
||||||
|
|
||||||
|
|
||||||
def __parse_spec(text: str) -> tuple[date | None, date | None]:
|
def __parse_spec(text: str) -> tuple[dt.date | None, dt.date | None]:
|
||||||
"""Parses the period specification.
|
"""Parses the period specification.
|
||||||
|
|
||||||
:param text: The period specification.
|
:param text: The period specification.
|
||||||
@@ -67,6 +67,7 @@ def __parse_spec(text: str) -> tuple[date | None, date | None]:
|
|||||||
"""
|
"""
|
||||||
if text == "-":
|
if text == "-":
|
||||||
return None, None
|
return None, None
|
||||||
|
m: re.Match[str] | None
|
||||||
m = re.match(f"^{DATE_SPEC_RE}$", text)
|
m = re.match(f"^{DATE_SPEC_RE}$", text)
|
||||||
if m is not None:
|
if m is not None:
|
||||||
return __get_start(m[1], m[2], m[3]), \
|
return __get_start(m[1], m[2], m[3]), \
|
||||||
@@ -84,7 +85,7 @@ def __parse_spec(text: str) -> tuple[date | None, date | None]:
|
|||||||
raise ValueError
|
raise ValueError
|
||||||
|
|
||||||
|
|
||||||
def __get_start(year: str, month: str | None, day: str | None) -> date:
|
def __get_start(year: str, month: str | None, day: str | None) -> dt.date:
|
||||||
"""Returns the start of the period from the date representation.
|
"""Returns the start of the period from the date representation.
|
||||||
|
|
||||||
:param year: The year.
|
:param year: The year.
|
||||||
@@ -94,13 +95,13 @@ def __get_start(year: str, month: str | None, day: str | None) -> date:
|
|||||||
:raise ValueError: When the date is invalid.
|
:raise ValueError: When the date is invalid.
|
||||||
"""
|
"""
|
||||||
if day is not None:
|
if day is not None:
|
||||||
return date(int(year), int(month), int(day))
|
return dt.date(int(year), int(month), int(day))
|
||||||
if month is not None:
|
if month is not None:
|
||||||
return date(int(year), int(month), 1)
|
return dt.date(int(year), int(month), 1)
|
||||||
return date(int(year), 1, 1)
|
return dt.date(int(year), 1, 1)
|
||||||
|
|
||||||
|
|
||||||
def __get_end(year: str, month: str | None, day: str | None) -> date:
|
def __get_end(year: str, month: str | None, day: str | None) -> dt.date:
|
||||||
"""Returns the end of the period from the date representation.
|
"""Returns the end of the period from the date representation.
|
||||||
|
|
||||||
:param year: The year.
|
:param year: The year.
|
||||||
@@ -110,10 +111,10 @@ def __get_end(year: str, month: str | None, day: str | None) -> date:
|
|||||||
:raise ValueError: When the date is invalid.
|
:raise ValueError: When the date is invalid.
|
||||||
"""
|
"""
|
||||||
if day is not None:
|
if day is not None:
|
||||||
return date(int(year), int(month), int(day))
|
return dt.date(int(year), int(month), int(day))
|
||||||
if month is not None:
|
if month is not None:
|
||||||
year_n: int = int(year)
|
year_n: int = int(year)
|
||||||
month_n: int = int(month)
|
month_n: int = int(month)
|
||||||
day_n: int = calendar.monthrange(year_n, month_n)[1]
|
day_n: int = calendar.monthrange(year_n, month_n)[1]
|
||||||
return date(year_n, month_n, day_n)
|
return dt.date(year_n, month_n, day_n)
|
||||||
return date(int(year), 12, 31)
|
return dt.date(int(year), 12, 31)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@@ -20,8 +20,8 @@ This file is largely taken from the NanoParma ERP project, first written in
|
|||||||
2021/9/16 by imacat (imacat@nanoparma.com).
|
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
import datetime as dt
|
||||||
from datetime import date, timedelta
|
from typing import Self
|
||||||
|
|
||||||
from .description import get_desc
|
from .description import get_desc
|
||||||
from .month_end import month_end
|
from .month_end import month_end
|
||||||
@@ -31,18 +31,18 @@ from .specification import get_spec
|
|||||||
class Period:
|
class Period:
|
||||||
"""A date period."""
|
"""A date period."""
|
||||||
|
|
||||||
def __init__(self, start: date | None, end: date | None):
|
def __init__(self, start: dt.date | None, end: dt.date | None):
|
||||||
"""Constructs a new date period.
|
"""Constructs a new date period.
|
||||||
|
|
||||||
:param start: The start date, or None from the very beginning.
|
:param start: The start date, or None from the very beginning.
|
||||||
:param end: The end date, or None till no end.
|
:param end: The end date, or None till no end.
|
||||||
"""
|
"""
|
||||||
self.start: date | None = start
|
self.start: dt.date | None = start
|
||||||
"""The start of the period."""
|
"""The start of the period."""
|
||||||
self.end: date | None = end
|
self.end: dt.date | None = end
|
||||||
"""The end of the period."""
|
"""The end of the period."""
|
||||||
self.is_default: bool = False
|
self.is_default: bool = False
|
||||||
"""Whether the is the default period."""
|
"""Whether this is the default period."""
|
||||||
self.is_this_month: bool = False
|
self.is_this_month: bool = False
|
||||||
"""Whether the period is this month."""
|
"""Whether the period is this month."""
|
||||||
self.is_last_month: bool = False
|
self.is_last_month: bool = False
|
||||||
@@ -95,8 +95,8 @@ class Period:
|
|||||||
self.is_a_month = self.start.day == 1 \
|
self.is_a_month = self.start.day == 1 \
|
||||||
and self.end == month_end(self.start)
|
and self.end == month_end(self.start)
|
||||||
self.is_type_month = self.is_a_month
|
self.is_type_month = self.is_a_month
|
||||||
self.is_a_year = self.start == date(self.start.year, 1, 1) \
|
self.is_a_year = self.start == dt.date(self.start.year, 1, 1) \
|
||||||
and self.end == date(self.start.year, 12, 31)
|
and self.end == dt.date(self.start.year, 12, 31)
|
||||||
self.is_a_day = self.start == self.end
|
self.is_a_day = self.start == self.end
|
||||||
|
|
||||||
def is_year(self, year: int) -> bool:
|
def is_year(self, year: int) -> bool:
|
||||||
@@ -119,11 +119,11 @@ class Period:
|
|||||||
and not self.is_a_day
|
and not self.is_a_day
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def before(self) -> t.Self | None:
|
def before(self) -> Self | None:
|
||||||
"""Returns the period before this period.
|
"""Returns the period before this period.
|
||||||
|
|
||||||
:return: The period before this period.
|
:return: The period before this period.
|
||||||
"""
|
"""
|
||||||
if self.start is None:
|
if self.start is None:
|
||||||
return None
|
return None
|
||||||
return Period(None, self.start - timedelta(days=1))
|
return Period(None, self.start - dt.timedelta(days=1))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,18 +17,19 @@
|
|||||||
"""The named shortcut periods.
|
"""The named shortcut periods.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from datetime import date, timedelta
|
import datetime as dt
|
||||||
|
|
||||||
from accounting.locale import gettext
|
|
||||||
from .month_end import month_end
|
from .month_end import month_end
|
||||||
from .period import Period
|
from .period import Period
|
||||||
|
from ...locale import gettext
|
||||||
|
from ...utils.timezone import get_tz_today
|
||||||
|
|
||||||
|
|
||||||
class ThisMonth(Period):
|
class ThisMonth(Period):
|
||||||
"""The period of this month."""
|
"""The period of this month."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
today: date = date.today()
|
today: dt.date = get_tz_today()
|
||||||
this_month_start: date = date(today.year, today.month, 1)
|
this_month_start: dt.date = dt.date(today.year, today.month, 1)
|
||||||
super().__init__(this_month_start, month_end(today))
|
super().__init__(this_month_start, month_end(today))
|
||||||
self.is_default = True
|
self.is_default = True
|
||||||
self.is_this_month = True
|
self.is_this_month = True
|
||||||
@@ -43,13 +44,13 @@ class ThisMonth(Period):
|
|||||||
class LastMonth(Period):
|
class LastMonth(Period):
|
||||||
"""The period of this month."""
|
"""The period of this month."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
today: date = date.today()
|
today: dt.date = get_tz_today()
|
||||||
year: int = today.year
|
year: int = today.year
|
||||||
month: int = today.month - 1
|
month: int = today.month - 1
|
||||||
if month < 1:
|
if month < 1:
|
||||||
year = year - 1
|
year = year - 1
|
||||||
month = 12
|
month = 12
|
||||||
start: date = date(year, month, 1)
|
start: dt.date = dt.date(year, month, 1)
|
||||||
super().__init__(start, month_end(start))
|
super().__init__(start, month_end(start))
|
||||||
self.is_last_month = True
|
self.is_last_month = True
|
||||||
|
|
||||||
@@ -63,13 +64,13 @@ class LastMonth(Period):
|
|||||||
class SinceLastMonth(Period):
|
class SinceLastMonth(Period):
|
||||||
"""The period of this month."""
|
"""The period of this month."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
today: date = date.today()
|
today: dt.date = get_tz_today()
|
||||||
year: int = today.year
|
year: int = today.year
|
||||||
month: int = today.month - 1
|
month: int = today.month - 1
|
||||||
if month < 1:
|
if month < 1:
|
||||||
year = year - 1
|
year = year - 1
|
||||||
month = 12
|
month = 12
|
||||||
start: date = date(year, month, 1)
|
start: dt.date = dt.date(year, month, 1)
|
||||||
super().__init__(start, None)
|
super().__init__(start, None)
|
||||||
self.is_since_last_month = True
|
self.is_since_last_month = True
|
||||||
|
|
||||||
@@ -82,9 +83,9 @@ class SinceLastMonth(Period):
|
|||||||
class ThisYear(Period):
|
class ThisYear(Period):
|
||||||
"""The period of this year."""
|
"""The period of this year."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
year: int = date.today().year
|
year: int = get_tz_today().year
|
||||||
start: date = date(year, 1, 1)
|
start: dt.date = dt.date(year, 1, 1)
|
||||||
end: date = date(year, 12, 31)
|
end: dt.date = dt.date(year, 12, 31)
|
||||||
super().__init__(start, end)
|
super().__init__(start, end)
|
||||||
self.is_this_year = True
|
self.is_this_year = True
|
||||||
|
|
||||||
@@ -97,9 +98,9 @@ class ThisYear(Period):
|
|||||||
class LastYear(Period):
|
class LastYear(Period):
|
||||||
"""The period of last year."""
|
"""The period of last year."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
year: int = date.today().year
|
year: int = get_tz_today().year
|
||||||
start: date = date(year - 1, 1, 1)
|
start: dt.date = dt.date(year - 1, 1, 1)
|
||||||
end: date = date(year - 1, 12, 31)
|
end: dt.date = dt.date(year - 1, 12, 31)
|
||||||
super().__init__(start, end)
|
super().__init__(start, end)
|
||||||
self.is_last_year = True
|
self.is_last_year = True
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ class LastYear(Period):
|
|||||||
class Today(Period):
|
class Today(Period):
|
||||||
"""The period of today."""
|
"""The period of today."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
today: date = date.today()
|
today: dt.date = get_tz_today()
|
||||||
super().__init__(today, today)
|
super().__init__(today, today)
|
||||||
self.is_today = True
|
self.is_today = True
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ class Today(Period):
|
|||||||
class Yesterday(Period):
|
class Yesterday(Period):
|
||||||
"""The period of yesterday."""
|
"""The period of yesterday."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
yesterday: date = date.today() - timedelta(days=1)
|
yesterday: dt.date = get_tz_today() - dt.timedelta(days=1)
|
||||||
super().__init__(yesterday, yesterday)
|
super().__init__(yesterday, yesterday)
|
||||||
self.is_yesterday = True
|
self.is_yesterday = True
|
||||||
|
|
||||||
@@ -163,6 +164,6 @@ class YearPeriod(Period):
|
|||||||
|
|
||||||
:param year: The year.
|
:param year: The year.
|
||||||
"""
|
"""
|
||||||
start: date = date(year, 1, 1)
|
start: dt.date = dt.date(year, 1, 1)
|
||||||
end: date = date(year, 12, 31)
|
end: dt.date = dt.date(year, 12, 31)
|
||||||
super().__init__(start, end)
|
super().__init__(start, end)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@@ -17,22 +17,28 @@
|
|||||||
"""The period specification composer.
|
"""The period specification composer.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from datetime import date, timedelta
|
import datetime as dt
|
||||||
|
|
||||||
|
|
||||||
def get_spec(start: date | None, end: date | None) -> str:
|
def get_spec(start: dt.date | None, end: dt.date | None) -> str:
|
||||||
"""Returns the period specification.
|
"""Returns the period specification.
|
||||||
|
|
||||||
:param start: The start of the period.
|
:param start: The start of the period.
|
||||||
:param end: The end of the period.
|
:param end: The end of the period.
|
||||||
:return: The period specification.
|
:return: The period specification.
|
||||||
"""
|
"""
|
||||||
if start is None and end is None:
|
|
||||||
return "-"
|
|
||||||
if end is None:
|
|
||||||
return __get_since_spec(start)
|
|
||||||
if start is None:
|
if start is None:
|
||||||
return __get_until_spec(end)
|
return "-" if end is None else __get_until_spec(end)
|
||||||
|
return __get_since_spec(start) if end is None else __get_spec(start, end)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_spec(start: dt.date, end: dt.date) -> str:
|
||||||
|
"""Returns the period specification with both start and end.
|
||||||
|
|
||||||
|
:param start: The start of the period.
|
||||||
|
:param end: The end of the period.
|
||||||
|
:return: The period specification.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
return __get_year_spec(start, end)
|
return __get_year_spec(start, end)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -44,7 +50,7 @@ def get_spec(start: date | None, end: date | None) -> str:
|
|||||||
return __get_day_spec(start, end)
|
return __get_day_spec(start, end)
|
||||||
|
|
||||||
|
|
||||||
def __get_since_spec(start: date) -> str:
|
def __get_since_spec(start: dt.date) -> str:
|
||||||
"""Returns the period specification without the end day.
|
"""Returns the period specification without the end day.
|
||||||
|
|
||||||
:param start: The start of the period.
|
:param start: The start of the period.
|
||||||
@@ -57,7 +63,7 @@ def __get_since_spec(start: date) -> str:
|
|||||||
return start.strftime("%Y-%m-%d-")
|
return start.strftime("%Y-%m-%d-")
|
||||||
|
|
||||||
|
|
||||||
def __get_until_spec(end: date) -> str:
|
def __get_until_spec(end: dt.date) -> str:
|
||||||
"""Returns the period specification without the start day.
|
"""Returns the period specification without the start day.
|
||||||
|
|
||||||
:param end: The end of the period.
|
:param end: The end of the period.
|
||||||
@@ -65,12 +71,12 @@ def __get_until_spec(end: date) -> str:
|
|||||||
"""
|
"""
|
||||||
if end.month == 12 and end.day == 31:
|
if end.month == 12 and end.day == 31:
|
||||||
return end.strftime("-%Y")
|
return end.strftime("-%Y")
|
||||||
if (end + timedelta(days=1)).day == 1:
|
if (end + dt.timedelta(days=1)).day == 1:
|
||||||
return end.strftime("-%Y-%m")
|
return end.strftime("-%Y-%m")
|
||||||
return end.strftime("-%Y-%m-%d")
|
return end.strftime("-%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
def __get_year_spec(start: date, end: date) -> str:
|
def __get_year_spec(start: dt.date, end: dt.date) -> str:
|
||||||
"""Returns the period specification as a year range.
|
"""Returns the period specification as a year range.
|
||||||
|
|
||||||
:param start: The start of the period.
|
:param start: The start of the period.
|
||||||
@@ -88,7 +94,7 @@ def __get_year_spec(start: date, end: date) -> str:
|
|||||||
return f"{start_spec}-{end_spec}"
|
return f"{start_spec}-{end_spec}"
|
||||||
|
|
||||||
|
|
||||||
def __get_month_spec(start: date, end: date) -> str:
|
def __get_month_spec(start: dt.date, end: dt.date) -> str:
|
||||||
"""Returns the period specification as a month range.
|
"""Returns the period specification as a month range.
|
||||||
|
|
||||||
:param start: The start of the period.
|
:param start: The start of the period.
|
||||||
@@ -96,7 +102,7 @@ def __get_month_spec(start: date, end: date) -> str:
|
|||||||
:return: The period specification as a month range.
|
:return: The period specification as a month range.
|
||||||
:raise ValueError: The period is not a month range.
|
:raise ValueError: The period is not a month range.
|
||||||
"""
|
"""
|
||||||
if start.day != 1 or (end + timedelta(days=1)).day != 1:
|
if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
start_spec: str = start.strftime("%Y-%m")
|
start_spec: str = start.strftime("%Y-%m")
|
||||||
if start.year == end.year and start.month == end.month:
|
if start.year == end.year and start.month == end.month:
|
||||||
@@ -105,7 +111,7 @@ def __get_month_spec(start: date, end: date) -> str:
|
|||||||
return f"{start_spec}-{end_spec}"
|
return f"{start_spec}-{end_spec}"
|
||||||
|
|
||||||
|
|
||||||
def __get_day_spec(start: date, end: date) -> str:
|
def __get_day_spec(start: dt.date, end: dt.date) -> str:
|
||||||
"""Returns the period specification as a day range.
|
"""Returns the period specification as a day range.
|
||||||
|
|
||||||
:param start: The start of the period.
|
:param start: The start of the period.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -22,20 +22,18 @@ from decimal import Decimal
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import render_template, Response
|
from flask import render_template, Response
|
||||||
|
|
||||||
from accounting import db
|
from ..period import Period, PeriodChooser
|
||||||
from accounting.locale import gettext
|
from ..utils.base_page_params import BasePageParams
|
||||||
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
|
from ..utils.base_report import BaseReport
|
||||||
|
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
|
||||||
|
from ..utils.option_link import OptionLink
|
||||||
|
from ..utils.report_chooser import ReportChooser
|
||||||
|
from ..utils.report_type import ReportType
|
||||||
|
from ..utils.urls import ledger_url, balance_sheet_url, income_statement_url
|
||||||
|
from ... import db
|
||||||
|
from ...locale import gettext
|
||||||
|
from ...models import Currency, BaseAccount, Account, JournalEntry, \
|
||||||
JournalEntryLineItem
|
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.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, balance_sheet_url, \
|
|
||||||
income_statement_url
|
|
||||||
|
|
||||||
|
|
||||||
class ReportAccount:
|
class ReportAccount:
|
||||||
@@ -121,9 +119,9 @@ class AccountCollector:
|
|||||||
|
|
||||||
:return: The balances.
|
:return: The balances.
|
||||||
"""
|
"""
|
||||||
sub_conditions: list[sa.BinaryExpression] \
|
sub_conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
|
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
sa.or_(*sub_conditions)]
|
sa.or_(*sub_conditions)]
|
||||||
if self.__period.end is not None:
|
if self.__period.end is not None:
|
||||||
@@ -135,15 +133,18 @@ class AccountCollector:
|
|||||||
= sa.select(Account.id, Account.base_code, Account.no,
|
= sa.select(Account.id, Account.base_code, Account.no,
|
||||||
balance_func)\
|
balance_func)\
|
||||||
.join(JournalEntry).join(Account)\
|
.join(JournalEntry).join(Account)\
|
||||||
.filter(*conditions)\
|
.where(*conditions)\
|
||||||
.group_by(Account.id, Account.base_code, Account.no)\
|
.group_by(Account.id, Account.base_code, Account.no)\
|
||||||
|
.having(balance_func != 0)\
|
||||||
.order_by(Account.base_code, Account.no)
|
.order_by(Account.base_code, Account.no)
|
||||||
account_balances: list[sa.Row] \
|
account_balances: list[sa.Row] \
|
||||||
= db.session.execute(select_balance).all()
|
= db.session.execute(select_balance).all()
|
||||||
self.__all_accounts: list[Account] = Account.query\
|
self.__all_accounts: list[Account] = db.session.scalars(
|
||||||
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
|
sa.select(Account)
|
||||||
Account.base_code == "3351",
|
.where(sa.or_(Account.id.in_({x.id for x in account_balances}),
|
||||||
Account.base_code == "3353")).all()
|
Account.base_code == "3351",
|
||||||
|
Account.base_code == "3353"))).unique().all()
|
||||||
|
"""The accounts."""
|
||||||
account_by_id: dict[int, Account] \
|
account_by_id: dict[int, Account] \
|
||||||
= {x.id: x for x in self.__all_accounts}
|
= {x.id: x for x in self.__all_accounts}
|
||||||
self.accounts: list[ReportAccount] \
|
self.accounts: list[ReportAccount] \
|
||||||
@@ -153,6 +154,7 @@ class AccountCollector:
|
|||||||
account_by_id[x.id],
|
account_by_id[x.id],
|
||||||
self.__period))
|
self.__period))
|
||||||
for x in account_balances]
|
for x in account_balances]
|
||||||
|
"""The accounts on the balance sheet."""
|
||||||
self.__add_accumulated()
|
self.__add_accumulated()
|
||||||
self.__add_current_period()
|
self.__add_current_period()
|
||||||
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
|
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
|
||||||
@@ -177,7 +179,7 @@ class AccountCollector:
|
|||||||
"""
|
"""
|
||||||
if self.__period.start is None:
|
if self.__period.start is None:
|
||||||
return None
|
return None
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
JournalEntry.date < self.__period.start]
|
JournalEntry.date < self.__period.start]
|
||||||
return self.__query_balance(conditions)
|
return self.__query_balance(conditions)
|
||||||
@@ -196,7 +198,7 @@ class AccountCollector:
|
|||||||
|
|
||||||
:return: The net income or loss for current period.
|
:return: The net income or loss for current period.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(JournalEntry.date >= self.__period.start)
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
@@ -205,7 +207,7 @@ class AccountCollector:
|
|||||||
return self.__query_balance(conditions)
|
return self.__query_balance(conditions)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __query_balance(conditions: list[sa.BinaryExpression])\
|
def __query_balance(conditions: list[sa.ColumnElement[bool]])\
|
||||||
-> Decimal:
|
-> Decimal:
|
||||||
"""Queries the balance.
|
"""Queries the balance.
|
||||||
|
|
||||||
@@ -218,7 +220,7 @@ class AccountCollector:
|
|||||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
else_=-JournalEntryLineItem.amount))
|
else_=-JournalEntryLineItem.amount))
|
||||||
select_balance: sa.Select = sa.select(balance_func)\
|
select_balance: sa.Select = sa.select(balance_func)\
|
||||||
.join(JournalEntry).join(Account).filter(*conditions)
|
.join(JournalEntry).join(Account).where(*conditions)
|
||||||
return db.session.scalar(select_balance)
|
return db.session.scalar(select_balance)
|
||||||
|
|
||||||
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
|
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
|
||||||
@@ -382,11 +384,13 @@ class BalanceSheet(BaseReport):
|
|||||||
balances: list[ReportAccount] = AccountCollector(
|
balances: list[ReportAccount] = AccountCollector(
|
||||||
self.__currency, self.__period).accounts
|
self.__currency, self.__period).accounts
|
||||||
|
|
||||||
titles: list[BaseAccount] = BaseAccount.query\
|
titles: list[BaseAccount] = db.session.scalars(
|
||||||
.filter(BaseAccount.code.in_({"1", "2", "3"})).all()
|
sa.select(BaseAccount)
|
||||||
subtitles: list[BaseAccount] = BaseAccount.query\
|
.where(BaseAccount.code.in_({"1", "2", "3"}))).unique().all()
|
||||||
.filter(BaseAccount.code.in_({x.account.base_code[:2]
|
subtitle_codes: set[str] = {x.account.base_code[:2] for x in balances}
|
||||||
for x in balances})).all()
|
subtitles: list[BaseAccount] = db.session.scalars(
|
||||||
|
sa.select(BaseAccount)
|
||||||
|
.where(BaseAccount.code.in_(subtitle_codes))).unique().all()
|
||||||
|
|
||||||
sections: dict[str, Section] = {x.code: Section(x) for x in titles}
|
sections: dict[str, Section] = {x.code: Section(x) for x in titles}
|
||||||
subsections: dict[str, Subsection] = {x.code: Subsection(x)
|
subsections: dict[str, Subsection] = {x.code: Subsection(x)
|
||||||
@@ -451,11 +455,11 @@ class BalanceSheet(BaseReport):
|
|||||||
:return: The CSV rows for the section.
|
:return: The CSV rows for the section.
|
||||||
"""
|
"""
|
||||||
rows: list[CSVHalfRow] \
|
rows: list[CSVHalfRow] \
|
||||||
= [CSVHalfRow(section.title.title.title(), None)]
|
= [CSVHalfRow(section.title.title, None)]
|
||||||
for subsection in section.subsections:
|
for subsection in section.subsections:
|
||||||
rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None))
|
rows.append(CSVHalfRow(f" {subsection.title.title}", None))
|
||||||
for account in subsection.accounts:
|
for account in subsection.accounts:
|
||||||
rows.append(CSVHalfRow(f" {str(account.account).title()}",
|
rows.append(CSVHalfRow(f" {str(account.account)}",
|
||||||
account.amount))
|
account.amount))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,29 +17,26 @@
|
|||||||
"""The income and expenses log.
|
"""The income and expenses log.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
import datetime as dt
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import url_for, render_template, Response
|
from flask import url_for, render_template, Response
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from accounting import db
|
from ..period import Period, PeriodChooser
|
||||||
from accounting.locale import gettext
|
from ..utils.base_page_params import BasePageParams
|
||||||
from accounting.models import Currency, Account, JournalEntry, \
|
from ..utils.base_report import BaseReport
|
||||||
JournalEntryLineItem
|
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from ..utils.option_link import OptionLink
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from ..utils.report_chooser import ReportChooser
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from ..utils.report_type import ReportType
|
||||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
from ..utils.urls import income_expenses_url
|
||||||
period_spec
|
from ... import db
|
||||||
from accounting.report.utils.option_link import OptionLink
|
from ...locale import gettext
|
||||||
from accounting.report.utils.report_chooser import ReportChooser
|
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||||
from accounting.report.utils.report_type import ReportType
|
from ...utils.current_account import CurrentAccount
|
||||||
from accounting.report.utils.urls import income_expenses_url
|
from ...utils.pagination import Pagination
|
||||||
from accounting.utils.cast import be
|
|
||||||
from accounting.utils.current_account import CurrentAccount
|
|
||||||
from accounting.utils.pagination import Pagination
|
|
||||||
|
|
||||||
|
|
||||||
class ReportLineItem:
|
class ReportLineItem:
|
||||||
@@ -54,7 +51,7 @@ class ReportLineItem:
|
|||||||
"""Whether this is the brought-forward line item."""
|
"""Whether this is the brought-forward line item."""
|
||||||
self.is_total: bool = False
|
self.is_total: bool = False
|
||||||
"""Whether this is the total line item."""
|
"""Whether this is the total line item."""
|
||||||
self.date: date | None = None
|
self.date: dt.date | None = None
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.account: Account | None = None
|
self.account: Account | None = None
|
||||||
"""The account."""
|
"""The account."""
|
||||||
@@ -120,13 +117,12 @@ class LineItemCollector:
|
|||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
else_=-JournalEntryLineItem.amount))
|
else_=-JournalEntryLineItem.amount))
|
||||||
select: sa.Select = sa.Select(balance_func)\
|
select: sa.Select[tuple[Decimal]] = sa.Select(balance_func)\
|
||||||
.join(JournalEntry).join(Account)\
|
.join(JournalEntry).join(Account)\
|
||||||
.filter(be(JournalEntryLineItem.currency_code
|
.where(JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
== self.__currency.code),
|
self.__account_condition,
|
||||||
self.__account_condition,
|
JournalEntry.date < self.__period.start)
|
||||||
JournalEntry.date < self.__period.start)
|
balance: Decimal | None = db.session.scalar(select)
|
||||||
balance: int | None = db.session.scalar(select)
|
|
||||||
if balance is None:
|
if balance is None:
|
||||||
return None
|
return None
|
||||||
line_item: ReportLineItem = ReportLineItem()
|
line_item: ReportLineItem = ReportLineItem()
|
||||||
@@ -146,7 +142,7 @@ class LineItemCollector:
|
|||||||
|
|
||||||
:return: The line items.
|
:return: The line items.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
self.__account_condition]
|
self.__account_condition]
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
@@ -154,25 +150,25 @@ class LineItemCollector:
|
|||||||
if self.__period.end is not None:
|
if self.__period.end is not None:
|
||||||
conditions.append(JournalEntry.date <= self.__period.end)
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
|
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
|
||||||
join(JournalEntryLineItem).join(Account).filter(*conditions)
|
join(JournalEntryLineItem).join(Account).where(*conditions)
|
||||||
|
|
||||||
return [ReportLineItem(x)
|
return [ReportLineItem(x) for x in db.session.scalars(
|
||||||
for x in JournalEntryLineItem.query
|
sa.select(JournalEntryLineItem)
|
||||||
.join(JournalEntry).join(Account)
|
.join(JournalEntry).join(Account)
|
||||||
.filter(JournalEntryLineItem.journal_entry_id
|
.where(JournalEntryLineItem.journal_entry_id
|
||||||
.in_(journal_entry_with_account),
|
.in_(journal_entry_with_account),
|
||||||
JournalEntryLineItem.currency_code
|
JournalEntryLineItem.currency_code
|
||||||
== self.__currency.code,
|
== self.__currency.code,
|
||||||
sa.not_(self.__account_condition))
|
sa.not_(self.__account_condition))
|
||||||
.order_by(JournalEntry.date,
|
.order_by(JournalEntry.date,
|
||||||
JournalEntry.no,
|
JournalEntry.no,
|
||||||
JournalEntryLineItem.is_debit,
|
JournalEntryLineItem.is_debit,
|
||||||
JournalEntryLineItem.no)
|
JournalEntryLineItem.no)
|
||||||
.options(selectinload(JournalEntryLineItem.account),
|
.options(selectinload(JournalEntryLineItem.account),
|
||||||
selectinload(JournalEntryLineItem.journal_entry))]
|
selectinload(JournalEntryLineItem.journal_entry)))]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def __account_condition(self) -> sa.BinaryExpression:
|
def __account_condition(self) -> sa.ColumnElement[bool]:
|
||||||
if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
|
if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
|
||||||
return CurrentAccount.sql_condition()
|
return CurrentAccount.sql_condition()
|
||||||
return Account.id == self.__account.id
|
return Account.id == self.__account.id
|
||||||
@@ -215,7 +211,7 @@ class LineItemCollector:
|
|||||||
class CSVRow(BaseCSVRow):
|
class CSVRow(BaseCSVRow):
|
||||||
"""A row in the CSV."""
|
"""A row in the CSV."""
|
||||||
|
|
||||||
def __init__(self, journal_entry_date: date | str | None,
|
def __init__(self, date: dt.date | str | None,
|
||||||
account: str | None,
|
account: str | None,
|
||||||
description: str | None,
|
description: str | None,
|
||||||
income: str | Decimal | None,
|
income: str | Decimal | None,
|
||||||
@@ -224,7 +220,7 @@ class CSVRow(BaseCSVRow):
|
|||||||
note: str | None):
|
note: str | None):
|
||||||
"""Constructs a row in the CSV.
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
:param journal_entry_date: The journal entry date.
|
:param date: The journal entry date.
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
:param description: The description.
|
:param description: The description.
|
||||||
:param income: The income.
|
:param income: The income.
|
||||||
@@ -232,7 +228,7 @@ class CSVRow(BaseCSVRow):
|
|||||||
:param balance: The balance.
|
:param balance: The balance.
|
||||||
:param note: The note.
|
:param note: The note.
|
||||||
"""
|
"""
|
||||||
self.date: date | str | None = journal_entry_date
|
self.date: dt.date | str | None = date
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.account: str | None = account
|
self.account: str | None = account
|
||||||
"""The account."""
|
"""The account."""
|
||||||
@@ -347,9 +343,8 @@ class PageParams(BasePageParams):
|
|||||||
self.account.id == 0)]
|
self.account.id == 0)]
|
||||||
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
|
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
|
||||||
.join(Account)\
|
.join(Account)\
|
||||||
.filter(be(JournalEntryLineItem.currency_code
|
.where(JournalEntryLineItem.currency_code == self.currency.code,
|
||||||
== self.currency.code),
|
CurrentAccount.sql_condition())\
|
||||||
CurrentAccount.sql_condition())\
|
|
||||||
.group_by(JournalEntryLineItem.account_id)
|
.group_by(JournalEntryLineItem.account_id)
|
||||||
options.extend([OptionLink(str(x),
|
options.extend([OptionLink(str(x),
|
||||||
income_expenses_url(
|
income_expenses_url(
|
||||||
@@ -357,8 +352,10 @@ class PageParams(BasePageParams):
|
|||||||
CurrentAccount(x),
|
CurrentAccount(x),
|
||||||
self.period),
|
self.period),
|
||||||
x.id == self.account.id)
|
x.id == self.account.id)
|
||||||
for x in Account.query.filter(Account.id.in_(in_use))
|
for x in db.session.scalars(
|
||||||
.order_by(Account.base_code, Account.no).all()])
|
sa.select(Account).where(Account.id.in_(in_use))
|
||||||
|
.order_by(Account.base_code, Account.no))
|
||||||
|
.unique()])
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
@@ -410,13 +407,13 @@ class IncomeExpenses(BaseReport):
|
|||||||
gettext("Note"))]
|
gettext("Note"))]
|
||||||
if self.__brought_forward is not None:
|
if self.__brought_forward is not None:
|
||||||
rows.append(CSVRow(self.__brought_forward.date,
|
rows.append(CSVRow(self.__brought_forward.date,
|
||||||
str(self.__brought_forward.account).title(),
|
str(self.__brought_forward.account),
|
||||||
self.__brought_forward.description,
|
self.__brought_forward.description,
|
||||||
self.__brought_forward.income,
|
self.__brought_forward.income,
|
||||||
self.__brought_forward.expense,
|
self.__brought_forward.expense,
|
||||||
self.__brought_forward.balance,
|
self.__brought_forward.balance,
|
||||||
None))
|
None))
|
||||||
rows.extend([CSVRow(x.date, str(x.account).title(), x.description,
|
rows.extend([CSVRow(x.date, str(x.account), x.description,
|
||||||
x.income, x.expense, x.balance, x.note)
|
x.income, x.expense, x.balance, x.note)
|
||||||
for x in self.__line_items])
|
for x in self.__line_items])
|
||||||
if self.__total is not None:
|
if self.__total is not None:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -22,19 +22,18 @@ from decimal import Decimal
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import render_template, Response
|
from flask import render_template, Response
|
||||||
|
|
||||||
from accounting import db
|
from ..period import Period, PeriodChooser
|
||||||
from accounting.locale import gettext
|
from ..utils.base_page_params import BasePageParams
|
||||||
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
|
from ..utils.base_report import BaseReport
|
||||||
|
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
|
||||||
|
from ..utils.option_link import OptionLink
|
||||||
|
from ..utils.report_chooser import ReportChooser
|
||||||
|
from ..utils.report_type import ReportType
|
||||||
|
from ..utils.urls import ledger_url, income_statement_url
|
||||||
|
from ... import db
|
||||||
|
from ...locale import gettext
|
||||||
|
from ...models import Currency, BaseAccount, Account, JournalEntry, \
|
||||||
JournalEntryLineItem
|
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.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, income_statement_url
|
|
||||||
|
|
||||||
|
|
||||||
class ReportAccount:
|
class ReportAccount:
|
||||||
@@ -106,6 +105,7 @@ class Section:
|
|||||||
"""The subsections in the section."""
|
"""The subsections in the section."""
|
||||||
self.accumulated: AccumulatedTotal \
|
self.accumulated: AccumulatedTotal \
|
||||||
= AccumulatedTotal(accumulated_title)
|
= AccumulatedTotal(accumulated_title)
|
||||||
|
"""The accumulated total."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total(self) -> Decimal:
|
def total(self) -> Decimal:
|
||||||
@@ -218,19 +218,22 @@ class IncomeStatement(BaseReport):
|
|||||||
"""
|
"""
|
||||||
balances: list[ReportAccount] = self.__query_balances()
|
balances: list[ReportAccount] = self.__query_balances()
|
||||||
|
|
||||||
titles: list[BaseAccount] = BaseAccount.query\
|
title_codes: set[str] = {"4", "5", "6", "7", "8", "9"}
|
||||||
.filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all()
|
titles: list[BaseAccount] = db.session.scalars(
|
||||||
subtitles: list[BaseAccount] = BaseAccount.query\
|
sa.select(BaseAccount)
|
||||||
.filter(BaseAccount.code.in_({x.account.base_code[:2]
|
.where(BaseAccount.code.in_(title_codes))).unique().all()
|
||||||
for x in balances})).all()
|
subtitle_codes: set[str] = {x.account.base_code[:2] for x in balances}
|
||||||
|
subtitles: list[BaseAccount] = db.session.scalars(
|
||||||
|
sa.select(BaseAccount)
|
||||||
|
.where(BaseAccount.code.in_(subtitle_codes))).unique().all()
|
||||||
|
|
||||||
total_titles: dict[str, str] \
|
total_titles: dict[str, str] \
|
||||||
= {"4": gettext("total operating revenue"),
|
= {"4": gettext("Total Operating Revenue"),
|
||||||
"5": gettext("gross income"),
|
"5": gettext("Gross Income"),
|
||||||
"6": gettext("operating income"),
|
"6": gettext("Operating Income"),
|
||||||
"7": gettext("before tax income"),
|
"7": gettext("Before Tax Income"),
|
||||||
"8": gettext("after tax income"),
|
"8": gettext("After Tax Income"),
|
||||||
"9": gettext("net income or loss for current period")}
|
"9": gettext("Net Income or Loss for Current Period")}
|
||||||
|
|
||||||
sections: dict[str, Section] \
|
sections: dict[str, Section] \
|
||||||
= {x.code: Section(x, total_titles[x.code]) for x in titles}
|
= {x.code: Section(x, total_titles[x.code]) for x in titles}
|
||||||
@@ -253,9 +256,9 @@ class IncomeStatement(BaseReport):
|
|||||||
|
|
||||||
:return: The balances.
|
:return: The balances.
|
||||||
"""
|
"""
|
||||||
sub_conditions: list[sa.BinaryExpression] \
|
sub_conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
|
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
sa.or_(*sub_conditions)]
|
sa.or_(*sub_conditions)]
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
@@ -267,13 +270,15 @@ class IncomeStatement(BaseReport):
|
|||||||
else_=JournalEntryLineItem.amount)).label("balance")
|
else_=JournalEntryLineItem.amount)).label("balance")
|
||||||
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||||
.join(JournalEntry).join(Account)\
|
.join(JournalEntry).join(Account)\
|
||||||
.filter(*conditions)\
|
.where(*conditions)\
|
||||||
.group_by(Account.id)\
|
.group_by(Account.id)\
|
||||||
|
.having(balance_func != 0)\
|
||||||
.order_by(Account.base_code, Account.no)
|
.order_by(Account.base_code, Account.no)
|
||||||
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
||||||
accounts: dict[int, Account] \
|
accounts: dict[int, Account] \
|
||||||
= {x.id: x for x in Account.query
|
= {x.id: x for x in db.session.scalars(
|
||||||
.filter(Account.id.in_([x.id for x in balances])).all()}
|
sa.select(Account)
|
||||||
|
.where(Account.id.in_([x.id for x in balances]))).unique()}
|
||||||
return [ReportAccount(account=accounts[x.id],
|
return [ReportAccount(account=accounts[x.id],
|
||||||
amount=x.balance,
|
amount=x.balance,
|
||||||
url=ledger_url(self.__currency,
|
url=ledger_url(self.__currency,
|
||||||
@@ -299,14 +304,14 @@ class IncomeStatement(BaseReport):
|
|||||||
total_str: str = gettext("Total")
|
total_str: str = gettext("Total")
|
||||||
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
|
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
|
||||||
for section in self.__sections:
|
for section in self.__sections:
|
||||||
rows.append(CSVRow(str(section.title).title(), None))
|
rows.append(CSVRow(str(section.title), None))
|
||||||
for subsection in section.subsections:
|
for subsection in section.subsections:
|
||||||
rows.append(CSVRow(f" {str(subsection.title).title()}", None))
|
rows.append(CSVRow(f" {str(subsection.title)}", None))
|
||||||
for account in subsection.accounts:
|
for account in subsection.accounts:
|
||||||
rows.append(CSVRow(f" {str(account.account).title()}",
|
rows.append(CSVRow(f" {str(account.account)}",
|
||||||
account.amount))
|
account.amount))
|
||||||
rows.append(CSVRow(f" {total_str}", subsection.total))
|
rows.append(CSVRow(f" {total_str}", subsection.total))
|
||||||
rows.append(CSVRow(section.accumulated.title.title(),
|
rows.append(CSVRow(section.accumulated.title,
|
||||||
section.accumulated.amount))
|
section.accumulated.amount))
|
||||||
rows.append(CSVRow(None, None))
|
rows.append(CSVRow(None, None))
|
||||||
rows = rows[:-1]
|
rows = rows[:-1]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,25 +17,24 @@
|
|||||||
"""The journal.
|
"""The journal.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
import datetime as dt
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import render_template, Response
|
from flask import render_template, Response
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from accounting.locale import gettext
|
from ..period import Period, PeriodChooser
|
||||||
from accounting.models import Currency, Account, JournalEntry, \
|
from ..utils.base_page_params import BasePageParams
|
||||||
JournalEntryLineItem
|
from ..utils.base_report import BaseReport
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from ..utils.report_chooser import ReportChooser
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from ..utils.report_type import ReportType
|
||||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
from ..utils.urls import journal_url
|
||||||
period_spec
|
from ... import db
|
||||||
from accounting.report.utils.report_chooser import ReportChooser
|
from ...locale import gettext
|
||||||
from accounting.report.utils.report_type import ReportType
|
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||||
from accounting.report.utils.urls import journal_url
|
from ...utils.pagination import Pagination
|
||||||
from accounting.utils.pagination import Pagination
|
|
||||||
|
|
||||||
|
|
||||||
class ReportLineItem:
|
class ReportLineItem:
|
||||||
@@ -67,7 +66,7 @@ class ReportLineItem:
|
|||||||
class CSVRow(BaseCSVRow):
|
class CSVRow(BaseCSVRow):
|
||||||
"""A row in the CSV."""
|
"""A row in the CSV."""
|
||||||
|
|
||||||
def __init__(self, journal_entry_date: str | date,
|
def __init__(self, journal_entry_date: str | dt.date,
|
||||||
currency: str,
|
currency: str,
|
||||||
account: str,
|
account: str,
|
||||||
description: str | None,
|
description: str | None,
|
||||||
@@ -77,12 +76,14 @@ class CSVRow(BaseCSVRow):
|
|||||||
"""Constructs a row in the CSV.
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
:param journal_entry_date: The journal entry date.
|
:param journal_entry_date: The journal entry date.
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
:param description: The description.
|
:param description: The description.
|
||||||
:param debit: The debit amount.
|
:param debit: The debit amount.
|
||||||
:param credit: The credit amount.
|
:param credit: The credit amount.
|
||||||
:param note: The note.
|
:param note: The note.
|
||||||
"""
|
"""
|
||||||
self.date: str | date = journal_entry_date
|
self.date: str | dt.date = journal_entry_date
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.currency: str = currency
|
self.currency: str = currency
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
@@ -116,6 +117,7 @@ class PageParams(BasePageParams):
|
|||||||
"""Constructs the HTML page parameters.
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
:param period: The period.
|
:param period: The period.
|
||||||
|
:param pagination: The pagination.
|
||||||
:param line_items: The line items.
|
:param line_items: The line items.
|
||||||
"""
|
"""
|
||||||
self.period: Period = period
|
self.period: Period = period
|
||||||
@@ -157,7 +159,7 @@ def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
|
|||||||
gettext("Debit"), gettext("Credit"),
|
gettext("Debit"), gettext("Credit"),
|
||||||
gettext("Note"))]
|
gettext("Note"))]
|
||||||
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
|
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
|
||||||
str(x.account).title(), x.description,
|
str(x.account), x.description,
|
||||||
x.debit, x.credit, x.journal_entry.note)
|
x.debit, x.credit, x.journal_entry.note)
|
||||||
for x in line_items])
|
for x in line_items])
|
||||||
return rows
|
return rows
|
||||||
@@ -182,20 +184,21 @@ class Journal(BaseReport):
|
|||||||
|
|
||||||
:return: The line items.
|
:return: The line items.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.ColumnElement[bool]] = []
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(JournalEntry.date >= self.__period.start)
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
if self.__period.end is not None:
|
if self.__period.end is not None:
|
||||||
conditions.append(JournalEntry.date <= self.__period.end)
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
return JournalEntryLineItem.query.join(JournalEntry)\
|
return db.session.scalars(
|
||||||
.filter(*conditions)\
|
sa.select(JournalEntryLineItem).join(JournalEntry)
|
||||||
|
.where(*conditions)
|
||||||
.order_by(JournalEntry.date,
|
.order_by(JournalEntry.date,
|
||||||
JournalEntry.no,
|
JournalEntry.no,
|
||||||
JournalEntryLineItem.is_debit.desc(),
|
JournalEntryLineItem.is_debit.desc(),
|
||||||
JournalEntryLineItem.no)\
|
JournalEntryLineItem.no)
|
||||||
.options(selectinload(JournalEntryLineItem.account),
|
.options(selectinload(JournalEntryLineItem.account),
|
||||||
selectinload(JournalEntryLineItem.currency),
|
selectinload(JournalEntryLineItem.currency),
|
||||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
selectinload(JournalEntryLineItem.journal_entry))).all()
|
||||||
|
|
||||||
def csv(self) -> Response:
|
def csv(self) -> Response:
|
||||||
"""Returns the report as CSV for download.
|
"""Returns the report as CSV for download.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,28 +17,25 @@
|
|||||||
"""The ledger.
|
"""The ledger.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
import datetime as dt
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import url_for, render_template, Response
|
from flask import url_for, render_template, Response
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from accounting import db
|
from ..period import Period, PeriodChooser
|
||||||
from accounting.locale import gettext
|
from ..utils.base_page_params import BasePageParams
|
||||||
from accounting.models import Currency, Account, JournalEntry, \
|
from ..utils.base_report import BaseReport
|
||||||
JournalEntryLineItem
|
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from ..utils.option_link import OptionLink
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from ..utils.report_chooser import ReportChooser
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from ..utils.report_type import ReportType
|
||||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
from ..utils.urls import ledger_url
|
||||||
period_spec
|
from ... import db
|
||||||
from accounting.report.utils.option_link import OptionLink
|
from ...locale import gettext
|
||||||
from accounting.report.utils.report_chooser import ReportChooser
|
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||||
from accounting.report.utils.report_type import ReportType
|
from ...utils.pagination import Pagination
|
||||||
from accounting.report.utils.urls import ledger_url
|
|
||||||
from accounting.utils.cast import be
|
|
||||||
from accounting.utils.pagination import Pagination
|
|
||||||
|
|
||||||
|
|
||||||
class ReportLineItem:
|
class ReportLineItem:
|
||||||
@@ -53,7 +50,7 @@ class ReportLineItem:
|
|||||||
"""Whether this is the brought-forward line item."""
|
"""Whether this is the brought-forward line item."""
|
||||||
self.is_total: bool = False
|
self.is_total: bool = False
|
||||||
"""Whether this is the total line item."""
|
"""Whether this is the total line item."""
|
||||||
self.date: date | None = None
|
self.date: dt.date | None = None
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.description: str | None = None
|
self.description: str | None = None
|
||||||
"""The description."""
|
"""The description."""
|
||||||
@@ -118,11 +115,9 @@ class LineItemCollector:
|
|||||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||||
else_=-JournalEntryLineItem.amount))
|
else_=-JournalEntryLineItem.amount))
|
||||||
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
|
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
|
||||||
.filter(be(JournalEntryLineItem.currency_code
|
.where(JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
== self.__currency.code),
|
JournalEntryLineItem.account_id == self.__account.id,
|
||||||
be(JournalEntryLineItem.account_id
|
JournalEntry.date < self.__period.start)
|
||||||
== self.__account.id),
|
|
||||||
JournalEntry.date < self.__period.start)
|
|
||||||
balance: int | None = db.session.scalar(select)
|
balance: int | None = db.session.scalar(select)
|
||||||
if balance is None:
|
if balance is None:
|
||||||
return None
|
return None
|
||||||
@@ -142,22 +137,22 @@ class LineItemCollector:
|
|||||||
|
|
||||||
:return: The line items.
|
:return: The line items.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||||
JournalEntryLineItem.account_id == self.__account.id]
|
JournalEntryLineItem.account_id == self.__account.id]
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(JournalEntry.date >= self.__period.start)
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
if self.__period.end is not None:
|
if self.__period.end is not None:
|
||||||
conditions.append(JournalEntry.date <= self.__period.end)
|
conditions.append(JournalEntry.date <= self.__period.end)
|
||||||
return [ReportLineItem(x) for x in JournalEntryLineItem.query
|
return [ReportLineItem(x) for x in db.session.scalars(
|
||||||
.join(JournalEntry)
|
sa.select(JournalEntryLineItem).join(JournalEntry)
|
||||||
.filter(*conditions)
|
.where(*conditions)
|
||||||
.order_by(JournalEntry.date,
|
.order_by(JournalEntry.date,
|
||||||
JournalEntry.no,
|
JournalEntry.no,
|
||||||
JournalEntryLineItem.is_debit.desc(),
|
JournalEntryLineItem.is_debit.desc(),
|
||||||
JournalEntryLineItem.no)
|
JournalEntryLineItem.no)
|
||||||
.options(selectinload(JournalEntryLineItem.journal_entry))
|
.options(selectinload(JournalEntryLineItem.journal_entry)))
|
||||||
.all()]
|
.unique()]
|
||||||
|
|
||||||
def __get_total(self) -> ReportLineItem | None:
|
def __get_total(self) -> ReportLineItem | None:
|
||||||
"""Composes the total line item.
|
"""Composes the total line item.
|
||||||
@@ -199,7 +194,7 @@ class LineItemCollector:
|
|||||||
class CSVRow(BaseCSVRow):
|
class CSVRow(BaseCSVRow):
|
||||||
"""A row in the CSV."""
|
"""A row in the CSV."""
|
||||||
|
|
||||||
def __init__(self, journal_entry_date: date | str | None,
|
def __init__(self, date: dt.date | str | None,
|
||||||
description: str | None,
|
description: str | None,
|
||||||
debit: str | Decimal | None,
|
debit: str | Decimal | None,
|
||||||
credit: str | Decimal | None,
|
credit: str | Decimal | None,
|
||||||
@@ -207,14 +202,14 @@ class CSVRow(BaseCSVRow):
|
|||||||
note: str | None):
|
note: str | None):
|
||||||
"""Constructs a row in the CSV.
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
:param journal_entry_date: The journal entry date.
|
:param date: The journal entry date.
|
||||||
:param description: The description.
|
:param description: The description.
|
||||||
:param debit: The debit amount.
|
:param debit: The debit amount.
|
||||||
:param credit: The credit amount.
|
:param credit: The credit amount.
|
||||||
:param balance: The balance.
|
:param balance: The balance.
|
||||||
:param note: The note.
|
:param note: The note.
|
||||||
"""
|
"""
|
||||||
self.date: date | str | None = journal_entry_date
|
self.date: dt.date | str | None = date
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.description: str | None = description
|
self.description: str | None = description
|
||||||
"""The description."""
|
"""The description."""
|
||||||
@@ -313,13 +308,13 @@ class PageParams(BasePageParams):
|
|||||||
:return: The account options.
|
:return: The account options.
|
||||||
"""
|
"""
|
||||||
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
|
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
|
||||||
.filter(be(JournalEntryLineItem.currency_code
|
.where(JournalEntryLineItem.currency_code == self.currency.code)\
|
||||||
== self.currency.code))\
|
|
||||||
.group_by(JournalEntryLineItem.account_id)
|
.group_by(JournalEntryLineItem.account_id)
|
||||||
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
|
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
|
||||||
x.id == self.account.id)
|
x.id == self.account.id)
|
||||||
for x in Account.query.filter(Account.id.in_(in_use))
|
for x in db.session.scalars(
|
||||||
.order_by(Account.base_code, Account.no).all()]
|
sa.select(Account).where(Account.id.in_(in_use))
|
||||||
|
.order_by(Account.base_code, Account.no)).unique()]
|
||||||
|
|
||||||
|
|
||||||
class Ledger(BaseReport):
|
class Ledger(BaseReport):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,25 +17,25 @@
|
|||||||
"""The search.
|
"""The search.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
import datetime as dt
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import Response, render_template, request
|
from flask import Response, render_template, request
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from accounting.locale import gettext
|
|
||||||
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
|
|
||||||
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
|
from .journal import get_csv_rows
|
||||||
|
from ..utils.base_page_params import BasePageParams
|
||||||
|
from ..utils.base_report import BaseReport
|
||||||
|
from ..utils.csv_export import csv_download
|
||||||
|
from ..utils.report_chooser import ReportChooser
|
||||||
|
from ..utils.report_type import ReportType
|
||||||
|
from ... import db
|
||||||
|
from ...locale import gettext
|
||||||
|
from ...models import Currency, CurrencyL10n, Account, AccountL10n, \
|
||||||
|
JournalEntry, JournalEntryLineItem
|
||||||
|
from ...utils.pagination import Pagination
|
||||||
|
from ...utils.query import parse_query_keywords
|
||||||
|
|
||||||
|
|
||||||
class LineItemCollector:
|
class LineItemCollector:
|
||||||
@@ -54,9 +54,9 @@ class LineItemCollector:
|
|||||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||||
if len(keywords) == 0:
|
if len(keywords) == 0:
|
||||||
return []
|
return []
|
||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.ColumnElement[bool]] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
sub_conditions: list[sa.BinaryExpression] \
|
sub_conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [JournalEntryLineItem.description.icontains(k),
|
= [JournalEntryLineItem.description.icontains(k),
|
||||||
JournalEntryLineItem.account_id.in_(
|
JournalEntryLineItem.account_id.in_(
|
||||||
self.__get_account_condition(k)),
|
self.__get_account_condition(k)),
|
||||||
@@ -70,15 +70,16 @@ class LineItemCollector:
|
|||||||
except ArithmeticError:
|
except ArithmeticError:
|
||||||
pass
|
pass
|
||||||
conditions.append(sa.or_(*sub_conditions))
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
return JournalEntryLineItem.query.join(JournalEntry)\
|
return db.session.scalars(
|
||||||
.filter(*conditions)\
|
sa.select(JournalEntryLineItem).join(JournalEntry)
|
||||||
|
.where(*conditions)
|
||||||
.order_by(JournalEntry.date,
|
.order_by(JournalEntry.date,
|
||||||
JournalEntry.no,
|
JournalEntry.no,
|
||||||
JournalEntryLineItem.is_debit,
|
JournalEntryLineItem.is_debit,
|
||||||
JournalEntryLineItem.no)\
|
JournalEntryLineItem.no)
|
||||||
.options(selectinload(JournalEntryLineItem.account),
|
.options(selectinload(JournalEntryLineItem.account),
|
||||||
selectinload(JournalEntryLineItem.currency),
|
selectinload(JournalEntryLineItem.currency),
|
||||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
selectinload(JournalEntryLineItem.journal_entry))).all()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_account_condition(k: str) -> sa.Select:
|
def __get_account_condition(k: str) -> sa.Select:
|
||||||
@@ -87,20 +88,20 @@ class LineItemCollector:
|
|||||||
:param k: The keyword.
|
:param k: The keyword.
|
||||||
:return: The condition to filter the account.
|
:return: The condition to filter the account.
|
||||||
"""
|
"""
|
||||||
code: sa.BinaryExpression = Account.base_code + "-" \
|
code: sa.ColumnElement[str] = Account.base_code + "-" \
|
||||||
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
|
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
|
||||||
sa.func.char_length(sa.cast(Account.no,
|
sa.func.char_length(sa.cast(Account.no,
|
||||||
sa.String)) + 1)
|
sa.String)) + 1)
|
||||||
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
|
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
|
||||||
.filter(AccountL10n.title.icontains(k))
|
.where(AccountL10n.title.icontains(k))
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [Account.base_code.contains(k),
|
= [Account.base_code.contains(k),
|
||||||
Account.title_l10n.icontains(k),
|
Account.title_l10n.icontains(k),
|
||||||
code.contains(k),
|
code.contains(k),
|
||||||
Account.id.in_(select_l10n)]
|
Account.id.in_(select_l10n)]
|
||||||
if k in gettext("Needs Offset"):
|
if k in gettext("Needs Offset"):
|
||||||
conditions.append(Account.is_need_offset)
|
conditions.append(Account.is_need_offset)
|
||||||
return sa.select(Account.id).filter(sa.or_(*conditions))
|
return sa.select(Account.id).where(sa.or_(*conditions))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_currency_condition(k: str) -> sa.Select:
|
def __get_currency_condition(k: str) -> sa.Select:
|
||||||
@@ -110,11 +111,11 @@ class LineItemCollector:
|
|||||||
:return: The condition to filter the currency.
|
:return: The condition to filter the currency.
|
||||||
"""
|
"""
|
||||||
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
|
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
|
||||||
.filter(CurrencyL10n.name.icontains(k))
|
.where(CurrencyL10n.name.icontains(k))
|
||||||
return sa.select(Currency.code).filter(
|
return sa.select(Currency.code)\
|
||||||
sa.or_(Currency.code.icontains(k),
|
.where(sa.or_(Currency.code.icontains(k),
|
||||||
Currency.name_l10n.icontains(k),
|
Currency.name_l10n.icontains(k),
|
||||||
Currency.code.in_(select_l10n)))
|
Currency.code.in_(select_l10n)))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_journal_entry_condition(k: str) -> sa.Select:
|
def __get_journal_entry_condition(k: str) -> sa.Select:
|
||||||
@@ -123,35 +124,38 @@ class LineItemCollector:
|
|||||||
:param k: The keyword.
|
:param k: The keyword.
|
||||||
:return: The condition to filter the journal entry.
|
:return: The condition to filter the journal entry.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [JournalEntry.note.icontains(k)]
|
= [JournalEntry.note.icontains(k)]
|
||||||
journal_entry_date: datetime
|
date: dt.datetime
|
||||||
try:
|
try:
|
||||||
journal_entry_date = datetime.strptime(k, "%Y")
|
date = dt.datetime.strptime(k, "%Y")
|
||||||
conditions.append(
|
conditions.append(
|
||||||
be(sa.extract("year", JournalEntry.date)
|
sa.extract("year", JournalEntry.date) == date.year)
|
||||||
== journal_entry_date.year))
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
journal_entry_date = datetime.strptime(k, "%Y/%m")
|
date = dt.datetime.strptime(k, "%Y/%m")
|
||||||
conditions.append(sa.and_(
|
conditions.append(sa.and_(
|
||||||
sa.extract("year", JournalEntry.date)
|
sa.extract("year", JournalEntry.date) == date.year,
|
||||||
== journal_entry_date.year,
|
sa.extract("month", JournalEntry.date) == date.month))
|
||||||
sa.extract("month", JournalEntry.date)
|
|
||||||
== journal_entry_date.month))
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
date = dt.datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||||
conditions.append(sa.and_(
|
conditions.append(sa.and_(
|
||||||
sa.extract("month", JournalEntry.date)
|
sa.extract("month", JournalEntry.date) == date.month,
|
||||||
== journal_entry_date.month,
|
sa.extract("day", JournalEntry.date) == date.day))
|
||||||
sa.extract("day", JournalEntry.date)
|
|
||||||
== journal_entry_date.day))
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
|
try:
|
||||||
|
date = dt.datetime.strptime(k, "%Y/%m/%d")
|
||||||
|
conditions.append(sa.and_(
|
||||||
|
sa.extract("year", JournalEntry.date) == date.year,
|
||||||
|
sa.extract("month", JournalEntry.date) == date.month,
|
||||||
|
sa.extract("day", JournalEntry.date) == date.day))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return sa.select(JournalEntry.id).where(sa.or_(*conditions))
|
||||||
|
|
||||||
|
|
||||||
class PageParams(BasePageParams):
|
class PageParams(BasePageParams):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -22,19 +22,17 @@ from decimal import Decimal
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import Response, render_template
|
from flask import Response, render_template
|
||||||
|
|
||||||
from accounting import db
|
from ..period import Period, PeriodChooser
|
||||||
from accounting.locale import gettext
|
from ..utils.base_page_params import BasePageParams
|
||||||
from accounting.models import Currency, Account, JournalEntry, \
|
from ..utils.base_report import BaseReport
|
||||||
JournalEntryLineItem
|
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from ..utils.option_link import OptionLink
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from ..utils.report_chooser import ReportChooser
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from ..utils.report_type import ReportType
|
||||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
from ..utils.urls import ledger_url, trial_balance_url
|
||||||
period_spec
|
from ... import db
|
||||||
from accounting.report.utils.option_link import OptionLink
|
from ...locale import gettext
|
||||||
from accounting.report.utils.report_chooser import ReportChooser
|
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||||
from accounting.report.utils.report_type import ReportType
|
|
||||||
from accounting.report.utils.urls import ledger_url, trial_balance_url
|
|
||||||
|
|
||||||
|
|
||||||
class ReportAccount:
|
class ReportAccount:
|
||||||
@@ -178,7 +176,7 @@ class TrialBalance(BaseReport):
|
|||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.ColumnElement[bool]] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(JournalEntry.date >= self.__period.start)
|
conditions.append(JournalEntry.date >= self.__period.start)
|
||||||
@@ -189,13 +187,15 @@ class TrialBalance(BaseReport):
|
|||||||
else_=-JournalEntryLineItem.amount)).label("balance")
|
else_=-JournalEntryLineItem.amount)).label("balance")
|
||||||
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||||
.join(JournalEntry).join(Account)\
|
.join(JournalEntry).join(Account)\
|
||||||
.filter(*conditions)\
|
.where(*conditions)\
|
||||||
.group_by(Account.id)\
|
.group_by(Account.id)\
|
||||||
|
.having(balance_func != 0)\
|
||||||
.order_by(Account.base_code, Account.no)
|
.order_by(Account.base_code, Account.no)
|
||||||
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
||||||
accounts: dict[int, Account] \
|
accounts: dict[int, Account] \
|
||||||
= {x.id: x for x in Account.query
|
= {x.id: x for x in db.session.scalars(
|
||||||
.filter(Account.id.in_([x.id for x in balances])).all()}
|
sa.select(Account)
|
||||||
|
.where(Account.id.in_([x.id for x in balances]))).unique()}
|
||||||
self.__accounts = [ReportAccount(account=accounts[x.id],
|
self.__accounts = [ReportAccount(account=accounts[x.id],
|
||||||
amount=x.balance,
|
amount=x.balance,
|
||||||
url=ledger_url(self.__currency,
|
url=ledger_url(self.__currency,
|
||||||
@@ -223,7 +223,7 @@ class TrialBalance(BaseReport):
|
|||||||
"""
|
"""
|
||||||
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
|
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
|
||||||
gettext("Credit"))]
|
gettext("Credit"))]
|
||||||
rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit)
|
rows.extend([CSVRow(str(x.account), x.debit, x.credit)
|
||||||
for x in self.__accounts])
|
for x in self.__accounts])
|
||||||
rows.append(CSVRow(gettext("Total"), self.__total.debit,
|
rows.append(CSVRow(gettext("Total"), self.__total.debit,
|
||||||
self.__total.credit))
|
self.__total.credit))
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023-2026 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import render_template, Response
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from ..utils.base_page_params import BasePageParams
|
||||||
|
from ..utils.base_report import BaseReport
|
||||||
|
from ..utils.csv_export import BaseCSVRow, csv_download
|
||||||
|
from ..utils.option_link import OptionLink
|
||||||
|
from ..utils.report_chooser import ReportChooser
|
||||||
|
from ..utils.report_type import ReportType
|
||||||
|
from ..utils.unapplied import get_accounts_with_unapplied, get_net_balances
|
||||||
|
from ..utils.urls import unapplied_url
|
||||||
|
from ... import db
|
||||||
|
from ...locale import gettext
|
||||||
|
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||||
|
from ...utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, journal_entry_date: str | dt.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 | dt.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 | dt.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, currency: Currency,
|
||||||
|
account: Account,
|
||||||
|
pagination: Pagination[JournalEntryLineItem],
|
||||||
|
line_items: list[JournalEntryLineItem]):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param pagination: The pagination.
|
||||||
|
:param line_items: The line items.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.line_items: list[JournalEntryLineItem] = line_items
|
||||||
|
"""The line items."""
|
||||||
|
|
||||||
|
@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, currency=self.currency,
|
||||||
|
account=self.account)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(
|
||||||
|
lambda x: unapplied_url(x, self.account), self.currency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the account options.
|
||||||
|
|
||||||
|
:return: The account options.
|
||||||
|
"""
|
||||||
|
options: list[OptionLink] \
|
||||||
|
= [OptionLink(gettext("Accounts"),
|
||||||
|
unapplied_url(self.currency, None),
|
||||||
|
False)]
|
||||||
|
options.extend(
|
||||||
|
[OptionLink(str(x), unapplied_url(self.currency, x),
|
||||||
|
x.id == self.account.id)
|
||||||
|
for x in get_accounts_with_unapplied(self.currency)])
|
||||||
|
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, currency: Currency, account: Account):
|
||||||
|
"""Constructs the unapplied original line items.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.__line_items: list[JournalEntryLineItem] \
|
||||||
|
= self.__query_line_items()
|
||||||
|
"""The line items."""
|
||||||
|
|
||||||
|
def __query_line_items(self) -> list[JournalEntryLineItem]:
|
||||||
|
"""Queries and returns the line items.
|
||||||
|
|
||||||
|
:return: The line items.
|
||||||
|
"""
|
||||||
|
net_balances: dict[int, Decimal | None] \
|
||||||
|
= get_net_balances(self.__currency, self.__account)
|
||||||
|
line_items: list[JournalEntryLineItem] = db.session.scalars(
|
||||||
|
sa.select(JournalEntryLineItem).join(Account).join(JournalEntry)
|
||||||
|
.where(JournalEntryLineItem.id.in_(net_balances))
|
||||||
|
.order_by(JournalEntry.date, JournalEntry.no,
|
||||||
|
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
|
||||||
|
.options(selectinload(JournalEntryLineItem.currency),
|
||||||
|
selectinload(JournalEntryLineItem.journal_entry)))\
|
||||||
|
.unique().all()
|
||||||
|
for line_item in line_items:
|
||||||
|
line_item.net_balance = line_item.amount \
|
||||||
|
if net_balances[line_item.id] is None \
|
||||||
|
else net_balances[line_item.id]
|
||||||
|
return line_items
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "unapplied-{currency}-{account}.csv"\
|
||||||
|
.format(currency=self.__currency.code, account=self.__account.code)
|
||||||
|
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(currency=self.__currency,
|
||||||
|
account=self.__account,
|
||||||
|
pagination=pagination,
|
||||||
|
line_items=pagination.list)
|
||||||
|
return render_template("accounting/report/unapplied.html",
|
||||||
|
report=params)
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023-2026 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from flask import render_template, Response
|
||||||
|
|
||||||
|
from ..utils.base_page_params import BasePageParams
|
||||||
|
from ..utils.base_report import BaseReport
|
||||||
|
from ..utils.csv_export import BaseCSVRow, csv_download
|
||||||
|
from ..utils.option_link import OptionLink
|
||||||
|
from ..utils.report_chooser import ReportChooser
|
||||||
|
from ..utils.report_type import ReportType
|
||||||
|
from ..utils.unapplied import get_accounts_with_unapplied
|
||||||
|
from ..utils.urls import unapplied_url
|
||||||
|
from ...locale import gettext
|
||||||
|
from ...models import Currency, Account
|
||||||
|
|
||||||
|
|
||||||
|
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 | dt.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, currency: Currency, accounts: list[Account]):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param accounts: The accounts.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
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, currency=self.currency,
|
||||||
|
account=None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(lambda x: unapplied_url(x, None),
|
||||||
|
self.currency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the account options.
|
||||||
|
|
||||||
|
:return: The account options.
|
||||||
|
"""
|
||||||
|
options: list[OptionLink] \
|
||||||
|
= [OptionLink(gettext("Accounts"),
|
||||||
|
unapplied_url(self.currency, None),
|
||||||
|
True)]
|
||||||
|
options.extend(
|
||||||
|
[OptionLink(str(x), unapplied_url(self.currency, 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), x.count) for x in accounts])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsWithUnappliedOriginalLineItems(BaseReport):
|
||||||
|
"""The accounts with unapplied original line items."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency):
|
||||||
|
"""Constructs the outstanding balances.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__accounts: list[Account] = get_accounts_with_unapplied(currency)
|
||||||
|
"""The accounts."""
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "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(currency=self.__currency,
|
||||||
|
accounts=self.__accounts))
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
|
||||||
|
|
||||||
|
# Copyright (c) 2023-2026 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 unmatched offsets.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from flask import render_template, Response
|
||||||
|
from flask_babel import LazyString
|
||||||
|
|
||||||
|
from ..utils.base_page_params import BasePageParams
|
||||||
|
from ..utils.base_report import BaseReport
|
||||||
|
from ..utils.csv_export import BaseCSVRow, csv_download
|
||||||
|
from ..utils.offset_matcher import OffsetMatcher, OffsetPair
|
||||||
|
from ..utils.option_link import OptionLink
|
||||||
|
from ..utils.report_chooser import ReportChooser
|
||||||
|
from ..utils.report_type import ReportType
|
||||||
|
from ..utils.unmatched import get_accounts_with_unmatched
|
||||||
|
from ..utils.urls import unmatched_url
|
||||||
|
from ...locale import gettext
|
||||||
|
from ...models import Currency, Account, JournalEntryLineItem
|
||||||
|
from ...utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow(BaseCSVRow):
|
||||||
|
"""A row in the CSV."""
|
||||||
|
|
||||||
|
def __init__(self, journal_entry_date: str | dt.date, currency: str,
|
||||||
|
description: str | None, debit: str | Decimal,
|
||||||
|
credit: str | Decimal, 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 debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
:param balance: The balance.
|
||||||
|
"""
|
||||||
|
self.date: str | dt.date = journal_entry_date
|
||||||
|
"""The date."""
|
||||||
|
self.currency: str = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.description: str | None = description
|
||||||
|
"""The description."""
|
||||||
|
self.debit: str | Decimal | None = debit
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: str | Decimal | None = credit
|
||||||
|
"""The credit amount."""
|
||||||
|
self.balance: str | Decimal = balance
|
||||||
|
"""The balance."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | dt.date | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.date, self.currency, self.description, self.debit,
|
||||||
|
self.credit, self.balance]
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(BasePageParams):
|
||||||
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
account: Account,
|
||||||
|
match_status: str | LazyString,
|
||||||
|
matched_pairs: list[OffsetPair],
|
||||||
|
pagination: Pagination[JournalEntryLineItem],
|
||||||
|
line_items: list[JournalEntryLineItem]):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param match_status: The match status message.
|
||||||
|
:param matched_pairs: A list of matched pairs.
|
||||||
|
:param pagination: The pagination.
|
||||||
|
:param line_items: The line items.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.match_status: str | LazyString = match_status
|
||||||
|
"""The match status message."""
|
||||||
|
self.matched_pairs: list[OffsetPair] = matched_pairs
|
||||||
|
"""A list of matched pairs."""
|
||||||
|
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.line_items: list[JournalEntryLineItem] = line_items
|
||||||
|
"""The line items."""
|
||||||
|
|
||||||
|
@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.UNMATCHED, currency=self.currency,
|
||||||
|
account=self.account)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(
|
||||||
|
lambda x: unmatched_url(x, self.account), self.currency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the account options.
|
||||||
|
|
||||||
|
:return: The account options.
|
||||||
|
"""
|
||||||
|
options: list[OptionLink] \
|
||||||
|
= [OptionLink(gettext("Accounts"),
|
||||||
|
unmatched_url(self.currency, None),
|
||||||
|
False)]
|
||||||
|
options.extend(
|
||||||
|
[OptionLink(str(x), unmatched_url(self.currency, x),
|
||||||
|
x.id == self.account.id)
|
||||||
|
for x in get_accounts_with_unmatched(self.currency)])
|
||||||
|
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("Debit"),
|
||||||
|
gettext("Credit"), gettext("Balance"))]
|
||||||
|
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
|
||||||
|
x.description, x.debit, x.credit, x.balance)
|
||||||
|
for x in line_items])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class UnmatchedOffsets(BaseReport):
|
||||||
|
"""The unmatched offsets."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: Account):
|
||||||
|
"""Constructs the unmatched offsets.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
offset_matcher: OffsetMatcher \
|
||||||
|
= OffsetMatcher(self.__currency, self.__account)
|
||||||
|
self.__line_items: list[JournalEntryLineItem] \
|
||||||
|
= offset_matcher.line_items
|
||||||
|
"""The line items."""
|
||||||
|
self.__match_status: str | LazyString = offset_matcher.status
|
||||||
|
"""The match status message."""
|
||||||
|
self.__matched_pairs: list[OffsetPair] = offset_matcher.matched_pairs
|
||||||
|
"""A list of matched pairs."""
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "unmatched-{currency}-{account}.csv"\
|
||||||
|
.format(currency=self.__currency.code, account=self.__account.code)
|
||||||
|
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(currency=self.__currency,
|
||||||
|
account=self.__account,
|
||||||
|
match_status=self.__match_status,
|
||||||
|
matched_pairs=self.__matched_pairs,
|
||||||
|
pagination=pagination,
|
||||||
|
line_items=pagination.list)
|
||||||
|
return render_template("accounting/report/unmatched.html",
|
||||||
|
report=params)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
|
||||||
|
|
||||||
|
# Copyright (c) 2023-2026 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 unmatched offsets.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from flask import render_template, Response
|
||||||
|
|
||||||
|
from ..utils.base_page_params import BasePageParams
|
||||||
|
from ..utils.base_report import BaseReport
|
||||||
|
from ..utils.csv_export import BaseCSVRow, csv_download
|
||||||
|
from ..utils.option_link import OptionLink
|
||||||
|
from ..utils.report_chooser import ReportChooser
|
||||||
|
from ..utils.report_type import ReportType
|
||||||
|
from ..utils.unmatched import get_accounts_with_unmatched
|
||||||
|
from ..utils.urls import unmatched_url
|
||||||
|
from ...locale import gettext
|
||||||
|
from ...models import Currency, Account
|
||||||
|
|
||||||
|
|
||||||
|
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 | dt.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, currency: Currency, accounts: list[Account]):
|
||||||
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param accounts: The accounts.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
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.UNMATCHED, currency=self.currency,
|
||||||
|
account=None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def currency_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the currency options.
|
||||||
|
|
||||||
|
:return: The currency options.
|
||||||
|
"""
|
||||||
|
return self._get_currency_options(lambda x: unmatched_url(x, None),
|
||||||
|
self.currency)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the account options.
|
||||||
|
|
||||||
|
:return: The account options.
|
||||||
|
"""
|
||||||
|
options: list[OptionLink] \
|
||||||
|
= [OptionLink(gettext("Accounts"),
|
||||||
|
unmatched_url(self.currency, None),
|
||||||
|
True)]
|
||||||
|
options.extend(
|
||||||
|
[OptionLink(str(x), unmatched_url(self.currency, 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), x.count) for x in accounts])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsWithUnmatchedOffsets(BaseReport):
|
||||||
|
"""The accounts with unmatched offsets."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency):
|
||||||
|
"""Constructs the outstanding balances.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__accounts: list[Account] \
|
||||||
|
= get_accounts_with_unmatched(currency)
|
||||||
|
"""The accounts."""
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "unmatched-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/unmatched-accounts.html",
|
||||||
|
report=PageParams(currency=self.__currency,
|
||||||
|
accounts=self.__accounts))
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"""
|
"""
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from accounting.template_filters import format_amount as core_format_amount
|
from ..template_filters import format_amount as core_format_amount
|
||||||
|
|
||||||
|
|
||||||
def format_amount(value: Decimal | None) -> str | None:
|
def format_amount(value: Decimal | None) -> str | None:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,19 +17,19 @@
|
|||||||
"""The page parameters of a report.
|
"""The page parameters of a report.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import Callable
|
||||||
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
|
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
|
||||||
urlunparse
|
urlunparse
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.models import Currency, JournalEntryLineItem
|
|
||||||
from accounting.utils.journal_entry_types import JournalEntryType
|
|
||||||
from .option_link import OptionLink
|
from .option_link import OptionLink
|
||||||
from .report_chooser import ReportChooser
|
from .report_chooser import ReportChooser
|
||||||
|
from ... import db
|
||||||
|
from ...models import Currency, JournalEntryLineItem
|
||||||
|
from ...utils.journal_entry_types import JournalEntryType
|
||||||
|
|
||||||
|
|
||||||
class BasePageParams(ABC):
|
class BasePageParams(ABC):
|
||||||
@@ -52,7 +52,7 @@ class BasePageParams(ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def journal_entry_types(self) -> t.Type[JournalEntryType]:
|
def journal_entry_types(self) -> type[JournalEntryType]:
|
||||||
"""Returns the journal entry types.
|
"""Returns the journal entry types.
|
||||||
|
|
||||||
:return: The journal entry types.
|
:return: The journal entry types.
|
||||||
@@ -72,7 +72,7 @@ class BasePageParams(ABC):
|
|||||||
return urlunparse(parts)
|
return urlunparse(parts)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_currency_options(get_url: t.Callable[[Currency], str],
|
def _get_currency_options(get_url: Callable[[Currency], str],
|
||||||
active_currency: Currency) -> list[OptionLink]:
|
active_currency: Currency) -> list[OptionLink]:
|
||||||
"""Returns the currency options.
|
"""Returns the currency options.
|
||||||
|
|
||||||
@@ -84,5 +84,6 @@ class BasePageParams(ABC):
|
|||||||
sa.select(JournalEntryLineItem.currency_code)
|
sa.select(JournalEntryLineItem.currency_code)
|
||||||
.group_by(JournalEntryLineItem.currency_code)).all())
|
.group_by(JournalEntryLineItem.currency_code)).all())
|
||||||
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
|
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
|
||||||
for x in Currency.query.filter(Currency.code.in_(in_use))
|
for x in db.session.scalars(
|
||||||
.order_by(Currency.code).all()]
|
sa.select(Currency).where(Currency.code.in_(in_use))
|
||||||
|
.order_by(Currency.code)).unique()]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -18,14 +18,15 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import csv
|
import csv
|
||||||
|
import datetime as dt
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import timedelta, date
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from flask import Response
|
from flask import Response
|
||||||
|
|
||||||
from accounting.report.period import Period
|
from ..period import Period
|
||||||
|
|
||||||
|
|
||||||
class BaseCSVRow(ABC):
|
class BaseCSVRow(ABC):
|
||||||
@@ -53,7 +54,7 @@ def csv_download(filename: str, rows: list[BaseCSVRow]) -> Response:
|
|||||||
fp.seek(0)
|
fp.seek(0)
|
||||||
response: Response = Response(fp.read(), mimetype="text/csv")
|
response: Response = Response(fp.read(), mimetype="text/csv")
|
||||||
response.headers["Content-Disposition"] \
|
response.headers["Content-Disposition"] \
|
||||||
= f"attachment; filename={filename}"
|
= f"attachment; filename={quote(filename)}"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -65,18 +66,22 @@ def period_spec(period: Period) -> str:
|
|||||||
"""
|
"""
|
||||||
start: str | None = __get_start_str(period.start)
|
start: str | None = __get_start_str(period.start)
|
||||||
end: str | None = __get_end_str(period.end)
|
end: str | None = __get_end_str(period.end)
|
||||||
if period.start is None and period.end is None:
|
if start is None:
|
||||||
return "all-time"
|
return "all-time" if end is None else f"until-{end}"
|
||||||
if start == end:
|
return f"since-{start}" if end is None else __get_spec(start, end)
|
||||||
return start
|
|
||||||
if period.start is None:
|
|
||||||
return f"until-{end}"
|
|
||||||
if period.end is None:
|
|
||||||
return f"since-{start}"
|
|
||||||
return f"{start}-{end}"
|
|
||||||
|
|
||||||
|
|
||||||
def __get_start_str(start: date | None) -> str | None:
|
def __get_spec(start: str, end: str) -> str:
|
||||||
|
"""Constructs the period specification with both start and end
|
||||||
|
|
||||||
|
:param start: The start date.
|
||||||
|
:param end: The end date.
|
||||||
|
:return: The period specification.
|
||||||
|
"""
|
||||||
|
return start if start == end else f"{start}-{end}"
|
||||||
|
|
||||||
|
|
||||||
|
def __get_start_str(start: dt.date | None) -> str | None:
|
||||||
"""Returns the string representation of the start date.
|
"""Returns the string representation of the start date.
|
||||||
|
|
||||||
:param start: The start date.
|
:param start: The start date.
|
||||||
@@ -92,7 +97,7 @@ def __get_start_str(start: date | None) -> str | None:
|
|||||||
return start.strftime("%Y%m%d")
|
return start.strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
def __get_end_str(end: date | None) -> str | None:
|
def __get_end_str(end: dt.date | None) -> str | None:
|
||||||
"""Returns the string representation of the end date.
|
"""Returns the string representation of the end date.
|
||||||
|
|
||||||
:param end: The end date.
|
:param end: The end date.
|
||||||
@@ -103,6 +108,6 @@ def __get_end_str(end: date | None) -> str | None:
|
|||||||
return None
|
return None
|
||||||
if end.month == 12 and end.day == 31:
|
if end.month == 12 and end.day == 31:
|
||||||
return str(end.year)
|
return str(end.year)
|
||||||
if (end + timedelta(days=1)).day == 1:
|
if (end + dt.timedelta(days=1)).day == 1:
|
||||||
return end.strftime("%Y%m")
|
return end.strftime("%Y%m")
|
||||||
return end.strftime("%Y%m%d")
|
return end.strftime("%Y%m%d")
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
|
||||||
|
|
||||||
|
# Copyright (c) 2023-2026 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 unmatched offset management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask_babel import LazyString
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from ..utils.unapplied import get_net_balances
|
||||||
|
from ... import db
|
||||||
|
from ...locale import lazy_gettext
|
||||||
|
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||||
|
|
||||||
|
|
||||||
|
class OffsetPair:
|
||||||
|
"""A pair of an original line item and its offset."""
|
||||||
|
|
||||||
|
def __init__(self, original_line_item: JournalEntryLineItem,
|
||||||
|
offset: JournalEntryLineItem):
|
||||||
|
"""Constructs a pair of an original line item and its offset.
|
||||||
|
|
||||||
|
:param original_line_item: The original line item.
|
||||||
|
:param offset: The offset.
|
||||||
|
"""
|
||||||
|
self.original_line_item: JournalEntryLineItem = original_line_item
|
||||||
|
"""The original line item."""
|
||||||
|
self.offset: JournalEntryLineItem = offset
|
||||||
|
"""The offset."""
|
||||||
|
|
||||||
|
|
||||||
|
class OffsetMatcher:
|
||||||
|
"""The offset matcher."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: Account):
|
||||||
|
"""Constructs the offset matcher.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.matched_pairs: list[OffsetPair] = []
|
||||||
|
"""A list of matched pairs."""
|
||||||
|
self.line_items: list[JournalEntryLineItem] = []
|
||||||
|
"""The unapplied debits or credits and unmatched offsets."""
|
||||||
|
self.unapplied: list[JournalEntryLineItem] = []
|
||||||
|
"""The unapplied debits or credits."""
|
||||||
|
self.unmatched: list[JournalEntryLineItem] = []
|
||||||
|
"""The unmatched offsets."""
|
||||||
|
self.__find_matches()
|
||||||
|
|
||||||
|
def __find_matches(self) -> None:
|
||||||
|
"""Finds the matched original line items and their offsets.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__get_line_items()
|
||||||
|
if len(self.unapplied) == 0 or len(self.unmatched) == 0:
|
||||||
|
return
|
||||||
|
remains: list[JournalEntryLineItem] = self.unmatched.copy()
|
||||||
|
for original_item in self.unapplied:
|
||||||
|
offset_candidates: list[JournalEntryLineItem] \
|
||||||
|
= [x for x in remains
|
||||||
|
if (x.journal_entry.date > original_item.journal_entry.date
|
||||||
|
or (x.journal_entry.date
|
||||||
|
== original_item.journal_entry.date
|
||||||
|
and x.journal_entry.no
|
||||||
|
> original_item.journal_entry.no))
|
||||||
|
and x.currency_code == original_item.currency_code
|
||||||
|
and x.description == original_item.description
|
||||||
|
and x.amount == original_item.net_balance]
|
||||||
|
if len(offset_candidates) == 0:
|
||||||
|
continue
|
||||||
|
self.matched_pairs.append(
|
||||||
|
OffsetPair(original_item, offset_candidates[0]))
|
||||||
|
original_item.match = offset_candidates[0]
|
||||||
|
offset_candidates[0].match = original_item
|
||||||
|
remains.remove(offset_candidates[0])
|
||||||
|
|
||||||
|
def __get_line_items(self) -> None:
|
||||||
|
"""Returns the unapplied original line items and unmatched offsets of
|
||||||
|
the account.
|
||||||
|
|
||||||
|
:return: The unapplied original line items and unmatched offsets of the
|
||||||
|
account.
|
||||||
|
"""
|
||||||
|
net_balances: dict[int, Decimal | None] \
|
||||||
|
= get_net_balances(self.__currency, self.__account)
|
||||||
|
unmatched_offset_condition: sa.ColumnElement[bool] \
|
||||||
|
= sa.and_(Account.id == self.__account.id,
|
||||||
|
JournalEntryLineItem.currency_code
|
||||||
|
== self.__currency.code,
|
||||||
|
JournalEntryLineItem.original_line_item_id.is_(None),
|
||||||
|
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||||
|
JournalEntryLineItem.is_debit),
|
||||||
|
sa.and_(Account.base_code.startswith("1"),
|
||||||
|
sa.not_(JournalEntryLineItem.is_debit))))
|
||||||
|
self.line_items = db.session.scalars(
|
||||||
|
sa.select(JournalEntryLineItem).join(Account).join(JournalEntry)
|
||||||
|
.where(sa.or_(JournalEntryLineItem.id.in_(net_balances),
|
||||||
|
unmatched_offset_condition))
|
||||||
|
.order_by(JournalEntry.date, JournalEntry.no,
|
||||||
|
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
|
||||||
|
.options(selectinload(JournalEntryLineItem.currency),
|
||||||
|
selectinload(JournalEntryLineItem.journal_entry)))\
|
||||||
|
.unique().all()
|
||||||
|
for line_item in self.line_items:
|
||||||
|
line_item.is_offset = line_item.id not in net_balances
|
||||||
|
self.unapplied = [x for x in self.line_items if not x.is_offset]
|
||||||
|
for line_item in self.unapplied:
|
||||||
|
line_item.net_balance = line_item.amount \
|
||||||
|
if net_balances[line_item.id] is None \
|
||||||
|
else net_balances[line_item.id]
|
||||||
|
self.unmatched = [x for x in self.line_items if x.is_offset]
|
||||||
|
self.__populate_accumulated_balances()
|
||||||
|
|
||||||
|
def __populate_accumulated_balances(self) -> None:
|
||||||
|
"""Populates the accumulated balances of the line items.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
balance: Decimal = Decimal("0")
|
||||||
|
for line_item in self.line_items:
|
||||||
|
amount: Decimal = line_item.amount if line_item.is_offset \
|
||||||
|
else line_item.net_balance
|
||||||
|
if line_item.is_debit:
|
||||||
|
line_item.debit = amount
|
||||||
|
line_item.credit = None
|
||||||
|
balance = balance + amount
|
||||||
|
else:
|
||||||
|
line_item.debit = None
|
||||||
|
line_item.credit = amount
|
||||||
|
balance = balance - amount
|
||||||
|
line_item.balance = balance
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str | LazyString:
|
||||||
|
"""Returns the match status message.
|
||||||
|
|
||||||
|
:return: The match status message.
|
||||||
|
"""
|
||||||
|
if len(self.unmatched) == 0:
|
||||||
|
return lazy_gettext("There is no unmatched offset.")
|
||||||
|
if len(self.matched_pairs) == 0:
|
||||||
|
return lazy_gettext(
|
||||||
|
"%(total)s unmatched offsets without original items.",
|
||||||
|
total=len(self.unmatched))
|
||||||
|
return lazy_gettext(
|
||||||
|
"%(matches)s unmatched offsets out of %(total)s"
|
||||||
|
" can match with their original items.",
|
||||||
|
matches=len(self.matched_pairs),
|
||||||
|
total=len(self.unmatched))
|
||||||
|
|
||||||
|
def match(self) -> None:
|
||||||
|
"""Matches the original line items with offsets.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
for pair in self.matched_pairs:
|
||||||
|
pair.offset.original_line_item_id = pair.original_line_item.id
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/5
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -21,20 +21,22 @@ This file is largely taken from the NanoParma ERP project, first written in
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
import typing as t
|
from collections.abc import Iterator
|
||||||
|
|
||||||
from flask_babel import LazyString
|
from flask_babel import LazyString
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
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 accounting.utils.current_account import CurrentAccount
|
|
||||||
from .option_link import OptionLink
|
from .option_link import OptionLink
|
||||||
from .report_type import ReportType
|
from .report_type import ReportType
|
||||||
from .urls import journal_url, ledger_url, income_expenses_url, \
|
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, unmatched_url
|
||||||
|
from ..period import Period, get_period
|
||||||
|
from ... import db
|
||||||
|
from ...locale import gettext
|
||||||
|
from ...models import Currency, Account
|
||||||
|
from ...template_globals import default_currency_code
|
||||||
|
from ...utils.current_account import CurrentAccount
|
||||||
|
from ...utils.permission import can_edit
|
||||||
|
|
||||||
|
|
||||||
class ReportChooser:
|
class ReportChooser:
|
||||||
@@ -68,40 +70,21 @@ class ReportChooser:
|
|||||||
"""The title of the current report."""
|
"""The title of the current report."""
|
||||||
self.is_search: bool = active_report == ReportType.SEARCH
|
self.is_search: bool = active_report == ReportType.SEARCH
|
||||||
"""Whether the current report is the search page."""
|
"""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.__income_expenses)
|
||||||
|
self.__reports.append(self.__ledger)
|
||||||
|
self.__reports.append(self.__journal)
|
||||||
self.__reports.append(self.__trial_balance)
|
self.__reports.append(self.__trial_balance)
|
||||||
self.__reports.append(self.__income_statement)
|
self.__reports.append(self.__income_statement)
|
||||||
self.__reports.append(self.__balance_sheet)
|
self.__reports.append(self.__balance_sheet)
|
||||||
|
self.__reports.append(self.__unapplied)
|
||||||
|
if can_edit():
|
||||||
|
self.__reports.append(self.__unmatched)
|
||||||
for report in self.__reports:
|
for report in self.__reports:
|
||||||
if report.is_active:
|
if report.is_active:
|
||||||
self.current_report = report.title
|
self.current_report = report.title
|
||||||
if self.is_search:
|
if self.is_search:
|
||||||
self.current_report = gettext("Search")
|
self.current_report = gettext("Search")
|
||||||
|
|
||||||
@property
|
|
||||||
def __journal(self) -> OptionLink:
|
|
||||||
"""Returns the journal.
|
|
||||||
|
|
||||||
:return: The journal.
|
|
||||||
"""
|
|
||||||
return OptionLink(gettext("Journal"), journal_url(self.__period),
|
|
||||||
self.__active_report == ReportType.JOURNAL,
|
|
||||||
fa_icon="fa-solid fa-book")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def __ledger(self) -> OptionLink:
|
|
||||||
"""Returns the ledger.
|
|
||||||
|
|
||||||
:return: The ledger.
|
|
||||||
"""
|
|
||||||
return OptionLink(gettext("Ledger"),
|
|
||||||
ledger_url(self.__currency, self.__account,
|
|
||||||
self.__period),
|
|
||||||
self.__active_report == ReportType.LEDGER,
|
|
||||||
fa_icon="fa-solid fa-clipboard")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def __income_expenses(self) -> OptionLink:
|
def __income_expenses(self) -> OptionLink:
|
||||||
"""Returns the income and expenses log.
|
"""Returns the income and expenses log.
|
||||||
@@ -118,6 +101,28 @@ class ReportChooser:
|
|||||||
self.__active_report == ReportType.INCOME_EXPENSES,
|
self.__active_report == ReportType.INCOME_EXPENSES,
|
||||||
fa_icon="fa-solid fa-money-bill-wave")
|
fa_icon="fa-solid fa-money-bill-wave")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __ledger(self) -> OptionLink:
|
||||||
|
"""Returns the ledger.
|
||||||
|
|
||||||
|
:return: The ledger.
|
||||||
|
"""
|
||||||
|
return OptionLink(gettext("Ledger"),
|
||||||
|
ledger_url(self.__currency, self.__account,
|
||||||
|
self.__period),
|
||||||
|
self.__active_report == ReportType.LEDGER,
|
||||||
|
fa_icon="fa-solid fa-clipboard")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __journal(self) -> OptionLink:
|
||||||
|
"""Returns the journal.
|
||||||
|
|
||||||
|
:return: The journal.
|
||||||
|
"""
|
||||||
|
return OptionLink(gettext("Journal"), journal_url(self.__period),
|
||||||
|
self.__active_report == ReportType.JOURNAL,
|
||||||
|
fa_icon="fa-solid fa-book")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def __trial_balance(self) -> OptionLink:
|
def __trial_balance(self) -> OptionLink:
|
||||||
"""Returns the trial balance.
|
"""Returns the trial balance.
|
||||||
@@ -151,7 +156,41 @@ class ReportChooser:
|
|||||||
self.__active_report == ReportType.BALANCE_SHEET,
|
self.__active_report == ReportType.BALANCE_SHEET,
|
||||||
fa_icon="fa-solid fa-scale-balanced")
|
fa_icon="fa-solid fa-scale-balanced")
|
||||||
|
|
||||||
def __iter__(self) -> t.Iterator[OptionLink]:
|
@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 Items"),
|
||||||
|
unapplied_url(self.__currency, None),
|
||||||
|
self.__active_report == ReportType.UNAPPLIED,
|
||||||
|
fa_icon="fa-solid fa-link-slash")
|
||||||
|
return OptionLink(gettext("Unapplied Items"),
|
||||||
|
unapplied_url(self.__currency, self.__account),
|
||||||
|
self.__active_report == ReportType.UNAPPLIED,
|
||||||
|
fa_icon="fa-solid fa-link-slash")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __unmatched(self) -> OptionLink:
|
||||||
|
"""Returns the unmatched offsets.
|
||||||
|
|
||||||
|
:return: The unmatched offsets.
|
||||||
|
"""
|
||||||
|
account: Account = self.__account
|
||||||
|
if not account.is_need_offset:
|
||||||
|
return OptionLink(gettext("Unmatched Offsets"),
|
||||||
|
unmatched_url(self.__currency, None),
|
||||||
|
self.__active_report == ReportType.UNMATCHED,
|
||||||
|
fa_icon="fa-solid fa-file-circle-question")
|
||||||
|
return OptionLink(gettext("Unmatched Offsets"),
|
||||||
|
unmatched_url(self.__currency, self.__account),
|
||||||
|
self.__active_report == ReportType.UNMATCHED,
|
||||||
|
fa_icon="fa-solid fa-file-circle-question")
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[OptionLink]:
|
||||||
"""Returns the iteration of the reports.
|
"""Returns the iteration of the reports.
|
||||||
|
|
||||||
:return: The iteration of the reports.
|
:return: 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
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
@@ -22,17 +22,21 @@ from enum import Enum
|
|||||||
|
|
||||||
class ReportType(Enum):
|
class ReportType(Enum):
|
||||||
"""The report types."""
|
"""The report types."""
|
||||||
JOURNAL: str = "journal"
|
JOURNAL = "journal"
|
||||||
"""The journal."""
|
"""The journal."""
|
||||||
LEDGER: str = "ledger"
|
LEDGER = "ledger"
|
||||||
"""The ledger."""
|
"""The ledger."""
|
||||||
INCOME_EXPENSES: str = "income-expenses"
|
INCOME_EXPENSES = "income-expenses"
|
||||||
"""The income and expenses log."""
|
"""The income and expenses log."""
|
||||||
TRIAL_BALANCE: str = "trial-balance"
|
TRIAL_BALANCE = "trial-balance"
|
||||||
"""The trial balance."""
|
"""The trial balance."""
|
||||||
INCOME_STATEMENT: str = "income-statement"
|
INCOME_STATEMENT = "income-statement"
|
||||||
"""The income statement."""
|
"""The income statement."""
|
||||||
BALANCE_SHEET: str = "balance-sheet"
|
BALANCE_SHEET = "balance-sheet"
|
||||||
"""The balance sheet."""
|
"""The balance sheet."""
|
||||||
SEARCH: str = "search"
|
UNAPPLIED = "unapplied"
|
||||||
|
"""The unapplied original line items."""
|
||||||
|
UNMATCHED = "unmatched"
|
||||||
|
"""The unmatched offsets."""
|
||||||
|
SEARCH = "search"
|
||||||
"""The search."""
|
"""The search."""
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
||||||
|
|
||||||
|
# Copyright (c) 2023-2026 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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from ... import db
|
||||||
|
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||||
|
from ...utils.offset_alias import offset_alias
|
||||||
|
|
||||||
|
|
||||||
|
def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
|
||||||
|
"""Returns the accounts with unapplied original line items.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The accounts with unapplied original line items.
|
||||||
|
"""
|
||||||
|
offset: sa.Alias = offset_alias()
|
||||||
|
net_balance: sa.Label \
|
||||||
|
= (JournalEntryLineItem.amount
|
||||||
|
+ sa.func.sum(sa.case(
|
||||||
|
(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(JournalEntry).join(Account)\
|
||||||
|
.join(offset,
|
||||||
|
JournalEntryLineItem.id == offset.c.original_line_item_id,
|
||||||
|
isouter=True)\
|
||||||
|
.where(Account.is_need_offset,
|
||||||
|
JournalEntryLineItem.currency_code == currency.code,
|
||||||
|
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)\
|
||||||
|
.where(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] = db.session.scalars(
|
||||||
|
sa.select(Account).where(Account.id.in_(counts))
|
||||||
|
.order_by(Account.base_code, Account.no)).unique().all()
|
||||||
|
for account in accounts:
|
||||||
|
account.count = counts[account.id]
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
def get_net_balances(currency: Currency, account: Account) \
|
||||||
|
-> dict[int, Decimal | None]:
|
||||||
|
"""Returns the net balances of the unapplied line items of the account.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:return: The net balances of the unapplied line items of the account.
|
||||||
|
"""
|
||||||
|
offset: sa.Alias = offset_alias()
|
||||||
|
net_balance: sa.Label \
|
||||||
|
= (JournalEntryLineItem.amount
|
||||||
|
+ sa.func.sum(sa.case(
|
||||||
|
(offset.c.is_debit == JournalEntryLineItem.is_debit,
|
||||||
|
offset.c.amount),
|
||||||
|
else_=-offset.c.amount))).label("net_balance")
|
||||||
|
select_net_balances: sa.Select \
|
||||||
|
= sa.select(JournalEntryLineItem.id, net_balance) \
|
||||||
|
.join(JournalEntry).join(Account) \
|
||||||
|
.join(offset,
|
||||||
|
JournalEntryLineItem.id == offset.c.original_line_item_id,
|
||||||
|
isouter=True) \
|
||||||
|
.where(Account.id == account.id,
|
||||||
|
JournalEntryLineItem.currency_code == currency.code,
|
||||||
|
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))
|
||||||
|
return {x.id: x.net_balance
|
||||||
|
for x in db.session.execute(select_net_balances)}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# The Mia! Accounting Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
|
||||||
|
|
||||||
|
# Copyright (c) 2023-2026 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 unmatched offset utilities.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from ... import db
|
||||||
|
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||||
|
|
||||||
|
|
||||||
|
def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
|
||||||
|
"""Returns the accounts with unmatched offsets.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The accounts with unmatched offsets, with the "count" property set
|
||||||
|
to the number of unmatched offsets.
|
||||||
|
"""
|
||||||
|
count_func: sa.Label \
|
||||||
|
= sa.func.count(JournalEntryLineItem.id).label("count")
|
||||||
|
select: sa.Select = sa.select(Account.id, count_func)\
|
||||||
|
.select_from(Account)\
|
||||||
|
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
|
||||||
|
.where(Account.is_need_offset,
|
||||||
|
JournalEntryLineItem.currency_code == currency.code,
|
||||||
|
JournalEntryLineItem.original_line_item_id.is_(None),
|
||||||
|
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||||
|
JournalEntryLineItem.is_debit),
|
||||||
|
sa.and_(Account.base_code.startswith("1"),
|
||||||
|
sa.not_(JournalEntryLineItem.is_debit))))\
|
||||||
|
.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] = db.session.scalars(
|
||||||
|
sa.select(Account).where(Account.id.in_(counts))
|
||||||
|
.order_by(Account.base_code, Account.no)).unique().all()
|
||||||
|
for account in accounts:
|
||||||
|
account.count = counts[account.id]
|
||||||
|
return accounts
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -19,11 +19,11 @@
|
|||||||
"""
|
"""
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
|
|
||||||
from accounting.models import Currency, Account
|
from ...models import Currency, Account
|
||||||
from accounting.report.period import Period
|
from ...report.period import Period
|
||||||
from accounting.template_globals import default_currency_code
|
from ...template_globals import default_currency_code
|
||||||
from accounting.utils.current_account import CurrentAccount
|
from ...utils.current_account import CurrentAccount
|
||||||
from accounting.utils.options import options
|
from ...utils.options import options
|
||||||
|
|
||||||
|
|
||||||
def journal_url(period: Period) \
|
def journal_url(period: Period) \
|
||||||
@@ -34,8 +34,8 @@ def journal_url(period: Period) \
|
|||||||
:return: The URL of the journal.
|
:return: The URL of the journal.
|
||||||
"""
|
"""
|
||||||
if period.is_default:
|
if period.is_default:
|
||||||
return url_for("accounting.report.journal-default")
|
return url_for("accounting-report.journal-default")
|
||||||
return url_for("accounting.report.journal", period=period)
|
return url_for("accounting-report.journal", period=period)
|
||||||
|
|
||||||
|
|
||||||
def ledger_url(currency: Currency, account: Account, period: Period) \
|
def ledger_url(currency: Currency, account: Account, period: Period) \
|
||||||
@@ -47,10 +47,11 @@ def ledger_url(currency: Currency, account: Account, period: Period) \
|
|||||||
:param period: The period.
|
:param period: The period.
|
||||||
:return: The URL of the ledger.
|
:return: The URL of the ledger.
|
||||||
"""
|
"""
|
||||||
if period.is_default:
|
if currency.code == default_currency_code() \
|
||||||
return url_for("accounting.report.ledger-default",
|
and account.code == Account.CASH_CODE \
|
||||||
currency=currency, account=account)
|
and period.is_default:
|
||||||
return url_for("accounting.report.ledger",
|
return url_for("accounting-report.ledger-default")
|
||||||
|
return url_for("accounting-report.ledger",
|
||||||
currency=currency, account=account,
|
currency=currency, account=account,
|
||||||
period=period)
|
period=period)
|
||||||
|
|
||||||
@@ -67,11 +68,8 @@ def income_expenses_url(currency: Currency, account: CurrentAccount,
|
|||||||
if currency.code == default_currency_code() \
|
if currency.code == default_currency_code() \
|
||||||
and account.code == options.default_ie_account_code \
|
and account.code == options.default_ie_account_code \
|
||||||
and period.is_default:
|
and period.is_default:
|
||||||
return url_for("accounting.report.default")
|
return url_for("accounting-report.default")
|
||||||
if period.is_default:
|
return url_for("accounting-report.income-expenses",
|
||||||
return url_for("accounting.report.income-expenses-default",
|
|
||||||
currency=currency, account=account)
|
|
||||||
return url_for("accounting.report.income-expenses",
|
|
||||||
currency=currency, account=account,
|
currency=currency, account=account,
|
||||||
period=period)
|
period=period)
|
||||||
|
|
||||||
@@ -83,10 +81,9 @@ def trial_balance_url(currency: Currency, period: Period) -> str:
|
|||||||
:param period: The period.
|
:param period: The period.
|
||||||
:return: The URL of the trial balance.
|
:return: The URL of the trial balance.
|
||||||
"""
|
"""
|
||||||
if period.is_default:
|
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-default")
|
||||||
currency=currency)
|
return url_for("accounting-report.trial-balance",
|
||||||
return url_for("accounting.report.trial-balance",
|
|
||||||
currency=currency, period=period)
|
currency=currency, period=period)
|
||||||
|
|
||||||
|
|
||||||
@@ -97,10 +94,9 @@ def income_statement_url(currency: Currency, period: Period) -> str:
|
|||||||
:param period: The period.
|
:param period: The period.
|
||||||
:return: The URL of the income statement.
|
:return: The URL of the income statement.
|
||||||
"""
|
"""
|
||||||
if period.is_default:
|
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-default")
|
||||||
currency=currency)
|
return url_for("accounting-report.income-statement",
|
||||||
return url_for("accounting.report.income-statement",
|
|
||||||
currency=currency, period=period)
|
currency=currency, period=period)
|
||||||
|
|
||||||
|
|
||||||
@@ -111,8 +107,41 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
|
|||||||
:param period: The period.
|
:param period: The period.
|
||||||
:return: The URL of the balance sheet.
|
:return: The URL of the balance sheet.
|
||||||
"""
|
"""
|
||||||
if period.is_default:
|
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-default")
|
||||||
currency=currency)
|
return url_for("accounting-report.balance-sheet",
|
||||||
return url_for("accounting.report.balance-sheet",
|
|
||||||
currency=currency, period=period)
|
currency=currency, period=period)
|
||||||
|
|
||||||
|
|
||||||
|
def unapplied_url(currency: Currency, account: Account | None) -> str:
|
||||||
|
"""Returns the URL of the unapplied original line items.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
: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:
|
||||||
|
if currency.code == default_currency_code():
|
||||||
|
return url_for("accounting-report.unapplied-accounts-default")
|
||||||
|
return url_for("accounting-report.unapplied-accounts",
|
||||||
|
currency=currency)
|
||||||
|
return url_for("accounting-report.unapplied",
|
||||||
|
currency=currency, account=account)
|
||||||
|
|
||||||
|
|
||||||
|
def unmatched_url(currency: Currency, account: Account | None) -> str:
|
||||||
|
"""Returns the URL of the unmatched offset line items.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account, or None to list the accounts with unmatched
|
||||||
|
offset line items.
|
||||||
|
:return: The URL of the unmatched offset line items.
|
||||||
|
"""
|
||||||
|
if account is None:
|
||||||
|
if currency.code == default_currency_code():
|
||||||
|
return url_for("accounting-report.unmatched-accounts-default")
|
||||||
|
return url_for("accounting-report.unmatched-accounts",
|
||||||
|
currency=currency)
|
||||||
|
return url_for("accounting-report.unmatched",
|
||||||
|
currency=currency, account=account)
|
||||||
|
|||||||
+171
-48
@@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023-2026 imacat.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,20 +17,29 @@
|
|||||||
"""The views for the report management.
|
"""The views for the report management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint, request, Response
|
from flask import Blueprint, request, Response, redirect, flash
|
||||||
|
|
||||||
from accounting import db
|
from .period import Period, get_period
|
||||||
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, \
|
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
|
||||||
IncomeStatement, BalanceSheet, Search
|
IncomeStatement, BalanceSheet, Search
|
||||||
|
from .reports.unapplied import UnappliedOriginalLineItems
|
||||||
|
from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems
|
||||||
|
from .reports.unmatched import UnmatchedOffsets
|
||||||
|
from .reports.unmatched_accounts import AccountsWithUnmatchedOffsets
|
||||||
from .template_filters import format_amount
|
from .template_filters import format_amount
|
||||||
|
from .utils.offset_matcher import OffsetMatcher
|
||||||
|
from .utils.urls import unmatched_url
|
||||||
|
from .. import db
|
||||||
|
from ..locale import lazy_gettext
|
||||||
|
from ..models import Currency, Account
|
||||||
|
from ..template_globals import default_currency_code
|
||||||
|
from ..utils.cast import s
|
||||||
|
from ..utils.current_account import CurrentAccount
|
||||||
|
from ..utils.next_uri import or_next
|
||||||
|
from ..utils.options import options
|
||||||
|
from ..utils.permission import has_permission, can_view, can_edit
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("report", __name__)
|
bp: Blueprint = Blueprint("accounting-report", __name__)
|
||||||
"""The view blueprint for the reports."""
|
"""The view blueprint for the reports."""
|
||||||
bp.add_app_template_filter(format_amount, "accounting_report_format_amount")
|
bp.add_app_template_filter(format_amount, "accounting_report_format_amount")
|
||||||
|
|
||||||
@@ -42,10 +51,7 @@ def get_default_report() -> str | Response:
|
|||||||
|
|
||||||
:return: The income and expenses log in the default period.
|
:return: The income and expenses log in the default period.
|
||||||
"""
|
"""
|
||||||
return __get_income_expenses(
|
return get_default_income_expenses()
|
||||||
db.session.get(Currency, default_currency_code()),
|
|
||||||
options.default_ie_account,
|
|
||||||
get_period())
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("journal", endpoint="journal-default")
|
@bp.get("journal", endpoint="journal-default")
|
||||||
@@ -81,17 +87,15 @@ def __get_journal(period: Period) -> str | Response:
|
|||||||
return report.html()
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("ledger/<currency:currency>/<account:account>",
|
@bp.get("ledger", endpoint="ledger-default")
|
||||||
endpoint="ledger-default")
|
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def get_default_ledger(currency: Currency, account: Account) -> str | Response:
|
def get_default_ledger() -> str | Response:
|
||||||
"""Returns the ledger in the default period.
|
"""Returns the ledger in the default currency, cash, and default period.
|
||||||
|
|
||||||
:param currency: The currency.
|
:return: The ledger in the default currency, cash, and default period.
|
||||||
:param account: The account.
|
|
||||||
:return: The ledger in the 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>",
|
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
|
||||||
@@ -124,23 +128,21 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
|
|||||||
return report.html()
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
|
@bp.get("income-expenses", endpoint="income-expenses-default")
|
||||||
endpoint="income-expenses-default")
|
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def get_default_income_expenses(currency: Currency, account: CurrentAccount) \
|
def get_default_income_expenses() -> str | Response:
|
||||||
-> str | Response:
|
|
||||||
"""Returns the income and expenses log in the default period.
|
"""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: 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(
|
@bp.get("income-expenses/<currency:currency>/<currentAccount:account>/"
|
||||||
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
|
"<period:period>", endpoint="income-expenses")
|
||||||
endpoint="income-expenses")
|
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def get_income_expenses(currency: Currency, account: CurrentAccount,
|
def get_income_expenses(currency: Currency, account: CurrentAccount,
|
||||||
period: Period) -> str | Response:
|
period: Period) -> str | Response:
|
||||||
@@ -169,16 +171,15 @@ def __get_income_expenses(currency: Currency, account: CurrentAccount,
|
|||||||
return report.html()
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("trial-balance/<currency:currency>",
|
@bp.get("trial-balance", endpoint="trial-balance-default")
|
||||||
endpoint="trial-balance-default")
|
|
||||||
@has_permission(can_view)
|
@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.
|
"""Returns the trial balance in the default period.
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:return: The trial balance in the default period.
|
: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>",
|
@bp.get("trial-balance/<currency:currency>/<period:period>",
|
||||||
@@ -207,16 +208,15 @@ def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
|
|||||||
return report.html()
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("income-statement/<currency:currency>",
|
@bp.get("income-statement", endpoint="income-statement-default")
|
||||||
endpoint="income-statement-default")
|
|
||||||
@has_permission(can_view)
|
@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.
|
"""Returns the income statement in the default period.
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:return: The income statement in the default period.
|
: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>",
|
@bp.get("income-statement/<currency:currency>/<period:period>",
|
||||||
@@ -246,16 +246,15 @@ def __get_income_statement(currency: Currency, period: Period) \
|
|||||||
return report.html()
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("balance-sheet/<currency:currency>",
|
@bp.get("balance-sheet", endpoint="balance-sheet-default")
|
||||||
endpoint="balance-sheet-default")
|
|
||||||
@has_permission(can_view)
|
@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.
|
"""Returns the balance sheet in the default period.
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:return: The balance sheet in the default period.
|
: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>",
|
@bp.get("balance-sheet/<currency:currency>/<period:period>",
|
||||||
@@ -286,6 +285,130 @@ def __get_balance_sheet(currency: Currency, period: Period) \
|
|||||||
return report.html()
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("unapplied", endpoint="unapplied-accounts-default")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_default_unapplied_accounts() -> str | Response:
|
||||||
|
"""Returns the accounts with unapplied original line items.
|
||||||
|
|
||||||
|
:return: The accounts with unapplied original line items.
|
||||||
|
"""
|
||||||
|
return __get_unapplied_accounts(
|
||||||
|
db.session.get(Currency, default_currency_code()))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("unapplied/<currency:currency>", endpoint="unapplied-accounts")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_unapplied_accounts(currency: Currency) -> str | Response:
|
||||||
|
"""Returns the accounts with unapplied original line items.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The accounts with unapplied original line items.
|
||||||
|
"""
|
||||||
|
return __get_unapplied_accounts(currency)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_unapplied_accounts(currency: Currency) -> str | Response:
|
||||||
|
"""Returns the accounts with unapplied original line items.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The accounts with unapplied original line items.
|
||||||
|
"""
|
||||||
|
report: AccountsWithUnappliedOriginalLineItems \
|
||||||
|
= AccountsWithUnappliedOriginalLineItems(currency)
|
||||||
|
if "as" in request.args and request.args["as"] == "csv":
|
||||||
|
return report.csv()
|
||||||
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("unapplied/<currency:currency>/<needOffsetAccount:account>",
|
||||||
|
endpoint="unapplied")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_unapplied(currency: Currency, account: Account) -> str | Response:
|
||||||
|
"""Returns the unapplied original line items.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The Account.
|
||||||
|
:return: The unapplied original line items in the period.
|
||||||
|
"""
|
||||||
|
report: UnappliedOriginalLineItems \
|
||||||
|
= UnappliedOriginalLineItems(currency, account)
|
||||||
|
if "as" in request.args and request.args["as"] == "csv":
|
||||||
|
return report.csv()
|
||||||
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("unmatched", endpoint="unmatched-accounts-default")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def get_default_unmatched_accounts() -> str | Response:
|
||||||
|
"""Returns the accounts with unmatched offsets.
|
||||||
|
|
||||||
|
:return: The accounts with unmatched offsets.
|
||||||
|
"""
|
||||||
|
return __get_unmatched_accounts(
|
||||||
|
db.session.get(Currency, default_currency_code()))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("unmatched/<currency:currency>", endpoint="unmatched-accounts")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def get_unmatched_accounts(currency: Currency) -> str | Response:
|
||||||
|
"""Returns the accounts with unmatched offsets.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The accounts with unmatched offsets.
|
||||||
|
"""
|
||||||
|
return __get_unmatched_accounts(currency)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_unmatched_accounts(currency: Currency) -> str | Response:
|
||||||
|
"""Returns the accounts with unmatched offsets.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The accounts with unmatched offsets.
|
||||||
|
"""
|
||||||
|
report: AccountsWithUnmatchedOffsets \
|
||||||
|
= AccountsWithUnmatchedOffsets(currency)
|
||||||
|
if "as" in request.args and request.args["as"] == "csv":
|
||||||
|
return report.csv()
|
||||||
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("unmatched/<currency:currency>/<needOffsetAccount:account>",
|
||||||
|
endpoint="unmatched")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def get_unmatched(currency: Currency, account: Account) -> str | Response:
|
||||||
|
"""Returns the unmatched offsets.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The Account.
|
||||||
|
:return: The unmatched offsets in the period.
|
||||||
|
"""
|
||||||
|
report: UnmatchedOffsets = UnmatchedOffsets(currency, account)
|
||||||
|
if "as" in request.args and request.args["as"] == "csv":
|
||||||
|
return report.csv()
|
||||||
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("match-offsets/<currency:currency>/<needOffsetAccount:account>",
|
||||||
|
endpoint="match-offsets")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def match_offsets(currency: Currency, account: Account) -> Response:
|
||||||
|
"""Matches the original line items with their offsets.
|
||||||
|
|
||||||
|
:return: Redirection to the view of the unmatched offsets.
|
||||||
|
"""
|
||||||
|
matcher: OffsetMatcher = OffsetMatcher(currency, account)
|
||||||
|
if len(matcher.matched_pairs) == 0:
|
||||||
|
flash(s(lazy_gettext("No more offset to match automatically.")),
|
||||||
|
"success")
|
||||||
|
return redirect(or_next(
|
||||||
|
unmatched_url(currency, account)))
|
||||||
|
matcher.match()
|
||||||
|
db.session.commit()
|
||||||
|
flash(s(lazy_gettext("Matched %(matches)s offsets.",
|
||||||
|
matches=len(matcher.matched_pairs))), "success")
|
||||||
|
return redirect(or_next(unmatched_url(currency, account)))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("search", endpoint="search")
|
@bp.get("search", endpoint="search")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def search() -> str | Response:
|
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.
|
* style.css: The style sheet for the accounting application.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -39,6 +39,10 @@
|
|||||||
.accounting-toolbar {
|
.accounting-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
.accounting-toolbar-accounts {
|
||||||
|
max-height: 20rem;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
.accounting-toolbar .input-group > .input-group-text {
|
.accounting-toolbar .input-group > .input-group-text {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
@@ -209,11 +213,23 @@ a.accounting-report-table-row {
|
|||||||
.accounting-report-table-body .accounting-amount {
|
.accounting-report-table-body .accounting-amount {
|
||||||
font-style: italic;
|
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) {
|
.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 {
|
.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 {
|
.accounting-journal-table .accounting-report-table-row {
|
||||||
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
|
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
|
||||||
@@ -309,6 +325,26 @@ a.accounting-report-table-row {
|
|||||||
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
|
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
.accounting-unapplied-table .accounting-report-table-row {
|
||||||
|
grid-template-columns: 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;
|
||||||
|
}
|
||||||
|
.accounting-unmatched-table .accounting-report-table-row {
|
||||||
|
grid-template-columns: 1fr 5fr 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
.accounting-unmatched-account-table .accounting-report-table-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.accounting-unmatched-account-table .accounting-report-table-header .accounting-report-table-row {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* The accounting report */
|
/* The accounting report */
|
||||||
.accounting-mobile-journal-credit {
|
.accounting-mobile-journal-credit {
|
||||||
@@ -316,6 +352,10 @@ a.accounting-report-table-row {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The description editor */
|
/* The description editor */
|
||||||
|
.accounting-description-editor-buttons {
|
||||||
|
max-height: 7rem;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
.accounting-description-editor-buttons .btn {
|
.accounting-description-editor-buttons .btn {
|
||||||
margin-bottom: 0.3rem;
|
margin-bottom: 0.3rem;
|
||||||
}
|
}
|
||||||
@@ -339,6 +379,12 @@ a.accounting-report-table-row {
|
|||||||
margin: 0;
|
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) */
|
/* The Material Design text field (floating form control in Bootstrap) */
|
||||||
.accounting-material-text-field {
|
.accounting-material-text-field {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
/* The Mia! Accounting Project
|
||||||
* account-form.js: The JavaScript for the account form
|
* account-form.js: The JavaScript for the account form
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ class AccountForm {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The control of the base account
|
* The control of the base account
|
||||||
* @type {HTMLDivElement}
|
* @type {HTMLButtonElement}
|
||||||
*/
|
*/
|
||||||
#baseControl;
|
#baseControl;
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ class AccountForm {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The base account
|
* The base account
|
||||||
* @type {HTMLDivElement}
|
* @type {HTMLSpanElement}
|
||||||
*/
|
*/
|
||||||
#base;
|
#base;
|
||||||
|
|
||||||
@@ -114,10 +114,19 @@ class AccountForm {
|
|||||||
};
|
};
|
||||||
this.#baseControl.onclick = () => {
|
this.#baseControl.onclick = () => {
|
||||||
this.#baseControl.classList.add("accounting-not-empty");
|
this.#baseControl.classList.add("accounting-not-empty");
|
||||||
this.#baseAccountSelector.onOpen(this.#baseCode.value);
|
this.#baseAccountSelector.onOpen();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the base code.
|
||||||
|
*
|
||||||
|
* @return {string|null}
|
||||||
|
*/
|
||||||
|
get baseCode() {
|
||||||
|
return this.#baseCode.value === ""? null: this.#baseCode.value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The callback when the base account selector is closed.
|
* The callback when the base account selector is closed.
|
||||||
*
|
*
|
||||||
@@ -129,15 +138,14 @@ class AccountForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the base account.
|
* Saves the selected base account.
|
||||||
*
|
*
|
||||||
* @param code {string} the base account code
|
* @param account {BaseAccountOption} the selected base account
|
||||||
* @param text {string} the text for the base account
|
|
||||||
*/
|
*/
|
||||||
setBaseAccount(code, text) {
|
saveBaseAccount(account) {
|
||||||
this.#baseCode.value = code;
|
this.#baseCode.value = account.code;
|
||||||
this.#base.innerText = text;
|
this.#base.innerText = account.text;
|
||||||
if (["1", "2", "3"].includes(code.substring(0, 1))) {
|
if (["1", "2", "3"].includes(account.code.substring(0, 1))) {
|
||||||
this.#isNeedOffsetControl.classList.remove("d-none");
|
this.#isNeedOffsetControl.classList.remove("d-none");
|
||||||
this.#isNeedOffset.disabled = false;
|
this.#isNeedOffset.disabled = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -217,9 +225,10 @@ class AccountForm {
|
|||||||
/**
|
/**
|
||||||
* The base account selector.
|
* The base account selector.
|
||||||
*
|
*
|
||||||
|
* @extends {BaseCombobox<BaseAccountOption>}
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
class BaseAccountSelector {
|
class BaseAccountSelector extends BaseCombobox {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The account form
|
* The account form
|
||||||
@@ -233,12 +242,6 @@ class BaseAccountSelector {
|
|||||||
*/
|
*/
|
||||||
#modal;
|
#modal;
|
||||||
|
|
||||||
/**
|
|
||||||
* The query input
|
|
||||||
* @type {HTMLInputElement}
|
|
||||||
*/
|
|
||||||
#query;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The error message when the query has no result
|
* The error message when the query has no result
|
||||||
* @type {HTMLParagraphElement}
|
* @type {HTMLParagraphElement}
|
||||||
@@ -251,12 +254,6 @@ class BaseAccountSelector {
|
|||||||
*/
|
*/
|
||||||
#optionList;
|
#optionList;
|
||||||
|
|
||||||
/**
|
|
||||||
* The options
|
|
||||||
* @type {HTMLLIElement[]}
|
|
||||||
*/
|
|
||||||
#options;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The button to clear the base account value
|
* The button to clear the base account value
|
||||||
* @type {HTMLButtonElement}
|
* @type {HTMLButtonElement}
|
||||||
@@ -269,83 +266,56 @@ class BaseAccountSelector {
|
|||||||
* @param form {AccountForm} the form
|
* @param form {AccountForm} the form
|
||||||
*/
|
*/
|
||||||
constructor(form) {
|
constructor(form) {
|
||||||
|
const prefix = "accounting-base-selector";
|
||||||
|
const query = document.getElementById(`${prefix}-query`);
|
||||||
|
const options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(element, form.saveBaseAccount.bind(form)));
|
||||||
|
super(query, options);
|
||||||
this.#form = form;
|
this.#form = form;
|
||||||
this.#modal = document.getElementById("accounting-base-selector-modal");
|
this.#modal = document.getElementById(`${prefix}-modal`);
|
||||||
this.#query = document.getElementById("accounting-base-selector-query");
|
this.#modal.addEventListener("hidden.bs.modal", () => this.#form.onBaseAccountSelectorClosed());
|
||||||
this.#optionList = document.getElementById("accounting-base-selector-option-list");
|
this.#queryNoResult = document.getElementById(`${prefix}-option-no-result`);
|
||||||
// noinspection JSValidateTypes
|
this.#optionList = document.getElementById(`${prefix}-option-list`);
|
||||||
this.#options = Array.from(document.getElementsByClassName("accounting-base-selector-option"));
|
|
||||||
this.#clearButton = document.getElementById("accounting-base-selector-clear");
|
this.#clearButton = document.getElementById(`${prefix}-clear`);
|
||||||
this.#queryNoResult = document.getElementById("accounting-base-selector-option-no-result");
|
this.#clearButton.onclick = () => this.#form.clearBaseAccount();
|
||||||
this.#modal.addEventListener("hidden.bs.modal", () => {
|
|
||||||
this.#form.onBaseAccountSelectorClosed();
|
|
||||||
});
|
|
||||||
for (const option of this.#options) {
|
|
||||||
option.onclick = () => {
|
|
||||||
this.#form.setBaseAccount(option.dataset.code, option.dataset.text);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.#clearButton.onclick = () => {
|
|
||||||
this.#form.clearBaseAccount();
|
|
||||||
};
|
|
||||||
this.#initializeBaseAccountQuery();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the query.
|
* Filters the options.
|
||||||
*
|
*
|
||||||
|
* @override
|
||||||
*/
|
*/
|
||||||
#initializeBaseAccountQuery() {
|
filterOptions() {
|
||||||
this.#query.addEventListener("input", () => {
|
this.shownOptions = [];
|
||||||
if (this.#query.value === "") {
|
let isAnyMatched = false;
|
||||||
for (const option of this.#options) {
|
for (const option of this.options) {
|
||||||
option.classList.remove("d-none");
|
if (option.isMatched(this.query.value)) {
|
||||||
}
|
option.setShown(true);
|
||||||
this.#optionList.classList.remove("d-none");
|
this.shownOptions.push(option);
|
||||||
this.#queryNoResult.classList.add("d-none");
|
isAnyMatched = true;
|
||||||
return
|
|
||||||
}
|
|
||||||
let hasAnyMatched = false;
|
|
||||||
for (const option of this.#options) {
|
|
||||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
|
||||||
let isMatched = false;
|
|
||||||
for (const queryValue of queryValues) {
|
|
||||||
if (queryValue.toLowerCase().includes(this.#query.value.toLowerCase())) {
|
|
||||||
isMatched = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isMatched) {
|
|
||||||
option.classList.remove("d-none");
|
|
||||||
hasAnyMatched = true;
|
|
||||||
} else {
|
|
||||||
option.classList.add("d-none");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!hasAnyMatched) {
|
|
||||||
this.#optionList.classList.add("d-none");
|
|
||||||
this.#queryNoResult.classList.remove("d-none");
|
|
||||||
} else {
|
} else {
|
||||||
this.#optionList.classList.remove("d-none");
|
option.setShown(false);
|
||||||
this.#queryNoResult.classList.add("d-none");
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
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.
|
* The callback when the base account selector is shown.
|
||||||
*
|
*
|
||||||
* @param baseCode {string} the active base code
|
|
||||||
*/
|
*/
|
||||||
onOpen(baseCode) {
|
onOpen() {
|
||||||
for (const option of this.#options) {
|
this.query.value = "";
|
||||||
if (option.dataset.code === baseCode) {
|
this.filterOptions();
|
||||||
option.classList.add("active");
|
this.query.removeAttribute("aria-activedescendant");
|
||||||
} else {
|
this.selectOption(this.shownOptions.find((option) => option.code === this.#form.baseCode));
|
||||||
option.classList.remove("active");
|
if (this.#form.baseCode === null) {
|
||||||
}
|
|
||||||
}
|
|
||||||
if (baseCode === "") {
|
|
||||||
this.#clearButton.classList.add("btn-secondary")
|
this.#clearButton.classList.add("btn-secondary")
|
||||||
this.#clearButton.classList.remove("btn-danger");
|
this.#clearButton.classList.remove("btn-danger");
|
||||||
this.#clearButton.disabled = true;
|
this.#clearButton.disabled = true;
|
||||||
@@ -356,3 +326,62 @@ class BaseAccountSelector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A base account option.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class BaseAccountOption extends BaseOption {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 element {HTMLLIElement} the element
|
||||||
|
* @param save {function(BaseAccountOption): void} the callback to save the option
|
||||||
|
*/
|
||||||
|
constructor(element, save) {
|
||||||
|
super(element);
|
||||||
|
this.code = element.dataset.code;
|
||||||
|
this.text = element.dataset.text;
|
||||||
|
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
||||||
|
|
||||||
|
element.onclick = () => save(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
/* The Mia! Accounting Project
|
||||||
* account-order.js: The JavaScript for the account order
|
* account-order.js: The JavaScript for the account order
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -29,10 +29,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const onReorder = () => {
|
const onReorder = () => {
|
||||||
const accounts = Array.from(list.children);
|
const accounts = Array.from(list.children);
|
||||||
for (let i = 0; i < accounts.length; i++) {
|
for (let i = 0; i < accounts.length; i++) {
|
||||||
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
const no = document.getElementById(`accounting-order-${accounts[i].dataset.id}-no`);
|
||||||
const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code");
|
const code = document.getElementById(`accounting-order-${accounts[i].dataset.id}-code`);
|
||||||
no.value = String(i + 1);
|
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);
|
initializeDragAndDropReordering(list, onReorder);
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
|
||||||
* account-selector.js: The JavaScript for the account selector
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* Copyright (c) 2023 imacat.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
|
||||||
* First written: 2023/2/28
|
|
||||||
*/
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The account selector.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
class AccountSelector {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The line item editor
|
|
||||||
* @type {JournalEntryLineItemEditor}
|
|
||||||
*/
|
|
||||||
#lineItemEditor;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Either "debit" or "credit"
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
#debitCredit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The prefix of the HTML ID and class
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
#prefix;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The button to clear the account
|
|
||||||
* @type {HTMLButtonElement}
|
|
||||||
*/
|
|
||||||
#clearButton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The query input
|
|
||||||
* @type {HTMLInputElement}
|
|
||||||
*/
|
|
||||||
#query;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The error message when the query has no result
|
|
||||||
* @type {HTMLParagraphElement}
|
|
||||||
*/
|
|
||||||
#queryNoResult;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The option list
|
|
||||||
* @type {HTMLUListElement}
|
|
||||||
*/
|
|
||||||
#optionList;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The options
|
|
||||||
* @type {HTMLLIElement[]}
|
|
||||||
*/
|
|
||||||
#options;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The more item to show all accounts
|
|
||||||
* @type {HTMLLIElement}
|
|
||||||
*/
|
|
||||||
#more;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructs an account selector.
|
|
||||||
*
|
|
||||||
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
|
||||||
* @param debitCredit {string} either "debit" or "credit"
|
|
||||||
*/
|
|
||||||
constructor(lineItemEditor, debitCredit) {
|
|
||||||
this.#lineItemEditor = lineItemEditor
|
|
||||||
this.#debitCredit = debitCredit;
|
|
||||||
this.#prefix = "accounting-account-selector-" + debitCredit;
|
|
||||||
this.#query = document.getElementById(this.#prefix + "-query");
|
|
||||||
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
|
||||||
this.#optionList = document.getElementById(this.#prefix + "-option-list");
|
|
||||||
// noinspection JSValidateTypes
|
|
||||||
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
|
||||||
this.#more = document.getElementById(this.#prefix + "-more");
|
|
||||||
this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
|
|
||||||
this.#more.onclick = () => {
|
|
||||||
this.#more.classList.add("d-none");
|
|
||||||
this.#filterOptions();
|
|
||||||
};
|
|
||||||
this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
|
|
||||||
for (const option of this.#options) {
|
|
||||||
option.onclick = () => this.#lineItemEditor.saveAccount(option.dataset.code, option.dataset.text, option.classList.contains("accounting-account-is-need-offset"));
|
|
||||||
}
|
|
||||||
this.#query.addEventListener("input", () => {
|
|
||||||
this.#filterOptions();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters the options.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
#filterOptions() {
|
|
||||||
const codesInUse = this.#getCodesUsedInForm();
|
|
||||||
let shouldAnyShow = false;
|
|
||||||
for (const option of this.#options) {
|
|
||||||
const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
|
|
||||||
if (shouldShow) {
|
|
||||||
option.classList.remove("d-none");
|
|
||||||
shouldAnyShow = true;
|
|
||||||
} else {
|
|
||||||
option.classList.add("d-none");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
|
|
||||||
this.#optionList.classList.add("d-none");
|
|
||||||
this.#queryNoResult.classList.remove("d-none");
|
|
||||||
} else {
|
|
||||||
this.#optionList.classList.remove("d-none");
|
|
||||||
this.#queryNoResult.classList.add("d-none");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the account codes that are used in the form.
|
|
||||||
*
|
|
||||||
* @return {string[]} the account codes that are used in the form
|
|
||||||
*/
|
|
||||||
#getCodesUsedInForm() {
|
|
||||||
const inUse = this.#lineItemEditor.form.getAccountCodesUsed(this.#debitCredit);
|
|
||||||
if (this.#lineItemEditor.accountCode !== null) {
|
|
||||||
inUse.push(this.#lineItemEditor.accountCode);
|
|
||||||
}
|
|
||||||
return inUse
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether an option should show.
|
|
||||||
*
|
|
||||||
* @param option {HTMLLIElement} the option
|
|
||||||
* @param more {HTMLLIElement} the more element
|
|
||||||
* @param inUse {string[]} the account codes that are used in the form
|
|
||||||
* @param query {HTMLInputElement} the query element, if any
|
|
||||||
* @return {boolean} true if the option should show, or false otherwise
|
|
||||||
*/
|
|
||||||
#shouldOptionShow(option, more, inUse, query) {
|
|
||||||
const isQueryMatched = () => {
|
|
||||||
if (query.value === "") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const queryValues = JSON.parse(option.dataset.queryValues);
|
|
||||||
for (const queryValue of queryValues) {
|
|
||||||
if (queryValue.toLowerCase().includes(query.value.toLowerCase())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
const isMoreMatched = () => {
|
|
||||||
if (more.classList.contains("d-none")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
|
|
||||||
};
|
|
||||||
return isMoreMatched() && isQueryMatched();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The callback when the account selector is shown.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
onOpen() {
|
|
||||||
this.#query.value = "";
|
|
||||||
this.#more.classList.remove("d-none");
|
|
||||||
this.#filterOptions();
|
|
||||||
for (const option of this.#options) {
|
|
||||||
if (option.dataset.code === this.#lineItemEditor.accountCode) {
|
|
||||||
option.classList.add("active");
|
|
||||||
} else {
|
|
||||||
option.classList.remove("active");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.#lineItemEditor.accountCode === null) {
|
|
||||||
this.#clearButton.classList.add("btn-secondary");
|
|
||||||
this.#clearButton.classList.remove("btn-danger");
|
|
||||||
this.#clearButton.disabled = true;
|
|
||||||
} else {
|
|
||||||
this.#clearButton.classList.add("btn-danger");
|
|
||||||
this.#clearButton.classList.remove("btn-secondary");
|
|
||||||
this.#clearButton.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the account selector instances.
|
|
||||||
*
|
|
||||||
* @param lineItemEditor {JournalEntryLineItemEditor} the line item editor
|
|
||||||
* @return {{debit: AccountSelector, credit: AccountSelector}}
|
|
||||||
*/
|
|
||||||
static getInstances(lineItemEditor) {
|
|
||||||
const selectors = {}
|
|
||||||
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
|
|
||||||
for (const modal of modals) {
|
|
||||||
selectors[modal.dataset.debitCredit] = new AccountSelector(lineItemEditor, modal.dataset.debitCredit);
|
|
||||||
}
|
|
||||||
return selectors;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/* The Mia! Accounting Project
|
||||||
|
* base-combobox.js: The JavaScript for the base abstract combobox
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2026 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: 2026/4/16
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base abstract combobox.
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
* @template {BaseOption} T
|
||||||
|
*/
|
||||||
|
class BaseCombobox {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query input
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options
|
||||||
|
* @type {T[]}
|
||||||
|
*/
|
||||||
|
options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options that are shown
|
||||||
|
* @type {T[]}
|
||||||
|
*/
|
||||||
|
shownOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a base abstract combobox.
|
||||||
|
*
|
||||||
|
* @param query {HTMLInputElement} the query input
|
||||||
|
* @param options {T[]} the options
|
||||||
|
*/
|
||||||
|
constructor(query, options) {
|
||||||
|
this.query = query;
|
||||||
|
this.query.oninput = () => this.filterOptions();
|
||||||
|
this.query.onkeydown = this.onQueryKeyDown.bind(this);
|
||||||
|
this.options = options;
|
||||||
|
this.shownOptions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions when keys are pressed on the query input.
|
||||||
|
*
|
||||||
|
* @param event {KeyboardEvent} the key event
|
||||||
|
*/
|
||||||
|
onQueryKeyDown(event) {
|
||||||
|
if (this.shownOptions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentID = this.query.getAttribute("aria-activedescendant");
|
||||||
|
const currentIndex = this.shownOptions.findIndex((option) => option.elementID === currentID);
|
||||||
|
|
||||||
|
let newIndex;
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowUp":
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
newIndex = this.shownOptions.length - 1;
|
||||||
|
} else {
|
||||||
|
newIndex = (currentIndex - 1 + this.shownOptions.length) % this.shownOptions.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
newIndex = 0;
|
||||||
|
} else {
|
||||||
|
newIndex = (currentIndex + 1) % this.shownOptions.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Home":
|
||||||
|
if (this.query.value !== "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newIndex = 0;
|
||||||
|
break;
|
||||||
|
case "End":
|
||||||
|
if (this.query.value !== "") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newIndex = this.shownOptions.length - 1;
|
||||||
|
break;
|
||||||
|
case "PageUp":
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
newIndex = this.shownOptions.length - 1;
|
||||||
|
} else {
|
||||||
|
newIndex = Math.max(currentIndex - 10, 0);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "PageDown":
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
newIndex = 0;
|
||||||
|
} else {
|
||||||
|
newIndex = Math.min(currentIndex + 10, this.shownOptions.length - 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
event.preventDefault();
|
||||||
|
if (currentIndex !== -1) {
|
||||||
|
this.shownOptions[currentIndex].click();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case "Escape":
|
||||||
|
if (this.query.value !== "") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.query.value = "";
|
||||||
|
this.filterOptions();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
this.selectOption(this.shownOptions[newIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the options.
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
*/
|
||||||
|
filterOptions() {
|
||||||
|
throw new Error("Method not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects an option.
|
||||||
|
*
|
||||||
|
* @param option {T|undefined} the option.
|
||||||
|
*/
|
||||||
|
selectOption(option) {
|
||||||
|
this.options.forEach((opt) => opt.setActive(false));
|
||||||
|
if (option === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
option.setActive(true);
|
||||||
|
this.query.setAttribute("aria-activedescendant", option.elementID);
|
||||||
|
option.scrollIntoView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base abstract option
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
*/
|
||||||
|
class BaseOption {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The element
|
||||||
|
* @type {HTMLLIElement}
|
||||||
|
*/
|
||||||
|
#element;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The element ID
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
elementID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the base abstract option.
|
||||||
|
*
|
||||||
|
* @param element {HTMLLIElement} the element
|
||||||
|
*/
|
||||||
|
constructor(element) {
|
||||||
|
this.#element = element;
|
||||||
|
this.elementID = element.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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");
|
||||||
|
this.#element.ariaSelected = "true";
|
||||||
|
} else {
|
||||||
|
this.#element.classList.remove("active");
|
||||||
|
this.#element.ariaSelected = "false";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clicks the option.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
click() {
|
||||||
|
this.#element.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the option into view.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
scrollIntoView() {
|
||||||
|
this.#element.scrollIntoView({block: "nearest"});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/* The Mia! Accounting Project
|
||||||
|
* base-tablist.js: The JavaScript for base abstract tablist
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2026 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: 2026/4/16
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base abstract tablist.
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
* @template {BaseTab} T
|
||||||
|
*/
|
||||||
|
class BaseTablist {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tabs.
|
||||||
|
* @type {T[]}
|
||||||
|
*/
|
||||||
|
tabs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current tab.
|
||||||
|
* @type {T}
|
||||||
|
*/
|
||||||
|
currentTab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new base abstract tablist.
|
||||||
|
*
|
||||||
|
* @param tablist {HTMLUListElement} the tab list
|
||||||
|
*/
|
||||||
|
constructor(tablist) {
|
||||||
|
tablist.onkeydown = this.onTabKeyDown.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions when keys are pressed on the tabs.
|
||||||
|
*
|
||||||
|
* @param event {KeyboardEvent} the key event
|
||||||
|
*/
|
||||||
|
onTabKeyDown(event) {
|
||||||
|
const currentIndex = this.tabs.indexOf(this.currentTab);
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newIndex = currentIndex;
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowRight":
|
||||||
|
newIndex = (newIndex + 1) % this.tabs.length;
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
newIndex = (newIndex - 1 + this.tabs.length) % this.tabs.length;
|
||||||
|
break;
|
||||||
|
case "Home":
|
||||||
|
newIndex = 0;
|
||||||
|
break;
|
||||||
|
case "End":
|
||||||
|
newIndex = this.tabs.length - 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
this.tabs[newIndex].focus();
|
||||||
|
this.onTabFocus(this.tabs[newIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions when a tab is focused.
|
||||||
|
*
|
||||||
|
* @param tab {T} the tab
|
||||||
|
*/
|
||||||
|
onTabFocus(tab) { /* Do nothing */ }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches to a tab.
|
||||||
|
*
|
||||||
|
* @param tab {T} the tab
|
||||||
|
*/
|
||||||
|
switchTo(tab) {
|
||||||
|
this.tabs.forEach(t => t.setActive(t === tab));
|
||||||
|
this.currentTab = tab;
|
||||||
|
this.currentTab.onActivated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base abstract tab.
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
*/
|
||||||
|
class BaseTab {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab element.
|
||||||
|
* @type {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#tab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel element.
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#panel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new base abstract tab.
|
||||||
|
*
|
||||||
|
* @param tab {HTMLButtonElement} The tab element.
|
||||||
|
* @param panel {HTMLDivElement} The panel element.
|
||||||
|
* @param switchTo {function(BaseTab): void} The function to switch to the tab.
|
||||||
|
*/
|
||||||
|
constructor(tab, panel, switchTo) {
|
||||||
|
this.#tab = tab;
|
||||||
|
this.#panel = panel;
|
||||||
|
this.#tab.onclick = () => switchTo(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the active state of the tab.
|
||||||
|
*
|
||||||
|
* @param isActive {boolean} true if the tab is active, false otherwise
|
||||||
|
*/
|
||||||
|
setActive(isActive) {
|
||||||
|
if (isActive) {
|
||||||
|
this.#tab.classList.add("active");
|
||||||
|
this.#tab.tabIndex = 0;
|
||||||
|
this.#tab.ariaSelected = "true";
|
||||||
|
this.#panel.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
this.#tab.classList.remove("active");
|
||||||
|
this.#tab.tabIndex = -1;
|
||||||
|
this.#tab.ariaSelected = "false";
|
||||||
|
this.#panel.classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the tab is active.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if the tab is active, false otherwise
|
||||||
|
*/
|
||||||
|
isActive() {
|
||||||
|
return this.#tab.classList.contains("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions when the tab is activated.
|
||||||
|
*/
|
||||||
|
onActivated() { /* Do nothing */ }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focuses the tab.
|
||||||
|
*/
|
||||||
|
focus() {
|
||||||
|
this.#tab.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
/* The Mia! Accounting Project
|
||||||
* currency-form.js: The JavaScript for the currency form
|
* currency-form.js: The JavaScript for the currency form
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ class CurrencyForm {
|
|||||||
}
|
}
|
||||||
const original = this.#code.dataset.original;
|
const original = this.#code.dataset.original;
|
||||||
if (original === "" || this.#code.value !== original) {
|
if (original === "" || this.#code.value !== original) {
|
||||||
const response = await fetch(this.#code.dataset.existsUrl + "?q=" + encodeURIComponent(this.#code.value));
|
const response = await fetch(`${this.#code.dataset.existsUrl}?q=${encodeURIComponent(this.#code.value)}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data["exists"]) {
|
if (data["exists"]) {
|
||||||
this.#code.classList.add("is-invalid");
|
this.#code.classList.add("is-invalid");
|
||||||
|
|||||||
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