Compare commits
No commits in common. "main" and "v0.6.0" have entirely different histories.
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
|
@ -1,41 +0,0 @@
|
|||||||
# 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
|
|
22
MANIFEST.in
22
MANIFEST.in
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Flask 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,14 +15,16 @@
|
|||||||
# 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
|
||||||
recursive-include src/accounting/templates *
|
include src/accounting/translations/*
|
||||||
recursive-include src/accounting/translations *
|
include src/accounting/translations/*/LC_MESSAGES/*
|
||||||
recursive-include src/accounting/data *
|
include docs/*
|
||||||
recursive-include docs *
|
include docs/source/*
|
||||||
recursive-exclude docs/build *
|
include docs/source/_static/*
|
||||||
recursive-include tests *
|
include docs/source/_templates/*
|
||||||
|
include tests/*
|
||||||
exclude tests/test_temp.py
|
exclude tests/test_temp.py
|
||||||
recursive-exclude tests *.pyc
|
include tests/test_site/*
|
||||||
recursive-exclude tests/instance *
|
include tests/test_site/templates/*
|
||||||
|
include tests/test_site/translations/*
|
||||||
|
include tests/test_site/translations/*/LC_MESSAGES/*
|
||||||
|
68
README.rst
68
README.rst
@ -1,65 +1,30 @@
|
|||||||
===============
|
=====================
|
||||||
Mia! Accounting
|
Mia! Accounting Flask
|
||||||
===============
|
=====================
|
||||||
|
|
||||||
|
|
||||||
Description
|
Description
|
||||||
===========
|
===========
|
||||||
|
|
||||||
*Mia! Accounting* is an accounting module for Flask_ applications.
|
This is the Mia! Accounting Flask project. It is an accounting
|
||||||
It is designed both for mobile and desktop environments. It
|
module for the Flask_ applications.
|
||||||
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
|
Install
|
||||||
================================
|
=======
|
||||||
|
|
||||||
There is a `live demonstration`_ for *Mia! Accounting*. It runs the
|
Install the latest source from the
|
||||||
same code as the `test site`_ in the `source distribution`_. It is
|
`Mia! Accounting Flask repository`_.
|
||||||
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
|
pip install git+https://gitea.imacat.idv.tw/imacat/mia-accounting-flask.git
|
||||||
|
|
||||||
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-2024 imacat.
|
Copyright (c) 2023 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.
|
||||||
@ -81,14 +46,5 @@ 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
|
||||||
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
|
.. _Mia! Accounting Flask repository: https://gitea.imacat.idv.tw/imacat/mia-accounting-flask
|
||||||
.. _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
|
|
||||||
|
@ -1 +0,0 @@
|
|||||||
sphinx_rtd_theme
|
|
@ -1,45 +0,0 @@
|
|||||||
accounting.journal\_entry.forms package
|
|
||||||
=======================================
|
|
||||||
|
|
||||||
Submodules
|
|
||||||
----------
|
|
||||||
|
|
||||||
accounting.journal\_entry.forms.currency module
|
|
||||||
-----------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.forms.currency
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.journal\_entry.forms.journal\_entry module
|
|
||||||
-----------------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.forms.journal_entry
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.journal\_entry.forms.line\_item module
|
|
||||||
-------------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.forms.line_item
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.journal\_entry.forms.reorder module
|
|
||||||
----------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.forms.reorder
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
Module contents
|
|
||||||
---------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.forms
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
@ -1,46 +0,0 @@
|
|||||||
accounting.journal\_entry package
|
|
||||||
=================================
|
|
||||||
|
|
||||||
Subpackages
|
|
||||||
-----------
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 4
|
|
||||||
|
|
||||||
accounting.journal_entry.forms
|
|
||||||
accounting.journal_entry.utils
|
|
||||||
|
|
||||||
Submodules
|
|
||||||
----------
|
|
||||||
|
|
||||||
accounting.journal\_entry.converters module
|
|
||||||
-------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.converters
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.journal\_entry.template\_filters module
|
|
||||||
--------------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.template_filters
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.journal\_entry.views module
|
|
||||||
--------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.views
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
Module contents
|
|
||||||
---------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
@ -1,45 +0,0 @@
|
|||||||
accounting.journal\_entry.utils package
|
|
||||||
=======================================
|
|
||||||
|
|
||||||
Submodules
|
|
||||||
----------
|
|
||||||
|
|
||||||
accounting.journal\_entry.utils.account\_option module
|
|
||||||
------------------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.utils.account_option
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.journal\_entry.utils.description\_editor module
|
|
||||||
----------------------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.utils.description_editor
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.journal\_entry.utils.operators module
|
|
||||||
------------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.utils.operators
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.journal\_entry.utils.original\_line\_items module
|
|
||||||
------------------------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.utils.original_line_items
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
Module contents
|
|
||||||
---------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.journal_entry.utils
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
@ -1,29 +0,0 @@
|
|||||||
accounting.option package
|
|
||||||
=========================
|
|
||||||
|
|
||||||
Submodules
|
|
||||||
----------
|
|
||||||
|
|
||||||
accounting.option.forms module
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.option.forms
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.option.views module
|
|
||||||
------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.option.views
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
Module contents
|
|
||||||
---------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.option
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
@ -60,38 +60,6 @@ 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,10 +28,10 @@ accounting.report.utils.csv\_export module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.report.utils.offset\_matcher module
|
accounting.report.utils.ie\_account module
|
||||||
----------------------------------------------
|
------------------------------------------
|
||||||
|
|
||||||
.. automodule:: accounting.report.utils.offset_matcher
|
.. automodule:: accounting.report.utils.ie_account
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
@ -60,22 +60,6 @@ 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
|
||||||
-----------------------------------
|
-----------------------------------
|
||||||
|
|
||||||
|
@ -10,30 +10,13 @@ Subpackages
|
|||||||
accounting.account
|
accounting.account
|
||||||
accounting.base_account
|
accounting.base_account
|
||||||
accounting.currency
|
accounting.currency
|
||||||
accounting.journal_entry
|
|
||||||
accounting.option
|
|
||||||
accounting.report
|
accounting.report
|
||||||
|
accounting.transaction
|
||||||
accounting.utils
|
accounting.utils
|
||||||
|
|
||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
accounting.commands module
|
|
||||||
--------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.commands
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.forms module
|
|
||||||
-----------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.forms
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.locale module
|
accounting.locale module
|
||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
|
45
docs/source/accounting.transaction.forms.rst
Normal file
45
docs/source/accounting.transaction.forms.rst
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
accounting.transaction.forms package
|
||||||
|
====================================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.transaction.forms.currency module
|
||||||
|
--------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.forms.currency
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.forms.journal\_entry module
|
||||||
|
--------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.forms.journal_entry
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.forms.reorder module
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.forms.reorder
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.forms.transaction module
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.forms.transaction
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.forms
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
46
docs/source/accounting.transaction.rst
Normal file
46
docs/source/accounting.transaction.rst
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
accounting.transaction package
|
||||||
|
==============================
|
||||||
|
|
||||||
|
Subpackages
|
||||||
|
-----------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 4
|
||||||
|
|
||||||
|
accounting.transaction.forms
|
||||||
|
accounting.transaction.utils
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.transaction.converters module
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.converters
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.template\_filters module
|
||||||
|
-----------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.template_filters
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.views module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.views
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
53
docs/source/accounting.transaction.utils.rst
Normal file
53
docs/source/accounting.transaction.utils.rst
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
accounting.transaction.utils package
|
||||||
|
====================================
|
||||||
|
|
||||||
|
Submodules
|
||||||
|
----------
|
||||||
|
|
||||||
|
accounting.transaction.utils.account\_option module
|
||||||
|
---------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.utils.account_option
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.utils.offset\_alias module
|
||||||
|
-------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.utils.offset_alias
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.utils.operators module
|
||||||
|
---------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.utils.operators
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.utils.original\_entries module
|
||||||
|
-----------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.utils.original_entries
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.transaction.utils.summary\_editor module
|
||||||
|
---------------------------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.utils.summary_editor
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
Module contents
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.transaction.utils
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
@ -12,14 +12,6 @@ accounting.utils.cast module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.utils.current\_account module
|
|
||||||
----------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.utils.current_account
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.utils.flash\_errors module
|
accounting.utils.flash\_errors module
|
||||||
-------------------------------------
|
-------------------------------------
|
||||||
|
|
||||||
@ -28,14 +20,6 @@ accounting.utils.flash\_errors module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.utils.journal\_entry\_types module
|
|
||||||
---------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.utils.journal_entry_types
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.utils.next\_uri module
|
accounting.utils.next\_uri module
|
||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
@ -44,22 +28,6 @@ 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
|
|
||||||
-------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.utils.options
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.utils.pagination module
|
accounting.utils.pagination module
|
||||||
----------------------------------
|
----------------------------------
|
||||||
|
|
||||||
@ -100,10 +68,10 @@ accounting.utils.strip\_text module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.utils.title\_case module
|
accounting.utils.txn\_types module
|
||||||
-----------------------------------
|
----------------------------------
|
||||||
|
|
||||||
.. automodule:: accounting.utils.title_case
|
.. automodule:: accounting.utils.txn_types
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
@ -1,455 +0,0 @@
|
|||||||
Change Log
|
|
||||||
==========
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
@ -6,15 +6,14 @@ 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'
|
project = 'Mia! Accounting Flask'
|
||||||
copyright = '2023, imacat'
|
copyright = '2023, imacat'
|
||||||
author = 'imacat'
|
author = 'imacat'
|
||||||
release = accounting.VERSION
|
release = '0.4.0'
|
||||||
|
|
||||||
# -- General configuration ---------------------------------------------------
|
# -- General configuration ---------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||||
|
@ -1,61 +0,0 @@
|
|||||||
Examples
|
|
||||||
========
|
|
||||||
|
|
||||||
|
|
||||||
.. _example-userutils:
|
|
||||||
|
|
||||||
An Example Configuration
|
|
||||||
------------------------
|
|
||||||
|
|
||||||
The following is an example configuration for *Mia! Accounting*.
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
from flask import Response, redirect
|
|
||||||
from .auth import current_user()
|
|
||||||
from .modules import User
|
|
||||||
|
|
||||||
def create_app(test_config=None) -> Flask:
|
|
||||||
app: Flask = Flask(__name__)
|
|
||||||
|
|
||||||
... (Configuration of SQLAlchemy, CSRF, Babel_JS, ... etc) ...
|
|
||||||
|
|
||||||
import accounting
|
|
||||||
|
|
||||||
class UserUtils(accounting.UserUtilityInterface[User]):
|
|
||||||
|
|
||||||
def can_view(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def can_edit(self) -> bool:
|
|
||||||
return "editor" in current_user().roles
|
|
||||||
|
|
||||||
def can_admin(self) -> bool:
|
|
||||||
return current_user().is_admin
|
|
||||||
|
|
||||||
def unauthorized(self) -> Response:
|
|
||||||
return redirect("/login")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cls(self) -> t.Type[User]:
|
|
||||||
return User
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pk_column(self) -> Column:
|
|
||||||
return User.id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_user(self) -> User | None:
|
|
||||||
return current_user()
|
|
||||||
|
|
||||||
def get_by_username(self, username: str) -> User | None:
|
|
||||||
return User.query.filter(User.username == username).first()
|
|
||||||
|
|
||||||
def get_pk(self, user: User) -> int:
|
|
||||||
return user.id
|
|
||||||
|
|
||||||
accounting.init_app(app, UserUtils())
|
|
||||||
|
|
||||||
... (Any other configuration) ...
|
|
||||||
|
|
||||||
return app
|
|
@ -1,57 +0,0 @@
|
|||||||
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,21 +1,15 @@
|
|||||||
.. Mia! Accounting documentation master file, created by
|
.. Mia! Accounting Flask 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's documentation!
|
Welcome to Mia! Accounting Flask's documentation!
|
||||||
===========================================
|
=================================================
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
:caption: Contents:
|
:caption: Contents:
|
||||||
|
|
||||||
intro
|
|
||||||
accounting
|
|
||||||
examples
|
|
||||||
history
|
|
||||||
changelog
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Indices and tables
|
Indices and tables
|
||||||
|
@ -1,120 +0,0 @@
|
|||||||
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/
|
|
@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Flask Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
||||||
|
|
||||||
# Copyright (c) 2022-2024 imacat.
|
# Copyright (c) 2022 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,55 +15,6 @@
|
|||||||
# 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",
|
|
||||||
"Flask-WTF",
|
|
||||||
"Flask-Babel >= 3",
|
|
||||||
"Flask-Babel-JS",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
devel = [
|
|
||||||
"httpx",
|
|
||||||
"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",
|
|
||||||
]
|
|
||||||
|
56
setup.cfg
Normal file
56
setup.cfg
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# 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.6.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 Project.
|
# The Mia! Accounting Flask Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||||
|
|
||||||
# Copyright (c) 2023-2024 imacat.
|
# Copyright (c) 2023 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,8 +24,6 @@ from flask_sqlalchemy import SQLAlchemy
|
|||||||
|
|
||||||
from accounting.utils.user import UserUtilityInterface
|
from accounting.utils.user import UserUtilityInterface
|
||||||
|
|
||||||
VERSION: str = "1.6.0"
|
|
||||||
"""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"
|
||||||
@ -49,6 +47,7 @@ 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")
|
||||||
|
|
||||||
@ -63,10 +62,6 @@ 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)
|
||||||
|
|
||||||
@ -85,13 +80,10 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
|
|||||||
from . import currency
|
from . import currency
|
||||||
currency.init_app(app, bp)
|
currency.init_app(app, bp)
|
||||||
|
|
||||||
from . import journal_entry
|
from . import transaction
|
||||||
journal_entry.init_app(app, bp)
|
transaction.init_app(app, bp)
|
||||||
|
|
||||||
from . import report
|
from . import report
|
||||||
report.init_app(app, url_prefix)
|
report.init_app(app, bp)
|
||||||
|
|
||||||
from . import option
|
app.register_blueprint(bp)
|
||||||
option.init_app(bp)
|
|
||||||
|
|
||||||
app.register_blueprint(bp, url_prefix=url_prefix)
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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,8 +19,6 @@
|
|||||||
"""
|
"""
|
||||||
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.
|
||||||
@ -34,3 +32,6 @@ 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 Project.
|
# The Mia! Accounting Flask Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||||
|
|
||||||
# Copyright (c) 2023-2024 imacat.
|
# Copyright (c) 2023 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,21 +17,44 @@
|
|||||||
"""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
|
||||||
import sqlalchemy as sa
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.models import BaseAccount, Account, AccountL10n
|
from accounting.models import BaseAccount, Account, AccountL10n
|
||||||
from accounting.utils.user import get_user_pk
|
from accounting.utils.user import has_user, get_user_pk
|
||||||
|
|
||||||
type AccountData = tuple[int, str, int, str, str, str, bool]
|
AccountData = tuple[int, str, int, str, str, str, bool]
|
||||||
"""The format of the account data, as a list of (ID, base account code, number,
|
"""The format of the account data, as a list of (ID, base account code, number,
|
||||||
English, Traditional Chinese, Simplified Chinese, is-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)
|
||||||
@ -40,6 +63,8 @@ def init_accounts_command(username: str) -> None:
|
|||||||
.filter(db.func.length(BaseAccount.code) == 4)\
|
.filter(db.func.length(BaseAccount.code) == 4)\
|
||||||
.order_by(BaseAccount.code).all()
|
.order_by(BaseAccount.code).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] = Account.query.all()
|
||||||
@ -48,6 +73,7 @@ def init_accounts_command(username: str) -> None:
|
|||||||
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}
|
||||||
@ -63,45 +89,60 @@ 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[dict[str, Any]] = []
|
data: list[AccountData] = []
|
||||||
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}
|
||||||
account_id: int = get_new_id()
|
is_need_offset: bool = __is_need_offset(base.code)
|
||||||
data.append({"id": account_id,
|
data.append((get_new_id(), base.code, 1, base.title_l10n,
|
||||||
"base_code": base.code,
|
l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset))
|
||||||
"no": 1,
|
__add_accounting_accounts(data, creator_pk)
|
||||||
"title_l10n": base.title_l10n,
|
click.echo(F"{len(data)} added. Accounting accounts initialized.")
|
||||||
"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:
|
||||||
"""Checks that whether journal entry line items in the account need offset.
|
"""Checks that whether entries in the account need offset.
|
||||||
|
|
||||||
:param base_code: The code of the base account.
|
:param base_code: The code of the base account.
|
||||||
:return: True if journal entry line items in the account need offset, or
|
:return: True if entries in the account need offset, or False otherwise.
|
||||||
False otherwise.
|
|
||||||
"""
|
"""
|
||||||
# Assets
|
# Assets
|
||||||
if base_code[0] == "1":
|
if base_code[0] == "1":
|
||||||
if base_code[:3] in {"113", "114", "118", "184", "186"}:
|
if base_code[:3] in {"113", "114", "118", "184"}:
|
||||||
return True
|
return True
|
||||||
if base_code in {"1286", "1411", "1421", "1431", "1441", "1511",
|
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
|
||||||
"1521", "1581", "1611", "1851"}:
|
"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", "2861"}:
|
if base_code in {"2111", "2114", "2284", "2293"}:
|
||||||
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,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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 imacat.
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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 imacat.
|
||||||
@ -82,7 +82,7 @@ class AccountForm(FlaskForm):
|
|||||||
"""The title."""
|
"""The title."""
|
||||||
is_need_offset = BooleanField(
|
is_need_offset = BooleanField(
|
||||||
validators=[NoOffsetNominalAccount()])
|
validators=[NoOffsetNominalAccount()])
|
||||||
"""Whether the the journal entry line items of this account need offset."""
|
"""Whether the the entries of this account need offset."""
|
||||||
|
|
||||||
def populate_obj(self, obj: Account) -> None:
|
def populate_obj(self, obj: Account) -> None:
|
||||||
"""Populates the form data into an account object.
|
"""Populates the form data into an account object.
|
||||||
@ -168,9 +168,7 @@ 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,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -40,14 +40,14 @@ def get_account_query() -> list[Account]:
|
|||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
l10n: list[AccountL10n] = AccountL10n.query\
|
l10n: list[AccountL10n] = AccountL10n.query\
|
||||||
.filter(AccountL10n.title.icontains(k)).all()
|
.filter(AccountL10n.title.contains(k)).all()
|
||||||
l10n_matches: set[str] = {x.account_id for x in l10n}
|
l10n_matches: set[str] = {x.account_id for x in l10n}
|
||||||
sub_conditions: list[sa.BinaryExpression] \
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
= [Account.base_code.contains(k),
|
= [Account.base_code.contains(k),
|
||||||
Account.title_l10n.icontains(k),
|
Account.title_l10n.contains(k),
|
||||||
code.contains(k),
|
code.contains(k),
|
||||||
Account.id.in_(l10n_matches)]
|
Account.id.in_(l10n_matches)]
|
||||||
if k in gettext("Needs Offset"):
|
if k in gettext("Need offset"):
|
||||||
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))
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -53,7 +53,7 @@ def list_accounts() -> str:
|
|||||||
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,7 +70,7 @@ 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() -> redirect:
|
||||||
"""Adds an account.
|
"""Adds an 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,7 +121,7 @@ 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) -> redirect:
|
||||||
"""Updates an account.
|
"""Updates an account.
|
||||||
@ -148,7 +148,7 @@ 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) -> redirect:
|
||||||
"""Deletes an account.
|
"""Deletes an account.
|
||||||
@ -157,9 +157,6 @@ def delete_account(account: Account) -> redirect:
|
|||||||
:return: The redirection to the account list on success, or the account
|
:return: The redirection to the account list on success, or the account
|
||||||
detail on error.
|
detail on error.
|
||||||
"""
|
"""
|
||||||
if not account.can_delete:
|
|
||||||
flash(s(lazy_gettext("The account cannot be deleted.")), "error")
|
|
||||||
return redirect(inherit_next(__get_detail_uri(account)))
|
|
||||||
account.delete()
|
account.delete()
|
||||||
sort_accounts_in(account.base_code, account.id)
|
sort_accounts_in(account.base_code, account.id)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@ -167,7 +164,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,7 +175,7 @@ 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) -> redirect:
|
||||||
"""Reorders the accounts under a base account.
|
"""Reorders the accounts under a base account.
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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,8 +19,6 @@
|
|||||||
"""
|
"""
|
||||||
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.
|
||||||
@ -34,3 +32,6 @@ 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,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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,28 +19,33 @@
|
|||||||
"""
|
"""
|
||||||
import csv
|
import csv
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import click
|
||||||
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
from accounting import data_dir
|
from accounting import data_dir
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.models import BaseAccount, BaseAccountL10n
|
from accounting.models import BaseAccount, BaseAccountL10n
|
||||||
from accounting.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 BaseAccount.query.first() is not None:
|
||||||
return
|
click.echo("Base accounts already exist.")
|
||||||
|
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]] = [{"code": x["code"],
|
||||||
"title_l10n": title_case(x["title"])}
|
"title_l10n": 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.execute(sa.insert(BaseAccount), account_data)
|
db.session.bulk_insert_mappings(BaseAccount, account_data)
|
||||||
db.session.execute(sa.insert(BaseAccountL10n), l10n_data)
|
db.session.bulk_insert_mappings(BaseAccountL10n, l10n_data)
|
||||||
|
db.session.commit()
|
||||||
|
click.echo("Base accounts initialized.")
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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 imacat.
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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 imacat.
|
||||||
@ -35,10 +35,10 @@ def get_base_account_query() -> list[BaseAccount]:
|
|||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
|
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
|
||||||
.filter(BaseAccountL10n.title.icontains(k)).all()
|
.filter(BaseAccountL10n.title.contains(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.contains(k),
|
||||||
BaseAccount.code.in_(l10n_matches)))
|
BaseAccount.code.in_(l10n_matches)))
|
||||||
return BaseAccount.query.filter(*conditions)\
|
return BaseAccount.query.filter(*conditions)\
|
||||||
.order_by(BaseAccount.code).all()
|
.order_by(BaseAccount.code).all()
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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 imacat.
|
||||||
@ -22,7 +22,6 @@ from flask import Blueprint, render_template
|
|||||||
from accounting.models import BaseAccount
|
from accounting.models import BaseAccount
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
from accounting.utils.permission import has_permission, can_view
|
from accounting.utils.permission import has_permission, can_view
|
||||||
from .queries import get_base_account_query
|
|
||||||
|
|
||||||
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."""
|
||||||
@ -35,13 +34,14 @@ 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.
|
||||||
|
@ -1,94 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The console commands.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
import click
|
|
||||||
from flask.cli import with_appcontext
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.account import init_accounts_command
|
|
||||||
from accounting.base_account import init_base_accounts_command
|
|
||||||
from accounting.currency import init_currencies_command
|
|
||||||
from accounting.models import BaseAccount, Account
|
|
||||||
from accounting.utils.title_case import title_case
|
|
||||||
from accounting.utils.user import has_user, get_user_pk
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
|
||||||
value: str) -> str:
|
|
||||||
"""Validates the username for the click console command.
|
|
||||||
|
|
||||||
:param ctx: The console command context.
|
|
||||||
:param param: The console command option.
|
|
||||||
:param value: The username.
|
|
||||||
:raise click.BadParameter: When validation fails.
|
|
||||||
:return: The username.
|
|
||||||
"""
|
|
||||||
value = value.strip()
|
|
||||||
if value == "":
|
|
||||||
raise click.BadParameter("Username empty.")
|
|
||||||
if not has_user(value):
|
|
||||||
raise click.BadParameter(f"User {value} does not exist.")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@click.command("accounting-init-db")
|
|
||||||
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
|
||||||
help="The username.", callback=__validate_username,
|
|
||||||
default=lambda: os.getlogin())
|
|
||||||
@with_appcontext
|
|
||||||
def init_db_command(username: str) -> None:
|
|
||||||
"""Initializes the accounting database."""
|
|
||||||
db.create_all()
|
|
||||||
init_base_accounts_command()
|
|
||||||
init_accounts_command(username)
|
|
||||||
init_currencies_command(username)
|
|
||||||
db.session.commit()
|
|
||||||
click.echo("Accounting database initialized.")
|
|
||||||
|
|
||||||
|
|
||||||
@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 BaseAccount.query:
|
|
||||||
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 Account.query:
|
|
||||||
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 Project.
|
# The Mia! Accounting Flask 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,8 +19,6 @@
|
|||||||
"""
|
"""
|
||||||
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.
|
||||||
@ -35,3 +33,6 @@ 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,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -18,15 +18,42 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import csv
|
import csv
|
||||||
from typing import Any
|
import os
|
||||||
|
import typing as t
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import click
|
||||||
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
from accounting import db, data_dir
|
from accounting import db, data_dir
|
||||||
from accounting.models import Currency, CurrencyL10n
|
from accounting.models import Currency, CurrencyL10n
|
||||||
from accounting.utils.user import get_user_pk
|
from accounting.utils.user import has_user, get_user_pk
|
||||||
|
|
||||||
|
CurrencyData = tuple[str, str, str, str]
|
||||||
|
|
||||||
|
|
||||||
|
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||||
|
value: str) -> str:
|
||||||
|
"""Validates the username for the click console command.
|
||||||
|
|
||||||
|
:param ctx: The console command context.
|
||||||
|
:param param: The console command option.
|
||||||
|
:param value: The username.
|
||||||
|
:raise click.BadParameter: When validation fails.
|
||||||
|
:return: The username.
|
||||||
|
"""
|
||||||
|
value = value.strip()
|
||||||
|
if value == "":
|
||||||
|
raise click.BadParameter("Username empty.")
|
||||||
|
if not has_user(value):
|
||||||
|
raise click.BadParameter(f"User {value} does not exist.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("accounting-init-currencies")
|
||||||
|
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||||
|
help="The username.", callback=__validate_username,
|
||||||
|
default=lambda: os.getlogin())
|
||||||
|
@with_appcontext
|
||||||
def init_currencies_command(username: str) -> None:
|
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 Currency.query.all()}
|
||||||
@ -36,18 +63,22 @@ def init_currencies_command(username: str) -> None:
|
|||||||
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, Any]] = [{"code": x["code"],
|
currency_data: list[dict[str, t.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.execute(sa.insert(Currency), currency_data)
|
db.session.bulk_insert_mappings(Currency, currency_data)
|
||||||
db.session.execute(sa.insert(CurrencyL10n), l10n_data)
|
db.session.bulk_insert_mappings(CurrencyL10n, l10n_data)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
click.echo(F"{len(to_add)} added. Currencies initialized.")
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -35,10 +35,10 @@ def get_currency_query() -> list[Currency]:
|
|||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
||||||
.filter(CurrencyL10n.name.icontains(k)).all()
|
.filter(CurrencyL10n.name.contains(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.contains(k),
|
||||||
Currency.name_l10n.icontains(k),
|
Currency.name_l10n.contains(k),
|
||||||
Currency.code.in_(l10n_matches)))
|
Currency.code.in_(l10n_matches)))
|
||||||
return Currency.query.filter(*conditions)\
|
return Currency.query.filter(*conditions)\
|
||||||
.order_by(Currency.code).all()
|
.order_by(Currency.code).all()
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -34,7 +34,6 @@ from accounting.utils.pagination import Pagination
|
|||||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||||
from accounting.utils.user import get_current_user_pk
|
from accounting.utils.user import get_current_user_pk
|
||||||
from .forms import CurrencyForm
|
from .forms import CurrencyForm
|
||||||
from .queries import get_currency_query
|
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("currency", __name__)
|
bp: Blueprint = Blueprint("currency", __name__)
|
||||||
"""The view blueprint for the currency management."""
|
"""The view blueprint for the currency management."""
|
||||||
@ -49,13 +48,14 @@ 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,7 +72,7 @@ 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() -> redirect:
|
||||||
"""Adds a currency.
|
"""Adds a currency.
|
||||||
@ -89,11 +89,11 @@ def add_currency() -> redirect:
|
|||||||
form.populate_obj(currency)
|
form.populate_obj(currency)
|
||||||
db.session.add(currency)
|
db.session.add(currency)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(s(lazy_gettext("The currency is added successfully.")), "success")
|
flash(s(lazy_gettext("The currency is added successfully")), "success")
|
||||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
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,7 +123,7 @@ 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) -> redirect:
|
||||||
"""Updates a currency.
|
"""Updates a currency.
|
||||||
@ -151,7 +151,7 @@ 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) -> redirect:
|
||||||
"""Deletes a currency.
|
"""Deletes a currency.
|
||||||
@ -160,16 +160,13 @@ def delete_currency(currency: Currency) -> redirect:
|
|||||||
:return: The redirection to the currency list on success, or the currency
|
:return: The redirection to the currency list on success, or the currency
|
||||||
detail on error.
|
detail on error.
|
||||||
"""
|
"""
|
||||||
if not currency.can_delete:
|
|
||||||
flash(s(lazy_gettext("The currency cannot be deleted.")), "error")
|
|
||||||
return redirect(inherit_next(__get_detail_uri(currency)))
|
|
||||||
currency.delete()
|
currency.delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
|
flash(s(lazy_gettext("The currency is deleted successfully.")), "success")
|
||||||
return redirect(or_next(url_for("accounting.currency.list")))
|
return redirect(or_next(url_for("accounting.currency.list")))
|
||||||
|
|
||||||
|
|
||||||
@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,96 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The forms.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
from flask_babel import LazyString
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from wtforms import StringField, ValidationError
|
|
||||||
from wtforms.validators import DataRequired
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.locale import lazy_gettext
|
|
||||||
from accounting.models import Currency, Account
|
|
||||||
|
|
||||||
|
|
||||||
ACCOUNT_REQUIRED: DataRequired = DataRequired(
|
|
||||||
lazy_gettext("Please select the account."))
|
|
||||||
"""The validator to check if the account code is empty."""
|
|
||||||
|
|
||||||
|
|
||||||
class CurrencyExists:
|
|
||||||
"""The validator to check if the account exists."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
if field.data is None:
|
|
||||||
return
|
|
||||||
if db.session.get(Currency, field.data) is None:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The currency does not exist."))
|
|
||||||
|
|
||||||
|
|
||||||
class AccountExists:
|
|
||||||
"""The validator to check if the account exists."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
if field.data is None:
|
|
||||||
return
|
|
||||||
if Account.find_by_code(field.data) is None:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The account does not exist."))
|
|
||||||
|
|
||||||
|
|
||||||
class IsDebitAccount:
|
|
||||||
"""The validator to check if the account is for debit line items."""
|
|
||||||
|
|
||||||
def __init__(self, message: str | LazyString):
|
|
||||||
"""Constructs the validator.
|
|
||||||
|
|
||||||
:param message: The error message.
|
|
||||||
"""
|
|
||||||
self.__message: str | LazyString = message
|
|
||||||
"""The error message."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
if field.data is None:
|
|
||||||
return
|
|
||||||
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
|
|
||||||
and not field.data.startswith("3353-"):
|
|
||||||
return
|
|
||||||
raise ValidationError(self.__message)
|
|
||||||
|
|
||||||
|
|
||||||
class IsCreditAccount:
|
|
||||||
"""The validator to check if the account is for credit line items."""
|
|
||||||
|
|
||||||
def __init__(self, message: str | LazyString):
|
|
||||||
"""Constructs the validator.
|
|
||||||
|
|
||||||
:param message: The error message.
|
|
||||||
"""
|
|
||||||
self.__message: str | LazyString = message
|
|
||||||
"""The error message."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
if field.data is None:
|
|
||||||
return
|
|
||||||
if re.match(r"^(?:[123489]|7[1234])", field.data) \
|
|
||||||
and not field.data.startswith("3353-"):
|
|
||||||
return
|
|
||||||
raise ValidationError(self.__message)
|
|
@ -1,102 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The path converters for the journal entry management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import datetime as dt
|
|
||||||
|
|
||||||
from flask import abort
|
|
||||||
from werkzeug.routing import BaseConverter
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.models import JournalEntry
|
|
||||||
from accounting.utils.journal_entry_types import JournalEntryType
|
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryConverter(BaseConverter):
|
|
||||||
"""The journal entry converter to convert the journal entry ID from and to
|
|
||||||
the corresponding journal entry in the routes."""
|
|
||||||
|
|
||||||
def to_python(self, value: str) -> JournalEntry:
|
|
||||||
"""Converts a journal entry ID to a journal entry.
|
|
||||||
|
|
||||||
:param value: The journal entry ID.
|
|
||||||
:return: The corresponding journal entry.
|
|
||||||
"""
|
|
||||||
journal_entry: JournalEntry | None \
|
|
||||||
= db.session.get(JournalEntry, value)
|
|
||||||
if journal_entry is None:
|
|
||||||
abort(404)
|
|
||||||
return journal_entry
|
|
||||||
|
|
||||||
def to_url(self, value: JournalEntry) -> str:
|
|
||||||
"""Converts a journal entry to its ID.
|
|
||||||
|
|
||||||
:param value: The journal entry.
|
|
||||||
:return: The ID.
|
|
||||||
"""
|
|
||||||
return str(value.id)
|
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryTypeConverter(BaseConverter):
|
|
||||||
"""The journal entry converter to convert the journal entry type ID from
|
|
||||||
and to the corresponding journal entry type in the routes."""
|
|
||||||
|
|
||||||
def to_python(self, value: str) -> JournalEntryType:
|
|
||||||
"""Converts a journal entry ID to a journal entry.
|
|
||||||
|
|
||||||
:param value: The journal entry ID.
|
|
||||||
:return: The corresponding journal entry type.
|
|
||||||
"""
|
|
||||||
type_dict: dict[str, JournalEntryType] \
|
|
||||||
= {x.value: x for x in JournalEntryType}
|
|
||||||
journal_entry_type: JournalEntryType | None = type_dict.get(value)
|
|
||||||
if journal_entry_type is None:
|
|
||||||
abort(404)
|
|
||||||
return journal_entry_type
|
|
||||||
|
|
||||||
def to_url(self, value: JournalEntryType) -> str:
|
|
||||||
"""Converts a journal entry type to its ID.
|
|
||||||
|
|
||||||
:param value: The journal entry type.
|
|
||||||
:return: The ID.
|
|
||||||
"""
|
|
||||||
return str(value.value)
|
|
||||||
|
|
||||||
|
|
||||||
class DateConverter(BaseConverter):
|
|
||||||
"""The date converter to convert the ISO date from and to the
|
|
||||||
corresponding date in the routes."""
|
|
||||||
|
|
||||||
def to_python(self, value: str) -> dt.date:
|
|
||||||
"""Converts an ISO date to a date.
|
|
||||||
|
|
||||||
:param value: The ISO date.
|
|
||||||
:return: The corresponding date.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return dt.date.fromisoformat(value)
|
|
||||||
except ValueError:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
def to_url(self, value: dt.date) -> str:
|
|
||||||
"""Converts a date to its ISO date.
|
|
||||||
|
|
||||||
:param value: The date.
|
|
||||||
:return: The ISO date.
|
|
||||||
"""
|
|
||||||
return value.isoformat()
|
|
@ -1,588 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
|
||||||
|
|
||||||
# Copyright (c) 2023-2024 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The journal entry forms for the journal entry management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import datetime as dt
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Type
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from flask_babel import LazyString
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from wtforms import DateField, FieldList, FormField, TextAreaField, \
|
|
||||||
BooleanField
|
|
||||||
from wtforms.validators import DataRequired, ValidationError
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.journal_entry.utils.account_option import AccountOption
|
|
||||||
from accounting.journal_entry.utils.description_editor import DescriptionEditor
|
|
||||||
from accounting.journal_entry.utils.original_line_items import \
|
|
||||||
get_selectable_original_line_items
|
|
||||||
from accounting.locale import lazy_gettext
|
|
||||||
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
|
|
||||||
JournalEntryCurrency
|
|
||||||
from accounting.utils.random_id import new_id
|
|
||||||
from accounting.utils.strip_text import strip_multiline_text
|
|
||||||
from accounting.utils.user import get_current_user_pk
|
|
||||||
from .currency import CurrencyForm, CashReceiptCurrencyForm, \
|
|
||||||
CashDisbursementCurrencyForm, TransferCurrencyForm
|
|
||||||
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
|
|
||||||
from .reorder import sort_journal_entries_in
|
|
||||||
|
|
||||||
DATE_REQUIRED: DataRequired = DataRequired(
|
|
||||||
lazy_gettext("Please fill in the date."))
|
|
||||||
"""The validator to check if the date is empty."""
|
|
||||||
|
|
||||||
|
|
||||||
class NotBeforeOriginalLineItems:
|
|
||||||
"""The validator to check if the date is not before the
|
|
||||||
original line items."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: DateField) -> None:
|
|
||||||
assert isinstance(form, JournalEntryForm)
|
|
||||||
if field.data is None:
|
|
||||||
return
|
|
||||||
min_date: dt.date | None = form.min_date
|
|
||||||
if min_date is None:
|
|
||||||
return
|
|
||||||
if field.data < min_date:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The date cannot be earlier than the original line items."))
|
|
||||||
|
|
||||||
|
|
||||||
class NotAfterOffsetItems:
|
|
||||||
"""The validator to check if the date is not after the offset items."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: DateField) -> None:
|
|
||||||
assert isinstance(form, JournalEntryForm)
|
|
||||||
if field.data is None:
|
|
||||||
return
|
|
||||||
max_date: dt.date | None = form.max_date
|
|
||||||
if max_date is None:
|
|
||||||
return
|
|
||||||
if field.data > max_date:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The date cannot be later than the offset items."))
|
|
||||||
|
|
||||||
|
|
||||||
class NeedSomeCurrencies:
|
|
||||||
"""The validator to check if there is any currency sub-form."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: FieldList) -> None:
|
|
||||||
if len(field) == 0:
|
|
||||||
raise ValidationError(lazy_gettext("Please add some currencies."))
|
|
||||||
|
|
||||||
|
|
||||||
class CannotDeleteOriginalLineItemsWithOffset:
|
|
||||||
"""The validator to check the original line items with offset."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: FieldList) -> None:
|
|
||||||
assert isinstance(form, JournalEntryForm)
|
|
||||||
if form.obj is None:
|
|
||||||
return
|
|
||||||
existing_matched_original_line_item_id: set[int] \
|
|
||||||
= {x.id for x in form.obj.line_items if len(x.offsets) > 0}
|
|
||||||
line_item_id_in_form: set[int] \
|
|
||||||
= {x.id.data for x in form.line_items if x.id.data is not None}
|
|
||||||
for line_item_id in existing_matched_original_line_item_id:
|
|
||||||
if line_item_id not in line_item_id_in_form:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"Line items with offset cannot be deleted."))
|
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryForm(FlaskForm):
|
|
||||||
"""The base form to create or edit a journal entry."""
|
|
||||||
date = DateField()
|
|
||||||
"""The date."""
|
|
||||||
currencies = FieldList(FormField(CurrencyForm))
|
|
||||||
"""The line items categorized by their currencies."""
|
|
||||||
note = TextAreaField()
|
|
||||||
"""The note."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
"""Constructs a base journal entry form.
|
|
||||||
|
|
||||||
:param args: The arguments.
|
|
||||||
:param kwargs: The keyword arguments.
|
|
||||||
"""
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.is_modified: bool = False
|
|
||||||
"""Whether the journal entry is modified during populate_obj()."""
|
|
||||||
self.collector: Type[LineItemCollector] = LineItemCollector
|
|
||||||
"""The line item collector. The default is the base abstract
|
|
||||||
collector only to provide the correct type. The subclass forms should
|
|
||||||
provide their own collectors."""
|
|
||||||
self.obj: JournalEntry | None = kwargs.get("obj")
|
|
||||||
"""The journal entry, when editing an existing one."""
|
|
||||||
self._is_need_payable: bool = False
|
|
||||||
"""Whether we need the payable original line items."""
|
|
||||||
self._is_need_receivable: bool = False
|
|
||||||
"""Whether we need the receivable original line items."""
|
|
||||||
self.__original_line_item_options: list[JournalEntryLineItem] | None \
|
|
||||||
= None
|
|
||||||
"""The options of the original line items."""
|
|
||||||
self.__net_balance_exceeded: dict[int, LazyString] | None = None
|
|
||||||
"""The original line items whose net balances were exceeded by the
|
|
||||||
amounts in the line item sub-forms."""
|
|
||||||
for line_item in self.line_items:
|
|
||||||
line_item.journal_entry_form = self
|
|
||||||
|
|
||||||
def populate_obj(self, obj: JournalEntry) -> None:
|
|
||||||
"""Populates the form data into a journal entry object.
|
|
||||||
|
|
||||||
:param obj: The journal entry object.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
is_new: bool = obj.id is None
|
|
||||||
if is_new:
|
|
||||||
obj.id = new_id(JournalEntry)
|
|
||||||
self.__set_date(obj, self.date.data)
|
|
||||||
obj.note = self.note.data
|
|
||||||
|
|
||||||
collector_cls: Type[LineItemCollector] = self.collector
|
|
||||||
collector: collector_cls = collector_cls(self, obj)
|
|
||||||
collector.collect()
|
|
||||||
|
|
||||||
to_delete: set[int] = {x.id for x in obj.line_items
|
|
||||||
if x.id not in collector.to_keep}
|
|
||||||
if len(to_delete) > 0:
|
|
||||||
JournalEntryLineItem.query\
|
|
||||||
.filter(JournalEntryLineItem.id.in_(to_delete)).delete()
|
|
||||||
self.is_modified = True
|
|
||||||
|
|
||||||
if is_new or db.session.is_modified(obj):
|
|
||||||
self.is_modified = True
|
|
||||||
|
|
||||||
if is_new:
|
|
||||||
current_user_pk: int = get_current_user_pk()
|
|
||||||
obj.created_by_id = current_user_pk
|
|
||||||
obj.updated_by_id = current_user_pk
|
|
||||||
|
|
||||||
@property
|
|
||||||
def line_items(self) -> list[LineItemForm]:
|
|
||||||
"""Collects and returns the line item sub-forms.
|
|
||||||
|
|
||||||
:return: The line item sub-forms.
|
|
||||||
"""
|
|
||||||
line_items: list[LineItemForm] = []
|
|
||||||
for currency in self.currencies:
|
|
||||||
line_items.extend(currency.line_items)
|
|
||||||
return line_items
|
|
||||||
|
|
||||||
def __set_date(self, obj: JournalEntry, new_date: dt.date) -> None:
|
|
||||||
"""Sets the journal entry date and number.
|
|
||||||
|
|
||||||
:param obj: The journal entry object.
|
|
||||||
:param new_date: The new date.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
if obj.date is None or obj.date != new_date:
|
|
||||||
if obj.date is not None:
|
|
||||||
sort_journal_entries_in(obj.date, obj.id)
|
|
||||||
if self.max_date is not None and new_date == self.max_date:
|
|
||||||
db_min_no: int | None = db.session.scalar(
|
|
||||||
sa.select(sa.func.min(JournalEntry.no))
|
|
||||||
.filter(JournalEntry.date == new_date))
|
|
||||||
if db_min_no is None:
|
|
||||||
obj.date = new_date
|
|
||||||
obj.no = 1
|
|
||||||
else:
|
|
||||||
obj.date = new_date
|
|
||||||
obj.no = db_min_no - 1
|
|
||||||
sort_journal_entries_in(new_date)
|
|
||||||
else:
|
|
||||||
sort_journal_entries_in(new_date, obj.id)
|
|
||||||
count: int = JournalEntry.query\
|
|
||||||
.filter(JournalEntry.date == new_date).count()
|
|
||||||
obj.date = new_date
|
|
||||||
obj.no = count + 1
|
|
||||||
|
|
||||||
@property
|
|
||||||
def debit_account_options(self) -> list[AccountOption]:
|
|
||||||
"""The selectable debit accounts.
|
|
||||||
|
|
||||||
:return: The selectable debit accounts.
|
|
||||||
"""
|
|
||||||
accounts: list[AccountOption] \
|
|
||||||
= [AccountOption(x) for x in Account.selectable_debit()
|
|
||||||
if not (x.code[0] == "2" and x.is_need_offset)]
|
|
||||||
in_use: set[int] = set(db.session.scalars(
|
|
||||||
sa.select(JournalEntryLineItem.account_id)
|
|
||||||
.filter(JournalEntryLineItem.is_debit)
|
|
||||||
.group_by(JournalEntryLineItem.account_id)).all())
|
|
||||||
for account in accounts:
|
|
||||||
account.is_in_use = account.id in in_use
|
|
||||||
return accounts
|
|
||||||
|
|
||||||
@property
|
|
||||||
def credit_account_options(self) -> list[AccountOption]:
|
|
||||||
"""The selectable credit accounts.
|
|
||||||
|
|
||||||
:return: The selectable credit accounts.
|
|
||||||
"""
|
|
||||||
accounts: list[AccountOption] \
|
|
||||||
= [AccountOption(x) for x in Account.selectable_credit()
|
|
||||||
if not (x.code[0] == "1" and x.is_need_offset)]
|
|
||||||
in_use: set[int] = set(db.session.scalars(
|
|
||||||
sa.select(JournalEntryLineItem.account_id)
|
|
||||||
.filter(sa.not_(JournalEntryLineItem.is_debit))
|
|
||||||
.group_by(JournalEntryLineItem.account_id)).all())
|
|
||||||
for account in accounts:
|
|
||||||
account.is_in_use = account.id in in_use
|
|
||||||
return accounts
|
|
||||||
|
|
||||||
@property
|
|
||||||
def currencies_errors(self) -> list[str | LazyString]:
|
|
||||||
"""Returns the currency errors, without the errors in their sub-forms.
|
|
||||||
|
|
||||||
:return: The currency errors, without the errors in their sub-forms.
|
|
||||||
"""
|
|
||||||
return [x for x in self.currencies.errors
|
|
||||||
if isinstance(x, str) or isinstance(x, LazyString)]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description_editor(self) -> DescriptionEditor:
|
|
||||||
"""Returns the description editor.
|
|
||||||
|
|
||||||
:return: The description editor.
|
|
||||||
"""
|
|
||||||
return DescriptionEditor()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def original_line_item_options(self) -> list[JournalEntryLineItem]:
|
|
||||||
"""Returns the selectable original line items.
|
|
||||||
|
|
||||||
:return: The selectable original line items.
|
|
||||||
"""
|
|
||||||
if self.__original_line_item_options is None:
|
|
||||||
self.__original_line_item_options \
|
|
||||||
= get_selectable_original_line_items(
|
|
||||||
{x.id.data for x in self.line_items
|
|
||||||
if x.id.data is not None},
|
|
||||||
self._is_need_payable, self._is_need_receivable)
|
|
||||||
return self.__original_line_item_options
|
|
||||||
|
|
||||||
@property
|
|
||||||
def min_date(self) -> dt.date | None:
|
|
||||||
"""Returns the minimal available date.
|
|
||||||
|
|
||||||
:return: The minimal available date.
|
|
||||||
"""
|
|
||||||
original_line_item_id: set[int] \
|
|
||||||
= {x.original_line_item_id.data for x in self.line_items
|
|
||||||
if x.original_line_item_id.data is not None}
|
|
||||||
if len(original_line_item_id) == 0:
|
|
||||||
return None
|
|
||||||
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
|
|
||||||
.join(JournalEntryLineItem)\
|
|
||||||
.filter(JournalEntryLineItem.id.in_(original_line_item_id))
|
|
||||||
return db.session.scalar(select)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def max_date(self) -> dt.date | None:
|
|
||||||
"""Returns the maximum available date.
|
|
||||||
|
|
||||||
:return: The maximum available date.
|
|
||||||
"""
|
|
||||||
line_item_id: set[int] = {x.id.data for x in self.line_items
|
|
||||||
if x.id.data is not None}
|
|
||||||
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
|
|
||||||
.join(JournalEntryLineItem)\
|
|
||||||
.filter(JournalEntryLineItem.original_line_item_id
|
|
||||||
.in_(line_item_id))
|
|
||||||
return db.session.scalar(select)
|
|
||||||
|
|
||||||
|
|
||||||
class LineItemCollector[T: JournalEntryForm](ABC):
|
|
||||||
"""The line item collector."""
|
|
||||||
|
|
||||||
def __init__(self, form: T, obj: JournalEntry):
|
|
||||||
"""Constructs the line item collector.
|
|
||||||
|
|
||||||
:param form: The journal entry form.
|
|
||||||
:param obj: The journal entry.
|
|
||||||
"""
|
|
||||||
self.form: T = form
|
|
||||||
"""The journal entry form."""
|
|
||||||
self.__obj: JournalEntry = obj
|
|
||||||
"""The journal entry object."""
|
|
||||||
self.__line_items: list[JournalEntryLineItem] = list(obj.line_items)
|
|
||||||
"""The existing line items."""
|
|
||||||
self.__line_items_by_id: dict[int, JournalEntryLineItem] \
|
|
||||||
= {x.id: x for x in self.__line_items}
|
|
||||||
"""A dictionary from the line item ID to their line items."""
|
|
||||||
self.__no_by_id: dict[int, int] \
|
|
||||||
= {x.id: x.no for x in self.__line_items}
|
|
||||||
"""A dictionary from the line item number to their line items."""
|
|
||||||
self.__currencies: list[JournalEntryCurrency] = obj.currencies
|
|
||||||
"""The currencies in the journal entry."""
|
|
||||||
self._debit_no: int = 1
|
|
||||||
"""The number index for the debit line items."""
|
|
||||||
self._credit_no: int = 1
|
|
||||||
"""The number index for the credit line items."""
|
|
||||||
self.to_keep: set[int] = set()
|
|
||||||
"""The ID of the existing line items to keep."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def collect(self) -> set[int]:
|
|
||||||
"""Collects the line items.
|
|
||||||
|
|
||||||
:return: The ID of the line items to keep.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _add_line_item(self, form: LineItemForm, currency_code: str, no: int) \
|
|
||||||
-> None:
|
|
||||||
"""Composes a line item from the form.
|
|
||||||
|
|
||||||
:param form: The line item form.
|
|
||||||
:param currency_code: The code of the currency.
|
|
||||||
:param no: The number of the line item.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
line_item: JournalEntryLineItem | None \
|
|
||||||
= self.__line_items_by_id.get(form.id.data)
|
|
||||||
if line_item is not None:
|
|
||||||
line_item.currency_code = currency_code
|
|
||||||
form.populate_obj(line_item)
|
|
||||||
line_item.no = no
|
|
||||||
if db.session.is_modified(line_item):
|
|
||||||
self.form.is_modified = True
|
|
||||||
else:
|
|
||||||
line_item = JournalEntryLineItem()
|
|
||||||
line_item.currency_code = currency_code
|
|
||||||
form.populate_obj(line_item)
|
|
||||||
line_item.no = no
|
|
||||||
self.__obj.line_items.append(line_item)
|
|
||||||
self.form.is_modified = True
|
|
||||||
self.to_keep.add(line_item.id)
|
|
||||||
|
|
||||||
def _make_cash_line_item(self, forms: list[LineItemForm], is_debit: bool,
|
|
||||||
currency_code: str, no: int) -> None:
|
|
||||||
"""Composes the cash line item at the other debit or credit of the
|
|
||||||
cash journal entry.
|
|
||||||
|
|
||||||
:param forms: The line item forms in the same currency.
|
|
||||||
:param is_debit: True for a cash receipt journal entry, or False for a
|
|
||||||
cash disbursement journal entry.
|
|
||||||
:param currency_code: The code of the currency.
|
|
||||||
:param no: The number of the line item.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
candidates: list[JournalEntryLineItem] \
|
|
||||||
= [x for x in self.__line_items
|
|
||||||
if x.is_debit == is_debit and x.currency_code == currency_code]
|
|
||||||
line_item: JournalEntryLineItem
|
|
||||||
if len(candidates) > 0:
|
|
||||||
candidates.sort(key=lambda x: x.no)
|
|
||||||
line_item = candidates[0]
|
|
||||||
line_item.account_id = Account.cash().id
|
|
||||||
line_item.description = None
|
|
||||||
line_item.amount = sum([x.amount.data for x in forms])
|
|
||||||
line_item.no = no
|
|
||||||
if db.session.is_modified(line_item):
|
|
||||||
self.form.is_modified = True
|
|
||||||
else:
|
|
||||||
line_item = JournalEntryLineItem()
|
|
||||||
line_item.id = new_id(JournalEntryLineItem)
|
|
||||||
line_item.is_debit = is_debit
|
|
||||||
line_item.currency_code = currency_code
|
|
||||||
line_item.account_id = Account.cash().id
|
|
||||||
line_item.description = None
|
|
||||||
line_item.amount = sum([x.amount.data for x in forms])
|
|
||||||
line_item.no = no
|
|
||||||
self.__obj.line_items.append(line_item)
|
|
||||||
self.form.is_modified = True
|
|
||||||
self.to_keep.add(line_item.id)
|
|
||||||
|
|
||||||
def _sort_line_item_forms(self, forms: list[LineItemForm]) -> None:
|
|
||||||
"""Sorts the line item sub-forms.
|
|
||||||
|
|
||||||
:param forms: The line item sub-forms.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
missing_no: int = 100 if len(self.__no_by_id) == 0 \
|
|
||||||
else max(self.__no_by_id.values()) + 100
|
|
||||||
ord_by_form: dict[LineItemForm, int] \
|
|
||||||
= {forms[i]: i for i in range(len(forms))}
|
|
||||||
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
|
||||||
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
|
||||||
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
|
||||||
missing_no if x.id.data is None else
|
|
||||||
self.__no_by_id.get(x.id.data, missing_no),
|
|
||||||
ord_by_form.get(x)))
|
|
||||||
|
|
||||||
def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None:
|
|
||||||
"""Sorts the currency forms.
|
|
||||||
|
|
||||||
:param forms: The currency forms.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
missing_no: int = len(self.__currencies) + 100
|
|
||||||
no_by_code: dict[str, int] = {self.__currencies[i].code: i
|
|
||||||
for i in range(len(self.__currencies))}
|
|
||||||
ord_by_form: dict[CurrencyForm, int] \
|
|
||||||
= {forms[i]: i for i in range(len(forms))}
|
|
||||||
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
|
||||||
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
|
||||||
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
|
||||||
no_by_code.get(x.code.data, missing_no),
|
|
||||||
ord_by_form.get(x)))
|
|
||||||
|
|
||||||
|
|
||||||
class CashReceiptJournalEntryForm(JournalEntryForm):
|
|
||||||
"""The form to create or edit a cash receipt journal entry."""
|
|
||||||
date = DateField(
|
|
||||||
validators=[DATE_REQUIRED,
|
|
||||||
NotBeforeOriginalLineItems(),
|
|
||||||
NotAfterOffsetItems()])
|
|
||||||
"""The date."""
|
|
||||||
currencies = FieldList(FormField(CashReceiptCurrencyForm), name="currency",
|
|
||||||
validators=[NeedSomeCurrencies()])
|
|
||||||
"""The line items categorized by their currencies."""
|
|
||||||
note = TextAreaField(filters=[strip_multiline_text])
|
|
||||||
"""The note."""
|
|
||||||
whole_form = BooleanField(
|
|
||||||
validators=[CannotDeleteOriginalLineItemsWithOffset()])
|
|
||||||
"""The pseudo field for the whole form validators."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self._is_need_receivable = True
|
|
||||||
|
|
||||||
class Collector(LineItemCollector[CashReceiptJournalEntryForm]):
|
|
||||||
"""The line item collector for the cash receipt journal entries."""
|
|
||||||
|
|
||||||
def collect(self) -> None:
|
|
||||||
currencies: list[CashReceiptCurrencyForm] \
|
|
||||||
= [x.form for x in self.form.currencies]
|
|
||||||
self._sort_currency_forms(currencies)
|
|
||||||
for currency in currencies:
|
|
||||||
# The debit cash line item
|
|
||||||
self._make_cash_line_item(list(currency.credit), True,
|
|
||||||
currency.code.data,
|
|
||||||
self._debit_no)
|
|
||||||
self._debit_no = self._debit_no + 1
|
|
||||||
|
|
||||||
# The credit forms
|
|
||||||
credit_forms: list[CreditLineItemForm] \
|
|
||||||
= [x.form for x in currency.credit]
|
|
||||||
self._sort_line_item_forms(credit_forms)
|
|
||||||
for credit_form in credit_forms:
|
|
||||||
self._add_line_item(credit_form, currency.code.data,
|
|
||||||
self._credit_no)
|
|
||||||
self._credit_no = self._credit_no + 1
|
|
||||||
|
|
||||||
self.collector = Collector
|
|
||||||
|
|
||||||
|
|
||||||
class CashDisbursementJournalEntryForm(JournalEntryForm):
|
|
||||||
"""The form to create or edit a cash disbursement journal entry."""
|
|
||||||
date = DateField(
|
|
||||||
validators=[DATE_REQUIRED,
|
|
||||||
NotBeforeOriginalLineItems(),
|
|
||||||
NotAfterOffsetItems()])
|
|
||||||
"""The date."""
|
|
||||||
currencies = FieldList(FormField(CashDisbursementCurrencyForm),
|
|
||||||
name="currency",
|
|
||||||
validators=[NeedSomeCurrencies()])
|
|
||||||
"""The line items categorized by their currencies."""
|
|
||||||
note = TextAreaField(filters=[strip_multiline_text])
|
|
||||||
"""The note."""
|
|
||||||
whole_form = BooleanField(
|
|
||||||
validators=[CannotDeleteOriginalLineItemsWithOffset()])
|
|
||||||
"""The pseudo field for the whole form validators."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self._is_need_payable = True
|
|
||||||
|
|
||||||
class Collector(LineItemCollector[CashDisbursementJournalEntryForm]):
|
|
||||||
"""The line item collector for the cash disbursement journal
|
|
||||||
entries."""
|
|
||||||
|
|
||||||
def collect(self) -> None:
|
|
||||||
currencies: list[CashDisbursementCurrencyForm] \
|
|
||||||
= [x.form for x in self.form.currencies]
|
|
||||||
self._sort_currency_forms(currencies)
|
|
||||||
for currency in currencies:
|
|
||||||
# The debit forms
|
|
||||||
debit_forms: list[DebitLineItemForm] \
|
|
||||||
= [x.form for x in currency.debit]
|
|
||||||
self._sort_line_item_forms(debit_forms)
|
|
||||||
for debit_form in debit_forms:
|
|
||||||
self._add_line_item(debit_form, currency.code.data,
|
|
||||||
self._debit_no)
|
|
||||||
self._debit_no = self._debit_no + 1
|
|
||||||
|
|
||||||
# The credit forms
|
|
||||||
self._make_cash_line_item(list(currency.debit), False,
|
|
||||||
currency.code.data,
|
|
||||||
self._credit_no)
|
|
||||||
self._credit_no = self._credit_no + 1
|
|
||||||
|
|
||||||
self.collector = Collector
|
|
||||||
|
|
||||||
|
|
||||||
class TransferJournalEntryForm(JournalEntryForm):
|
|
||||||
"""The form to create or edit a transfer journal entry."""
|
|
||||||
date = DateField(
|
|
||||||
validators=[DATE_REQUIRED,
|
|
||||||
NotBeforeOriginalLineItems(),
|
|
||||||
NotAfterOffsetItems()])
|
|
||||||
"""The date."""
|
|
||||||
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
|
|
||||||
validators=[NeedSomeCurrencies()])
|
|
||||||
"""The line items categorized by their currencies."""
|
|
||||||
note = TextAreaField(filters=[strip_multiline_text])
|
|
||||||
"""The note."""
|
|
||||||
whole_form = BooleanField(
|
|
||||||
validators=[CannotDeleteOriginalLineItemsWithOffset()])
|
|
||||||
"""The pseudo field for the whole form validators."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self._is_need_payable = True
|
|
||||||
self._is_need_receivable = True
|
|
||||||
|
|
||||||
class Collector(LineItemCollector[TransferJournalEntryForm]):
|
|
||||||
"""The line item collector for the transfer journal entries."""
|
|
||||||
|
|
||||||
def collect(self) -> None:
|
|
||||||
currencies: list[TransferCurrencyForm] \
|
|
||||||
= [x.form for x in self.form.currencies]
|
|
||||||
self._sort_currency_forms(currencies)
|
|
||||||
for currency in currencies:
|
|
||||||
# The debit forms
|
|
||||||
debit_forms: list[DebitLineItemForm] \
|
|
||||||
= [x.form for x in currency.debit]
|
|
||||||
self._sort_line_item_forms(debit_forms)
|
|
||||||
for debit_form in debit_forms:
|
|
||||||
self._add_line_item(debit_form, currency.code.data,
|
|
||||||
self._debit_no)
|
|
||||||
self._debit_no = self._debit_no + 1
|
|
||||||
|
|
||||||
# The credit forms
|
|
||||||
credit_forms: list[CreditLineItemForm] \
|
|
||||||
= [x.form for x in currency.credit]
|
|
||||||
self._sort_line_item_forms(credit_forms)
|
|
||||||
for credit_form in credit_forms:
|
|
||||||
self._add_line_item(credit_form, currency.code.data,
|
|
||||||
self._credit_no)
|
|
||||||
self._credit_no = self._credit_no + 1
|
|
||||||
|
|
||||||
self.collector = Collector
|
|
@ -1,507 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The line item sub-forms for the journal entry management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import datetime as dt
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from flask_babel import LazyString
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from wtforms import StringField, ValidationError, DecimalField, IntegerField
|
|
||||||
from wtforms.validators import Optional
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
|
|
||||||
IsCreditAccount
|
|
||||||
from accounting.locale import lazy_gettext
|
|
||||||
from accounting.models import Account, JournalEntry, JournalEntryLineItem
|
|
||||||
from accounting.template_filters import format_amount
|
|
||||||
from accounting.utils.random_id import new_id
|
|
||||||
from accounting.utils.strip_text import strip_text
|
|
||||||
from accounting.utils.user import get_current_user_pk
|
|
||||||
|
|
||||||
|
|
||||||
class OriginalLineItemExists:
|
|
||||||
"""The validator to check if the original line item exists."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
|
||||||
if field.data is None:
|
|
||||||
return
|
|
||||||
if db.session.get(JournalEntryLineItem, field.data) is None:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The original line item does not exist."))
|
|
||||||
|
|
||||||
|
|
||||||
class OriginalLineItemOppositeDebitCredit:
|
|
||||||
"""The validator to check if the original line item is on the opposite
|
|
||||||
debit or credit."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
|
||||||
if field.data is None:
|
|
||||||
return
|
|
||||||
original_line_item: JournalEntryLineItem | None \
|
|
||||||
= db.session.get(JournalEntryLineItem, field.data)
|
|
||||||
if original_line_item is None:
|
|
||||||
return
|
|
||||||
if isinstance(form, CreditLineItemForm) \
|
|
||||||
and original_line_item.is_debit:
|
|
||||||
return
|
|
||||||
if isinstance(form, DebitLineItemForm) \
|
|
||||||
and not original_line_item.is_debit:
|
|
||||||
return
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The original line item is on the same debit or credit."))
|
|
||||||
|
|
||||||
|
|
||||||
class OriginalLineItemNeedOffset:
|
|
||||||
"""The validator to check if the original line item needs offset."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
|
||||||
if field.data is None:
|
|
||||||
return
|
|
||||||
original_line_item: JournalEntryLineItem | None \
|
|
||||||
= db.session.get(JournalEntryLineItem, field.data)
|
|
||||||
if original_line_item is None:
|
|
||||||
return
|
|
||||||
if not original_line_item.account.is_need_offset:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The original line item does not need offset."))
|
|
||||||
|
|
||||||
|
|
||||||
class OriginalLineItemNotOffset:
|
|
||||||
"""The validator to check if the original line item is not itself an
|
|
||||||
offset item."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: IntegerField) -> None:
|
|
||||||
if field.data is None:
|
|
||||||
return
|
|
||||||
original_line_item: JournalEntryLineItem | None \
|
|
||||||
= db.session.get(JournalEntryLineItem, field.data)
|
|
||||||
if original_line_item is None:
|
|
||||||
return
|
|
||||||
if original_line_item.original_line_item_id is not None:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The original line item cannot be an offset item."))
|
|
||||||
|
|
||||||
|
|
||||||
class SameAccountAsOriginalLineItem:
|
|
||||||
"""The validator to check if the account is the same as the
|
|
||||||
original line item."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
assert isinstance(form, LineItemForm)
|
|
||||||
if field.data is None or form.original_line_item_id.data is None:
|
|
||||||
return
|
|
||||||
original_line_item: JournalEntryLineItem | None \
|
|
||||||
= db.session.get(JournalEntryLineItem,
|
|
||||||
form.original_line_item_id.data)
|
|
||||||
if original_line_item is None:
|
|
||||||
return
|
|
||||||
if field.data != original_line_item.account_code:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The account must be the same as the original line item."))
|
|
||||||
|
|
||||||
|
|
||||||
class KeepAccountWhenHavingOffset:
|
|
||||||
"""The validator to check if the account is the same when having offset."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
assert isinstance(form, LineItemForm)
|
|
||||||
if field.data is None or form.id.data is None:
|
|
||||||
return
|
|
||||||
line_item: JournalEntryLineItem | None \
|
|
||||||
= db.session.get(JournalEntryLineItem, form.id.data)
|
|
||||||
if line_item is None or len(line_item.offsets) == 0:
|
|
||||||
return
|
|
||||||
if field.data != line_item.account_code:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The account must not be changed when there is offset."))
|
|
||||||
|
|
||||||
|
|
||||||
class NotStartPayableFromDebit:
|
|
||||||
"""The validator to check that a payable line item does not start from
|
|
||||||
debit."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
assert isinstance(form, DebitLineItemForm)
|
|
||||||
if field.data is None \
|
|
||||||
or field.data[0] != "2" \
|
|
||||||
or form.original_line_item_id.data is not None:
|
|
||||||
return
|
|
||||||
account: Account | None = Account.find_by_code(field.data)
|
|
||||||
if account is not None and account.is_need_offset:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"A payable line item cannot start from debit."))
|
|
||||||
|
|
||||||
|
|
||||||
class NotStartReceivableFromCredit:
|
|
||||||
"""The validator to check that a receivable line item does not start
|
|
||||||
from credit."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
assert isinstance(form, CreditLineItemForm)
|
|
||||||
if field.data is None \
|
|
||||||
or field.data[0] != "1" \
|
|
||||||
or form.original_line_item_id.data is not None:
|
|
||||||
return
|
|
||||||
account: Account | None = Account.find_by_code(field.data)
|
|
||||||
if account is not None and account.is_need_offset:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"A receivable line item cannot start from credit."))
|
|
||||||
|
|
||||||
|
|
||||||
class PositiveAmount:
|
|
||||||
"""The validator to check if the amount is positive."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
|
||||||
if field.data is None:
|
|
||||||
return
|
|
||||||
if field.data <= 0:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"Please fill in a positive amount."))
|
|
||||||
|
|
||||||
|
|
||||||
class NotExceedingOriginalLineItemNetBalance:
|
|
||||||
"""The validator to check if the amount exceeds the net balance of the
|
|
||||||
original line item."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
|
||||||
assert isinstance(form, LineItemForm)
|
|
||||||
if field.data is None or form.original_line_item_id.data is None:
|
|
||||||
return
|
|
||||||
original_line_item: JournalEntryLineItem | None \
|
|
||||||
= db.session.get(JournalEntryLineItem,
|
|
||||||
form.original_line_item_id.data)
|
|
||||||
if original_line_item is None:
|
|
||||||
return
|
|
||||||
is_debit: bool = isinstance(form, DebitLineItemForm)
|
|
||||||
existing_line_item_id: set[int] = set()
|
|
||||||
if form.journal_entry_form.obj is not None:
|
|
||||||
existing_line_item_id \
|
|
||||||
= {x.id for x in form.journal_entry_form.obj.line_items}
|
|
||||||
offset_total_func: sa.Function = sa.func.sum(sa.case(
|
|
||||||
(JournalEntryLineItem.is_debit == is_debit,
|
|
||||||
JournalEntryLineItem.amount),
|
|
||||||
else_=-JournalEntryLineItem.amount))
|
|
||||||
offset_total_but_form: Decimal | None = db.session.scalar(
|
|
||||||
sa.select(offset_total_func)
|
|
||||||
.filter(JournalEntryLineItem.original_line_item_id
|
|
||||||
== original_line_item.id,
|
|
||||||
JournalEntryLineItem.id.not_in(existing_line_item_id)))
|
|
||||||
if offset_total_but_form is None:
|
|
||||||
offset_total_but_form = Decimal("0")
|
|
||||||
offset_total_on_form: Decimal = sum(
|
|
||||||
[x.amount.data for x in form.journal_entry_form.line_items
|
|
||||||
if x.original_line_item_id.data == original_line_item.id
|
|
||||||
and x.amount != field and x.amount.data is not None])
|
|
||||||
net_balance: Decimal = original_line_item.amount \
|
|
||||||
- offset_total_but_form - offset_total_on_form
|
|
||||||
if field.data > net_balance:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The amount must not exceed the net balance %(balance)s of the"
|
|
||||||
" original line item.", balance=format_amount(net_balance)))
|
|
||||||
|
|
||||||
|
|
||||||
class NotLessThanOffsetTotal:
|
|
||||||
"""The validator to check if the amount is less than the offset total."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
|
|
||||||
assert isinstance(form, LineItemForm)
|
|
||||||
if field.data is None or form.id.data is None:
|
|
||||||
return
|
|
||||||
is_debit: bool = isinstance(form, DebitLineItemForm)
|
|
||||||
select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
|
|
||||||
(JournalEntryLineItem.is_debit != is_debit,
|
|
||||||
JournalEntryLineItem.amount),
|
|
||||||
else_=-JournalEntryLineItem.amount)))\
|
|
||||||
.filter(JournalEntryLineItem.original_line_item_id == form.id.data)
|
|
||||||
offset_total: Decimal | None = db.session.scalar(select_offset_total)
|
|
||||||
if offset_total is not None and field.data < offset_total:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The amount must not be less than the offset total %(total)s.",
|
|
||||||
total=format_amount(offset_total)))
|
|
||||||
|
|
||||||
|
|
||||||
class LineItemForm(FlaskForm):
|
|
||||||
"""The base form to create or edit a line item."""
|
|
||||||
id = IntegerField()
|
|
||||||
"""The existing line item ID."""
|
|
||||||
no = IntegerField()
|
|
||||||
"""The order in the currency."""
|
|
||||||
original_line_item_id = IntegerField()
|
|
||||||
"""The Id of the original line item."""
|
|
||||||
account_code = StringField()
|
|
||||||
"""The account code."""
|
|
||||||
description = StringField()
|
|
||||||
"""The description."""
|
|
||||||
amount = DecimalField()
|
|
||||||
"""The amount."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
"""Constructs a base line item form.
|
|
||||||
|
|
||||||
:param args: The arguments.
|
|
||||||
:param kwargs: The keyword arguments.
|
|
||||||
"""
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
from .journal_entry import JournalEntryForm
|
|
||||||
self.journal_entry_form: JournalEntryForm | None = None
|
|
||||||
"""The source journal entry form."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def account_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
|
|
||||||
def account_text(self) -> str:
|
|
||||||
"""Returns the text representation of the account.
|
|
||||||
|
|
||||||
:return: The text representation of the account.
|
|
||||||
"""
|
|
||||||
if self.account_code.data is None:
|
|
||||||
return ""
|
|
||||||
account: Account | None = Account.find_by_code(self.account_code.data)
|
|
||||||
if account is None:
|
|
||||||
return ""
|
|
||||||
return str(account)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def __original_line_item(self) -> JournalEntryLineItem | None:
|
|
||||||
"""Returns the original line item.
|
|
||||||
|
|
||||||
:return: The original line item.
|
|
||||||
"""
|
|
||||||
if not hasattr(self, "____original_line_item"):
|
|
||||||
def get_line_item() -> JournalEntryLineItem | None:
|
|
||||||
if self.original_line_item_id.data is None:
|
|
||||||
return None
|
|
||||||
return db.session.get(JournalEntryLineItem,
|
|
||||||
self.original_line_item_id.data)
|
|
||||||
setattr(self, "____original_line_item", get_line_item())
|
|
||||||
return getattr(self, "____original_line_item")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def original_line_item_date(self) -> dt.date | None:
|
|
||||||
"""Returns the text representation of the original line item.
|
|
||||||
|
|
||||||
:return: The text representation of the original line item.
|
|
||||||
"""
|
|
||||||
return None if self.__original_line_item is None \
|
|
||||||
else self.__original_line_item.journal_entry.date
|
|
||||||
|
|
||||||
@property
|
|
||||||
def original_line_item_text(self) -> str | None:
|
|
||||||
"""Returns the text representation of the original line item.
|
|
||||||
|
|
||||||
:return: The text representation of the original line item.
|
|
||||||
"""
|
|
||||||
return None if self.__original_line_item is None \
|
|
||||||
else str(self.__original_line_item)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_need_offset(self) -> bool:
|
|
||||||
"""Returns whether the line item needs offset.
|
|
||||||
|
|
||||||
:return: True if the line item needs offset, or False otherwise.
|
|
||||||
"""
|
|
||||||
if self.account_code.data is None:
|
|
||||||
return False
|
|
||||||
if self.account_code.data[0] == "1":
|
|
||||||
if isinstance(self, CreditLineItemForm):
|
|
||||||
return False
|
|
||||||
elif self.account_code.data[0] == "2":
|
|
||||||
if isinstance(self, DebitLineItemForm):
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
account: Account | None = Account.find_by_code(self.account_code.data)
|
|
||||||
return account is not None and account.is_need_offset
|
|
||||||
|
|
||||||
@property
|
|
||||||
def offsets(self) -> list[JournalEntryLineItem]:
|
|
||||||
"""Returns the offsets.
|
|
||||||
|
|
||||||
:return: The offsets.
|
|
||||||
"""
|
|
||||||
if not hasattr(self, "__offsets"):
|
|
||||||
def get_offsets() -> list[JournalEntryLineItem]:
|
|
||||||
if not self.is_need_offset or self.id.data is None:
|
|
||||||
return []
|
|
||||||
return JournalEntryLineItem.query.join(JournalEntry)\
|
|
||||||
.filter(JournalEntryLineItem.original_line_item_id
|
|
||||||
== self.id.data)\
|
|
||||||
.order_by(JournalEntry.date, JournalEntry.no,
|
|
||||||
JournalEntryLineItem.no)\
|
|
||||||
.options(selectinload(JournalEntryLineItem.journal_entry),
|
|
||||||
selectinload(JournalEntryLineItem.account)).all()
|
|
||||||
setattr(self, "__offsets", get_offsets())
|
|
||||||
return getattr(self, "__offsets")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def offset_total(self) -> Decimal | None:
|
|
||||||
"""Returns the total amount of the offsets.
|
|
||||||
|
|
||||||
:return: The total amount of the offsets.
|
|
||||||
"""
|
|
||||||
if not hasattr(self, "__offset_total"):
|
|
||||||
def get_offset_total():
|
|
||||||
if not self.is_need_offset or self.id.data is None:
|
|
||||||
return None
|
|
||||||
is_debit: bool = isinstance(self, DebitLineItemForm)
|
|
||||||
return sum([x.amount if x.is_debit != is_debit else -x.amount
|
|
||||||
for x in self.offsets])
|
|
||||||
setattr(self, "__offset_total", get_offset_total())
|
|
||||||
return getattr(self, "__offset_total")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def net_balance(self) -> Decimal | None:
|
|
||||||
"""Returns the net balance.
|
|
||||||
|
|
||||||
:return: The net balance.
|
|
||||||
"""
|
|
||||||
if not self.is_need_offset or self.id.data is None \
|
|
||||||
or self.amount.data is None:
|
|
||||||
return None
|
|
||||||
return self.amount.data - self.offset_total
|
|
||||||
|
|
||||||
@property
|
|
||||||
def all_errors(self) -> list[str | LazyString]:
|
|
||||||
"""Returns all the errors of the form.
|
|
||||||
|
|
||||||
:return: All the errors of the form.
|
|
||||||
"""
|
|
||||||
all_errors: list[str | LazyString] = []
|
|
||||||
for key in self.errors:
|
|
||||||
if key != "csrf_token":
|
|
||||||
all_errors.extend(self.errors[key])
|
|
||||||
return all_errors
|
|
||||||
|
|
||||||
|
|
||||||
class DebitLineItemForm(LineItemForm):
|
|
||||||
"""The form to create or edit a debit line item."""
|
|
||||||
id = IntegerField()
|
|
||||||
"""The existing line item ID."""
|
|
||||||
no = IntegerField()
|
|
||||||
"""The order in the currency."""
|
|
||||||
original_line_item_id = IntegerField(
|
|
||||||
validators=[Optional(),
|
|
||||||
OriginalLineItemExists(),
|
|
||||||
OriginalLineItemOppositeDebitCredit(),
|
|
||||||
OriginalLineItemNeedOffset(),
|
|
||||||
OriginalLineItemNotOffset()])
|
|
||||||
"""The ID of the original line item."""
|
|
||||||
account_code = StringField(
|
|
||||||
filters=[strip_text],
|
|
||||||
validators=[
|
|
||||||
ACCOUNT_REQUIRED,
|
|
||||||
AccountExists(),
|
|
||||||
IsDebitAccount(lazy_gettext(
|
|
||||||
"This account is not for debit line items.")),
|
|
||||||
SameAccountAsOriginalLineItem(),
|
|
||||||
KeepAccountWhenHavingOffset(),
|
|
||||||
NotStartPayableFromDebit()])
|
|
||||||
"""The account code."""
|
|
||||||
description = StringField(filters=[strip_text])
|
|
||||||
"""The description."""
|
|
||||||
amount = DecimalField(
|
|
||||||
validators=[PositiveAmount(),
|
|
||||||
NotExceedingOriginalLineItemNetBalance(),
|
|
||||||
NotLessThanOffsetTotal()])
|
|
||||||
"""The amount."""
|
|
||||||
|
|
||||||
def populate_obj(self, obj: JournalEntryLineItem) -> None:
|
|
||||||
"""Populates the form data into a line item object.
|
|
||||||
|
|
||||||
:param obj: The line item object.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
is_new: bool = obj.id is None
|
|
||||||
if is_new:
|
|
||||||
obj.id = new_id(JournalEntryLineItem)
|
|
||||||
obj.original_line_item_id = self.original_line_item_id.data
|
|
||||||
obj.account_id = Account.find_by_code(self.account_code.data).id
|
|
||||||
obj.description = self.description.data
|
|
||||||
obj.is_debit = True
|
|
||||||
obj.amount = self.amount.data
|
|
||||||
if is_new:
|
|
||||||
current_user_pk: int = get_current_user_pk()
|
|
||||||
obj.created_by_id = current_user_pk
|
|
||||||
obj.updated_by_id = current_user_pk
|
|
||||||
|
|
||||||
|
|
||||||
class CreditLineItemForm(LineItemForm):
|
|
||||||
"""The form to create or edit a credit line item."""
|
|
||||||
id = IntegerField()
|
|
||||||
"""The existing line item ID."""
|
|
||||||
no = IntegerField()
|
|
||||||
"""The order in the currency."""
|
|
||||||
original_line_item_id = IntegerField(
|
|
||||||
validators=[Optional(),
|
|
||||||
OriginalLineItemExists(),
|
|
||||||
OriginalLineItemOppositeDebitCredit(),
|
|
||||||
OriginalLineItemNeedOffset(),
|
|
||||||
OriginalLineItemNotOffset()])
|
|
||||||
"""The ID of the original line item."""
|
|
||||||
account_code = StringField(
|
|
||||||
filters=[strip_text],
|
|
||||||
validators=[
|
|
||||||
ACCOUNT_REQUIRED,
|
|
||||||
AccountExists(),
|
|
||||||
IsCreditAccount(lazy_gettext(
|
|
||||||
"This account is not for credit line items.")),
|
|
||||||
SameAccountAsOriginalLineItem(),
|
|
||||||
KeepAccountWhenHavingOffset(),
|
|
||||||
NotStartReceivableFromCredit()])
|
|
||||||
"""The account code."""
|
|
||||||
description = StringField(filters=[strip_text])
|
|
||||||
"""The description."""
|
|
||||||
amount = DecimalField(
|
|
||||||
validators=[PositiveAmount(),
|
|
||||||
NotExceedingOriginalLineItemNetBalance(),
|
|
||||||
NotLessThanOffsetTotal()])
|
|
||||||
"""The amount."""
|
|
||||||
|
|
||||||
def populate_obj(self, obj: JournalEntryLineItem) -> None:
|
|
||||||
"""Populates the form data into a line item object.
|
|
||||||
|
|
||||||
:param obj: The line item object.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
is_new: bool = obj.id is None
|
|
||||||
if is_new:
|
|
||||||
obj.id = new_id(JournalEntryLineItem)
|
|
||||||
obj.original_line_item_id = self.original_line_item_id.data
|
|
||||||
obj.account_id = Account.find_by_code(self.account_code.data).id
|
|
||||||
obj.description = self.description.data
|
|
||||||
obj.is_debit = False
|
|
||||||
obj.amount = self.amount.data
|
|
||||||
if is_new:
|
|
||||||
current_user_pk: int = get_current_user_pk()
|
|
||||||
obj.created_by_id = current_user_pk
|
|
||||||
obj.updated_by_id = current_user_pk
|
|
@ -1,95 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The reorder forms for the journal entry management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import datetime as dt
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from flask import request
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.models import JournalEntry
|
|
||||||
|
|
||||||
|
|
||||||
def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
|
|
||||||
"""Sorts the journal entries under a date after changing the date or
|
|
||||||
deleting a journal entry.
|
|
||||||
|
|
||||||
:param date: The date of the journal entry.
|
|
||||||
:param exclude: The journal entry ID to exclude.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
conditions: list[sa.BinaryExpression] = [JournalEntry.date == date]
|
|
||||||
if exclude is not None:
|
|
||||||
conditions.append(JournalEntry.id != exclude)
|
|
||||||
journal_entries: list[JournalEntry] = JournalEntry.query\
|
|
||||||
.filter(*conditions)\
|
|
||||||
.order_by(JournalEntry.no).all()
|
|
||||||
for i in range(len(journal_entries)):
|
|
||||||
if journal_entries[i].no != i + 1:
|
|
||||||
journal_entries[i].no = i + 1
|
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryReorderForm:
|
|
||||||
"""The form to reorder the journal entries."""
|
|
||||||
|
|
||||||
def __init__(self, date: dt.date):
|
|
||||||
"""Constructs the form to reorder the journal entries in a day.
|
|
||||||
|
|
||||||
:param date: The date.
|
|
||||||
"""
|
|
||||||
self.date: dt.date = date
|
|
||||||
"""The date."""
|
|
||||||
self.is_modified: bool = False
|
|
||||||
"""Whether the order is modified."""
|
|
||||||
|
|
||||||
def save_order(self) -> None:
|
|
||||||
"""Saves the order of the account.
|
|
||||||
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
journal_entries: list[JournalEntry] = JournalEntry.query\
|
|
||||||
.filter(JournalEntry.date == self.date).all()
|
|
||||||
|
|
||||||
# Collects the specified order.
|
|
||||||
orders: dict[JournalEntry, int] = {}
|
|
||||||
for journal_entry in journal_entries:
|
|
||||||
if f"{journal_entry.id}-no" in request.form:
|
|
||||||
try:
|
|
||||||
orders[journal_entry] \
|
|
||||||
= int(request.form[f"{journal_entry.id}-no"])
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Missing and invalid orders are appended to the end.
|
|
||||||
missing: list[JournalEntry] \
|
|
||||||
= [x for x in journal_entries if x not in orders]
|
|
||||||
if len(missing) > 0:
|
|
||||||
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
|
|
||||||
for journal_entry in missing:
|
|
||||||
orders[journal_entry] = next_no
|
|
||||||
|
|
||||||
# Sort by the specified order first, and their original order.
|
|
||||||
journal_entries.sort(key=lambda x: (orders[x], x.no))
|
|
||||||
|
|
||||||
# Update the orders.
|
|
||||||
with db.session.no_autoflush:
|
|
||||||
for i in range(len(journal_entries)):
|
|
||||||
if journal_entries[i].no != i + 1:
|
|
||||||
journal_entries[i].no = i + 1
|
|
||||||
self.is_modified = True
|
|
@ -1,357 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The description editor.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.models import Account, JournalEntryLineItem
|
|
||||||
from accounting.utils.options import options, Recurring
|
|
||||||
|
|
||||||
|
|
||||||
class DescriptionAccount:
|
|
||||||
"""An account for a description tag."""
|
|
||||||
|
|
||||||
def __init__(self, account: Account, freq: int):
|
|
||||||
"""Constructs an account for a description tag.
|
|
||||||
|
|
||||||
:param account: The account.
|
|
||||||
:param freq: The frequency of the tag with the account.
|
|
||||||
"""
|
|
||||||
self.__account: Account = account
|
|
||||||
"""The account."""
|
|
||||||
self.id: int = account.id
|
|
||||||
"""The account ID."""
|
|
||||||
self.code: str = account.code
|
|
||||||
"""The account code."""
|
|
||||||
self.is_need_offset: bool = account.is_need_offset
|
|
||||||
"""Whether the journal entry line items of this account need offset."""
|
|
||||||
self.freq: int = freq
|
|
||||||
"""The frequency of the tag with the account."""
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
"""Returns the string representation of the account.
|
|
||||||
|
|
||||||
:return: The string representation of the 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:
|
|
||||||
"""Adds the frequency of an account.
|
|
||||||
|
|
||||||
:param freq: The frequency of the tag name with the account.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
self.freq = self.freq + freq
|
|
||||||
|
|
||||||
|
|
||||||
class DescriptionTag:
|
|
||||||
"""A description tag."""
|
|
||||||
|
|
||||||
def __init__(self, name: str):
|
|
||||||
"""Constructs a description tag.
|
|
||||||
|
|
||||||
:param name: The tag name.
|
|
||||||
"""
|
|
||||||
self.name: str = name
|
|
||||||
"""The tag name."""
|
|
||||||
self.__account_dict: dict[int, DescriptionAccount] = {}
|
|
||||||
"""The accounts that come with the tag, in the order of their
|
|
||||||
frequency."""
|
|
||||||
self.freq: int = 0
|
|
||||||
"""The frequency of the tag."""
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
"""Returns the string representation of the tag.
|
|
||||||
|
|
||||||
:return: The string representation of the tag.
|
|
||||||
"""
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def add_account(self, account: Account, freq: int):
|
|
||||||
"""Adds an account.
|
|
||||||
|
|
||||||
:param account: The associated account.
|
|
||||||
:param freq: The frequency of the tag name with the account.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
self.__account_dict[account.id] = DescriptionAccount(account, freq)
|
|
||||||
self.freq = self.freq + freq
|
|
||||||
|
|
||||||
@property
|
|
||||||
def accounts(self) -> list[DescriptionAccount]:
|
|
||||||
"""Returns the accounts by the order of their frequencies.
|
|
||||||
|
|
||||||
:return: The accounts by the order of their frequencies.
|
|
||||||
"""
|
|
||||||
return sorted(self.__account_dict.values(), key=lambda x: -x.freq)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def account_codes(self) -> list[str]:
|
|
||||||
"""Returns the account codes by the order of their frequencies.
|
|
||||||
|
|
||||||
:return: The account codes by the order of their frequencies.
|
|
||||||
"""
|
|
||||||
return [x.code for x in self.accounts]
|
|
||||||
|
|
||||||
|
|
||||||
class DescriptionType:
|
|
||||||
"""A description type"""
|
|
||||||
|
|
||||||
def __init__(self, type_id: Literal["general", "travel", "bus"]):
|
|
||||||
"""Constructs a description type.
|
|
||||||
|
|
||||||
:param type_id: The type ID, either "general", "travel", or "bus".
|
|
||||||
"""
|
|
||||||
self.id: Literal["general", "travel", "bus"] = type_id
|
|
||||||
"""The type ID."""
|
|
||||||
self.__tag_dict: dict[str, DescriptionTag] = {}
|
|
||||||
"""A dictionary from the tag name to their corresponding tag."""
|
|
||||||
|
|
||||||
def add_tag(self, name: str, account: Account, freq: int) -> None:
|
|
||||||
"""Adds a tag.
|
|
||||||
|
|
||||||
:param name: The tag name.
|
|
||||||
:param account: The associated account.
|
|
||||||
:param freq: The frequency of the tag name with the account.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
if name not in self.__tag_dict:
|
|
||||||
self.__tag_dict[name] = DescriptionTag(name)
|
|
||||||
self.__tag_dict[name].add_account(account, freq)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tags(self) -> list[DescriptionTag]:
|
|
||||||
"""Returns the tags by the order of their frequencies.
|
|
||||||
|
|
||||||
:return: The tags by the order of their frequencies.
|
|
||||||
"""
|
|
||||||
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
|
|
||||||
|
|
||||||
|
|
||||||
class DescriptionRecurring:
|
|
||||||
"""A recurring transaction."""
|
|
||||||
|
|
||||||
def __init__(self, name: str, account: Account, description_template: str):
|
|
||||||
"""Constructs a recurring transaction.
|
|
||||||
|
|
||||||
:param name: The name.
|
|
||||||
:param description_template: The description template.
|
|
||||||
:param account: The account.
|
|
||||||
"""
|
|
||||||
self.name: str = name
|
|
||||||
"""The name."""
|
|
||||||
self.account: DescriptionAccount = DescriptionAccount(account, 0)
|
|
||||||
"""The account."""
|
|
||||||
self.description_template: str = description_template
|
|
||||||
"""The description template."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def account_codes(self) -> list[str]:
|
|
||||||
"""Returns the account codes by the order of their frequencies.
|
|
||||||
|
|
||||||
:return: The account codes by the order of their frequencies.
|
|
||||||
"""
|
|
||||||
return [self.account.code]
|
|
||||||
|
|
||||||
|
|
||||||
class DescriptionDebitCredit:
|
|
||||||
"""The description on debit or credit."""
|
|
||||||
|
|
||||||
def __init__(self, debit_credit: Literal["debit", "credit"]):
|
|
||||||
"""Constructs the description on debit or credit.
|
|
||||||
|
|
||||||
:param debit_credit: Either "debit" or "credit".
|
|
||||||
"""
|
|
||||||
self.debit_credit: Literal["debit", "credit"] = debit_credit
|
|
||||||
"""Either debit or credit."""
|
|
||||||
self.general: DescriptionType = DescriptionType("general")
|
|
||||||
"""The general tags."""
|
|
||||||
self.travel: DescriptionType = DescriptionType("travel")
|
|
||||||
"""The travel tags."""
|
|
||||||
self.bus: DescriptionType = DescriptionType("bus")
|
|
||||||
"""The bus tags."""
|
|
||||||
self.__type_dict: dict[Literal["general", "travel", "bus"],
|
|
||||||
DescriptionType] \
|
|
||||||
= {x.id: x for x in {self.general, self.travel, self.bus}}
|
|
||||||
"""A dictionary from the type ID to the corresponding tags."""
|
|
||||||
self.recurring: list[DescriptionRecurring] = []
|
|
||||||
"""The recurring transactions."""
|
|
||||||
|
|
||||||
def add_tag(self, tag_type: Literal["general", "travel", "bus"],
|
|
||||||
name: str, account: Account, freq: int) -> None:
|
|
||||||
"""Adds a tag.
|
|
||||||
|
|
||||||
:param tag_type: The tag type, either "general", "travel", or "bus".
|
|
||||||
:param name: The name.
|
|
||||||
:param account: The associated account.
|
|
||||||
:param freq: The frequency of the tag name with the account.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
self.__type_dict[tag_type].add_tag(name, account, freq)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def accounts(self) -> list[DescriptionAccount]:
|
|
||||||
"""Returns the suggested accounts of all tags in the description editor
|
|
||||||
in debit or credit, in their frequency order.
|
|
||||||
|
|
||||||
:return: The suggested accounts of all tags, in their frequency order.
|
|
||||||
"""
|
|
||||||
accounts: dict[int, DescriptionAccount] = {}
|
|
||||||
freq: dict[int, int] = {}
|
|
||||||
for tag_type in self.__type_dict.values():
|
|
||||||
for tag in tag_type.tags:
|
|
||||||
for account in tag.accounts:
|
|
||||||
accounts[account.id] = account
|
|
||||||
if account.id not in freq:
|
|
||||||
freq[account.id] = 0
|
|
||||||
freq[account.id] \
|
|
||||||
= freq[account.id] + account.freq
|
|
||||||
for recurring in self.recurring:
|
|
||||||
accounts[recurring.account.id] = recurring.account
|
|
||||||
if recurring.account.id not in freq:
|
|
||||||
freq[recurring.account.id] = 0
|
|
||||||
return [accounts[y] for y in sorted(freq.keys(),
|
|
||||||
key=lambda x: -freq[x])]
|
|
||||||
|
|
||||||
|
|
||||||
class DescriptionEditor:
|
|
||||||
"""The description editor."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Constructs the description editor."""
|
|
||||||
self.debit: DescriptionDebitCredit = DescriptionDebitCredit("debit")
|
|
||||||
"""The debit tags."""
|
|
||||||
self.credit: DescriptionDebitCredit = DescriptionDebitCredit("credit")
|
|
||||||
"""The credit tags."""
|
|
||||||
self.__init_tags()
|
|
||||||
self.__init_recurring()
|
|
||||||
|
|
||||||
def __init_tags(self):
|
|
||||||
"""Initializes the tags.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
debit_credit: sa.Label = sa.case(
|
|
||||||
(JournalEntryLineItem.is_debit, "debit"),
|
|
||||||
else_="credit").label("debit_credit")
|
|
||||||
tag_type: sa.Label = sa.case(
|
|
||||||
(JournalEntryLineItem.description.like("_%—_%—_%→_%"), "bus"),
|
|
||||||
(sa.or_(JournalEntryLineItem.description.like("_%—_%→_%"),
|
|
||||||
JournalEntryLineItem.description.like("_%—_%↔_%")),
|
|
||||||
"travel"),
|
|
||||||
else_="general").label("tag_type")
|
|
||||||
tag: sa.Label = get_prefix(JournalEntryLineItem.description, "—")\
|
|
||||||
.label("tag")
|
|
||||||
select: sa.Select = sa.Select(debit_credit, tag_type, tag,
|
|
||||||
JournalEntryLineItem.account_id,
|
|
||||||
sa.func.count().label("freq"))\
|
|
||||||
.filter(JournalEntryLineItem.description.is_not(None),
|
|
||||||
JournalEntryLineItem.description.like("_%—_%"),
|
|
||||||
JournalEntryLineItem.original_line_item_id.is_(None))\
|
|
||||||
.group_by(debit_credit, tag_type, tag,
|
|
||||||
JournalEntryLineItem.account_id)
|
|
||||||
result: list[sa.Row] = db.session.execute(select).all()
|
|
||||||
accounts: dict[int, Account] \
|
|
||||||
= {x.id: x for x in Account.query
|
|
||||||
.filter(Account.id.in_({x.account_id for x in result})).all()}
|
|
||||||
debit_credit_dict: dict[Literal["debit", "credit"],
|
|
||||||
DescriptionDebitCredit] \
|
|
||||||
= {x.debit_credit: x for x in {self.debit, self.credit}}
|
|
||||||
for row in result:
|
|
||||||
debit_credit_dict[row.debit_credit].add_tag(
|
|
||||||
row.tag_type, row.tag, accounts[row.account_id], row.freq)
|
|
||||||
|
|
||||||
def __init_recurring(self) -> None:
|
|
||||||
"""Initializes the recurring transactions.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
recurring: Recurring = options.recurring
|
|
||||||
accounts: dict[str, Account] \
|
|
||||||
= self.__get_accounts(recurring.codes)
|
|
||||||
self.debit.recurring \
|
|
||||||
= [DescriptionRecurring(x.name, accounts[x.account_code],
|
|
||||||
x.description_template)
|
|
||||||
for x in recurring.expenses]
|
|
||||||
self.credit.recurring \
|
|
||||||
= [DescriptionRecurring(x.name, accounts[x.account_code],
|
|
||||||
x.description_template)
|
|
||||||
for x in recurring.incomes]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __get_accounts(codes: set[str]) -> dict[str, Account]:
|
|
||||||
"""Finds and returns the accounts by codes.
|
|
||||||
|
|
||||||
:param codes: The account codes.
|
|
||||||
:return: The account.
|
|
||||||
"""
|
|
||||||
if len(codes) == 0:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def get_condition(code0: str) -> sa.BinaryExpression:
|
|
||||||
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
|
|
||||||
assert m is not None,\
|
|
||||||
f"Malformed account code \"{code0}\" for regular transactions."
|
|
||||||
return sa.and_(Account.base_code == m.group(1),
|
|
||||||
Account.no == int(m.group(2)))
|
|
||||||
|
|
||||||
conditions: list[sa.BinaryExpression] \
|
|
||||||
= [get_condition(x) for x in codes]
|
|
||||||
accounts: dict[str, Account] \
|
|
||||||
= {x.code: x for x in
|
|
||||||
Account.query.filter(sa.or_(*conditions)).all()}
|
|
||||||
for code in codes:
|
|
||||||
assert code in accounts,\
|
|
||||||
f"Unknown account \"{code}\" for regular transactions."
|
|
||||||
return accounts
|
|
||||||
|
|
||||||
|
|
||||||
def get_prefix(string: str | sa.Column, separator: str | sa.Column) \
|
|
||||||
-> sa.Function:
|
|
||||||
"""Returns the SQL function to find the prefix of a string.
|
|
||||||
|
|
||||||
:param string: The string.
|
|
||||||
:param separator: The separator.
|
|
||||||
:return: The position of the substring, starting from 1.
|
|
||||||
"""
|
|
||||||
return sa.func.substr(string, 0, get_position(string, separator))
|
|
||||||
|
|
||||||
|
|
||||||
def get_position(string: str | sa.Column, substring: str | sa.Column) \
|
|
||||||
-> sa.Function:
|
|
||||||
"""Returns the SQL function to find the position of a substring.
|
|
||||||
|
|
||||||
:param string: The string.
|
|
||||||
:param substring: The substring.
|
|
||||||
:return: The position of the substring, starting from 1.
|
|
||||||
"""
|
|
||||||
if db.engine.name == "postgresql":
|
|
||||||
return sa.func.strpos(string, substring)
|
|
||||||
return sa.func.instr(string, substring)
|
|
@ -1,336 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The operators for different journal entry types.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Type
|
|
||||||
|
|
||||||
from flask import render_template, request, abort
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
|
|
||||||
from accounting.journal_entry.forms import JournalEntryForm, \
|
|
||||||
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
|
|
||||||
TransferJournalEntryForm
|
|
||||||
from accounting.journal_entry.forms.line_item import LineItemForm
|
|
||||||
from accounting.models import JournalEntry
|
|
||||||
from accounting.template_globals import default_currency_code
|
|
||||||
from accounting.utils.journal_entry_types import JournalEntryType
|
|
||||||
|
|
||||||
|
|
||||||
class JournalEntryOperator(ABC):
|
|
||||||
"""The base journal entry operator."""
|
|
||||||
CHECK_ORDER: int = -1
|
|
||||||
"""The order when checking the journal entry operator."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def form(self) -> Type[JournalEntryForm]:
|
|
||||||
"""Returns the form class.
|
|
||||||
|
|
||||||
:return: The form class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def render_create_template(self, form: FlaskForm) -> str:
|
|
||||||
"""Renders the template for the form to create a journal entry.
|
|
||||||
|
|
||||||
:param form: The journal entry form.
|
|
||||||
:return: the form to create a journal entry.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def render_detail_template(self, journal_entry: JournalEntry) -> str:
|
|
||||||
"""Renders the template for the detail page.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: the detail page.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def render_edit_template(self, journal_entry: JournalEntry,
|
|
||||||
form: FlaskForm) -> str:
|
|
||||||
"""Renders the template for the form to edit a journal entry.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:param form: The form.
|
|
||||||
:return: the form to edit a journal entry.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def is_my_type(self, journal_entry: JournalEntry) -> bool:
|
|
||||||
"""Checks and returns whether the journal entry belongs to the type.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: True if the journal entry belongs to the type, or False
|
|
||||||
otherwise.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _line_item_template(self) -> str:
|
|
||||||
"""Renders and returns the template for the line item sub-form.
|
|
||||||
|
|
||||||
:return: The template for the line item sub-form.
|
|
||||||
"""
|
|
||||||
return render_template(
|
|
||||||
"accounting/journal-entry/include/form-line-item.html",
|
|
||||||
currency_index="CURRENCY_INDEX",
|
|
||||||
debit_credit="DEBIT_CREDIT",
|
|
||||||
line_item_index="LINE_ITEM_INDEX",
|
|
||||||
form=LineItemForm())
|
|
||||||
|
|
||||||
|
|
||||||
class CashReceiptJournalEntry(JournalEntryOperator):
|
|
||||||
"""A cash receipt journal entry."""
|
|
||||||
CHECK_ORDER: int = 2
|
|
||||||
"""The order when checking the journal entry operator."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def form(self) -> Type[JournalEntryForm]:
|
|
||||||
"""Returns the form class.
|
|
||||||
|
|
||||||
:return: The form class.
|
|
||||||
"""
|
|
||||||
return CashReceiptJournalEntryForm
|
|
||||||
|
|
||||||
def render_create_template(self, form: CashReceiptJournalEntryForm) -> str:
|
|
||||||
"""Renders the template for the form to create a journal entry.
|
|
||||||
|
|
||||||
:param form: The journal entry form.
|
|
||||||
:return: the form to create a journal entry.
|
|
||||||
"""
|
|
||||||
return render_template(
|
|
||||||
"accounting/journal-entry/receipt/create.html",
|
|
||||||
form=form,
|
|
||||||
journal_entry_type=JournalEntryType.CASH_RECEIPT,
|
|
||||||
currency_template=self.__currency_template,
|
|
||||||
line_item_template=self._line_item_template)
|
|
||||||
|
|
||||||
def render_detail_template(self, journal_entry: JournalEntry) -> str:
|
|
||||||
"""Renders the template for the detail page.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: the detail page.
|
|
||||||
"""
|
|
||||||
return render_template("accounting/journal-entry/receipt/detail.html",
|
|
||||||
obj=journal_entry)
|
|
||||||
|
|
||||||
def render_edit_template(self, journal_entry: JournalEntry,
|
|
||||||
form: CashReceiptJournalEntryForm) -> str:
|
|
||||||
"""Renders the template for the form to edit a journal entry.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:param form: The form.
|
|
||||||
:return: the form to edit a journal entry.
|
|
||||||
"""
|
|
||||||
return render_template("accounting/journal-entry/receipt/edit.html",
|
|
||||||
journal_entry=journal_entry, form=form,
|
|
||||||
currency_template=self.__currency_template,
|
|
||||||
line_item_template=self._line_item_template)
|
|
||||||
|
|
||||||
def is_my_type(self, journal_entry: JournalEntry) -> bool:
|
|
||||||
"""Checks and returns whether the journal entry belongs to the type.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: True if the journal entry belongs to the type, or False
|
|
||||||
otherwise.
|
|
||||||
"""
|
|
||||||
return journal_entry.is_cash_receipt
|
|
||||||
|
|
||||||
@property
|
|
||||||
def __currency_template(self) -> str:
|
|
||||||
"""Renders and returns the template for the currency sub-form.
|
|
||||||
|
|
||||||
:return: The template for the currency sub-form.
|
|
||||||
"""
|
|
||||||
return render_template(
|
|
||||||
"accounting/journal-entry/receipt/include/form-currency.html",
|
|
||||||
currency_index="CURRENCY_INDEX",
|
|
||||||
currency_code_data=default_currency_code(),
|
|
||||||
credit_total="-")
|
|
||||||
|
|
||||||
|
|
||||||
class CashDisbursementJournalEntry(JournalEntryOperator):
|
|
||||||
"""A cash disbursement journal entry."""
|
|
||||||
CHECK_ORDER: int = 1
|
|
||||||
"""The order when checking the journal entry operator."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def form(self) -> Type[JournalEntryForm]:
|
|
||||||
"""Returns the form class.
|
|
||||||
|
|
||||||
:return: The form class.
|
|
||||||
"""
|
|
||||||
return CashDisbursementJournalEntryForm
|
|
||||||
|
|
||||||
def render_create_template(self, form: CashDisbursementJournalEntryForm) \
|
|
||||||
-> str:
|
|
||||||
"""Renders the template for the form to create a journal entry.
|
|
||||||
|
|
||||||
:param form: The journal entry form.
|
|
||||||
:return: the form to create a journal entry.
|
|
||||||
"""
|
|
||||||
return render_template(
|
|
||||||
"accounting/journal-entry/disbursement/create.html",
|
|
||||||
form=form,
|
|
||||||
journal_entry_type=JournalEntryType.CASH_DISBURSEMENT,
|
|
||||||
currency_template=self.__currency_template,
|
|
||||||
line_item_template=self._line_item_template)
|
|
||||||
|
|
||||||
def render_detail_template(self, journal_entry: JournalEntry) -> str:
|
|
||||||
"""Renders the template for the detail page.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: the detail page.
|
|
||||||
"""
|
|
||||||
return render_template(
|
|
||||||
"accounting/journal-entry/disbursement/detail.html",
|
|
||||||
obj=journal_entry)
|
|
||||||
|
|
||||||
def render_edit_template(self, journal_entry: JournalEntry,
|
|
||||||
form: CashDisbursementJournalEntryForm) -> str:
|
|
||||||
"""Renders the template for the form to edit a journal entry.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:param form: The form.
|
|
||||||
:return: the form to edit a journal entry.
|
|
||||||
"""
|
|
||||||
return render_template(
|
|
||||||
"accounting/journal-entry/disbursement/edit.html",
|
|
||||||
journal_entry=journal_entry, form=form,
|
|
||||||
currency_template=self.__currency_template,
|
|
||||||
line_item_template=self._line_item_template)
|
|
||||||
|
|
||||||
def is_my_type(self, journal_entry: JournalEntry) -> bool:
|
|
||||||
"""Checks and returns whether the journal entry belongs to the type.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: True if the journal entry belongs to the type, or False
|
|
||||||
otherwise.
|
|
||||||
"""
|
|
||||||
return journal_entry.is_cash_disbursement
|
|
||||||
|
|
||||||
@property
|
|
||||||
def __currency_template(self) -> str:
|
|
||||||
"""Renders and returns the template for the currency sub-form.
|
|
||||||
|
|
||||||
:return: The template for the currency sub-form.
|
|
||||||
"""
|
|
||||||
return render_template(
|
|
||||||
"accounting/journal-entry/disbursement/include/form-currency.html",
|
|
||||||
currency_index="CURRENCY_INDEX",
|
|
||||||
currency_code_data=default_currency_code(),
|
|
||||||
debit_total="-")
|
|
||||||
|
|
||||||
|
|
||||||
class TransferJournalEntry(JournalEntryOperator):
|
|
||||||
"""A transfer journal entry."""
|
|
||||||
CHECK_ORDER: int = 3
|
|
||||||
"""The order when checking the journal entry operator."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def form(self) -> Type[JournalEntryForm]:
|
|
||||||
"""Returns the form class.
|
|
||||||
|
|
||||||
:return: The form class.
|
|
||||||
"""
|
|
||||||
return TransferJournalEntryForm
|
|
||||||
|
|
||||||
def render_create_template(self, form: TransferJournalEntryForm) -> str:
|
|
||||||
"""Renders the template for the form to create a journal entry.
|
|
||||||
|
|
||||||
:param form: The journal entry form.
|
|
||||||
:return: the form to create a journal entry.
|
|
||||||
"""
|
|
||||||
return render_template(
|
|
||||||
"accounting/journal-entry/transfer/create.html",
|
|
||||||
form=form,
|
|
||||||
journal_entry_type=JournalEntryType.TRANSFER,
|
|
||||||
currency_template=self.__currency_template,
|
|
||||||
line_item_template=self._line_item_template)
|
|
||||||
|
|
||||||
def render_detail_template(self, journal_entry: JournalEntry) -> str:
|
|
||||||
"""Renders the template for the detail page.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: the detail page.
|
|
||||||
"""
|
|
||||||
return render_template("accounting/journal-entry/transfer/detail.html",
|
|
||||||
obj=journal_entry)
|
|
||||||
|
|
||||||
def render_edit_template(self, journal_entry: JournalEntry,
|
|
||||||
form: TransferJournalEntryForm) -> str:
|
|
||||||
"""Renders the template for the form to edit a journal entry.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:param form: The form.
|
|
||||||
:return: the form to edit a journal entry.
|
|
||||||
"""
|
|
||||||
return render_template("accounting/journal-entry/transfer/edit.html",
|
|
||||||
journal_entry=journal_entry, form=form,
|
|
||||||
currency_template=self.__currency_template,
|
|
||||||
line_item_template=self._line_item_template)
|
|
||||||
|
|
||||||
def is_my_type(self, journal_entry: JournalEntry) -> bool:
|
|
||||||
"""Checks and returns whether the journal entry belongs to the type.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: True if the journal entry belongs to the type, or False
|
|
||||||
otherwise.
|
|
||||||
"""
|
|
||||||
return True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def __currency_template(self) -> str:
|
|
||||||
"""Renders and returns the template for the currency sub-form.
|
|
||||||
|
|
||||||
:return: The template for the currency sub-form.
|
|
||||||
"""
|
|
||||||
return render_template(
|
|
||||||
"accounting/journal-entry/transfer/include/form-currency.html",
|
|
||||||
currency_index="CURRENCY_INDEX",
|
|
||||||
currency_code_data=default_currency_code(),
|
|
||||||
debit_total="-", credit_total="-")
|
|
||||||
|
|
||||||
|
|
||||||
JOURNAL_ENTRY_TYPE_TO_OP: dict[JournalEntryType, JournalEntryOperator] \
|
|
||||||
= {JournalEntryType.CASH_RECEIPT: CashReceiptJournalEntry(),
|
|
||||||
JournalEntryType.CASH_DISBURSEMENT: CashDisbursementJournalEntry(),
|
|
||||||
JournalEntryType.TRANSFER: TransferJournalEntry()}
|
|
||||||
"""The map from the journal entry types to their operators."""
|
|
||||||
|
|
||||||
|
|
||||||
def get_journal_entry_op(journal_entry: JournalEntry,
|
|
||||||
is_check_as: bool = False) -> JournalEntryOperator:
|
|
||||||
"""Returns the journal entry operator that may be specified in the "as"
|
|
||||||
query parameter. If it is not specified, check the journal entry type from
|
|
||||||
the journal entry.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:param is_check_as: True to check the "as" parameter, or False otherwise.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
if is_check_as and "as" in request.args:
|
|
||||||
type_dict: dict[str, JournalEntryType] \
|
|
||||||
= {x.value: x for x in JournalEntryType}
|
|
||||||
if request.args["as"] not in type_dict:
|
|
||||||
abort(404)
|
|
||||||
return JOURNAL_ENTRY_TYPE_TO_OP[type_dict[request.args["as"]]]
|
|
||||||
for journal_entry_type in sorted(JOURNAL_ENTRY_TYPE_TO_OP.values(),
|
|
||||||
key=lambda x: x.CHECK_ORDER):
|
|
||||||
if journal_entry_type.is_my_type(journal_entry):
|
|
||||||
return journal_entry_type
|
|
@ -1,83 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The selectable original line items.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.models import Account, JournalEntry, JournalEntryLineItem
|
|
||||||
from accounting.utils.offset_alias import offset_alias
|
|
||||||
|
|
||||||
|
|
||||||
def get_selectable_original_line_items(
|
|
||||||
line_item_id_on_form: set[int], is_payable: bool,
|
|
||||||
is_receivable: bool) -> list[JournalEntryLineItem]:
|
|
||||||
"""Queries and returns the selectable original line items, with their net
|
|
||||||
balances. The offset amounts of the form is excluded.
|
|
||||||
|
|
||||||
:param line_item_id_on_form: The ID of the line items on the form.
|
|
||||||
:param is_payable: True to check the payable original line items, or False
|
|
||||||
otherwise.
|
|
||||||
:param is_receivable: True to check the receivable original line items, or
|
|
||||||
False otherwise.
|
|
||||||
:return: The selectable original line items, with their net balances.
|
|
||||||
"""
|
|
||||||
assert is_payable or is_receivable
|
|
||||||
offset: sa.Alias = offset_alias()
|
|
||||||
net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case(
|
|
||||||
(offset.c.id.in_(line_item_id_on_form), 0),
|
|
||||||
(offset.c.is_debit == JournalEntryLineItem.is_debit, offset.c.amount),
|
|
||||||
else_=-offset.c.amount))).label("net_balance")
|
|
||||||
conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
|
|
||||||
sub_conditions: list[sa.BinaryExpression] = []
|
|
||||||
if is_payable:
|
|
||||||
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
|
|
||||||
sa.not_(JournalEntryLineItem.is_debit)))
|
|
||||||
if is_receivable:
|
|
||||||
sub_conditions.append(sa.and_(Account.base_code.startswith("1"),
|
|
||||||
JournalEntryLineItem.is_debit))
|
|
||||||
conditions.append(sa.or_(*sub_conditions))
|
|
||||||
select_net_balances: sa.Select \
|
|
||||||
= sa.select(JournalEntryLineItem.id, net_balance)\
|
|
||||||
.join(Account)\
|
|
||||||
.join(offset,
|
|
||||||
JournalEntryLineItem.id == offset.c.original_line_item_id,
|
|
||||||
isouter=True)\
|
|
||||||
.filter(*conditions)\
|
|
||||||
.group_by(JournalEntryLineItem.id)\
|
|
||||||
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
|
|
||||||
net_balances: dict[int, Decimal] \
|
|
||||||
= {x.id: x.net_balance
|
|
||||||
for x in db.session.execute(select_net_balances).all()}
|
|
||||||
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\
|
|
||||||
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\
|
|
||||||
.join(JournalEntry)\
|
|
||||||
.order_by(JournalEntry.date, JournalEntry.no,
|
|
||||||
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
|
|
||||||
.options(selectinload(JournalEntryLineItem.currency),
|
|
||||||
selectinload(JournalEntryLineItem.account),
|
|
||||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
|
||||||
line_items.reverse()
|
|
||||||
for line_item in line_items:
|
|
||||||
line_item.net_balance = line_item.amount \
|
|
||||||
if net_balances[line_item.id] is None \
|
|
||||||
else net_balances[line_item.id]
|
|
||||||
return line_items
|
|
@ -1,239 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
|
||||||
|
|
||||||
# Copyright (c) 2023-2024 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The views for the journal entry management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import datetime as dt
|
|
||||||
from urllib.parse import parse_qsl, urlencode
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from flask import Blueprint, render_template, session, redirect, request, \
|
|
||||||
flash, url_for
|
|
||||||
from werkzeug.datastructures import ImmutableMultiDict
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.locale import lazy_gettext
|
|
||||||
from accounting.models import JournalEntry
|
|
||||||
from accounting.utils.cast import s
|
|
||||||
from accounting.utils.flash_errors import flash_form_errors
|
|
||||||
from accounting.utils.journal_entry_types import JournalEntryType
|
|
||||||
from accounting.utils.next_uri import inherit_next, or_next
|
|
||||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
|
||||||
from accounting.utils.timezone import get_tz_today
|
|
||||||
from accounting.utils.user import get_current_user_pk
|
|
||||||
from .forms import sort_journal_entries_in, JournalEntryReorderForm
|
|
||||||
from .template_filters import with_type, to_transfer, format_amount_input, \
|
|
||||||
text2html
|
|
||||||
from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
|
|
||||||
get_journal_entry_op
|
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("journal-entry", __name__)
|
|
||||||
"""The view blueprint for the journal entry management."""
|
|
||||||
bp.add_app_template_filter(with_type, "accounting_journal_entry_with_type")
|
|
||||||
bp.add_app_template_filter(to_transfer, "accounting_journal_entry_to_transfer")
|
|
||||||
bp.add_app_template_filter(format_amount_input,
|
|
||||||
"accounting_journal_entry_format_amount_input")
|
|
||||||
bp.add_app_template_filter(text2html, "accounting_journal_entry_text2html")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("create/<journalEntryType:journal_entry_type>", endpoint="create")
|
|
||||||
@has_permission(can_edit)
|
|
||||||
def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
|
|
||||||
"""Shows the form to add a journal entry.
|
|
||||||
|
|
||||||
:param journal_entry_type: The journal entry type.
|
|
||||||
:return: The form to add a journal entry.
|
|
||||||
"""
|
|
||||||
journal_entry_op: JournalEntryOperator \
|
|
||||||
= JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
|
|
||||||
form: journal_entry_op.form
|
|
||||||
if "form" in session:
|
|
||||||
form = journal_entry_op.form(
|
|
||||||
ImmutableMultiDict(parse_qsl(session["form"])))
|
|
||||||
del session["form"]
|
|
||||||
form.validate()
|
|
||||||
else:
|
|
||||||
form = journal_entry_op.form()
|
|
||||||
form.date.data = get_tz_today()
|
|
||||||
return journal_entry_op.render_create_template(form)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store")
|
|
||||||
@has_permission(can_edit)
|
|
||||||
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
|
|
||||||
"""Adds a journal entry.
|
|
||||||
|
|
||||||
:param journal_entry_type: The journal entry type.
|
|
||||||
:return: The redirection to the journal entry detail on success, or the
|
|
||||||
journal entry creation form on error.
|
|
||||||
"""
|
|
||||||
journal_entry_op: JournalEntryOperator \
|
|
||||||
= JOURNAL_ENTRY_TYPE_TO_OP[journal_entry_type]
|
|
||||||
form: journal_entry_op.form = journal_entry_op.form(request.form)
|
|
||||||
if not form.validate():
|
|
||||||
flash_form_errors(form)
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
|
||||||
return redirect(inherit_next(with_type(
|
|
||||||
url_for("accounting.journal-entry.create",
|
|
||||||
journal_entry_type=journal_entry_type))))
|
|
||||||
journal_entry: JournalEntry = JournalEntry()
|
|
||||||
form.populate_obj(journal_entry)
|
|
||||||
db.session.add(journal_entry)
|
|
||||||
db.session.commit()
|
|
||||||
flash(s(lazy_gettext("The journal entry is added successfully.")),
|
|
||||||
"success")
|
|
||||||
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("<journalEntry:journal_entry>", endpoint="detail")
|
|
||||||
@has_permission(can_view)
|
|
||||||
def show_journal_entry_detail(journal_entry: JournalEntry) -> str:
|
|
||||||
"""Shows the journal entry detail.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: The detail.
|
|
||||||
"""
|
|
||||||
journal_entry_op: JournalEntryOperator \
|
|
||||||
= get_journal_entry_op(journal_entry)
|
|
||||||
return journal_entry_op.render_detail_template(journal_entry)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("<journalEntry:journal_entry>/edit", endpoint="edit")
|
|
||||||
@has_permission(can_edit)
|
|
||||||
def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
|
|
||||||
"""Shows the form to edit a journal entry.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: The form to edit the journal entry.
|
|
||||||
"""
|
|
||||||
journal_entry_op: JournalEntryOperator \
|
|
||||||
= get_journal_entry_op(journal_entry, is_check_as=True)
|
|
||||||
form: journal_entry_op.form
|
|
||||||
if "form" in session:
|
|
||||||
form = journal_entry_op.form(
|
|
||||||
ImmutableMultiDict(parse_qsl(session["form"])))
|
|
||||||
del session["form"]
|
|
||||||
form.obj = journal_entry
|
|
||||||
form.validate()
|
|
||||||
else:
|
|
||||||
form = journal_entry_op.form(obj=journal_entry)
|
|
||||||
return journal_entry_op.render_edit_template(journal_entry, form)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("<journalEntry:journal_entry>/update", endpoint="update")
|
|
||||||
@has_permission(can_edit)
|
|
||||||
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
|
|
||||||
"""Updates a journal entry.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: The redirection to the journal entry detail on success, or the
|
|
||||||
journal entry edit form on error.
|
|
||||||
"""
|
|
||||||
journal_entry_op: JournalEntryOperator \
|
|
||||||
= get_journal_entry_op(journal_entry, is_check_as=True)
|
|
||||||
form: journal_entry_op.form = journal_entry_op.form(request.form)
|
|
||||||
form.obj = journal_entry
|
|
||||||
if not form.validate():
|
|
||||||
flash_form_errors(form)
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
|
||||||
return redirect(inherit_next(with_type(
|
|
||||||
url_for("accounting.journal-entry.edit",
|
|
||||||
journal_entry=journal_entry))))
|
|
||||||
with db.session.no_autoflush:
|
|
||||||
form.populate_obj(journal_entry)
|
|
||||||
if not form.is_modified:
|
|
||||||
flash(s(lazy_gettext("The journal entry was not modified.")),
|
|
||||||
"success")
|
|
||||||
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
|
||||||
journal_entry.updated_by_id = get_current_user_pk()
|
|
||||||
journal_entry.updated_at = sa.func.now()
|
|
||||||
db.session.commit()
|
|
||||||
flash(s(lazy_gettext("The journal entry is updated successfully.")),
|
|
||||||
"success")
|
|
||||||
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("<journalEntry:journal_entry>/delete", endpoint="delete")
|
|
||||||
@has_permission(can_edit)
|
|
||||||
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
|
|
||||||
"""Deletes a journal entry.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: The redirection to the journal entry list on success, or the
|
|
||||||
journal entry detail on error.
|
|
||||||
"""
|
|
||||||
if not journal_entry.can_delete:
|
|
||||||
flash(s(lazy_gettext("The journal entry cannot be deleted.")), "error")
|
|
||||||
return redirect(inherit_next(__get_detail_uri(journal_entry)))
|
|
||||||
journal_entry.delete()
|
|
||||||
sort_journal_entries_in(journal_entry.date, journal_entry.id)
|
|
||||||
db.session.commit()
|
|
||||||
flash(s(lazy_gettext("The journal entry is deleted successfully.")),
|
|
||||||
"success")
|
|
||||||
return redirect(or_next(__get_default_page_uri()))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("dates/<date:date>", endpoint="order")
|
|
||||||
@has_permission(can_view)
|
|
||||||
def show_journal_entry_order(date: dt.date) -> str:
|
|
||||||
"""Shows the order of the journal entries in a same date.
|
|
||||||
|
|
||||||
:param date: The date.
|
|
||||||
:return: The order of the journal entries in the date.
|
|
||||||
"""
|
|
||||||
journal_entries: list[JournalEntry] = JournalEntry.query \
|
|
||||||
.filter(JournalEntry.date == date) \
|
|
||||||
.order_by(JournalEntry.no).all()
|
|
||||||
return render_template("accounting/journal-entry/order.html",
|
|
||||||
date=date, list=journal_entries)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("dates/<date:date>", endpoint="sort")
|
|
||||||
@has_permission(can_edit)
|
|
||||||
def sort_journal_entries(date: dt.date) -> redirect:
|
|
||||||
"""Reorders the journal entries in a date.
|
|
||||||
|
|
||||||
:param date: The date.
|
|
||||||
:return: The redirection to the incoming account or the account list. The
|
|
||||||
reordering operation does not fail.
|
|
||||||
"""
|
|
||||||
form: JournalEntryReorderForm = JournalEntryReorderForm(date)
|
|
||||||
form.save_order()
|
|
||||||
if not form.is_modified:
|
|
||||||
flash(s(lazy_gettext("The order was not modified.")), "success")
|
|
||||||
return redirect(or_next(__get_default_page_uri()))
|
|
||||||
db.session.commit()
|
|
||||||
flash(s(lazy_gettext("The order is updated successfully.")), "success")
|
|
||||||
return redirect(or_next(__get_default_page_uri()))
|
|
||||||
|
|
||||||
|
|
||||||
def __get_detail_uri(journal_entry: JournalEntry) -> str:
|
|
||||||
"""Returns the detail URI of a journal entry.
|
|
||||||
|
|
||||||
:param journal_entry: The journal entry.
|
|
||||||
:return: The detail URI of the journal entry.
|
|
||||||
"""
|
|
||||||
return url_for("accounting.journal-entry.detail",
|
|
||||||
journal_entry=journal_entry)
|
|
||||||
|
|
||||||
|
|
||||||
def __get_default_page_uri() -> str:
|
|
||||||
"""Returns the URI for the default page.
|
|
||||||
|
|
||||||
:return: The URI for the default page.
|
|
||||||
"""
|
|
||||||
return url_for("accounting-report.default")
|
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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,10 +25,8 @@ 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:
|
||||||
@ -122,5 +120,6 @@ 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", __babel_js_catalog_view)
|
bp.add_url_rule("/_jstrans.js", "babel_catalog",
|
||||||
|
__babel_js_catalog_view)
|
||||||
app.jinja_env.globals["A_"] = domain.gettext
|
app.jinja_env.globals["A_"] = domain.gettext
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,30 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The option management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from flask import Blueprint
|
|
||||||
|
|
||||||
|
|
||||||
def init_app(bp: Blueprint) -> None:
|
|
||||||
"""Initialize the application.
|
|
||||||
|
|
||||||
:param bp: The blueprint of the accounting application.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
from .views import bp as option_bp
|
|
||||||
bp.register_blueprint(option_bp, url_prefix="/options")
|
|
@ -1,269 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The forms for the option management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from flask import render_template
|
|
||||||
from flask_babel import LazyString
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from wtforms import StringField, FieldList, FormField, IntegerField
|
|
||||||
from wtforms.validators import DataRequired, ValidationError
|
|
||||||
|
|
||||||
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
|
|
||||||
IsDebitAccount, IsCreditAccount
|
|
||||||
from accounting.locale import lazy_gettext
|
|
||||||
from accounting.models import Account
|
|
||||||
from accounting.utils.current_account import CurrentAccount
|
|
||||||
from accounting.utils.options import Options
|
|
||||||
from accounting.utils.strip_text import strip_text
|
|
||||||
|
|
||||||
|
|
||||||
class CurrentAccountExists:
|
|
||||||
"""The validator to check that the current account exists."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
|
|
||||||
return
|
|
||||||
if Account.find_by_code(field.data) is None:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The account does not exist."))
|
|
||||||
|
|
||||||
|
|
||||||
class AccountNotCurrent:
|
|
||||||
"""The validator to check that the account is a current account."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
|
|
||||||
return
|
|
||||||
if field.data[:2] not in {"11", "12", "21", "22"}:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"This is not a current account."))
|
|
||||||
|
|
||||||
|
|
||||||
class NotStartPayableFromExpense:
|
|
||||||
"""The validator to check that a payable line item does not start from
|
|
||||||
expense."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
if field.data is None or field.data[0] != "2":
|
|
||||||
return
|
|
||||||
account: Account | None = Account.find_by_code(field.data)
|
|
||||||
if account is not None and account.is_need_offset:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"You cannot select a payable account as expense."))
|
|
||||||
|
|
||||||
|
|
||||||
class NotStartReceivableFromIncome:
|
|
||||||
"""The validator to check that a receivable line item does not start
|
|
||||||
from income."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
if field.data is None or field.data[0] != "1":
|
|
||||||
return
|
|
||||||
account: Account | None = Account.find_by_code(field.data)
|
|
||||||
if account is not None and account.is_need_offset:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"You cannot select a receivable account as income."))
|
|
||||||
|
|
||||||
|
|
||||||
class RecurringItemForm(FlaskForm):
|
|
||||||
"""The base sub-form to add or update the recurring item."""
|
|
||||||
no = IntegerField()
|
|
||||||
"""The order number of this recurring item."""
|
|
||||||
name = StringField()
|
|
||||||
"""The name of the recurring item."""
|
|
||||||
account_code = StringField()
|
|
||||||
"""The account code."""
|
|
||||||
description_template = StringField()
|
|
||||||
"""The description template."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def account_text(self) -> str | None:
|
|
||||||
"""Returns the account text.
|
|
||||||
|
|
||||||
:return: The account text.
|
|
||||||
"""
|
|
||||||
if self.account_code.data is None:
|
|
||||||
return None
|
|
||||||
account: Account | None = Account.find_by_code(self.account_code.data)
|
|
||||||
return None if account is None else str(account)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def all_errors(self) -> list[str | LazyString]:
|
|
||||||
"""Returns all the errors of the form.
|
|
||||||
|
|
||||||
:return: All the errors of the form.
|
|
||||||
"""
|
|
||||||
all_errors: list[str | LazyString] = []
|
|
||||||
for key in self.errors:
|
|
||||||
if key != "csrf_token":
|
|
||||||
all_errors.extend(self.errors[key])
|
|
||||||
return all_errors
|
|
||||||
|
|
||||||
|
|
||||||
class RecurringExpenseForm(RecurringItemForm):
|
|
||||||
"""The sub-form to add or update the recurring expenses."""
|
|
||||||
no = IntegerField()
|
|
||||||
"""The order number of this recurring item."""
|
|
||||||
name = StringField(
|
|
||||||
filters=[strip_text],
|
|
||||||
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
|
|
||||||
"""The name of the recurring item."""
|
|
||||||
account_code = StringField(
|
|
||||||
filters=[strip_text],
|
|
||||||
validators=[
|
|
||||||
ACCOUNT_REQUIRED,
|
|
||||||
AccountExists(),
|
|
||||||
IsDebitAccount(lazy_gettext("This account is not for expense.")),
|
|
||||||
NotStartPayableFromExpense()])
|
|
||||||
"""The account code."""
|
|
||||||
description_template = StringField(
|
|
||||||
filters=[strip_text],
|
|
||||||
validators=[
|
|
||||||
DataRequired(lazy_gettext(
|
|
||||||
"Please fill in the description template."))])
|
|
||||||
"""The template for the line item description."""
|
|
||||||
|
|
||||||
|
|
||||||
class RecurringIncomeForm(RecurringItemForm):
|
|
||||||
"""The sub-form to add or update the recurring incomes."""
|
|
||||||
no = IntegerField()
|
|
||||||
"""The order number of this recurring item."""
|
|
||||||
name = StringField(
|
|
||||||
filters=[strip_text],
|
|
||||||
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
|
|
||||||
"""The name of the recurring item."""
|
|
||||||
account_code = StringField(
|
|
||||||
filters=[strip_text],
|
|
||||||
validators=[
|
|
||||||
ACCOUNT_REQUIRED,
|
|
||||||
AccountExists(),
|
|
||||||
IsCreditAccount(lazy_gettext("This account is not for income.")),
|
|
||||||
NotStartReceivableFromIncome()])
|
|
||||||
"""The account code."""
|
|
||||||
description_template = StringField(
|
|
||||||
filters=[strip_text],
|
|
||||||
validators=[
|
|
||||||
DataRequired(lazy_gettext(
|
|
||||||
"Please fill in the description template."))])
|
|
||||||
"""The description template."""
|
|
||||||
|
|
||||||
|
|
||||||
class RecurringForm(RecurringItemForm):
|
|
||||||
"""The sub-form for the recurring expenses and incomes."""
|
|
||||||
expenses = FieldList(FormField(RecurringExpenseForm), name="expense")
|
|
||||||
"""The recurring expenses."""
|
|
||||||
incomes = FieldList(FormField(RecurringIncomeForm), name="income")
|
|
||||||
"""The recurring incomes."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def item_template(self) -> str:
|
|
||||||
"""Returns the template of a recurring item.
|
|
||||||
|
|
||||||
:return: The template of a recurring item.
|
|
||||||
"""
|
|
||||||
return render_template(
|
|
||||||
"accounting/option/include/form-recurring-item.html",
|
|
||||||
expense_income="EXPENSE_INCOME",
|
|
||||||
item_index="ITEM_INDEX",
|
|
||||||
form=RecurringItemForm())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def expense_accounts(self) -> list[Account]:
|
|
||||||
"""The expense accounts.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
return Account.selectable_debit()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def income_accounts(self) -> list[Account]:
|
|
||||||
"""The income accounts.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
return Account.selectable_credit()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def as_data(self) -> dict[str, list[tuple[str, str, str]]]:
|
|
||||||
"""Returns the form data.
|
|
||||||
|
|
||||||
:return: The form data.
|
|
||||||
"""
|
|
||||||
def as_tuple(item: RecurringItemForm) -> tuple[str, str, str]:
|
|
||||||
return (item.name.data, item.account_code.data,
|
|
||||||
item.description_template.data)
|
|
||||||
|
|
||||||
expenses: list[RecurringItemForm] = [x.form for x in self.expenses]
|
|
||||||
self.__sort_item_forms(expenses)
|
|
||||||
incomes: list[RecurringItemForm] = [x.form for x in self.incomes]
|
|
||||||
self.__sort_item_forms(incomes)
|
|
||||||
return {"expense": [as_tuple(x) for x in expenses],
|
|
||||||
"income": [as_tuple(x) for x in incomes]}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __sort_item_forms(forms: list[RecurringItemForm]) -> None:
|
|
||||||
"""Sorts the recurring item sub-forms.
|
|
||||||
|
|
||||||
:param forms: The recurring item sub-forms.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
ord_by_form: dict[RecurringItemForm, int] \
|
|
||||||
= {forms[i]: i for i in range(len(forms))}
|
|
||||||
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
|
|
||||||
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
|
|
||||||
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
|
|
||||||
ord_by_form.get(x)))
|
|
||||||
|
|
||||||
|
|
||||||
class OptionForm(FlaskForm):
|
|
||||||
"""The form to update the options."""
|
|
||||||
default_currency_code = StringField(
|
|
||||||
filters=[strip_text],
|
|
||||||
validators=[
|
|
||||||
DataRequired(lazy_gettext("Please select the default currency.")),
|
|
||||||
CurrencyExists()])
|
|
||||||
"""The default currency code."""
|
|
||||||
default_ie_account_code = StringField(
|
|
||||||
filters=[strip_text],
|
|
||||||
validators=[
|
|
||||||
DataRequired(lazy_gettext(
|
|
||||||
"Please select the default account"
|
|
||||||
" for the income and expenses log.")),
|
|
||||||
CurrentAccountExists(),
|
|
||||||
AccountNotCurrent()])
|
|
||||||
"""The default account code for the income and expenses log."""
|
|
||||||
recurring = FormField(RecurringForm)
|
|
||||||
"""The recurring expenses and incomes."""
|
|
||||||
|
|
||||||
def populate_obj(self, obj: Options) -> None:
|
|
||||||
"""Populates the form data into a currency object.
|
|
||||||
|
|
||||||
:param obj: The currency object.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
obj.default_currency_code = self.default_currency_code.data
|
|
||||||
obj.default_ie_account_code = self.default_ie_account_code.data
|
|
||||||
obj.recurring_data = self.recurring.form.as_data
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_accounts(self) -> list[CurrentAccount]:
|
|
||||||
"""Returns the current accounts.
|
|
||||||
|
|
||||||
:return: The current accounts.
|
|
||||||
"""
|
|
||||||
return CurrentAccount.accounts()
|
|
@ -1,83 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The views for the option management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from urllib.parse import parse_qsl, urlencode
|
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, session, request, \
|
|
||||||
flash, url_for
|
|
||||||
from werkzeug.datastructures import ImmutableMultiDict
|
|
||||||
|
|
||||||
from accounting.locale import lazy_gettext
|
|
||||||
from accounting.utils.cast import s
|
|
||||||
from accounting.utils.flash_errors import flash_form_errors
|
|
||||||
from accounting.utils.next_uri import inherit_next
|
|
||||||
from accounting.utils.options import options
|
|
||||||
from accounting.utils.permission import has_permission, can_admin
|
|
||||||
from .forms import OptionForm
|
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("option", __name__)
|
|
||||||
"""The view blueprint for the currency management."""
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("", endpoint="detail")
|
|
||||||
@has_permission(can_admin)
|
|
||||||
def show_options() -> str:
|
|
||||||
"""Shows the options.
|
|
||||||
|
|
||||||
:return: The options.
|
|
||||||
"""
|
|
||||||
return render_template("accounting/option/detail.html", obj=options)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("edit", endpoint="edit")
|
|
||||||
@has_permission(can_admin)
|
|
||||||
def show_option_form() -> str:
|
|
||||||
"""Shows the option form.
|
|
||||||
|
|
||||||
:return: The option form.
|
|
||||||
"""
|
|
||||||
form: OptionForm
|
|
||||||
if "form" in session:
|
|
||||||
form = OptionForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
|
||||||
del session["form"]
|
|
||||||
form.validate()
|
|
||||||
else:
|
|
||||||
form = OptionForm(obj=options)
|
|
||||||
return render_template("accounting/option/form.html", form=form)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("update", endpoint="update")
|
|
||||||
@has_permission(can_admin)
|
|
||||||
def update_options() -> redirect:
|
|
||||||
"""Updates the options.
|
|
||||||
|
|
||||||
:return: The redirection to the option form.
|
|
||||||
"""
|
|
||||||
form = OptionForm(request.form)
|
|
||||||
if not form.validate():
|
|
||||||
flash_form_errors(form)
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
|
||||||
return redirect(inherit_next(url_for("accounting.option.edit")))
|
|
||||||
form.populate_obj(options)
|
|
||||||
if not options.is_modified:
|
|
||||||
flash(s(lazy_gettext("The settings were not modified.")), "success")
|
|
||||||
return redirect(inherit_next(url_for("accounting.option.detail")))
|
|
||||||
options.commit()
|
|
||||||
flash(s(lazy_gettext("The settings are saved successfully.")), "success")
|
|
||||||
return redirect(inherit_next(url_for("accounting.option.detail")))
|
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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,21 +17,19 @@
|
|||||||
"""The report management.
|
"""The report management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from flask import Flask
|
from flask import Flask, Blueprint
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask, url_prefix: str) -> None:
|
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||||
"""Initialize the application.
|
"""Initialize the application.
|
||||||
|
|
||||||
:param app: The Flask application.
|
:param app: The Flask application.
|
||||||
:param url_prefix: The URL prefix of the accounting application.
|
:param bp: The blueprint of the accounting application.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from .converters import PeriodConverter, CurrentAccountConverter, \
|
from .converters import PeriodConverter, IncomeExpensesAccountConverter
|
||||||
NeedOffsetAccountConverter
|
|
||||||
app.url_map.converters["period"] = PeriodConverter
|
app.url_map.converters["period"] = PeriodConverter
|
||||||
app.url_map.converters["currentAccount"] = CurrentAccountConverter
|
app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter
|
||||||
app.url_map.converters["needOffsetAccount"] = NeedOffsetAccountConverter
|
|
||||||
|
|
||||||
from .views import bp as report_bp
|
from .views import bp as report_bp
|
||||||
app.register_blueprint(report_bp, url_prefix=url_prefix)
|
bp.register_blueprint(report_bp, url_prefix="/reports")
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -23,13 +23,13 @@ from flask import abort
|
|||||||
from werkzeug.routing import BaseConverter
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
from accounting.models import Account
|
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 .utils.ie_account import IncomeExpensesAccount
|
||||||
|
|
||||||
|
|
||||||
class PeriodConverter(BaseConverter):
|
class PeriodConverter(BaseConverter):
|
||||||
"""The converter to convert the period specification from and to the
|
"""The supplier converter to convert the period specification from and to
|
||||||
corresponding period in the routes."""
|
the 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,52 +51,26 @@ class PeriodConverter(BaseConverter):
|
|||||||
return value.spec
|
return value.spec
|
||||||
|
|
||||||
|
|
||||||
class CurrentAccountConverter(BaseConverter):
|
class IncomeExpensesAccountConverter(BaseConverter):
|
||||||
"""The converter to convert the current account code from and to the
|
"""The supplier converter to convert the income and expenses log pseudo
|
||||||
corresponding account in the routes."""
|
account code from and to the corresponding pseudo account in the routes."""
|
||||||
|
|
||||||
def to_python(self, value: str) -> CurrentAccount:
|
def to_python(self, value: str) -> IncomeExpensesAccount:
|
||||||
"""Converts an account code to an account.
|
"""Converts an account code to an account.
|
||||||
|
|
||||||
:param value: The account code.
|
:param value: The account code.
|
||||||
:return: The corresponding account.
|
:return: The corresponding account.
|
||||||
"""
|
"""
|
||||||
if value == CurrentAccount.CURRENT_AL_CODE:
|
if value == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||||
return CurrentAccount.current_assets_and_liabilities()
|
return IncomeExpensesAccount.current_assets_and_liabilities()
|
||||||
if not re.match("^[12][12]", value):
|
if not re.match("^[12][12]", value):
|
||||||
abort(404)
|
abort(404)
|
||||||
account: Account | None = Account.find_by_code(value)
|
account: Account | None = Account.find_by_code(value)
|
||||||
if account is None:
|
if account is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
return CurrentAccount(account)
|
return IncomeExpensesAccount(account)
|
||||||
|
|
||||||
def to_url(self, value: CurrentAccount) -> str:
|
def to_url(self, value: IncomeExpensesAccount) -> str:
|
||||||
"""Converts an account to account code.
|
|
||||||
|
|
||||||
:param value: The account.
|
|
||||||
:return: Its code.
|
|
||||||
"""
|
|
||||||
return value.code
|
|
||||||
|
|
||||||
|
|
||||||
class NeedOffsetAccountConverter(BaseConverter):
|
|
||||||
"""The converter to convert the unapplied original line item account code
|
|
||||||
from and to the corresponding account in the routes."""
|
|
||||||
|
|
||||||
def to_python(self, value: str) -> Account:
|
|
||||||
"""Converts an account code to an account.
|
|
||||||
|
|
||||||
:param value: The account code.
|
|
||||||
:return: The corresponding account.
|
|
||||||
"""
|
|
||||||
account: Account | None = Account.find_by_code(value)
|
|
||||||
if account is None:
|
|
||||||
abort(404)
|
|
||||||
if not account.is_need_offset:
|
|
||||||
abort(404)
|
|
||||||
return account
|
|
||||||
|
|
||||||
def to_url(self, value: Account) -> str:
|
|
||||||
"""Converts an account to account code.
|
"""Converts an account to account code.
|
||||||
|
|
||||||
:param value: The account.
|
:param value: The account.
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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 Project.
|
# The Mia! Accounting Flask Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
# Copyright (c) 2023-2024 imacat.
|
# Copyright (c) 2023 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,11 +20,10 @@ 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 datetime as dt
|
import typing as t
|
||||||
from collections.abc import Callable
|
from datetime import date
|
||||||
|
|
||||||
from accounting.models import JournalEntry
|
from accounting.models import Transaction
|
||||||
from accounting.utils.timezone import get_tz_today
|
|
||||||
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
|
||||||
@ -33,13 +32,13 @@ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
|||||||
class PeriodChooser:
|
class PeriodChooser:
|
||||||
"""The period chooser."""
|
"""The period chooser."""
|
||||||
|
|
||||||
def __init__(self, get_url: Callable[[Period], str]):
|
def __init__(self, get_url: t.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: Callable[[Period], str] = get_url
|
self.__get_url: t.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
|
||||||
@ -62,12 +61,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: Transaction | None \
|
||||||
= JournalEntry.query.order_by(JournalEntry.date).first()
|
= Transaction.query.order_by(Transaction.date).first()
|
||||||
start: dt.date | None = None if first is None else first.date
|
start: date | None = None if first is None else first.date
|
||||||
|
|
||||||
# Attributes
|
# Attributes
|
||||||
self.data_start: dt.date | None = start
|
self.data_start: 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."""
|
||||||
@ -81,8 +80,8 @@ class PeriodChooser:
|
|||||||
"""The available years."""
|
"""The available years."""
|
||||||
|
|
||||||
if self.has_data:
|
if self.has_data:
|
||||||
today: dt.date = get_tz_today()
|
today: date = date.today()
|
||||||
self.has_last_month = start < dt.date(today.year, today.month, 1)
|
self.has_last_month = start < 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,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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,12 +17,12 @@
|
|||||||
"""The period description composer.
|
"""The period description composer.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import datetime as dt
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
|
|
||||||
|
|
||||||
def get_desc(start: dt.date | None, end: dt.date | None) -> str:
|
def get_desc(start: date | None, end: 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: dt.date | None, end: dt.date | None) -> str:
|
|||||||
return __get_day_desc(start, end)
|
return __get_day_desc(start, end)
|
||||||
|
|
||||||
|
|
||||||
def __get_since_desc(start: dt.date) -> str:
|
def __get_since_desc(start: 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: dt.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: dt.date) -> str:
|
def __get_until_desc(end: 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: dt.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 + dt.timedelta(days=1)).day == 1:
|
if (end + 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: dt.date, end: dt.date) -> str:
|
def __get_year_desc(start: date, end: 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: dt.date, end: dt.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: dt.date, end: dt.date) -> str:
|
def __get_month_desc(start: date, end: 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: dt.date, end: dt.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 + dt.timedelta(days=1)).day != 1:
|
if start.day != 1 or (end + 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: dt.date, end: dt.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: dt.date, end: dt.date) -> str:
|
def __get_day_desc(start: date, end: 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: dt.date, end: dt.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: dt.date) -> str:
|
def __format_month(month: date) -> str:
|
||||||
"""Formats a month.
|
"""Formats a month.
|
||||||
|
|
||||||
:param month: The month.
|
:param month: The month.
|
||||||
@ -151,7 +151,7 @@ def __format_month(month: dt.date) -> str:
|
|||||||
return f"{month.year}/{month.month}"
|
return f"{month.year}/{month.month}"
|
||||||
|
|
||||||
|
|
||||||
def __format_day(day: dt.date) -> str:
|
def __format_day(day: date) -> str:
|
||||||
"""Formats a day.
|
"""Formats a day.
|
||||||
|
|
||||||
:param day: The day.
|
:param day: The day.
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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
|
||||||
import datetime as dt
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
def month_end(day: dt.date) -> dt.date:
|
def month_end(day: date) -> 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 dt.date(day.year, day.month, last_day)
|
return date(day.year, day.month, last_day)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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,10 +18,9 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import calendar
|
import calendar
|
||||||
import datetime as dt
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
import typing as t
|
||||||
from typing import Type
|
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, \
|
||||||
@ -40,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, Type[Callable[[], Period]]] = {
|
named_periods: dict[str, t.Type[t.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(),
|
||||||
@ -58,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[dt.date | None, dt.date | None]:
|
def __parse_spec(text: str) -> tuple[date | None, date | None]:
|
||||||
"""Parses the period specification.
|
"""Parses the period specification.
|
||||||
|
|
||||||
:param text: The period specification.
|
:param text: The period specification.
|
||||||
@ -85,7 +84,7 @@ def __parse_spec(text: str) -> tuple[dt.date | None, dt.date | None]:
|
|||||||
raise ValueError
|
raise ValueError
|
||||||
|
|
||||||
|
|
||||||
def __get_start(year: str, month: str | None, day: str | None) -> dt.date:
|
def __get_start(year: str, month: str | None, day: str | None) -> 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.
|
||||||
@ -95,13 +94,13 @@ def __get_start(year: str, month: str | None, day: str | None) -> dt.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 dt.date(int(year), int(month), int(day))
|
return date(int(year), int(month), int(day))
|
||||||
if month is not None:
|
if month is not None:
|
||||||
return dt.date(int(year), int(month), 1)
|
return date(int(year), int(month), 1)
|
||||||
return dt.date(int(year), 1, 1)
|
return date(int(year), 1, 1)
|
||||||
|
|
||||||
|
|
||||||
def __get_end(year: str, month: str | None, day: str | None) -> dt.date:
|
def __get_end(year: str, month: str | None, day: str | None) -> 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.
|
||||||
@ -111,10 +110,10 @@ def __get_end(year: str, month: str | None, day: str | None) -> dt.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 dt.date(int(year), int(month), int(day))
|
return 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 dt.date(year_n, month_n, day_n)
|
return date(year_n, month_n, day_n)
|
||||||
return dt.date(int(year), 12, 31)
|
return date(int(year), 12, 31)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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 datetime as dt
|
import typing as t
|
||||||
from typing import Self
|
from datetime import date, timedelta
|
||||||
|
|
||||||
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: dt.date | None, end: dt.date | None):
|
def __init__(self, start: date | None, end: 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: dt.date | None = start
|
self.start: date | None = start
|
||||||
"""The start of the period."""
|
"""The start of the period."""
|
||||||
self.end: dt.date | None = end
|
self.end: date | None = end
|
||||||
"""The end of the period."""
|
"""The end of the period."""
|
||||||
self.is_default: bool = False
|
self.is_default: bool = False
|
||||||
"""Whether this is the default period."""
|
"""Whether the 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 == dt.date(self.start.year, 1, 1) \
|
self.is_a_year = self.start == date(self.start.year, 1, 1) \
|
||||||
and self.end == dt.date(self.start.year, 12, 31)
|
and self.end == 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) -> Self | None:
|
def before(self) -> t.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 - dt.timedelta(days=1))
|
return Period(None, self.start - timedelta(days=1))
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
# Copyright (c) 2023-2024 imacat.
|
# Copyright (c) 2023 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,10 +17,9 @@
|
|||||||
"""The named shortcut periods.
|
"""The named shortcut periods.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import datetime as dt
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.utils.timezone import get_tz_today
|
|
||||||
from .month_end import month_end
|
from .month_end import month_end
|
||||||
from .period import Period
|
from .period import Period
|
||||||
|
|
||||||
@ -28,15 +27,15 @@ from .period import Period
|
|||||||
class ThisMonth(Period):
|
class ThisMonth(Period):
|
||||||
"""The period of this month."""
|
"""The period of this month."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
today: dt.date = get_tz_today()
|
today: date = date.today()
|
||||||
this_month_start: dt.date = dt.date(today.year, today.month, 1)
|
this_month_start: date = 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
|
||||||
|
|
||||||
def _set_properties(self) -> None:
|
def _set_properties(self) -> None:
|
||||||
self.spec = "this-month"
|
self.spec = "this-month"
|
||||||
self.desc = gettext("This Month")
|
self.desc = gettext("This month")
|
||||||
self.is_a_month = True
|
self.is_a_month = True
|
||||||
self.is_type_month = True
|
self.is_type_month = True
|
||||||
|
|
||||||
@ -44,19 +43,19 @@ 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: dt.date = get_tz_today()
|
today: date = date.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: dt.date = dt.date(year, month, 1)
|
start: date = 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
|
||||||
|
|
||||||
def _set_properties(self) -> None:
|
def _set_properties(self) -> None:
|
||||||
self.spec = "last-month"
|
self.spec = "last-month"
|
||||||
self.desc = gettext("Last Month")
|
self.desc = gettext("Last month")
|
||||||
self.is_a_month = True
|
self.is_a_month = True
|
||||||
self.is_type_month = True
|
self.is_type_month = True
|
||||||
|
|
||||||
@ -64,56 +63,56 @@ 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: dt.date = get_tz_today()
|
today: date = date.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: dt.date = dt.date(year, month, 1)
|
start: date = date(year, month, 1)
|
||||||
super().__init__(start, None)
|
super().__init__(start, None)
|
||||||
self.is_since_last_month = True
|
self.is_since_last_month = True
|
||||||
|
|
||||||
def _set_properties(self) -> None:
|
def _set_properties(self) -> None:
|
||||||
self.spec = "since-last-month"
|
self.spec = "since-last-month"
|
||||||
self.desc = gettext("Since Last Month")
|
self.desc = gettext("Since last month")
|
||||||
self.is_type_month = True
|
self.is_type_month = True
|
||||||
|
|
||||||
|
|
||||||
class ThisYear(Period):
|
class ThisYear(Period):
|
||||||
"""The period of this year."""
|
"""The period of this year."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
year: int = get_tz_today().year
|
year: int = date.today().year
|
||||||
start: dt.date = dt.date(year, 1, 1)
|
start: date = date(year, 1, 1)
|
||||||
end: dt.date = dt.date(year, 12, 31)
|
end: date = date(year, 12, 31)
|
||||||
super().__init__(start, end)
|
super().__init__(start, end)
|
||||||
self.is_this_year = True
|
self.is_this_year = True
|
||||||
|
|
||||||
def _set_properties(self) -> None:
|
def _set_properties(self) -> None:
|
||||||
self.spec = "this-year"
|
self.spec = "this-year"
|
||||||
self.desc = gettext("This Year")
|
self.desc = gettext("This year")
|
||||||
self.is_a_year = True
|
self.is_a_year = True
|
||||||
|
|
||||||
|
|
||||||
class LastYear(Period):
|
class LastYear(Period):
|
||||||
"""The period of last year."""
|
"""The period of last year."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
year: int = get_tz_today().year
|
year: int = date.today().year
|
||||||
start: dt.date = dt.date(year - 1, 1, 1)
|
start: date = date(year - 1, 1, 1)
|
||||||
end: dt.date = dt.date(year - 1, 12, 31)
|
end: date = date(year - 1, 12, 31)
|
||||||
super().__init__(start, end)
|
super().__init__(start, end)
|
||||||
self.is_last_year = True
|
self.is_last_year = True
|
||||||
|
|
||||||
def _set_properties(self) -> None:
|
def _set_properties(self) -> None:
|
||||||
self.spec = "last-year"
|
self.spec = "last-year"
|
||||||
self.desc = gettext("Last Year")
|
self.desc = gettext("Last year")
|
||||||
self.is_a_year = True
|
self.is_a_year = True
|
||||||
|
|
||||||
|
|
||||||
class Today(Period):
|
class Today(Period):
|
||||||
"""The period of today."""
|
"""The period of today."""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
today: dt.date = get_tz_today()
|
today: date = date.today()
|
||||||
super().__init__(today, today)
|
super().__init__(today, today)
|
||||||
self.is_today = True
|
self.is_today = True
|
||||||
|
|
||||||
@ -126,7 +125,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: dt.date = get_tz_today() - dt.timedelta(days=1)
|
yesterday: date = date.today() - timedelta(days=1)
|
||||||
super().__init__(yesterday, yesterday)
|
super().__init__(yesterday, yesterday)
|
||||||
self.is_yesterday = True
|
self.is_yesterday = True
|
||||||
|
|
||||||
@ -164,6 +163,6 @@ class YearPeriod(Period):
|
|||||||
|
|
||||||
:param year: The year.
|
:param year: The year.
|
||||||
"""
|
"""
|
||||||
start: dt.date = dt.date(year, 1, 1)
|
start: date = date(year, 1, 1)
|
||||||
end: dt.date = dt.date(year, 12, 31)
|
end: date = date(year, 12, 31)
|
||||||
super().__init__(start, end)
|
super().__init__(start, end)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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,10 +17,10 @@
|
|||||||
"""The period specification composer.
|
"""The period specification composer.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import datetime as dt
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
|
||||||
def get_spec(start: dt.date | None, end: dt.date | None) -> str:
|
def get_spec(start: date | None, end: 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.
|
||||||
@ -44,7 +44,7 @@ def get_spec(start: dt.date | None, end: dt.date | None) -> str:
|
|||||||
return __get_day_spec(start, end)
|
return __get_day_spec(start, end)
|
||||||
|
|
||||||
|
|
||||||
def __get_since_spec(start: dt.date) -> str:
|
def __get_since_spec(start: 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 +57,7 @@ def __get_since_spec(start: dt.date) -> str:
|
|||||||
return start.strftime("%Y-%m-%d-")
|
return start.strftime("%Y-%m-%d-")
|
||||||
|
|
||||||
|
|
||||||
def __get_until_spec(end: dt.date) -> str:
|
def __get_until_spec(end: 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 +65,12 @@ def __get_until_spec(end: dt.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 + dt.timedelta(days=1)).day == 1:
|
if (end + 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: dt.date, end: dt.date) -> str:
|
def __get_year_spec(start: date, end: 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 +88,7 @@ def __get_year_spec(start: dt.date, end: dt.date) -> str:
|
|||||||
return f"{start_spec}-{end_spec}"
|
return f"{start_spec}-{end_spec}"
|
||||||
|
|
||||||
|
|
||||||
def __get_month_spec(start: dt.date, end: dt.date) -> str:
|
def __get_month_spec(start: date, end: 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 +96,7 @@ def __get_month_spec(start: dt.date, end: dt.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 + dt.timedelta(days=1)).day != 1:
|
if start.day != 1 or (end + 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 +105,7 @@ def __get_month_spec(start: dt.date, end: dt.date) -> str:
|
|||||||
return f"{start_spec}-{end_spec}"
|
return f"{start_spec}-{end_spec}"
|
||||||
|
|
||||||
|
|
||||||
def __get_day_spec(start: dt.date, end: dt.date) -> str:
|
def __get_day_spec(start: date, end: 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 Project.
|
# The Mia! Accounting Flask 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,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -24,8 +24,8 @@ from flask import render_template, Response
|
|||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
|
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
||||||
JournalEntryLineItem
|
JournalEntry
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from accounting.report.period import Period, PeriodChooser
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
@ -124,20 +124,19 @@ class AccountCollector:
|
|||||||
sub_conditions: list[sa.BinaryExpression] \
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
|
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
sa.or_(*sub_conditions)]
|
sa.or_(*sub_conditions)]
|
||||||
if self.__period.end is not None:
|
if self.__period.end is not None:
|
||||||
conditions.append(JournalEntry.date <= self.__period.end)
|
conditions.append(Transaction.date <= self.__period.end)
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
else_=-JournalEntryLineItem.amount)).label("balance")
|
else_=-JournalEntry.amount)).label("balance")
|
||||||
select_balance: sa.Select \
|
select_balance: sa.Select \
|
||||||
= sa.select(Account.id, Account.base_code, Account.no,
|
= sa.select(Account.id, Account.base_code, Account.no,
|
||||||
balance_func)\
|
balance_func)\
|
||||||
.join(JournalEntry).join(Account)\
|
.join(Transaction).join(Account)\
|
||||||
.filter(*conditions)\
|
.filter(*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()
|
||||||
@ -145,7 +144,6 @@ class AccountCollector:
|
|||||||
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
|
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
|
||||||
Account.base_code == "3351",
|
Account.base_code == "3351",
|
||||||
Account.base_code == "3353")).all()
|
Account.base_code == "3353")).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] \
|
||||||
@ -155,7 +153,6 @@ 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))
|
||||||
@ -181,8 +178,8 @@ class AccountCollector:
|
|||||||
if self.__period.start is None:
|
if self.__period.start is None:
|
||||||
return None
|
return None
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
JournalEntry.date < self.__period.start]
|
Transaction.date < self.__period.start]
|
||||||
return self.__query_balance(conditions)
|
return self.__query_balance(conditions)
|
||||||
|
|
||||||
def __add_current_period(self) -> None:
|
def __add_current_period(self) -> None:
|
||||||
@ -200,11 +197,11 @@ 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.BinaryExpression] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
= [JournalEntry.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(Transaction.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(Transaction.date <= self.__period.end)
|
||||||
return self.__query_balance(conditions)
|
return self.__query_balance(conditions)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -218,10 +215,10 @@ class AccountCollector:
|
|||||||
conditions.extend([sa.not_(Account.base_code.startswith(x))
|
conditions.extend([sa.not_(Account.base_code.startswith(x))
|
||||||
for x in {"1", "2", "3"}])
|
for x in {"1", "2", "3"}])
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
else_=-JournalEntryLineItem.amount))
|
else_=-JournalEntry.amount))
|
||||||
select_balance: sa.Select = sa.select(balance_func)\
|
select_balance: sa.Select = sa.select(balance_func)\
|
||||||
.join(JournalEntry).join(Account).filter(*conditions)
|
.join(Transaction).join(Account).filter(*conditions)
|
||||||
return db.session.scalar(select_balance)
|
return db.session.scalar(select_balance)
|
||||||
|
|
||||||
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
|
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
|
||||||
@ -454,11 +451,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, None)]
|
= [CSVHalfRow(section.title.title.title(), None)]
|
||||||
for subsection in section.subsections:
|
for subsection in section.subsections:
|
||||||
rows.append(CSVHalfRow(f" {subsection.title.title}", None))
|
rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None))
|
||||||
for account in subsection.accounts:
|
for account in subsection.accounts:
|
||||||
rows.append(CSVHalfRow(f" {str(account.account)}",
|
rows.append(CSVHalfRow(f" {str(account.account).title()}",
|
||||||
account.amount))
|
account.amount))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -17,7 +17,7 @@
|
|||||||
"""The income and expenses log.
|
"""The income and expenses log.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import datetime as dt
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@ -26,39 +26,39 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, Account, JournalEntry, \
|
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||||
JournalEntryLineItem
|
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from accounting.report.period import Period, PeriodChooser
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||||
period_spec
|
period_spec
|
||||||
|
from accounting.report.utils.ie_account import IncomeExpensesAccount
|
||||||
from accounting.report.utils.option_link import OptionLink
|
from accounting.report.utils.option_link import OptionLink
|
||||||
from accounting.report.utils.report_chooser import ReportChooser
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
from accounting.report.utils.report_type import ReportType
|
from accounting.report.utils.report_type import ReportType
|
||||||
from accounting.report.utils.urls import income_expenses_url
|
from accounting.report.utils.urls import income_expenses_url
|
||||||
from accounting.utils.current_account import CurrentAccount
|
from accounting.utils.cast import be
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
class ReportLineItem:
|
class ReportEntry:
|
||||||
"""A line item in the report."""
|
"""An entry in the report."""
|
||||||
|
|
||||||
def __init__(self, line_item: JournalEntryLineItem | None = None):
|
def __init__(self, entry: JournalEntry | None = None):
|
||||||
"""Constructs the line item in the report.
|
"""Constructs the entry in the report.
|
||||||
|
|
||||||
:param line_item: The journal entry line item.
|
:param entry: The journal entry.
|
||||||
"""
|
"""
|
||||||
self.is_brought_forward: bool = False
|
self.is_brought_forward: bool = False
|
||||||
"""Whether this is the brought-forward line item."""
|
"""Whether this is the brought-forward entry."""
|
||||||
self.is_total: bool = False
|
self.is_total: bool = False
|
||||||
"""Whether this is the total line item."""
|
"""Whether this is the total entry."""
|
||||||
self.date: dt.date | None = None
|
self.date: date | None = None
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.account: Account | None = None
|
self.account: Account | None = None
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.description: str | None = None
|
self.summary: str | None = None
|
||||||
"""The description."""
|
"""The summary."""
|
||||||
self.income: Decimal | None = None
|
self.income: Decimal | None = None
|
||||||
"""The income amount."""
|
"""The income amount."""
|
||||||
self.expense: Decimal | None = None
|
self.expense: Decimal | None = None
|
||||||
@ -68,24 +68,24 @@ class ReportLineItem:
|
|||||||
self.note: str | None = None
|
self.note: str | None = None
|
||||||
"""The note."""
|
"""The note."""
|
||||||
self.url: str | None = None
|
self.url: str | None = None
|
||||||
"""The URL to the journal entry line item."""
|
"""The URL to the journal entry."""
|
||||||
if line_item is not None:
|
if entry is not None:
|
||||||
self.date = line_item.journal_entry.date
|
self.date = entry.transaction.date
|
||||||
self.account = line_item.account
|
self.account = entry.account
|
||||||
self.description = line_item.description
|
self.summary = entry.summary
|
||||||
self.income = None if line_item.is_debit else line_item.amount
|
self.income = None if entry.is_debit else entry.amount
|
||||||
self.expense = line_item.amount if line_item.is_debit else None
|
self.expense = entry.amount if entry.is_debit else None
|
||||||
self.note = line_item.journal_entry.note
|
self.note = entry.transaction.note
|
||||||
self.url = url_for("accounting.journal-entry.detail",
|
self.url = url_for("accounting.transaction.detail",
|
||||||
journal_entry=line_item.journal_entry)
|
txn=entry.transaction)
|
||||||
|
|
||||||
|
|
||||||
class LineItemCollector:
|
class EntryCollector:
|
||||||
"""The line item collector."""
|
"""The report entry collector."""
|
||||||
|
|
||||||
def __init__(self, currency: Currency, account: CurrentAccount,
|
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
|
||||||
period: Period):
|
period: Period):
|
||||||
"""Constructs the line item collector.
|
"""Constructs the report entry collector.
|
||||||
|
|
||||||
:param currency: The currency.
|
:param currency: The currency.
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
@ -93,149 +93,148 @@ class LineItemCollector:
|
|||||||
"""
|
"""
|
||||||
self.__currency: Currency = currency
|
self.__currency: Currency = currency
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
self.__account: CurrentAccount = account
|
self.__account: IncomeExpensesAccount = account
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.__period: Period = period
|
self.__period: Period = period
|
||||||
"""The period"""
|
"""The period"""
|
||||||
self.brought_forward: ReportLineItem | None
|
self.brought_forward: ReportEntry | None
|
||||||
"""The brought-forward line item."""
|
"""The brought-forward entry."""
|
||||||
self.line_items: list[ReportLineItem]
|
self.entries: list[ReportEntry]
|
||||||
"""The line items."""
|
"""The log entries."""
|
||||||
self.total: ReportLineItem | None
|
self.total: ReportEntry | None
|
||||||
"""The total line item."""
|
"""The total entry."""
|
||||||
self.brought_forward = self.__get_brought_forward()
|
self.brought_forward = self.__get_brought_forward_entry()
|
||||||
self.line_items = self.__query_line_items()
|
self.entries = self.__query_entries()
|
||||||
self.total = self.__get_total()
|
self.total = self.__get_total_entry()
|
||||||
self.__populate_balance()
|
self.__populate_balance()
|
||||||
|
|
||||||
def __get_brought_forward(self) -> ReportLineItem | None:
|
def __get_brought_forward_entry(self) -> ReportEntry | None:
|
||||||
"""Queries, composes and returns the brought-forward line item.
|
"""Queries, composes and returns the brought-forward entry.
|
||||||
|
|
||||||
:return: The brought-forward line item, or None if the period starts
|
:return: The brought-forward entry, or None if the period starts from
|
||||||
from the beginning.
|
the beginning.
|
||||||
"""
|
"""
|
||||||
if self.__period.start is None:
|
if self.__period.start is None:
|
||||||
return None
|
return None
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
else_=-JournalEntryLineItem.amount))
|
else_=-JournalEntry.amount))
|
||||||
select: sa.Select = sa.Select(balance_func)\
|
select: sa.Select = sa.Select(balance_func)\
|
||||||
.join(JournalEntry).join(Account)\
|
.join(Transaction).join(Account)\
|
||||||
.filter(JournalEntryLineItem.currency_code == self.__currency.code,
|
.filter(be(JournalEntry.currency_code == self.__currency.code),
|
||||||
self.__account_condition,
|
self.__account_condition,
|
||||||
JournalEntry.date < self.__period.start)
|
Transaction.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
|
||||||
line_item: ReportLineItem = ReportLineItem()
|
entry: ReportEntry = ReportEntry()
|
||||||
line_item.is_brought_forward = True
|
entry.is_brought_forward = True
|
||||||
line_item.date = self.__period.start
|
entry.date = self.__period.start
|
||||||
line_item.account = Account.accumulated_change()
|
entry.account = Account.accumulated_change()
|
||||||
line_item.description = gettext("Brought forward")
|
entry.summary = gettext("Brought forward")
|
||||||
if balance > 0:
|
if balance > 0:
|
||||||
line_item.income = balance
|
entry.income = balance
|
||||||
elif balance < 0:
|
elif balance < 0:
|
||||||
line_item.expense = -balance
|
entry.expense = -balance
|
||||||
line_item.balance = balance
|
entry.balance = balance
|
||||||
return line_item
|
return entry
|
||||||
|
|
||||||
def __query_line_items(self) -> list[ReportLineItem]:
|
def __query_entries(self) -> list[ReportEntry]:
|
||||||
"""Queries and returns the line items.
|
"""Queries and returns the log entries.
|
||||||
|
|
||||||
:return: The line items.
|
:return: The log entries.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
self.__account_condition]
|
self.__account_condition]
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(JournalEntry.date >= self.__period.start)
|
conditions.append(Transaction.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(Transaction.date <= self.__period.end)
|
||||||
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
|
txn_with_account: sa.Select = sa.Select(Transaction.id).\
|
||||||
join(JournalEntryLineItem).join(Account).filter(*conditions)
|
join(JournalEntry).join(Account).filter(*conditions)
|
||||||
|
|
||||||
return [ReportLineItem(x)
|
return [ReportEntry(x)
|
||||||
for x in JournalEntryLineItem.query
|
for x in JournalEntry.query.join(Transaction).join(Account)
|
||||||
.join(JournalEntry).join(Account)
|
.filter(JournalEntry.transaction_id.in_(txn_with_account),
|
||||||
.filter(JournalEntryLineItem.journal_entry_id
|
JournalEntry.currency_code == self.__currency.code,
|
||||||
.in_(journal_entry_with_account),
|
|
||||||
JournalEntryLineItem.currency_code
|
|
||||||
== self.__currency.code,
|
|
||||||
sa.not_(self.__account_condition))
|
sa.not_(self.__account_condition))
|
||||||
.order_by(JournalEntry.date,
|
.order_by(Transaction.date,
|
||||||
JournalEntry.no,
|
Transaction.no,
|
||||||
JournalEntryLineItem.is_debit,
|
JournalEntry.is_debit,
|
||||||
JournalEntryLineItem.no)
|
JournalEntry.no)
|
||||||
.options(selectinload(JournalEntryLineItem.account),
|
.options(selectinload(JournalEntry.account),
|
||||||
selectinload(JournalEntryLineItem.journal_entry))]
|
selectinload(JournalEntry.transaction))]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def __account_condition(self) -> sa.BinaryExpression:
|
def __account_condition(self) -> sa.BinaryExpression:
|
||||||
if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
|
if self.__account.code == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||||
return CurrentAccount.sql_condition()
|
return sa.or_(Account.base_code.startswith("11"),
|
||||||
|
Account.base_code.startswith("12"),
|
||||||
|
Account.base_code.startswith("21"),
|
||||||
|
Account.base_code.startswith("22"))
|
||||||
return Account.id == self.__account.id
|
return Account.id == self.__account.id
|
||||||
|
|
||||||
def __get_total(self) -> ReportLineItem | None:
|
def __get_total_entry(self) -> ReportEntry | None:
|
||||||
"""Composes the total line item.
|
"""Composes the total entry.
|
||||||
|
|
||||||
:return: The total line item, or None if there is no data.
|
:return: The total entry, or None if there is no data.
|
||||||
"""
|
"""
|
||||||
if self.brought_forward is None and len(self.line_items) == 0:
|
if self.brought_forward is None and len(self.entries) == 0:
|
||||||
return None
|
return None
|
||||||
line_item: ReportLineItem = ReportLineItem()
|
entry: ReportEntry = ReportEntry()
|
||||||
line_item.is_total = True
|
entry.is_total = True
|
||||||
line_item.description = gettext("Total")
|
entry.summary = gettext("Total")
|
||||||
line_item.income = sum([x.income for x in self.line_items
|
entry.income = sum([x.income for x in self.entries
|
||||||
if x.income is not None])
|
if x.income is not None])
|
||||||
line_item.expense = sum([x.expense for x in self.line_items
|
entry.expense = sum([x.expense for x in self.entries
|
||||||
if x.expense is not None])
|
if x.expense is not None])
|
||||||
line_item.balance = line_item.income - line_item.expense
|
entry.balance = entry.income - entry.expense
|
||||||
if self.brought_forward is not None:
|
if self.brought_forward is not None:
|
||||||
line_item.balance \
|
entry.balance = self.brought_forward.balance + entry.balance
|
||||||
= self.brought_forward.balance + line_item.balance
|
return entry
|
||||||
return line_item
|
|
||||||
|
|
||||||
def __populate_balance(self) -> None:
|
def __populate_balance(self) -> None:
|
||||||
"""Populates the balance of the line items.
|
"""Populates the balance of the entries.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
balance: Decimal = 0 if self.brought_forward is None \
|
balance: Decimal = 0 if self.brought_forward is None \
|
||||||
else self.brought_forward.balance
|
else self.brought_forward.balance
|
||||||
for line_item in self.line_items:
|
for entry in self.entries:
|
||||||
if line_item.income is not None:
|
if entry.income is not None:
|
||||||
balance = balance + line_item.income
|
balance = balance + entry.income
|
||||||
if line_item.expense is not None:
|
if entry.expense is not None:
|
||||||
balance = balance - line_item.expense
|
balance = balance - entry.expense
|
||||||
line_item.balance = balance
|
entry.balance = balance
|
||||||
|
|
||||||
|
|
||||||
class CSVRow(BaseCSVRow):
|
class CSVRow(BaseCSVRow):
|
||||||
"""A row in the CSV."""
|
"""A row in the CSV."""
|
||||||
|
|
||||||
def __init__(self, date: dt.date | str | None,
|
def __init__(self, txn_date: date | str | None,
|
||||||
account: str | None,
|
account: str | None,
|
||||||
description: str | None,
|
summary: str | None,
|
||||||
income: str | Decimal | None,
|
income: str | Decimal | None,
|
||||||
expense: str | Decimal | None,
|
expense: str | Decimal | None,
|
||||||
balance: str | Decimal | None,
|
balance: str | Decimal | None,
|
||||||
note: str | None):
|
note: str | None):
|
||||||
"""Constructs a row in the CSV.
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
:param date: The journal entry date.
|
:param txn_date: The transaction date.
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
:param description: The description.
|
:param summary: The summary.
|
||||||
:param income: The income.
|
:param income: The income.
|
||||||
:param expense: The expense.
|
:param expense: The expense.
|
||||||
:param balance: The balance.
|
:param balance: The balance.
|
||||||
:param note: The note.
|
:param note: The note.
|
||||||
"""
|
"""
|
||||||
self.date: dt.date | str | None = date
|
self.date: date | str | None = txn_date
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.account: str | None = account
|
self.account: str | None = account
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.description: str | None = description
|
self.summary: str | None = summary
|
||||||
"""The description."""
|
"""The summary."""
|
||||||
self.income: str | Decimal | None = income
|
self.income: str | Decimal | None = income
|
||||||
"""The income."""
|
"""The income."""
|
||||||
self.expense: str | Decimal | None = expense
|
self.expense: str | Decimal | None = expense
|
||||||
@ -251,7 +250,7 @@ class CSVRow(BaseCSVRow):
|
|||||||
|
|
||||||
:return: The values of the row.
|
:return: The values of the row.
|
||||||
"""
|
"""
|
||||||
return [self.date, self.account, self.description,
|
return [self.date, self.account, self.summary,
|
||||||
self.income, self.expense, self.balance, self.note]
|
self.income, self.expense, self.balance, self.note]
|
||||||
|
|
||||||
|
|
||||||
@ -259,39 +258,39 @@ class PageParams(BasePageParams):
|
|||||||
"""The HTML page parameters."""
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
def __init__(self, currency: Currency,
|
def __init__(self, currency: Currency,
|
||||||
account: CurrentAccount,
|
account: IncomeExpensesAccount,
|
||||||
period: Period,
|
period: Period,
|
||||||
has_data: bool,
|
has_data: bool,
|
||||||
pagination: Pagination[ReportLineItem],
|
pagination: Pagination[ReportEntry],
|
||||||
brought_forward: ReportLineItem | None,
|
brought_forward: ReportEntry | None,
|
||||||
line_items: list[ReportLineItem],
|
entries: list[ReportEntry],
|
||||||
total: ReportLineItem | None):
|
total: ReportEntry | None):
|
||||||
"""Constructs the HTML page parameters.
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
:param currency: The currency.
|
:param currency: The currency.
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
:param period: The period.
|
:param period: The period.
|
||||||
:param has_data: True if there is any data, or False otherwise.
|
:param has_data: True if there is any data, or False otherwise.
|
||||||
:param brought_forward: The brought-forward line item.
|
:param brought_forward: The brought-forward entry.
|
||||||
:param line_items: The line items.
|
:param entries: The log entries.
|
||||||
:param total: The total line item.
|
:param total: The total entry.
|
||||||
"""
|
"""
|
||||||
self.currency: Currency = currency
|
self.currency: Currency = currency
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
self.account: CurrentAccount = account
|
self.account: IncomeExpensesAccount = account
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.period: Period = period
|
self.period: Period = period
|
||||||
"""The period."""
|
"""The period."""
|
||||||
self.__has_data: bool = has_data
|
self.__has_data: bool = has_data
|
||||||
"""True if there is any data, or False otherwise."""
|
"""True if there is any data, or False otherwise."""
|
||||||
self.pagination: Pagination[ReportLineItem] = pagination
|
self.pagination: Pagination[ReportEntry] = pagination
|
||||||
"""The pagination."""
|
"""The pagination."""
|
||||||
self.brought_forward: ReportLineItem | None = brought_forward
|
self.brought_forward: ReportEntry | None = brought_forward
|
||||||
"""The brought-forward line item."""
|
"""The brought-forward entry."""
|
||||||
self.line_items: list[ReportLineItem] = line_items
|
self.entries: list[ReportEntry] = entries
|
||||||
"""The line items."""
|
"""The report entries."""
|
||||||
self.total: ReportLineItem | None = total
|
self.total: ReportEntry | None = total
|
||||||
"""The total line item."""
|
"""The total entry."""
|
||||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||||
lambda x: income_expenses_url(currency, account, x))
|
lambda x: income_expenses_url(currency, account, x))
|
||||||
"""The period chooser."""
|
"""The period chooser."""
|
||||||
@ -336,22 +335,25 @@ class PageParams(BasePageParams):
|
|||||||
|
|
||||||
:return: The account options.
|
:return: The account options.
|
||||||
"""
|
"""
|
||||||
current_al: CurrentAccount \
|
current_al: IncomeExpensesAccount \
|
||||||
= CurrentAccount.current_assets_and_liabilities()
|
= IncomeExpensesAccount.current_assets_and_liabilities()
|
||||||
options: list[OptionLink] \
|
options: list[OptionLink] \
|
||||||
= [OptionLink(str(current_al),
|
= [OptionLink(str(current_al),
|
||||||
income_expenses_url(self.currency, current_al,
|
income_expenses_url(self.currency, current_al,
|
||||||
self.period),
|
self.period),
|
||||||
self.account.id == 0)]
|
self.account.id == 0)]
|
||||||
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
|
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
||||||
.join(Account)\
|
.join(Account)\
|
||||||
.filter(JournalEntryLineItem.currency_code == self.currency.code,
|
.filter(be(JournalEntry.currency_code == self.currency.code),
|
||||||
CurrentAccount.sql_condition())\
|
sa.or_(Account.base_code.startswith("11"),
|
||||||
.group_by(JournalEntryLineItem.account_id)
|
Account.base_code.startswith("12"),
|
||||||
|
Account.base_code.startswith("21"),
|
||||||
|
Account.base_code.startswith("22")))\
|
||||||
|
.group_by(JournalEntry.account_id)
|
||||||
options.extend([OptionLink(str(x),
|
options.extend([OptionLink(str(x),
|
||||||
income_expenses_url(
|
income_expenses_url(
|
||||||
self.currency,
|
self.currency,
|
||||||
CurrentAccount(x),
|
IncomeExpensesAccount(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 Account.query.filter(Account.id.in_(in_use))
|
||||||
@ -362,7 +364,7 @@ class PageParams(BasePageParams):
|
|||||||
class IncomeExpenses(BaseReport):
|
class IncomeExpenses(BaseReport):
|
||||||
"""The income and expenses log."""
|
"""The income and expenses log."""
|
||||||
|
|
||||||
def __init__(self, currency: Currency, account: CurrentAccount,
|
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
|
||||||
period: Period):
|
period: Period):
|
||||||
"""Constructs an income and expenses log.
|
"""Constructs an income and expenses log.
|
||||||
|
|
||||||
@ -372,19 +374,18 @@ class IncomeExpenses(BaseReport):
|
|||||||
"""
|
"""
|
||||||
self.__currency: Currency = currency
|
self.__currency: Currency = currency
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
self.__account: CurrentAccount = account
|
self.__account: IncomeExpensesAccount = account
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.__period: Period = period
|
self.__period: Period = period
|
||||||
"""The period."""
|
"""The period."""
|
||||||
collector: LineItemCollector = LineItemCollector(
|
collector: EntryCollector = EntryCollector(
|
||||||
self.__currency, self.__account, self.__period)
|
self.__currency, self.__account, self.__period)
|
||||||
self.__brought_forward: ReportLineItem | None \
|
self.__brought_forward: ReportEntry | None = collector.brought_forward
|
||||||
= collector.brought_forward
|
"""The brought-forward entry."""
|
||||||
"""The brought-forward line item."""
|
self.__entries: list[ReportEntry] = collector.entries
|
||||||
self.__line_items: list[ReportLineItem] = collector.line_items
|
"""The report entries."""
|
||||||
"""The line items."""
|
self.__total: ReportEntry | None = collector.total
|
||||||
self.__total: ReportLineItem | None = collector.total
|
"""The total entry."""
|
||||||
"""The total line item."""
|
|
||||||
|
|
||||||
def csv(self) -> Response:
|
def csv(self) -> Response:
|
||||||
"""Returns the report as CSV for download.
|
"""Returns the report as CSV for download.
|
||||||
@ -402,20 +403,20 @@ class IncomeExpenses(BaseReport):
|
|||||||
:return: The CSV rows.
|
:return: The CSV rows.
|
||||||
"""
|
"""
|
||||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
|
||||||
gettext("Description"), gettext("Income"),
|
gettext("Summary"), gettext("Income"),
|
||||||
gettext("Expense"), gettext("Balance"),
|
gettext("Expense"), gettext("Balance"),
|
||||||
gettext("Note"))]
|
gettext("Note"))]
|
||||||
if self.__brought_forward is not None:
|
if self.__brought_forward is not None:
|
||||||
rows.append(CSVRow(self.__brought_forward.date,
|
rows.append(CSVRow(self.__brought_forward.date,
|
||||||
str(self.__brought_forward.account),
|
str(self.__brought_forward.account).title(),
|
||||||
self.__brought_forward.description,
|
self.__brought_forward.summary,
|
||||||
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), x.description,
|
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
|
||||||
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.__entries])
|
||||||
if self.__total is not None:
|
if self.__total is not None:
|
||||||
rows.append(CSVRow(gettext("Total"), None, None,
|
rows.append(CSVRow(gettext("Total"), None, None,
|
||||||
self.__total.income, self.__total.expense,
|
self.__total.income, self.__total.expense,
|
||||||
@ -427,31 +428,31 @@ class IncomeExpenses(BaseReport):
|
|||||||
|
|
||||||
:return: The report as HTML.
|
:return: The report as HTML.
|
||||||
"""
|
"""
|
||||||
all_line_items: list[ReportLineItem] = []
|
all_entries: list[ReportEntry] = []
|
||||||
if self.__brought_forward is not None:
|
if self.__brought_forward is not None:
|
||||||
all_line_items.append(self.__brought_forward)
|
all_entries.append(self.__brought_forward)
|
||||||
all_line_items.extend(self.__line_items)
|
all_entries.extend(self.__entries)
|
||||||
if self.__total is not None:
|
if self.__total is not None:
|
||||||
all_line_items.append(self.__total)
|
all_entries.append(self.__total)
|
||||||
pagination: Pagination[ReportLineItem] \
|
pagination: Pagination[ReportEntry] \
|
||||||
= Pagination[ReportLineItem](all_line_items, is_reversed=True)
|
= Pagination[ReportEntry](all_entries, is_reversed=True)
|
||||||
page_line_items: list[ReportLineItem] = pagination.list
|
page_entries: list[ReportEntry] = pagination.list
|
||||||
has_data: bool = len(page_line_items) > 0
|
has_data: bool = len(page_entries) > 0
|
||||||
brought_forward: ReportLineItem | None = None
|
brought_forward: ReportEntry | None = None
|
||||||
if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
|
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
|
||||||
brought_forward = page_line_items[0]
|
brought_forward = page_entries[0]
|
||||||
page_line_items = page_line_items[1:]
|
page_entries = page_entries[1:]
|
||||||
total: ReportLineItem | None = None
|
total: ReportEntry | None = None
|
||||||
if len(page_line_items) > 0 and page_line_items[-1].is_total:
|
if len(page_entries) > 0 and page_entries[-1].is_total:
|
||||||
total = page_line_items[-1]
|
total = page_entries[-1]
|
||||||
page_line_items = page_line_items[:-1]
|
page_entries = page_entries[:-1]
|
||||||
params: PageParams = PageParams(currency=self.__currency,
|
params: PageParams = PageParams(currency=self.__currency,
|
||||||
account=self.__account,
|
account=self.__account,
|
||||||
period=self.__period,
|
period=self.__period,
|
||||||
has_data=has_data,
|
has_data=has_data,
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
brought_forward=brought_forward,
|
brought_forward=brought_forward,
|
||||||
line_items=page_line_items,
|
entries=page_entries,
|
||||||
total=total)
|
total=total)
|
||||||
return render_template("accounting/report/income-expenses.html",
|
return render_template("accounting/report/income-expenses.html",
|
||||||
report=params)
|
report=params)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -24,8 +24,8 @@ from flask import render_template, Response
|
|||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
|
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
||||||
JournalEntryLineItem
|
JournalEntry
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from accounting.report.period import Period, PeriodChooser
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
@ -106,7 +106,6 @@ 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:
|
||||||
@ -226,12 +225,12 @@ class IncomeStatement(BaseReport):
|
|||||||
for x in balances})).all()
|
for x in balances})).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}
|
||||||
@ -257,20 +256,19 @@ class IncomeStatement(BaseReport):
|
|||||||
sub_conditions: list[sa.BinaryExpression] \
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
|
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
sa.or_(*sub_conditions)]
|
sa.or_(*sub_conditions)]
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(JournalEntry.date >= self.__period.start)
|
conditions.append(Transaction.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(Transaction.date <= self.__period.end)
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntryLineItem.is_debit, -JournalEntryLineItem.amount),
|
(JournalEntry.is_debit, -JournalEntry.amount),
|
||||||
else_=JournalEntryLineItem.amount)).label("balance")
|
else_=JournalEntry.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(Transaction).join(Account)\
|
||||||
.filter(*conditions)\
|
.filter(*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] \
|
||||||
@ -301,14 +299,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), None))
|
rows.append(CSVRow(str(section.title).title(), None))
|
||||||
for subsection in section.subsections:
|
for subsection in section.subsections:
|
||||||
rows.append(CSVRow(f" {str(subsection.title)}", None))
|
rows.append(CSVRow(f" {str(subsection.title).title()}", None))
|
||||||
for account in subsection.accounts:
|
for account in subsection.accounts:
|
||||||
rows.append(CSVRow(f" {str(account.account)}",
|
rows.append(CSVRow(f" {str(account.account).title()}",
|
||||||
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,
|
rows.append(CSVRow(section.accumulated.title.title(),
|
||||||
section.accumulated.amount))
|
section.accumulated.amount))
|
||||||
rows.append(CSVRow(None, None))
|
rows.append(CSVRow(None, None))
|
||||||
rows = rows[:-1]
|
rows = rows[:-1]
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -17,7 +17,7 @@
|
|||||||
"""The journal.
|
"""The journal.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import datetime as dt
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@ -25,8 +25,7 @@ from flask import render_template, Response
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, Account, JournalEntry, \
|
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||||
JournalEntryLineItem
|
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from accounting.report.period import Period, PeriodChooser
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
@ -38,60 +37,58 @@ from accounting.report.utils.urls import journal_url
|
|||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
class ReportLineItem:
|
class ReportEntry:
|
||||||
"""A line item in the report."""
|
"""An entry in the report."""
|
||||||
|
|
||||||
def __init__(self, line_item: JournalEntryLineItem):
|
def __init__(self, entry: JournalEntry):
|
||||||
"""Constructs the line item in the report.
|
"""Constructs the entry in the report.
|
||||||
|
|
||||||
:param line_item: The journal entry line item.
|
:param entry: The journal entry.
|
||||||
"""
|
"""
|
||||||
self.line_item: JournalEntryLineItem = line_item
|
self.entry: JournalEntry = entry
|
||||||
"""The journal entry line item."""
|
|
||||||
self.journal_entry: JournalEntry = line_item.journal_entry
|
|
||||||
"""The journal entry."""
|
"""The journal entry."""
|
||||||
self.currency: Currency = line_item.currency
|
self.transaction: Transaction = entry.transaction
|
||||||
|
"""The transaction."""
|
||||||
|
self.currency: Currency = entry.currency
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.account: Account = line_item.account
|
self.account: Account = entry.account
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.description: str | None = line_item.description
|
self.summary: str | None = entry.summary
|
||||||
"""The description."""
|
"""The summary."""
|
||||||
self.debit: Decimal | None = line_item.debit
|
self.debit: Decimal | None = entry.debit
|
||||||
"""The debit amount."""
|
"""The debit amount."""
|
||||||
self.credit: Decimal | None = line_item.credit
|
self.credit: Decimal | None = entry.credit
|
||||||
"""The credit amount."""
|
"""The credit amount."""
|
||||||
self.amount: Decimal = line_item.amount
|
self.amount: Decimal = entry.amount
|
||||||
"""The amount."""
|
"""The amount."""
|
||||||
|
|
||||||
|
|
||||||
class CSVRow(BaseCSVRow):
|
class CSVRow(BaseCSVRow):
|
||||||
"""A row in the CSV."""
|
"""A row in the CSV."""
|
||||||
|
|
||||||
def __init__(self, journal_entry_date: str | dt.date,
|
def __init__(self, txn_date: str | date,
|
||||||
currency: str,
|
currency: str,
|
||||||
account: str,
|
account: str,
|
||||||
description: str | None,
|
summary: str | None,
|
||||||
debit: str | Decimal | None,
|
debit: str | Decimal | None,
|
||||||
credit: str | Decimal | None,
|
credit: str | Decimal | None,
|
||||||
note: str | None):
|
note: str | None):
|
||||||
"""Constructs a row in the CSV.
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
:param journal_entry_date: The journal entry date.
|
:param txn_date: The transaction date.
|
||||||
:param currency: The currency.
|
:param summary: The summary.
|
||||||
:param account: The account.
|
|
||||||
: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 | dt.date = journal_entry_date
|
self.date: str | date = txn_date
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.currency: str = currency
|
self.currency: str = currency
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
self.account: str = account
|
self.account: str = account
|
||||||
"""The account."""
|
"""The account."""
|
||||||
self.description: str | None = description
|
self.summary: str | None = summary
|
||||||
"""The description."""
|
"""The summary."""
|
||||||
self.debit: str | Decimal | None = debit
|
self.debit: str | Decimal | None = debit
|
||||||
"""The debit amount."""
|
"""The debit amount."""
|
||||||
self.credit: str | Decimal | None = credit
|
self.credit: str | Decimal | None = credit
|
||||||
@ -105,7 +102,7 @@ class CSVRow(BaseCSVRow):
|
|||||||
|
|
||||||
:return: The values of the row.
|
:return: The values of the row.
|
||||||
"""
|
"""
|
||||||
return [self.date, self.currency, self.account, self.description,
|
return [self.date, self.currency, self.account, self.summary,
|
||||||
self.debit, self.credit, self.note]
|
self.debit, self.credit, self.note]
|
||||||
|
|
||||||
|
|
||||||
@ -113,20 +110,19 @@ class PageParams(BasePageParams):
|
|||||||
"""The HTML page parameters."""
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
def __init__(self, period: Period,
|
def __init__(self, period: Period,
|
||||||
pagination: Pagination[JournalEntryLineItem],
|
pagination: Pagination[JournalEntry],
|
||||||
line_items: list[JournalEntryLineItem]):
|
entries: list[JournalEntry]):
|
||||||
"""Constructs the HTML page parameters.
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
:param period: The period.
|
:param period: The period.
|
||||||
:param pagination: The pagination.
|
:param entries: The journal entries.
|
||||||
:param line_items: The line items.
|
|
||||||
"""
|
"""
|
||||||
self.period: Period = period
|
self.period: Period = period
|
||||||
"""The period."""
|
"""The period."""
|
||||||
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
self.pagination: Pagination[JournalEntry] = pagination
|
||||||
"""The pagination."""
|
"""The pagination."""
|
||||||
self.line_items: list[JournalEntryLineItem] = line_items
|
self.entries: list[JournalEntry] = entries
|
||||||
"""The line items."""
|
"""The entries."""
|
||||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||||
lambda x: journal_url(x))
|
lambda x: journal_url(x))
|
||||||
"""The period chooser."""
|
"""The period chooser."""
|
||||||
@ -137,7 +133,7 @@ class PageParams(BasePageParams):
|
|||||||
|
|
||||||
:return: True if there is any data, or False otherwise.
|
:return: True if there is any data, or False otherwise.
|
||||||
"""
|
"""
|
||||||
return len(self.line_items) > 0
|
return len(self.entries) > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def report_chooser(self) -> ReportChooser:
|
def report_chooser(self) -> ReportChooser:
|
||||||
@ -149,20 +145,20 @@ class PageParams(BasePageParams):
|
|||||||
period=self.period)
|
period=self.period)
|
||||||
|
|
||||||
|
|
||||||
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
|
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
|
||||||
"""Composes and returns the CSV rows from the line items.
|
"""Composes and returns the CSV rows from the report entries.
|
||||||
|
|
||||||
:param line_items: The line items.
|
:param entries: The report entries.
|
||||||
:return: The CSV rows.
|
:return: The CSV rows.
|
||||||
"""
|
"""
|
||||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
|
||||||
gettext("Account"), gettext("Description"),
|
gettext("Account"), gettext("Summary"),
|
||||||
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.transaction.date, x.currency.code,
|
||||||
str(x.account), x.description,
|
str(x.account).title(), x.summary,
|
||||||
x.debit, x.credit, x.journal_entry.note)
|
x.debit, x.credit, x.transaction.note)
|
||||||
for x in line_items])
|
for x in entries])
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@ -176,29 +172,28 @@ class Journal(BaseReport):
|
|||||||
"""
|
"""
|
||||||
self.__period: Period = period
|
self.__period: Period = period
|
||||||
"""The period."""
|
"""The period."""
|
||||||
self.__line_items: list[JournalEntryLineItem] \
|
self.__entries: list[JournalEntry] = self.__query_entries()
|
||||||
= self.__query_line_items()
|
"""The journal entries."""
|
||||||
"""The line items."""
|
|
||||||
|
|
||||||
def __query_line_items(self) -> list[JournalEntryLineItem]:
|
def __query_entries(self) -> list[JournalEntry]:
|
||||||
"""Queries and returns the line items.
|
"""Queries and returns the journal entries.
|
||||||
|
|
||||||
:return: The line items.
|
:return: The journal entries.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
if self.__period.start is not None:
|
if self.__period.start is not None:
|
||||||
conditions.append(JournalEntry.date >= self.__period.start)
|
conditions.append(Transaction.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(Transaction.date <= self.__period.end)
|
||||||
return JournalEntryLineItem.query.join(JournalEntry)\
|
return JournalEntry.query.join(Transaction)\
|
||||||
.filter(*conditions)\
|
.filter(*conditions)\
|
||||||
.order_by(JournalEntry.date,
|
.order_by(Transaction.date,
|
||||||
JournalEntry.no,
|
Transaction.no,
|
||||||
JournalEntryLineItem.is_debit.desc(),
|
JournalEntry.is_debit.desc(),
|
||||||
JournalEntryLineItem.no)\
|
JournalEntry.no)\
|
||||||
.options(selectinload(JournalEntryLineItem.account),
|
.options(selectinload(JournalEntry.account),
|
||||||
selectinload(JournalEntryLineItem.currency),
|
selectinload(JournalEntry.currency),
|
||||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
selectinload(JournalEntry.transaction)).all()
|
||||||
|
|
||||||
def csv(self) -> Response:
|
def csv(self) -> Response:
|
||||||
"""Returns the report as CSV for download.
|
"""Returns the report as CSV for download.
|
||||||
@ -206,18 +201,17 @@ class Journal(BaseReport):
|
|||||||
:return: The response of the report for download.
|
:return: The response of the report for download.
|
||||||
"""
|
"""
|
||||||
filename: str = f"journal-{period_spec(self.__period)}.csv"
|
filename: str = f"journal-{period_spec(self.__period)}.csv"
|
||||||
return csv_download(filename, get_csv_rows(self.__line_items))
|
return csv_download(filename, get_csv_rows(self.__entries))
|
||||||
|
|
||||||
def html(self) -> str:
|
def html(self) -> str:
|
||||||
"""Composes and returns the report as HTML.
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
:return: The report as HTML.
|
:return: The report as HTML.
|
||||||
"""
|
"""
|
||||||
pagination: Pagination[JournalEntryLineItem] \
|
pagination: Pagination[JournalEntry] \
|
||||||
= Pagination[JournalEntryLineItem](self.__line_items,
|
= Pagination[JournalEntry](self.__entries, is_reversed=True)
|
||||||
is_reversed=True)
|
|
||||||
params: PageParams = PageParams(period=self.__period,
|
params: PageParams = PageParams(period=self.__period,
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
line_items=pagination.list)
|
entries=pagination.list)
|
||||||
return render_template("accounting/report/journal.html",
|
return render_template("accounting/report/journal.html",
|
||||||
report=params)
|
report=params)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -17,7 +17,7 @@
|
|||||||
"""The ledger.
|
"""The ledger.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import datetime as dt
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@ -26,8 +26,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, Account, JournalEntry, \
|
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||||
JournalEntryLineItem
|
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from accounting.report.period import Period, PeriodChooser
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
@ -37,25 +36,26 @@ from accounting.report.utils.option_link import OptionLink
|
|||||||
from accounting.report.utils.report_chooser import ReportChooser
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
from accounting.report.utils.report_type import ReportType
|
from accounting.report.utils.report_type import ReportType
|
||||||
from accounting.report.utils.urls import ledger_url
|
from accounting.report.utils.urls import ledger_url
|
||||||
|
from accounting.utils.cast import be
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
class ReportLineItem:
|
class ReportEntry:
|
||||||
"""A line item in the report."""
|
"""An entry in the report."""
|
||||||
|
|
||||||
def __init__(self, line_item: JournalEntryLineItem | None = None):
|
def __init__(self, entry: JournalEntry | None = None):
|
||||||
"""Constructs the line item in the report.
|
"""Constructs the entry in the report.
|
||||||
|
|
||||||
:param line_item: The journal entry line item.
|
:param entry: The journal entry.
|
||||||
"""
|
"""
|
||||||
self.is_brought_forward: bool = False
|
self.is_brought_forward: bool = False
|
||||||
"""Whether this is the brought-forward line item."""
|
"""Whether this is the brought-forward entry."""
|
||||||
self.is_total: bool = False
|
self.is_total: bool = False
|
||||||
"""Whether this is the total line item."""
|
"""Whether this is the total entry."""
|
||||||
self.date: dt.date | None = None
|
self.date: date | None = None
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.description: str | None = None
|
self.summary: str | None = None
|
||||||
"""The description."""
|
"""The summary."""
|
||||||
self.debit: Decimal | None = None
|
self.debit: Decimal | None = None
|
||||||
"""The debit amount."""
|
"""The debit amount."""
|
||||||
self.credit: Decimal | None = None
|
self.credit: Decimal | None = None
|
||||||
@ -65,22 +65,22 @@ class ReportLineItem:
|
|||||||
self.note: str | None = None
|
self.note: str | None = None
|
||||||
"""The note."""
|
"""The note."""
|
||||||
self.url: str | None = None
|
self.url: str | None = None
|
||||||
"""The URL to the journal entry line item."""
|
"""The URL to the journal entry."""
|
||||||
if line_item is not None:
|
if entry is not None:
|
||||||
self.date = line_item.journal_entry.date
|
self.date = entry.transaction.date
|
||||||
self.description = line_item.description
|
self.summary = entry.summary
|
||||||
self.debit = line_item.amount if line_item.is_debit else None
|
self.debit = entry.amount if entry.is_debit else None
|
||||||
self.credit = None if line_item.is_debit else line_item.amount
|
self.credit = None if entry.is_debit else entry.amount
|
||||||
self.note = line_item.journal_entry.note
|
self.note = entry.transaction.note
|
||||||
self.url = url_for("accounting.journal-entry.detail",
|
self.url = url_for("accounting.transaction.detail",
|
||||||
journal_entry=line_item.journal_entry)
|
txn=entry.transaction)
|
||||||
|
|
||||||
|
|
||||||
class LineItemCollector:
|
class EntryCollector:
|
||||||
"""The line item collector."""
|
"""The report entry collector."""
|
||||||
|
|
||||||
def __init__(self, currency: Currency, account: Account, period: Period):
|
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||||
"""Constructs the line item collector.
|
"""Constructs the report entry collector.
|
||||||
|
|
||||||
:param currency: The currency.
|
:param currency: The currency.
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
@ -92,92 +92,89 @@ class LineItemCollector:
|
|||||||
"""The account."""
|
"""The account."""
|
||||||
self.__period: Period = period
|
self.__period: Period = period
|
||||||
"""The period"""
|
"""The period"""
|
||||||
self.brought_forward: ReportLineItem | None
|
self.brought_forward: ReportEntry | None
|
||||||
"""The brought-forward line item."""
|
"""The brought-forward entry."""
|
||||||
self.line_items: list[ReportLineItem]
|
self.entries: list[ReportEntry]
|
||||||
"""The line items."""
|
"""The report entries."""
|
||||||
self.total: ReportLineItem | None
|
self.total: ReportEntry | None
|
||||||
"""The total line item."""
|
"""The total entry."""
|
||||||
self.brought_forward = self.__get_brought_forward()
|
self.brought_forward = self.__get_brought_forward_entry()
|
||||||
self.line_items = self.__query_line_items()
|
self.entries = self.__query_entries()
|
||||||
self.total = self.__get_total()
|
self.total = self.__get_total_entry()
|
||||||
self.__populate_balance()
|
self.__populate_balance()
|
||||||
|
|
||||||
def __get_brought_forward(self) -> ReportLineItem | None:
|
def __get_brought_forward_entry(self) -> ReportEntry | None:
|
||||||
"""Queries, composes and returns the brought-forward line item.
|
"""Queries, composes and returns the brought-forward entry.
|
||||||
|
|
||||||
:return: The brought-forward line item, or None if the report starts
|
:return: The brought-forward entry, or None if the report starts from
|
||||||
from the beginning.
|
the beginning.
|
||||||
"""
|
"""
|
||||||
if self.__period.start is None:
|
if self.__period.start is None:
|
||||||
return None
|
return None
|
||||||
if self.__account.is_nominal:
|
if self.__account.is_nominal:
|
||||||
return None
|
return None
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
else_=-JournalEntryLineItem.amount))
|
else_=-JournalEntry.amount))
|
||||||
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
|
select: sa.Select = sa.Select(balance_func).join(Transaction)\
|
||||||
.filter(JournalEntryLineItem.currency_code == self.__currency.code,
|
.filter(be(JournalEntry.currency_code == self.__currency.code),
|
||||||
JournalEntryLineItem.account_id == self.__account.id,
|
be(JournalEntry.account_id == self.__account.id),
|
||||||
JournalEntry.date < self.__period.start)
|
Transaction.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
|
||||||
line_item: ReportLineItem = ReportLineItem()
|
entry: ReportEntry = ReportEntry()
|
||||||
line_item.is_brought_forward = True
|
entry.is_brought_forward = True
|
||||||
line_item.date = self.__period.start
|
entry.date = self.__period.start
|
||||||
line_item.description = gettext("Brought forward")
|
entry.summary = gettext("Brought forward")
|
||||||
if balance > 0:
|
if balance > 0:
|
||||||
line_item.debit = balance
|
entry.debit = balance
|
||||||
elif balance < 0:
|
elif balance < 0:
|
||||||
line_item.credit = -balance
|
entry.credit = -balance
|
||||||
line_item.balance = balance
|
entry.balance = balance
|
||||||
return line_item
|
return entry
|
||||||
|
|
||||||
def __query_line_items(self) -> list[ReportLineItem]:
|
def __query_entries(self) -> list[ReportEntry]:
|
||||||
"""Queries and returns the line items.
|
"""Queries and returns the report entries.
|
||||||
|
|
||||||
:return: The line items.
|
:return: The report entries.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
JournalEntryLineItem.account_id == self.__account.id]
|
JournalEntry.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(Transaction.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(Transaction.date <= self.__period.end)
|
||||||
return [ReportLineItem(x) for x in JournalEntryLineItem.query
|
return [ReportEntry(x) for x in JournalEntry.query.join(Transaction)
|
||||||
.join(JournalEntry)
|
|
||||||
.filter(*conditions)
|
.filter(*conditions)
|
||||||
.order_by(JournalEntry.date,
|
.order_by(Transaction.date,
|
||||||
JournalEntry.no,
|
Transaction.no,
|
||||||
JournalEntryLineItem.is_debit.desc(),
|
JournalEntry.is_debit.desc(),
|
||||||
JournalEntryLineItem.no)
|
JournalEntry.no)
|
||||||
.options(selectinload(JournalEntryLineItem.journal_entry))
|
.options(selectinload(JournalEntry.transaction)).all()]
|
||||||
.all()]
|
|
||||||
|
|
||||||
def __get_total(self) -> ReportLineItem | None:
|
def __get_total_entry(self) -> ReportEntry | None:
|
||||||
"""Composes the total line item.
|
"""Composes the total entry.
|
||||||
|
|
||||||
:return: The total line item, or None if there is no data.
|
:return: The total entry, or None if there is no data.
|
||||||
"""
|
"""
|
||||||
if self.brought_forward is None and len(self.line_items) == 0:
|
if self.brought_forward is None and len(self.entries) == 0:
|
||||||
return None
|
return None
|
||||||
line_item: ReportLineItem = ReportLineItem()
|
entry: ReportEntry = ReportEntry()
|
||||||
line_item.is_total = True
|
entry.is_total = True
|
||||||
line_item.description = gettext("Total")
|
entry.summary = gettext("Total")
|
||||||
line_item.debit = sum([x.debit for x in self.line_items
|
entry.debit = sum([x.debit for x in self.entries
|
||||||
if x.debit is not None])
|
if x.debit is not None])
|
||||||
line_item.credit = sum([x.credit for x in self.line_items
|
entry.credit = sum([x.credit for x in self.entries
|
||||||
if x.credit is not None])
|
if x.credit is not None])
|
||||||
line_item.balance = line_item.debit - line_item.credit
|
entry.balance = entry.debit - entry.credit
|
||||||
if self.brought_forward is not None:
|
if self.brought_forward is not None:
|
||||||
line_item.balance \
|
entry.balance = self.brought_forward.balance + entry.balance
|
||||||
= self.brought_forward.balance + line_item.balance
|
return entry
|
||||||
return line_item
|
|
||||||
|
|
||||||
def __populate_balance(self) -> None:
|
def __populate_balance(self) -> None:
|
||||||
"""Populates the balance of the line items.
|
"""Populates the balance of the entries.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
@ -185,36 +182,36 @@ class LineItemCollector:
|
|||||||
return None
|
return None
|
||||||
balance: Decimal = 0 if self.brought_forward is None \
|
balance: Decimal = 0 if self.brought_forward is None \
|
||||||
else self.brought_forward.balance
|
else self.brought_forward.balance
|
||||||
for line_item in self.line_items:
|
for entry in self.entries:
|
||||||
if line_item.debit is not None:
|
if entry.debit is not None:
|
||||||
balance = balance + line_item.debit
|
balance = balance + entry.debit
|
||||||
if line_item.credit is not None:
|
if entry.credit is not None:
|
||||||
balance = balance - line_item.credit
|
balance = balance - entry.credit
|
||||||
line_item.balance = balance
|
entry.balance = balance
|
||||||
|
|
||||||
|
|
||||||
class CSVRow(BaseCSVRow):
|
class CSVRow(BaseCSVRow):
|
||||||
"""A row in the CSV."""
|
"""A row in the CSV."""
|
||||||
|
|
||||||
def __init__(self, date: dt.date | str | None,
|
def __init__(self, txn_date: date | str | None,
|
||||||
description: str | None,
|
summary: str | None,
|
||||||
debit: str | Decimal | None,
|
debit: str | Decimal | None,
|
||||||
credit: str | Decimal | None,
|
credit: str | Decimal | None,
|
||||||
balance: str | Decimal | None,
|
balance: str | Decimal | None,
|
||||||
note: str | None):
|
note: str | None):
|
||||||
"""Constructs a row in the CSV.
|
"""Constructs a row in the CSV.
|
||||||
|
|
||||||
:param date: The journal entry date.
|
:param txn_date: The transaction date.
|
||||||
:param description: The description.
|
:param summary: The summary.
|
||||||
: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: dt.date | str | None = date
|
self.date: date | str | None = txn_date
|
||||||
"""The date."""
|
"""The date."""
|
||||||
self.description: str | None = description
|
self.summary: str | None = summary
|
||||||
"""The description."""
|
"""The summary."""
|
||||||
self.debit: str | Decimal | None = debit
|
self.debit: str | Decimal | None = debit
|
||||||
"""The debit amount."""
|
"""The debit amount."""
|
||||||
self.credit: str | Decimal | None = credit
|
self.credit: str | Decimal | None = credit
|
||||||
@ -230,7 +227,7 @@ class CSVRow(BaseCSVRow):
|
|||||||
|
|
||||||
:return: The values of the row.
|
:return: The values of the row.
|
||||||
"""
|
"""
|
||||||
return [self.date, self.description,
|
return [self.date, self.summary,
|
||||||
self.debit, self.credit, self.balance, self.note]
|
self.debit, self.credit, self.balance, self.note]
|
||||||
|
|
||||||
|
|
||||||
@ -241,19 +238,19 @@ class PageParams(BasePageParams):
|
|||||||
account: Account,
|
account: Account,
|
||||||
period: Period,
|
period: Period,
|
||||||
has_data: bool,
|
has_data: bool,
|
||||||
pagination: Pagination[ReportLineItem],
|
pagination: Pagination[ReportEntry],
|
||||||
brought_forward: ReportLineItem | None,
|
brought_forward: ReportEntry | None,
|
||||||
line_items: list[ReportLineItem],
|
entries: list[ReportEntry],
|
||||||
total: ReportLineItem | None):
|
total: ReportEntry | None):
|
||||||
"""Constructs the HTML page parameters.
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
:param currency: The currency.
|
:param currency: The currency.
|
||||||
:param account: The account.
|
:param account: The account.
|
||||||
:param period: The period.
|
:param period: The period.
|
||||||
:param has_data: True if there is any data, or False otherwise.
|
:param has_data: True if there is any data, or False otherwise.
|
||||||
:param brought_forward: The brought-forward line item.
|
:param brought_forward: The brought-forward entry.
|
||||||
:param line_items: The line items.
|
:param entries: The report entries.
|
||||||
:param total: The total line item.
|
:param total: The total entry.
|
||||||
"""
|
"""
|
||||||
self.currency: Currency = currency
|
self.currency: Currency = currency
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
@ -263,14 +260,14 @@ class PageParams(BasePageParams):
|
|||||||
"""The period."""
|
"""The period."""
|
||||||
self.__has_data: bool = has_data
|
self.__has_data: bool = has_data
|
||||||
"""True if there is any data, or False otherwise."""
|
"""True if there is any data, or False otherwise."""
|
||||||
self.pagination: Pagination[ReportLineItem] = pagination
|
self.pagination: Pagination[ReportEntry] = pagination
|
||||||
"""The pagination."""
|
"""The pagination."""
|
||||||
self.brought_forward: ReportLineItem | None = brought_forward
|
self.brought_forward: ReportEntry | None = brought_forward
|
||||||
"""The brought-forward line item."""
|
"""The brought-forward entry."""
|
||||||
self.line_items: list[ReportLineItem] = line_items
|
self.entries: list[ReportEntry] = entries
|
||||||
"""The line items."""
|
"""The entries."""
|
||||||
self.total: ReportLineItem | None = total
|
self.total: ReportEntry | None = total
|
||||||
"""The total line item."""
|
"""The total entry."""
|
||||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||||
lambda x: ledger_url(currency, account, x))
|
lambda x: ledger_url(currency, account, x))
|
||||||
"""The period chooser."""
|
"""The period chooser."""
|
||||||
@ -309,9 +306,9 @@ 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(JournalEntry.account_id)\
|
||||||
.filter(JournalEntryLineItem.currency_code == self.currency.code)\
|
.filter(be(JournalEntry.currency_code == self.currency.code))\
|
||||||
.group_by(JournalEntryLineItem.account_id)
|
.group_by(JournalEntry.account_id)
|
||||||
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
|
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
|
||||||
x.id == self.account.id)
|
x.id == self.account.id)
|
||||||
for x in Account.query.filter(Account.id.in_(in_use))
|
for x in Account.query.filter(Account.id.in_(in_use))
|
||||||
@ -334,15 +331,14 @@ class Ledger(BaseReport):
|
|||||||
"""The account."""
|
"""The account."""
|
||||||
self.__period: Period = period
|
self.__period: Period = period
|
||||||
"""The period."""
|
"""The period."""
|
||||||
collector: LineItemCollector = LineItemCollector(
|
collector: EntryCollector = EntryCollector(
|
||||||
self.__currency, self.__account, self.__period)
|
self.__currency, self.__account, self.__period)
|
||||||
self.__brought_forward: ReportLineItem | None \
|
self.__brought_forward: ReportEntry | None = collector.brought_forward
|
||||||
= collector.brought_forward
|
"""The brought-forward entry."""
|
||||||
"""The brought-forward line item."""
|
self.__entries: list[ReportEntry] = collector.entries
|
||||||
self.__line_items: list[ReportLineItem] = collector.line_items
|
"""The report entries."""
|
||||||
"""The line items."""
|
self.__total: ReportEntry | None = collector.total
|
||||||
self.__total: ReportLineItem | None = collector.total
|
"""The total entry."""
|
||||||
"""The total line item."""
|
|
||||||
|
|
||||||
def csv(self) -> Response:
|
def csv(self) -> Response:
|
||||||
"""Returns the report as CSV for download.
|
"""Returns the report as CSV for download.
|
||||||
@ -359,19 +355,19 @@ class Ledger(BaseReport):
|
|||||||
|
|
||||||
:return: The CSV rows.
|
:return: The CSV rows.
|
||||||
"""
|
"""
|
||||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Description"),
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
|
||||||
gettext("Debit"), gettext("Credit"),
|
gettext("Debit"), gettext("Credit"),
|
||||||
gettext("Balance"), gettext("Note"))]
|
gettext("Balance"), gettext("Note"))]
|
||||||
if self.__brought_forward is not None:
|
if self.__brought_forward is not None:
|
||||||
rows.append(CSVRow(self.__brought_forward.date,
|
rows.append(CSVRow(self.__brought_forward.date,
|
||||||
self.__brought_forward.description,
|
self.__brought_forward.summary,
|
||||||
self.__brought_forward.debit,
|
self.__brought_forward.debit,
|
||||||
self.__brought_forward.credit,
|
self.__brought_forward.credit,
|
||||||
self.__brought_forward.balance,
|
self.__brought_forward.balance,
|
||||||
None))
|
None))
|
||||||
rows.extend([CSVRow(x.date, x.description,
|
rows.extend([CSVRow(x.date, x.summary,
|
||||||
x.debit, x.credit, x.balance, x.note)
|
x.debit, x.credit, x.balance, x.note)
|
||||||
for x in self.__line_items])
|
for x in self.__entries])
|
||||||
if self.__total is not None:
|
if self.__total is not None:
|
||||||
rows.append(CSVRow(gettext("Total"), None,
|
rows.append(CSVRow(gettext("Total"), None,
|
||||||
self.__total.debit, self.__total.credit,
|
self.__total.debit, self.__total.credit,
|
||||||
@ -383,31 +379,31 @@ class Ledger(BaseReport):
|
|||||||
|
|
||||||
:return: The report as HTML.
|
:return: The report as HTML.
|
||||||
"""
|
"""
|
||||||
all_line_items: list[ReportLineItem] = []
|
all_entries: list[ReportEntry] = []
|
||||||
if self.__brought_forward is not None:
|
if self.__brought_forward is not None:
|
||||||
all_line_items.append(self.__brought_forward)
|
all_entries.append(self.__brought_forward)
|
||||||
all_line_items.extend(self.__line_items)
|
all_entries.extend(self.__entries)
|
||||||
if self.__total is not None:
|
if self.__total is not None:
|
||||||
all_line_items.append(self.__total)
|
all_entries.append(self.__total)
|
||||||
pagination: Pagination[ReportLineItem] \
|
pagination: Pagination[ReportEntry] \
|
||||||
= Pagination[ReportLineItem](all_line_items, is_reversed=True)
|
= Pagination[ReportEntry](all_entries, is_reversed=True)
|
||||||
page_line_items: list[ReportLineItem] = pagination.list
|
page_entries: list[ReportEntry] = pagination.list
|
||||||
has_data: bool = len(page_line_items) > 0
|
has_data: bool = len(page_entries) > 0
|
||||||
brought_forward: ReportLineItem | None = None
|
brought_forward: ReportEntry | None = None
|
||||||
if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
|
if len(page_entries) > 0 and page_entries[0].is_brought_forward:
|
||||||
brought_forward = page_line_items[0]
|
brought_forward = page_entries[0]
|
||||||
page_line_items = page_line_items[1:]
|
page_entries = page_entries[1:]
|
||||||
total: ReportLineItem | None = None
|
total: ReportEntry | None = None
|
||||||
if len(page_line_items) > 0 and page_line_items[-1].is_total:
|
if len(page_entries) > 0 and page_entries[-1].is_total:
|
||||||
total = page_line_items[-1]
|
total = page_entries[-1]
|
||||||
page_line_items = page_line_items[:-1]
|
page_entries = page_entries[:-1]
|
||||||
params: PageParams = PageParams(currency=self.__currency,
|
params: PageParams = PageParams(currency=self.__currency,
|
||||||
account=self.__account,
|
account=self.__account,
|
||||||
period=self.__period,
|
period=self.__period,
|
||||||
has_data=has_data,
|
has_data=has_data,
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
brought_forward=brought_forward,
|
brought_forward=brought_forward,
|
||||||
line_items=page_line_items,
|
entries=page_entries,
|
||||||
total=total)
|
total=total)
|
||||||
return render_template("accounting/report/ledger.html",
|
return render_template("accounting/report/ledger.html",
|
||||||
report=params)
|
report=params)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -17,7 +17,7 @@
|
|||||||
"""The search.
|
"""The search.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import datetime as dt
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@ -26,29 +26,30 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
|
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
|
||||||
JournalEntry, JournalEntryLineItem
|
Transaction, JournalEntry
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
from accounting.report.utils.csv_export import csv_download
|
from accounting.report.utils.csv_export import csv_download
|
||||||
from accounting.report.utils.report_chooser import ReportChooser
|
from accounting.report.utils.report_chooser import ReportChooser
|
||||||
from accounting.report.utils.report_type import ReportType
|
from accounting.report.utils.report_type import ReportType
|
||||||
|
from accounting.utils.cast import be
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
from accounting.utils.query import parse_query_keywords
|
from accounting.utils.query import parse_query_keywords
|
||||||
from .journal import get_csv_rows
|
from .journal import get_csv_rows
|
||||||
|
|
||||||
|
|
||||||
class LineItemCollector:
|
class EntryCollector:
|
||||||
"""The line item collector."""
|
"""The report entry collector."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Constructs the line item collector."""
|
"""Constructs the report entry collector."""
|
||||||
self.line_items: list[JournalEntryLineItem] = self.__query_line_items()
|
self.entries: list[JournalEntry] = self.__query_entries()
|
||||||
"""The line items."""
|
"""The report entries."""
|
||||||
|
|
||||||
def __query_line_items(self) -> list[JournalEntryLineItem]:
|
def __query_entries(self) -> list[JournalEntry]:
|
||||||
"""Queries and returns the line items.
|
"""Queries and returns the journal entries.
|
||||||
|
|
||||||
:return: The line items.
|
:return: The journal entries.
|
||||||
"""
|
"""
|
||||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||||
if len(keywords) == 0:
|
if len(keywords) == 0:
|
||||||
@ -56,28 +57,26 @@ class LineItemCollector:
|
|||||||
conditions: list[sa.BinaryExpression] = []
|
conditions: list[sa.BinaryExpression] = []
|
||||||
for k in keywords:
|
for k in keywords:
|
||||||
sub_conditions: list[sa.BinaryExpression] \
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntryLineItem.description.icontains(k),
|
= [JournalEntry.summary.contains(k),
|
||||||
JournalEntryLineItem.account_id.in_(
|
JournalEntry.account_id.in_(
|
||||||
self.__get_account_condition(k)),
|
self.__get_account_condition(k)),
|
||||||
JournalEntryLineItem.currency_code.in_(
|
JournalEntry.currency_code.in_(
|
||||||
self.__get_currency_condition(k)),
|
self.__get_currency_condition(k)),
|
||||||
JournalEntryLineItem.journal_entry_id.in_(
|
JournalEntry.transaction_id.in_(
|
||||||
self.__get_journal_entry_condition(k))]
|
self.__get_transaction_condition(k))]
|
||||||
try:
|
try:
|
||||||
sub_conditions.append(
|
sub_conditions.append(JournalEntry.amount == Decimal(k))
|
||||||
JournalEntryLineItem.amount == Decimal(k))
|
|
||||||
except ArithmeticError:
|
except ArithmeticError:
|
||||||
pass
|
pass
|
||||||
conditions.append(sa.or_(*sub_conditions))
|
conditions.append(sa.or_(*sub_conditions))
|
||||||
return JournalEntryLineItem.query.join(JournalEntry)\
|
return JournalEntry.query.join(Transaction).filter(*conditions)\
|
||||||
.filter(*conditions)\
|
.order_by(Transaction.date,
|
||||||
.order_by(JournalEntry.date,
|
Transaction.no,
|
||||||
JournalEntry.no,
|
JournalEntry.is_debit,
|
||||||
JournalEntryLineItem.is_debit,
|
JournalEntry.no)\
|
||||||
JournalEntryLineItem.no)\
|
.options(selectinload(JournalEntry.account),
|
||||||
.options(selectinload(JournalEntryLineItem.account),
|
selectinload(JournalEntry.currency),
|
||||||
selectinload(JournalEntryLineItem.currency),
|
selectinload(JournalEntry.transaction)).all()
|
||||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_account_condition(k: str) -> sa.Select:
|
def __get_account_condition(k: str) -> sa.Select:
|
||||||
@ -91,13 +90,13 @@ class LineItemCollector:
|
|||||||
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))
|
.filter(AccountL10n.title.contains(k))
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [Account.base_code.contains(k),
|
= [Account.base_code.contains(k),
|
||||||
Account.title_l10n.icontains(k),
|
Account.title_l10n.contains(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("Need 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).filter(sa.or_(*conditions))
|
||||||
|
|
||||||
@ -109,66 +108,57 @@ 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))
|
.filter(CurrencyL10n.name.contains(k))
|
||||||
return sa.select(Currency.code).filter(
|
return sa.select(Currency.code).filter(
|
||||||
sa.or_(Currency.code.icontains(k),
|
sa.or_(Currency.code.contains(k),
|
||||||
Currency.name_l10n.icontains(k),
|
Currency.name_l10n.contains(k),
|
||||||
Currency.code.in_(select_l10n)))
|
Currency.code.in_(select_l10n)))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __get_journal_entry_condition(k: str) -> sa.Select:
|
def __get_transaction_condition(k: str) -> sa.Select:
|
||||||
"""Composes and returns the condition to filter the journal entry.
|
"""Composes and returns the condition to filter the transaction.
|
||||||
|
|
||||||
:param k: The keyword.
|
:param k: The keyword.
|
||||||
:return: The condition to filter the journal entry.
|
:return: The condition to filter the transaction.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] = [Transaction.note.contains(k)]
|
||||||
= [JournalEntry.note.icontains(k)]
|
txn_date: datetime
|
||||||
date: dt.datetime
|
|
||||||
try:
|
try:
|
||||||
date = dt.datetime.strptime(k, "%Y")
|
txn_date = datetime.strptime(k, "%Y")
|
||||||
conditions.append(
|
conditions.append(
|
||||||
sa.extract("year", JournalEntry.date) == date.year)
|
be(sa.extract("year", Transaction.date) == txn_date.year))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
date = dt.datetime.strptime(k, "%Y/%m")
|
txn_date = datetime.strptime(k, "%Y/%m")
|
||||||
conditions.append(sa.and_(
|
conditions.append(sa.and_(
|
||||||
sa.extract("year", JournalEntry.date) == date.year,
|
sa.extract("year", Transaction.date) == txn_date.year,
|
||||||
sa.extract("month", JournalEntry.date) == date.month))
|
sa.extract("month", Transaction.date) == txn_date.month))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
date = dt.datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||||
conditions.append(sa.and_(
|
conditions.append(sa.and_(
|
||||||
sa.extract("month", JournalEntry.date) == date.month,
|
sa.extract("month", Transaction.date) == txn_date.month,
|
||||||
sa.extract("day", JournalEntry.date) == date.day))
|
sa.extract("day", Transaction.date) == txn_date.day))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
try:
|
return sa.select(Transaction.id).filter(sa.or_(*conditions))
|
||||||
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).filter(sa.or_(*conditions))
|
|
||||||
|
|
||||||
|
|
||||||
class PageParams(BasePageParams):
|
class PageParams(BasePageParams):
|
||||||
"""The HTML page parameters."""
|
"""The HTML page parameters."""
|
||||||
|
|
||||||
def __init__(self, pagination: Pagination[JournalEntryLineItem],
|
def __init__(self, pagination: Pagination[JournalEntry],
|
||||||
line_items: list[JournalEntryLineItem]):
|
entries: list[JournalEntry]):
|
||||||
"""Constructs the HTML page parameters.
|
"""Constructs the HTML page parameters.
|
||||||
|
|
||||||
:param line_items: The search result line items.
|
:param entries: The search result entries.
|
||||||
"""
|
"""
|
||||||
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
self.pagination: Pagination[JournalEntry] = pagination
|
||||||
"""The pagination."""
|
"""The pagination."""
|
||||||
self.line_items: list[JournalEntryLineItem] = line_items
|
self.entries: list[JournalEntry] = entries
|
||||||
"""The line items."""
|
"""The entries."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_data(self) -> bool:
|
def has_data(self) -> bool:
|
||||||
@ -176,7 +166,7 @@ class PageParams(BasePageParams):
|
|||||||
|
|
||||||
:return: True if there is any data, or False otherwise.
|
:return: True if there is any data, or False otherwise.
|
||||||
"""
|
"""
|
||||||
return len(self.line_items) > 0
|
return len(self.entries) > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def report_chooser(self) -> ReportChooser:
|
def report_chooser(self) -> ReportChooser:
|
||||||
@ -192,9 +182,8 @@ class Search(BaseReport):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Constructs a search."""
|
"""Constructs a search."""
|
||||||
self.__line_items: list[JournalEntryLineItem] \
|
self.__entries: list[JournalEntry] = EntryCollector().entries
|
||||||
= LineItemCollector().line_items
|
"""The journal entries."""
|
||||||
"""The line items."""
|
|
||||||
|
|
||||||
def csv(self) -> Response:
|
def csv(self) -> Response:
|
||||||
"""Returns the report as CSV for download.
|
"""Returns the report as CSV for download.
|
||||||
@ -202,17 +191,16 @@ class Search(BaseReport):
|
|||||||
:return: The response of the report for download.
|
:return: The response of the report for download.
|
||||||
"""
|
"""
|
||||||
filename: str = "search-{q}.csv".format(q=request.args["q"])
|
filename: str = "search-{q}.csv".format(q=request.args["q"])
|
||||||
return csv_download(filename, get_csv_rows(self.__line_items))
|
return csv_download(filename, get_csv_rows(self.__entries))
|
||||||
|
|
||||||
def html(self) -> str:
|
def html(self) -> str:
|
||||||
"""Composes and returns the report as HTML.
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
:return: The report as HTML.
|
:return: The report as HTML.
|
||||||
"""
|
"""
|
||||||
pagination: Pagination[JournalEntryLineItem] \
|
pagination: Pagination[JournalEntry] \
|
||||||
= Pagination[JournalEntryLineItem](self.__line_items,
|
= Pagination[JournalEntry](self.__entries, is_reversed=True)
|
||||||
is_reversed=True)
|
|
||||||
params: PageParams = PageParams(pagination=pagination,
|
params: PageParams = PageParams(pagination=pagination,
|
||||||
line_items=pagination.list)
|
entries=pagination.list)
|
||||||
return render_template("accounting/report/search.html",
|
return render_template("accounting/report/search.html",
|
||||||
report=params)
|
report=params)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -24,8 +24,7 @@ from flask import Response, render_template
|
|||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, Account, JournalEntry, \
|
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||||
JournalEntryLineItem
|
|
||||||
from accounting.report.period import Period, PeriodChooser
|
from accounting.report.period import Period, PeriodChooser
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
from accounting.report.utils.base_page_params import BasePageParams
|
||||||
from accounting.report.utils.base_report import BaseReport
|
from accounting.report.utils.base_report import BaseReport
|
||||||
@ -179,19 +178,18 @@ class TrialBalance(BaseReport):
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
conditions: list[sa.BinaryExpression] \
|
conditions: list[sa.BinaryExpression] \
|
||||||
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
= [JournalEntry.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(Transaction.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(Transaction.date <= self.__period.end)
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
else_=-JournalEntryLineItem.amount)).label("balance")
|
else_=-JournalEntry.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(Transaction).join(Account)\
|
||||||
.filter(*conditions)\
|
.filter(*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] \
|
||||||
@ -224,7 +222,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), x.debit, x.credit)
|
rows.extend([CSVRow(str(x.account).title(), 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))
|
||||||
|
@ -1,216 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The unapplied original line items.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import datetime as dt
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from flask import render_template, Response
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from accounting.locale import gettext
|
|
||||||
from accounting.models import Currency, Account, 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 BaseCSVRow, csv_download
|
|
||||||
from accounting.report.utils.option_link import OptionLink
|
|
||||||
from accounting.report.utils.report_chooser import ReportChooser
|
|
||||||
from accounting.report.utils.report_type import ReportType
|
|
||||||
from accounting.report.utils.unapplied import get_accounts_with_unapplied, \
|
|
||||||
get_net_balances
|
|
||||||
from accounting.report.utils.urls import unapplied_url
|
|
||||||
from accounting.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] = JournalEntryLineItem.query \
|
|
||||||
.join(Account).join(JournalEntry) \
|
|
||||||
.filter(JournalEntryLineItem.id.in_(net_balances)) \
|
|
||||||
.order_by(JournalEntry.date, JournalEntry.no,
|
|
||||||
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
|
|
||||||
.options(selectinload(JournalEntryLineItem.currency),
|
|
||||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
|
||||||
for line_item in line_items:
|
|
||||||
line_item.net_balance = line_item.amount \
|
|
||||||
if net_balances[line_item.id] is None \
|
|
||||||
else net_balances[line_item.id]
|
|
||||||
return line_items
|
|
||||||
|
|
||||||
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)
|
|
@ -1,155 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The accounts with unapplied original line items.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import datetime as dt
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from flask import render_template, Response
|
|
||||||
|
|
||||||
from accounting.locale import gettext
|
|
||||||
from accounting.models import Currency, Account
|
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
|
||||||
from accounting.report.utils.base_report import BaseReport
|
|
||||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
|
|
||||||
from accounting.report.utils.option_link import OptionLink
|
|
||||||
from accounting.report.utils.report_chooser import ReportChooser
|
|
||||||
from accounting.report.utils.report_type import ReportType
|
|
||||||
from accounting.report.utils.unapplied import get_accounts_with_unapplied
|
|
||||||
from accounting.report.utils.urls import unapplied_url
|
|
||||||
|
|
||||||
|
|
||||||
class CSVRow(BaseCSVRow):
|
|
||||||
"""A row in the CSV."""
|
|
||||||
|
|
||||||
def __init__(self, account: str, count: int | str):
|
|
||||||
"""Constructs a row in the CSV.
|
|
||||||
|
|
||||||
:param account: The account.
|
|
||||||
:param count: The number of unapplied original line items.
|
|
||||||
"""
|
|
||||||
self.account: str = account
|
|
||||||
"""The currency."""
|
|
||||||
self.count: int | str = count
|
|
||||||
"""The number of unapplied original line items."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def values(self) -> list[str | 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))
|
|
@ -1,214 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The unmatched offsets.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import datetime as dt
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from flask import render_template, Response
|
|
||||||
from flask_babel import LazyString
|
|
||||||
|
|
||||||
from accounting.locale import gettext
|
|
||||||
from accounting.models import Currency, Account, JournalEntryLineItem
|
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
|
||||||
from accounting.report.utils.base_report import BaseReport
|
|
||||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
|
|
||||||
from accounting.report.utils.offset_matcher import OffsetMatcher, OffsetPair
|
|
||||||
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.unmatched import get_accounts_with_unmatched
|
|
||||||
from accounting.report.utils.urls import unmatched_url
|
|
||||||
from accounting.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)
|
|
@ -1,156 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The accounts with unmatched offsets.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import datetime as dt
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from flask import render_template, Response
|
|
||||||
|
|
||||||
from accounting.locale import gettext
|
|
||||||
from accounting.models import Currency, Account
|
|
||||||
from accounting.report.utils.base_page_params import BasePageParams
|
|
||||||
from accounting.report.utils.base_report import BaseReport
|
|
||||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
|
|
||||||
from accounting.report.utils.option_link import OptionLink
|
|
||||||
from accounting.report.utils.report_chooser import ReportChooser
|
|
||||||
from accounting.report.utils.report_type import ReportType
|
|
||||||
from accounting.report.utils.unmatched import get_accounts_with_unmatched
|
|
||||||
from accounting.report.utils.urls import unmatched_url
|
|
||||||
|
|
||||||
|
|
||||||
class CSVRow(BaseCSVRow):
|
|
||||||
"""A row in the CSV."""
|
|
||||||
|
|
||||||
def __init__(self, account: str, count: int | str):
|
|
||||||
"""Constructs a row in the CSV.
|
|
||||||
|
|
||||||
:param account: The account.
|
|
||||||
:param count: The number of unapplied original line items.
|
|
||||||
"""
|
|
||||||
self.account: str = account
|
|
||||||
"""The currency."""
|
|
||||||
self.count: int | str = count
|
|
||||||
"""The number of unapplied original line items."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def values(self) -> list[str | 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,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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 imacat.
|
||||||
@ -17,9 +17,8 @@
|
|||||||
"""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 typing import Type
|
|
||||||
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
|
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
|
||||||
urlunparse
|
urlunparse
|
||||||
|
|
||||||
@ -27,8 +26,8 @@ import sqlalchemy as sa
|
|||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.models import Currency, JournalEntryLineItem
|
from accounting.models import Currency, JournalEntry
|
||||||
from accounting.utils.journal_entry_types import JournalEntryType
|
from accounting.utils.txn_types import TransactionType
|
||||||
from .option_link import OptionLink
|
from .option_link import OptionLink
|
||||||
from .report_chooser import ReportChooser
|
from .report_chooser import ReportChooser
|
||||||
|
|
||||||
@ -53,12 +52,12 @@ class BasePageParams(ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def journal_entry_types(self) -> Type[JournalEntryType]:
|
def txn_types(self) -> t.Type[TransactionType]:
|
||||||
"""Returns the journal entry types.
|
"""Returns the transaction types.
|
||||||
|
|
||||||
:return: The journal entry types.
|
:return: The transaction types.
|
||||||
"""
|
"""
|
||||||
return JournalEntryType
|
return TransactionType
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def csv_uri(self) -> str:
|
def csv_uri(self) -> str:
|
||||||
@ -73,7 +72,7 @@ class BasePageParams(ABC):
|
|||||||
return urlunparse(parts)
|
return urlunparse(parts)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_currency_options(get_url: Callable[[Currency], str],
|
def _get_currency_options(get_url: t.Callable[[Currency], str],
|
||||||
active_currency: Currency) -> list[OptionLink]:
|
active_currency: Currency) -> list[OptionLink]:
|
||||||
"""Returns the currency options.
|
"""Returns the currency options.
|
||||||
|
|
||||||
@ -82,8 +81,8 @@ class BasePageParams(ABC):
|
|||||||
:return: The currency options.
|
:return: The currency options.
|
||||||
"""
|
"""
|
||||||
in_use: set[str] = set(db.session.scalars(
|
in_use: set[str] = set(db.session.scalars(
|
||||||
sa.select(JournalEntryLineItem.currency_code)
|
sa.select(JournalEntry.currency_code)
|
||||||
.group_by(JournalEntryLineItem.currency_code)).all())
|
.group_by(JournalEntry.currency_code)).all())
|
||||||
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
|
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
|
||||||
for x in Currency.query.filter(Currency.code.in_(in_use))
|
for x in Currency.query.filter(Currency.code.in_(in_use))
|
||||||
.order_by(Currency.code).all()]
|
.order_by(Currency.code).all()]
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -18,11 +18,10 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
||||||
@ -54,7 +53,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={quote(filename)}"
|
= f"attachment; filename={filename}"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@ -77,7 +76,7 @@ def period_spec(period: Period) -> str:
|
|||||||
return f"{start}-{end}"
|
return f"{start}-{end}"
|
||||||
|
|
||||||
|
|
||||||
def __get_start_str(start: dt.date | None) -> str | None:
|
def __get_start_str(start: 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.
|
||||||
@ -93,7 +92,7 @@ def __get_start_str(start: dt.date | None) -> str | None:
|
|||||||
return start.strftime("%Y%m%d")
|
return start.strftime("%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
def __get_end_str(end: dt.date | None) -> str | None:
|
def __get_end_str(end: 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.
|
||||||
@ -104,6 +103,6 @@ def __get_end_str(end: dt.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 + dt.timedelta(days=1)).day == 1:
|
if (end + 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")
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
#
|
#
|
||||||
@ -14,29 +14,28 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
"""The current assets and liabilities account.
|
"""The pseudo account for the income and expenses log.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Self
|
import typing as t
|
||||||
|
|
||||||
import sqlalchemy as sa
|
from flask import current_app
|
||||||
|
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext
|
||||||
from accounting.models import Account
|
from accounting.models import Account
|
||||||
|
|
||||||
|
|
||||||
class CurrentAccount:
|
class IncomeExpensesAccount:
|
||||||
"""A current assets and liabilities account."""
|
"""The pseudo account for the income and expenses log."""
|
||||||
CURRENT_AL_CODE: str = "0000-000"
|
CURRENT_AL_CODE: str = "0000-000"
|
||||||
"""The account code for all current assets and liabilities."""
|
"""The account code for the current assets and liabilities."""
|
||||||
|
|
||||||
def __init__(self, account: Account | None = None):
|
def __init__(self, account: Account | None = None):
|
||||||
"""Constructs the current assets and liabilities account.
|
"""Constructs the pseudo account for the income and expenses log.
|
||||||
|
|
||||||
:param account: The actual account.
|
:param account: The actual account.
|
||||||
"""
|
"""
|
||||||
self.account: Account | None = account
|
self.account: Account | None = account
|
||||||
"""The actual account."""
|
|
||||||
self.id: int = -1 if account is None else account.id
|
self.id: int = -1 if account is None else account.id
|
||||||
"""The ID."""
|
"""The ID."""
|
||||||
self.code: str = "" if account is None else account.code
|
self.code: str = "" if account is None else account.code
|
||||||
@ -54,10 +53,10 @@ class CurrentAccount:
|
|||||||
return self.str
|
return self.str
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def current_assets_and_liabilities(cls) -> Self:
|
def current_assets_and_liabilities(cls) -> t.Self:
|
||||||
"""Returns the pseudo account for all current assets and liabilities.
|
"""Returns the pseudo account for current assets and liabilities.
|
||||||
|
|
||||||
:return: The pseudo account for all current assets and liabilities.
|
:return: The pseudo account for current assets and liabilities.
|
||||||
"""
|
"""
|
||||||
account: cls = cls()
|
account: cls = cls()
|
||||||
account.id = 0
|
account.id = 0
|
||||||
@ -66,28 +65,22 @@ class CurrentAccount:
|
|||||||
account.str = account.title
|
account.str = account.title
|
||||||
return account
|
return account
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def accounts(cls) -> list[Self]:
|
|
||||||
"""Returns the current assets and liabilities accounts.
|
|
||||||
|
|
||||||
:return: The current assets and liabilities accounts.
|
def default_ie_account_code() -> str:
|
||||||
"""
|
"""Returns the default account code for the income and expenses log.
|
||||||
accounts: list[cls] = [cls.current_assets_and_liabilities()]
|
|
||||||
accounts.extend([CurrentAccount(x)
|
|
||||||
for x in Account.query
|
|
||||||
.filter(cls.sql_condition())
|
|
||||||
.order_by(Account.base_code, Account.no)])
|
|
||||||
return accounts
|
|
||||||
|
|
||||||
@classmethod
|
:return: The default account code for the income and expenses log.
|
||||||
def sql_condition(cls) -> sa.BinaryExpression:
|
"""
|
||||||
"""Returns the SQL condition for the current assets and liabilities
|
with current_app.app_context():
|
||||||
accounts.
|
return current_app.config.get("DEFAULT_IE_ACCOUNT", Account.CASH_CODE)
|
||||||
|
|
||||||
:return: The SQL condition for the current assets and liabilities
|
|
||||||
accounts.
|
def default_ie_account() -> IncomeExpensesAccount:
|
||||||
"""
|
"""Returns the default account for the income and expenses log.
|
||||||
return sa.or_(Account.base_code.startswith("11"),
|
|
||||||
Account.base_code.startswith("12"),
|
:return: The default account for the income and expenses log.
|
||||||
Account.base_code.startswith("21"),
|
"""
|
||||||
Account.base_code.startswith("22"))
|
code: str = default_ie_account_code()
|
||||||
|
if code == IncomeExpensesAccount.CURRENT_AL_CODE:
|
||||||
|
return IncomeExpensesAccount.current_assets_and_liabilities()
|
||||||
|
return IncomeExpensesAccount(Account.find_by_code(code))
|
@ -1,178 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The forms for the unmatched offset management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from flask_babel import LazyString
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from accounting.locale import lazy_gettext
|
|
||||||
from accounting.models import Currency, Account, JournalEntry, \
|
|
||||||
JournalEntryLineItem
|
|
||||||
from accounting.report.utils.unapplied import get_net_balances
|
|
||||||
|
|
||||||
|
|
||||||
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: Account = 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.BinaryExpression \
|
|
||||||
= 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 = JournalEntryLineItem.query \
|
|
||||||
.join(Account).join(JournalEntry) \
|
|
||||||
.filter(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)).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 Project.
|
# The Mia! Accounting Flask 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,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -21,7 +21,7 @@ This file is largely taken from the NanoParma ERP project, first written in
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
from collections.abc import Iterator
|
import typing as t
|
||||||
|
|
||||||
from flask_babel import LazyString
|
from flask_babel import LazyString
|
||||||
|
|
||||||
@ -30,13 +30,11 @@ from accounting.locale import gettext
|
|||||||
from accounting.models import Currency, Account
|
from accounting.models import Currency, Account
|
||||||
from accounting.report.period import Period, get_period
|
from accounting.report.period import Period, get_period
|
||||||
from accounting.template_globals import default_currency_code
|
from accounting.template_globals import default_currency_code
|
||||||
from accounting.utils.current_account import CurrentAccount
|
from .ie_account import IncomeExpensesAccount
|
||||||
from accounting.utils.permission import can_edit
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class ReportChooser:
|
class ReportChooser:
|
||||||
@ -70,15 +68,12 @@ 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.__income_expenses)
|
|
||||||
self.__reports.append(self.__ledger)
|
|
||||||
self.__reports.append(self.__journal)
|
self.__reports.append(self.__journal)
|
||||||
|
self.__reports.append(self.__ledger)
|
||||||
|
self.__reports.append(self.__income_expenses)
|
||||||
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
|
||||||
@ -86,20 +81,14 @@ class ReportChooser:
|
|||||||
self.current_report = gettext("Search")
|
self.current_report = gettext("Search")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def __income_expenses(self) -> OptionLink:
|
def __journal(self) -> OptionLink:
|
||||||
"""Returns the income and expenses log.
|
"""Returns the journal.
|
||||||
|
|
||||||
:return: The income and expenses log.
|
:return: The journal.
|
||||||
"""
|
"""
|
||||||
account: Account = self.__account
|
return OptionLink(gettext("Journal"), journal_url(self.__period),
|
||||||
if not re.match(r"[12][12]", account.base_code):
|
self.__active_report == ReportType.JOURNAL,
|
||||||
account: Account = Account.cash()
|
fa_icon="fa-solid fa-book")
|
||||||
return OptionLink(gettext("Income and Expenses Log"),
|
|
||||||
income_expenses_url(self.__currency,
|
|
||||||
CurrentAccount(account),
|
|
||||||
self.__period),
|
|
||||||
self.__active_report == ReportType.INCOME_EXPENSES,
|
|
||||||
fa_icon="fa-solid fa-money-bill-wave")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def __ledger(self) -> OptionLink:
|
def __ledger(self) -> OptionLink:
|
||||||
@ -114,14 +103,20 @@ class ReportChooser:
|
|||||||
fa_icon="fa-solid fa-clipboard")
|
fa_icon="fa-solid fa-clipboard")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def __journal(self) -> OptionLink:
|
def __income_expenses(self) -> OptionLink:
|
||||||
"""Returns the journal.
|
"""Returns the income and expenses log.
|
||||||
|
|
||||||
:return: The journal.
|
:return: The income and expenses log.
|
||||||
"""
|
"""
|
||||||
return OptionLink(gettext("Journal"), journal_url(self.__period),
|
account: Account = self.__account
|
||||||
self.__active_report == ReportType.JOURNAL,
|
if not re.match(r"[12][12]", account.base_code):
|
||||||
fa_icon="fa-solid fa-book")
|
account: Account = Account.cash()
|
||||||
|
return OptionLink(gettext("Income and Expenses Log"),
|
||||||
|
income_expenses_url(self.__currency,
|
||||||
|
IncomeExpensesAccount(account),
|
||||||
|
self.__period),
|
||||||
|
self.__active_report == ReportType.INCOME_EXPENSES,
|
||||||
|
fa_icon="fa-solid fa-money-bill-wave")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def __trial_balance(self) -> OptionLink:
|
def __trial_balance(self) -> OptionLink:
|
||||||
@ -156,41 +151,7 @@ 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")
|
||||||
|
|
||||||
@property
|
def __iter__(self) -> t.Iterator[OptionLink]:
|
||||||
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 Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -34,9 +34,5 @@ class ReportType(Enum):
|
|||||||
"""The income statement."""
|
"""The income statement."""
|
||||||
BALANCE_SHEET: str = "balance-sheet"
|
BALANCE_SHEET: str = "balance-sheet"
|
||||||
"""The balance sheet."""
|
"""The balance sheet."""
|
||||||
UNAPPLIED: str = "unapplied"
|
|
||||||
"""The unapplied original line items."""
|
|
||||||
UNMATCHED: str = "unmatched"
|
|
||||||
"""The unmatched offsets."""
|
|
||||||
SEARCH: str = "search"
|
SEARCH: str = "search"
|
||||||
"""The search."""
|
"""The search."""
|
||||||
|
@ -1,104 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The unapplied original line item utilities.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.models import Currency, Account, JournalEntry, \
|
|
||||||
JournalEntryLineItem
|
|
||||||
from accounting.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)\
|
|
||||||
.filter(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)\
|
|
||||||
.filter(JournalEntryLineItem.id.in_(select_unapplied))\
|
|
||||||
.group_by(Account.id)\
|
|
||||||
.having(count_func > 0)
|
|
||||||
counts: dict[int, int] \
|
|
||||||
= {x.id: x.count for x in db.session.execute(select)}
|
|
||||||
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
|
|
||||||
.order_by(Account.base_code, Account.no).all()
|
|
||||||
for account in accounts:
|
|
||||||
account.count = counts[account.id]
|
|
||||||
return accounts
|
|
||||||
|
|
||||||
|
|
||||||
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) \
|
|
||||||
.filter(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).all()}
|
|
@ -1,54 +0,0 @@
|
|||||||
# The Mia! Accounting Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The unmatched offset utilities.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.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)\
|
|
||||||
.filter(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] = Account.query.filter(Account.id.in_(counts))\
|
|
||||||
.order_by(Account.base_code, Account.no).all()
|
|
||||||
for account in accounts:
|
|
||||||
account.count = counts[account.id]
|
|
||||||
return accounts
|
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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.
|
||||||
@ -22,8 +22,7 @@ from flask import url_for
|
|||||||
from accounting.models import Currency, Account
|
from accounting.models import Currency, Account
|
||||||
from accounting.report.period import Period
|
from accounting.report.period import Period
|
||||||
from accounting.template_globals import default_currency_code
|
from accounting.template_globals import default_currency_code
|
||||||
from accounting.utils.current_account import CurrentAccount
|
from .ie_account import IncomeExpensesAccount, default_ie_account_code
|
||||||
from accounting.utils.options import options
|
|
||||||
|
|
||||||
|
|
||||||
def journal_url(period: Period) \
|
def journal_url(period: Period) \
|
||||||
@ -34,8 +33,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,16 +46,15 @@ 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 currency.code == default_currency_code() \
|
if period.is_default:
|
||||||
and account.code == Account.CASH_CODE \
|
return url_for("accounting.report.ledger-default",
|
||||||
and period.is_default:
|
currency=currency, account=account)
|
||||||
return url_for("accounting-report.ledger-default")
|
return url_for("accounting.report.ledger",
|
||||||
return url_for("accounting-report.ledger",
|
|
||||||
currency=currency, account=account,
|
currency=currency, account=account,
|
||||||
period=period)
|
period=period)
|
||||||
|
|
||||||
|
|
||||||
def income_expenses_url(currency: Currency, account: CurrentAccount,
|
def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
|
||||||
period: Period) -> str:
|
period: Period) -> str:
|
||||||
"""Returns the URL of an income and expenses log.
|
"""Returns the URL of an income and expenses log.
|
||||||
|
|
||||||
@ -66,10 +64,13 @@ def income_expenses_url(currency: Currency, account: CurrentAccount,
|
|||||||
:return: The URL of the income and expenses log.
|
:return: The URL of the income and expenses log.
|
||||||
"""
|
"""
|
||||||
if currency.code == default_currency_code() \
|
if currency.code == default_currency_code() \
|
||||||
and account.code == options.default_ie_account_code \
|
and account.code == default_ie_account_code() \
|
||||||
and period.is_default:
|
and period.is_default:
|
||||||
return url_for("accounting-report.default")
|
return url_for("accounting.report.default")
|
||||||
return url_for("accounting-report.income-expenses",
|
if period.is_default:
|
||||||
|
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)
|
||||||
|
|
||||||
@ -81,9 +82,10 @@ 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 currency.code == default_currency_code() and period.is_default:
|
if period.is_default:
|
||||||
return url_for("accounting-report.trial-balance-default")
|
return url_for("accounting.report.trial-balance-default",
|
||||||
return url_for("accounting-report.trial-balance",
|
currency=currency)
|
||||||
|
return url_for("accounting.report.trial-balance",
|
||||||
currency=currency, period=period)
|
currency=currency, period=period)
|
||||||
|
|
||||||
|
|
||||||
@ -94,9 +96,10 @@ 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 currency.code == default_currency_code() and period.is_default:
|
if period.is_default:
|
||||||
return url_for("accounting-report.income-statement-default")
|
return url_for("accounting.report.income-statement-default",
|
||||||
return url_for("accounting-report.income-statement",
|
currency=currency)
|
||||||
|
return url_for("accounting.report.income-statement",
|
||||||
currency=currency, period=period)
|
currency=currency, period=period)
|
||||||
|
|
||||||
|
|
||||||
@ -107,41 +110,8 @@ 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 currency.code == default_currency_code() and period.is_default:
|
if period.is_default:
|
||||||
return url_for("accounting-report.balance-sheet-default")
|
return url_for("accounting.report.balance-sheet-default",
|
||||||
return url_for("accounting-report.balance-sheet",
|
currency=currency)
|
||||||
|
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)
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# The Mia! Accounting Project.
|
# The Mia! Accounting Flask 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,29 +17,19 @@
|
|||||||
"""The views for the report management.
|
"""The views for the report management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint, request, Response, redirect, flash
|
from flask import Blueprint, request, Response
|
||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
from accounting.locale import lazy_gettext
|
|
||||||
from accounting.models import Currency, Account
|
from accounting.models import Currency, Account
|
||||||
|
from accounting.report.period import Period, get_period
|
||||||
from accounting.template_globals import default_currency_code
|
from accounting.template_globals import default_currency_code
|
||||||
from accounting.utils.cast import s
|
from accounting.utils.permission import has_permission, can_view
|
||||||
from accounting.utils.current_account import CurrentAccount
|
|
||||||
from accounting.utils.next_uri import or_next
|
|
||||||
from accounting.utils.options import options
|
|
||||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
|
||||||
from .period import Period, get_period
|
|
||||||
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.ie_account import IncomeExpensesAccount, default_ie_account
|
||||||
from .utils.urls import unmatched_url
|
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("accounting-report", __name__)
|
bp: Blueprint = Blueprint("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")
|
||||||
|
|
||||||
@ -51,7 +41,10 @@ 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_default_income_expenses()
|
return __get_income_expenses(
|
||||||
|
db.session.get(Currency, default_currency_code()),
|
||||||
|
default_ie_account(),
|
||||||
|
get_period())
|
||||||
|
|
||||||
|
|
||||||
@bp.get("journal", endpoint="journal-default")
|
@bp.get("journal", endpoint="journal-default")
|
||||||
@ -87,15 +80,17 @@ def __get_journal(period: Period) -> str | Response:
|
|||||||
return report.html()
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("ledger", endpoint="ledger-default")
|
@bp.get("ledger/<currency:currency>/<account:account>",
|
||||||
|
endpoint="ledger-default")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def get_default_ledger() -> str | Response:
|
def get_default_ledger(currency: Currency, account: Account) -> str | Response:
|
||||||
"""Returns the ledger in the default currency, cash, and default period.
|
"""Returns the ledger in the default period.
|
||||||
|
|
||||||
:return: The ledger in the default currency, cash, and default period.
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:return: The ledger in the default period.
|
||||||
"""
|
"""
|
||||||
return __get_ledger(db.session.get(Currency, default_currency_code()),
|
return __get_ledger(currency, account, get_period())
|
||||||
Account.cash(), get_period())
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
|
@bp.get("ledger/<currency:currency>/<account:account>/<period:period>",
|
||||||
@ -128,23 +123,26 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
|
|||||||
return report.html()
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("income-expenses", endpoint="income-expenses-default")
|
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
|
||||||
|
endpoint="income-expenses-default")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def get_default_income_expenses() -> str | Response:
|
def get_default_income_expenses(currency: Currency,
|
||||||
|
account: IncomeExpensesAccount) \
|
||||||
|
-> 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(
|
return __get_income_expenses(currency, account, get_period())
|
||||||
db.session.get(Currency, default_currency_code()),
|
|
||||||
options.default_ie_account,
|
|
||||||
get_period())
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("income-expenses/<currency:currency>/<currentAccount:account>/"
|
@bp.get(
|
||||||
"<period:period>", endpoint="income-expenses")
|
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
|
||||||
|
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: IncomeExpensesAccount,
|
||||||
period: Period) -> str | Response:
|
period: Period) -> str | Response:
|
||||||
"""Returns the income and expenses log.
|
"""Returns the income and expenses log.
|
||||||
|
|
||||||
@ -156,7 +154,7 @@ def get_income_expenses(currency: Currency, account: CurrentAccount,
|
|||||||
return __get_income_expenses(currency, account, period)
|
return __get_income_expenses(currency, account, period)
|
||||||
|
|
||||||
|
|
||||||
def __get_income_expenses(currency: Currency, account: CurrentAccount,
|
def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
|
||||||
period: Period) -> str | Response:
|
period: Period) -> str | Response:
|
||||||
"""Returns the income and expenses log.
|
"""Returns the income and expenses log.
|
||||||
|
|
||||||
@ -171,15 +169,16 @@ def __get_income_expenses(currency: Currency, account: CurrentAccount,
|
|||||||
return report.html()
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("trial-balance", endpoint="trial-balance-default")
|
@bp.get("trial-balance/<currency:currency>",
|
||||||
|
endpoint="trial-balance-default")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def get_default_trial_balance() -> str | Response:
|
def get_default_trial_balance(currency: Currency) -> 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(
|
return __get_trial_balance(currency, get_period())
|
||||||
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>",
|
||||||
@ -208,15 +207,16 @@ def __get_trial_balance(currency: Currency, period: Period) -> str | Response:
|
|||||||
return report.html()
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("income-statement", endpoint="income-statement-default")
|
@bp.get("income-statement/<currency:currency>",
|
||||||
|
endpoint="income-statement-default")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def get_default_income_statement() -> str | Response:
|
def get_default_income_statement(currency: Currency) -> 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(
|
return __get_income_statement(currency, get_period())
|
||||||
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,15 +246,16 @@ def __get_income_statement(currency: Currency, period: Period) \
|
|||||||
return report.html()
|
return report.html()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("balance-sheet", endpoint="balance-sheet-default")
|
@bp.get("balance-sheet/<currency:currency>",
|
||||||
|
endpoint="balance-sheet-default")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def get_default_balance_sheet() -> str | Response:
|
def get_default_balance_sheet(currency: Currency) -> 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(
|
return __get_balance_sheet(currency, get_period())
|
||||||
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>",
|
||||||
@ -285,130 +286,6 @@ 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) -> redirect:
|
|
||||||
"""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 Project
|
/* The Mia! Accounting Flask Project
|
||||||
* style.css: The style sheet for the accounting application.
|
* style.css: The style sheet for the accounting application.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -39,10 +39,6 @@
|
|||||||
.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;
|
||||||
@ -80,7 +76,7 @@
|
|||||||
height: 3.2rem;
|
height: 3.2rem;
|
||||||
width: 3.2rem;
|
width: 3.2rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 0.5rem;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
|
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
|
||||||
padding-top: 0.7rem;
|
padding-top: 0.7rem;
|
||||||
@ -121,29 +117,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Links between objects */
|
/* Links between objects */
|
||||||
.accounting-original-line-item {
|
.accounting-original-entry {
|
||||||
border-top: thin solid darkslategray;
|
border-top: thin solid darkslategray;
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
}
|
}
|
||||||
.accounting-original-line-item a {
|
.accounting-original-entry a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.accounting-original-line-item a:hover {
|
.accounting-original-entry a:hover {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.accounting-offset-line-items {
|
.accounting-offset-entries {
|
||||||
border-top: thin solid darkslategray;
|
border-top: thin solid darkslategray;
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
}
|
}
|
||||||
.accounting-offset-line-items ul li {
|
.accounting-offset-entries ul li {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
.accounting-offset-line-items ul li a {
|
.accounting-offset-entries ul li a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.accounting-offset-line-items ul li a:hover {
|
.accounting-offset-entries ul li a:hover {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,35 +149,38 @@
|
|||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The journal entry management */
|
/** The transaction management */
|
||||||
.accounting-currency-control {
|
.accounting-currency-control {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
.accounting-currency-content {
|
.accounting-currency-content {
|
||||||
width: calc(100% - 3rem);
|
width: calc(100% - 3rem);
|
||||||
}
|
}
|
||||||
.accounting-line-item-content {
|
.accounting-entry-content {
|
||||||
width: calc(100% - 3rem);
|
width: calc(100% - 3rem);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
.accounting-entry-control {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
|
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
|
||||||
background-color: #f2f2f2;
|
background-color: #f2f2f2;
|
||||||
}
|
}
|
||||||
.accounting-list-group-hover .list-group-item:hover {
|
.accounting-list-group-hover .list-group-item:hover {
|
||||||
background-color: #ececec;
|
background-color: #ececec;
|
||||||
}
|
}
|
||||||
.accounting-journal-entry-line-item {
|
.accounting-transaction-entry {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
.accounting-journal-entry-line-item-header {
|
.accounting-transaction-entry-header {
|
||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
border-bottom: thick double slategray;
|
border-bottom: thick double slategray;
|
||||||
}
|
}
|
||||||
.list-group-item.accounting-journal-entry-line-item-total {
|
.list-group-item.accounting-transaction-entry-total {
|
||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
border-top: thick double slategray;
|
border-top: thick double slategray;
|
||||||
}
|
}
|
||||||
.accounting-line-item-editor-original-line-item-content {
|
.accounting-entry-editor-original-entry-content {
|
||||||
width: calc(100% - 3rem);
|
width: calc(100% - 3rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,23 +212,11 @@ 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: #ecedee;
|
background-color: #f2f2f2;
|
||||||
}
|
}
|
||||||
.accounting-report-table-body .accounting-report-table-row:hover {
|
.accounting-report-table-body .accounting-report-table-row:hover {
|
||||||
background-color: #e5e6e7;
|
background-color: rgba(0, 0, 0, 0.075);
|
||||||
}
|
|
||||||
.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;
|
||||||
@ -325,66 +312,12 @@ 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 {
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The description editor */
|
|
||||||
.accounting-description-editor-buttons {
|
|
||||||
max-height: 7rem;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
.accounting-description-editor-buttons .btn {
|
|
||||||
margin-bottom: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The order of the journal entries in a same day */
|
|
||||||
.accounting-journal-entry-order-item, .accounting-journal-entry-order-item:hover {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.accounting-journal-entry-order-item-currency {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
border-top: thin solid lightgray;
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The illustration of the description template for the recurring transactions */
|
|
||||||
.accounting-recurring-description-template-illustration p {
|
|
||||||
margin: 0.2rem 0;
|
|
||||||
}
|
|
||||||
.accounting-recurring-description-template-illustration ul {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The unmatched offsets */
|
|
||||||
.accounting-unmatched-offset-pair-list {
|
|
||||||
height: 20rem;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The Material Design text field (floating form control in Bootstrap) */
|
/* The Material Design text field (floating form control in Bootstrap) */
|
||||||
.accounting-material-text-field {
|
.accounting-material-text-field {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -409,7 +342,7 @@ a.accounting-report-table-row {
|
|||||||
.accounting-material-fab {
|
.accounting-material-fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 2rem;
|
right: 2rem;
|
||||||
bottom: 2rem;
|
bottom: 1rem;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/* The Mia! Accounting Project
|
/* The Mia! Accounting Flask Project
|
||||||
* account-form.js: The JavaScript for the account form
|
* account-form.js: The JavaScript for the account form
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -110,23 +110,14 @@ class AccountForm {
|
|||||||
this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
|
this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
|
||||||
this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
|
this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
|
||||||
this.#formElement.onsubmit = () => {
|
this.#formElement.onsubmit = () => {
|
||||||
return this.#validate();
|
return this.#validateForm();
|
||||||
};
|
};
|
||||||
this.#baseControl.onclick = () => {
|
this.#baseControl.onclick = () => {
|
||||||
this.#baseControl.classList.add("accounting-not-empty");
|
this.#baseControl.classList.add("accounting-not-empty");
|
||||||
this.#baseAccountSelector.onOpen();
|
this.#baseAccountSelector.onOpen(this.#baseCode.value);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
||||||
*
|
*
|
||||||
@ -138,14 +129,15 @@ class AccountForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves the selected base account.
|
* Sets the base account.
|
||||||
*
|
*
|
||||||
* @param account {BaseAccountOption} the selected base account
|
* @param code {string} the base account code
|
||||||
|
* @param text {string} the text for the base account
|
||||||
*/
|
*/
|
||||||
saveBaseAccount(account) {
|
setBaseAccount(code, text) {
|
||||||
this.#baseCode.value = account.code;
|
this.#baseCode.value = code;
|
||||||
this.#base.innerText = account.text;
|
this.#base.innerText = text;
|
||||||
if (["1", "2", "3"].includes(account.code.substring(0, 1))) {
|
if (["1", "2", "3"].includes(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 {
|
||||||
@ -171,7 +163,7 @@ class AccountForm {
|
|||||||
*
|
*
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
*/
|
*/
|
||||||
#validate() {
|
#validateForm() {
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
isValid = this.#validateBase() && isValid;
|
isValid = this.#validateBase() && isValid;
|
||||||
isValid = this.#validateTitle() && isValid;
|
isValid = this.#validateTitle() && isValid;
|
||||||
@ -233,7 +225,7 @@ class BaseAccountSelector {
|
|||||||
* The account form
|
* The account form
|
||||||
* @type {AccountForm}
|
* @type {AccountForm}
|
||||||
*/
|
*/
|
||||||
form;
|
#form;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The selector modal
|
* The selector modal
|
||||||
@ -261,7 +253,7 @@ class BaseAccountSelector {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The options
|
* The options
|
||||||
* @type {BaseAccountOption[]}
|
* @type {HTMLLIElement[]}
|
||||||
*/
|
*/
|
||||||
#options;
|
#options;
|
||||||
|
|
||||||
@ -277,54 +269,83 @@ class BaseAccountSelector {
|
|||||||
* @param form {AccountForm} the form
|
* @param form {AccountForm} the form
|
||||||
*/
|
*/
|
||||||
constructor(form) {
|
constructor(form) {
|
||||||
this.form = form;
|
this.#form = form;
|
||||||
const prefix = "accounting-base-selector";
|
this.#modal = document.getElementById("accounting-base-selector-modal");
|
||||||
this.#modal = document.getElementById(`${prefix}-modal`);
|
this.#query = document.getElementById("accounting-base-selector-query");
|
||||||
this.#query = document.getElementById(`${prefix}-query`);
|
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.#options = Array.from(document.getElementsByClassName(`${prefix}-option`)).map((element) => new BaseAccountOption(this, element));
|
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.#modal.addEventListener("hidden.bs.modal", () => {
|
||||||
this.#modal.addEventListener("hidden.bs.modal", () => this.form.onBaseAccountSelectorClosed());
|
this.#form.onBaseAccountSelectorClosed();
|
||||||
this.#query.oninput = () => this.#filterOptions();
|
});
|
||||||
this.#clearButton.onclick = () => this.form.clearBaseAccount();
|
for (const option of this.#options) {
|
||||||
|
option.onclick = () => {
|
||||||
|
this.#form.setBaseAccount(option.dataset.code, option.dataset.content);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.#clearButton.onclick = () => {
|
||||||
|
this.#form.clearBaseAccount();
|
||||||
|
};
|
||||||
|
this.#initializeBaseAccountQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters the options.
|
* Initializes the query.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
#filterOptions() {
|
#initializeBaseAccountQuery() {
|
||||||
let isAnyMatched = false;
|
this.#query.addEventListener("input", () => {
|
||||||
for (const option of this.#options) {
|
if (this.#query.value === "") {
|
||||||
if (option.isMatched(this.#query.value)) {
|
for (const option of this.#options) {
|
||||||
option.setShown(true);
|
option.classList.remove("d-none");
|
||||||
isAnyMatched = true;
|
}
|
||||||
} else {
|
this.#optionList.classList.remove("d-none");
|
||||||
option.setShown(false);
|
this.#queryNoResult.classList.add("d-none");
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
let hasAnyMatched = false;
|
||||||
if (!isAnyMatched) {
|
for (const option of this.#options) {
|
||||||
this.#optionList.classList.add("d-none");
|
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||||
this.#queryNoResult.classList.remove("d-none");
|
let isMatched = false;
|
||||||
} else {
|
for (const queryValue of queryValues) {
|
||||||
this.#optionList.classList.remove("d-none");
|
if (queryValue.includes(this.#query.value)) {
|
||||||
this.#queryNoResult.classList.add("d-none");
|
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 {
|
||||||
|
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() {
|
onOpen(baseCode) {
|
||||||
this.#query.value = "";
|
|
||||||
this.#filterOptions();
|
|
||||||
for (const option of this.#options) {
|
for (const option of this.#options) {
|
||||||
option.setActive(option.code === this.form.baseCode);
|
if (option.dataset.code === baseCode) {
|
||||||
|
option.classList.add("active");
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
@ -335,93 +356,3 @@ class BaseAccountSelector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A base account option.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
class BaseAccountOption {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The element
|
|
||||||
* @type {HTMLLIElement}
|
|
||||||
*/
|
|
||||||
#element;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The account code
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
code;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The account text
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
text;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The values to query against
|
|
||||||
* @type {string[]}
|
|
||||||
*/
|
|
||||||
#queryValues;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructs the account in the base account selector.
|
|
||||||
*
|
|
||||||
* @param selector {BaseAccountSelector} the base account selector
|
|
||||||
* @param element {HTMLLIElement} the element
|
|
||||||
*/
|
|
||||||
constructor(selector, element) {
|
|
||||||
this.#element = element;
|
|
||||||
this.code = element.dataset.code;
|
|
||||||
this.text = element.dataset.text;
|
|
||||||
this.#queryValues = JSON.parse(element.dataset.queryValues);
|
|
||||||
|
|
||||||
this.#element.onclick = () => selector.form.saveBaseAccount(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether the account matches the query.
|
|
||||||
*
|
|
||||||
* @param query {string} the query term
|
|
||||||
* @return {boolean} true if the option matches, or false otherwise
|
|
||||||
*/
|
|
||||||
isMatched(query) {
|
|
||||||
if (query === "") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for (const queryValue of this.#queryValues) {
|
|
||||||
if (queryValue.toLowerCase().includes(query.toLowerCase())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets whether the option is shown.
|
|
||||||
*
|
|
||||||
* @param isShown {boolean} true to show, or false otherwise
|
|
||||||
*/
|
|
||||||
setShown(isShown) {
|
|
||||||
if (isShown) {
|
|
||||||
this.#element.classList.remove("d-none");
|
|
||||||
} else {
|
|
||||||
this.#element.classList.add("d-none");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets whether the option is active.
|
|
||||||
*
|
|
||||||
* @param isActive {boolean} true if active, or false otherwise
|
|
||||||
*/
|
|
||||||
setActive(isActive) {
|
|
||||||
if (isActive) {
|
|
||||||
this.#element.classList.add("active");
|
|
||||||
} else {
|
|
||||||
this.#element.classList.remove("active");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
/* The Mia! Accounting Project
|
/* The Mia! Accounting Flask Project
|
||||||
* account-order.js: The JavaScript for the account order
|
* account-order.js: The JavaScript for the account order
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -29,11 +29,10 @@ 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);
|
||||||
const zeroPaddedNo = `000${no.value}`.slice(-3)
|
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3);
|
||||||
code.innerText = `${list.dataset.baseCode}-${zeroPaddedNo}`;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
initializeDragAndDropReordering(list, onReorder);
|
initializeDragAndDropReordering(list, onReorder);
|
||||||
|
224
src/accounting/static/js/account-selector.js
Normal file
224
src/accounting/static/js/account-selector.js
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/2/28
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account selector.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class AccountSelector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journal entry editor
|
||||||
|
* @type {JournalEntryEditor}
|
||||||
|
*/
|
||||||
|
#entryEditor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entry type
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
entryType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix of the HTML ID and class
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
#prefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The button to clear the account
|
||||||
|
* @type {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#clearButton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query input
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error message when the query has no result
|
||||||
|
* @type {HTMLParagraphElement}
|
||||||
|
*/
|
||||||
|
#queryNoResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The option list
|
||||||
|
* @type {HTMLUListElement}
|
||||||
|
*/
|
||||||
|
#optionList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options
|
||||||
|
* @type {HTMLLIElement[]}
|
||||||
|
*/
|
||||||
|
#options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The more item to show all accounts
|
||||||
|
* @type {HTMLLIElement}
|
||||||
|
*/
|
||||||
|
#more;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs an account selector.
|
||||||
|
*
|
||||||
|
* @param entryEditor {JournalEntryEditor} the journal entry editor
|
||||||
|
* @param entryType {string} the entry type, either "debit" or "credit"
|
||||||
|
*/
|
||||||
|
constructor(entryEditor, entryType) {
|
||||||
|
this.#entryEditor = entryEditor
|
||||||
|
this.entryType = entryType;
|
||||||
|
this.#prefix = "accounting-account-selector-" + entryType;
|
||||||
|
this.#query = document.getElementById(this.#prefix + "-query");
|
||||||
|
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
|
||||||
|
this.#optionList = document.getElementById(this.#prefix + "-option-list");
|
||||||
|
// noinspection JSValidateTypes
|
||||||
|
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
|
||||||
|
this.#more = document.getElementById(this.#prefix + "-more");
|
||||||
|
this.#clearButton = document.getElementById(this.#prefix + "-btn-clear");
|
||||||
|
this.#more.onclick = () => {
|
||||||
|
this.#more.classList.add("d-none");
|
||||||
|
this.#filterOptions();
|
||||||
|
};
|
||||||
|
this.#clearButton.onclick = () => this.#entryEditor.clearAccount();
|
||||||
|
for (const option of this.#options) {
|
||||||
|
option.onclick = () => this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
|
||||||
|
}
|
||||||
|
this.#query.addEventListener("input", () => {
|
||||||
|
this.#filterOptions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters the options.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#filterOptions() {
|
||||||
|
const codesInUse = this.#getCodesUsedInForm();
|
||||||
|
let shouldAnyShow = false;
|
||||||
|
for (const option of this.#options) {
|
||||||
|
const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query);
|
||||||
|
if (shouldShow) {
|
||||||
|
option.classList.remove("d-none");
|
||||||
|
shouldAnyShow = true;
|
||||||
|
} else {
|
||||||
|
option.classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!shouldAnyShow && this.#more.classList.contains("d-none")) {
|
||||||
|
this.#optionList.classList.add("d-none");
|
||||||
|
this.#queryNoResult.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
this.#optionList.classList.remove("d-none");
|
||||||
|
this.#queryNoResult.classList.add("d-none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the account codes that are used in the form.
|
||||||
|
*
|
||||||
|
* @return {string[]} the account codes that are used in the form
|
||||||
|
*/
|
||||||
|
#getCodesUsedInForm() {
|
||||||
|
const inUse = this.#entryEditor.form.getAccountCodesUsed(this.entryType);
|
||||||
|
if (this.#entryEditor.accountCode !== null) {
|
||||||
|
inUse.push(this.#entryEditor.accountCode);
|
||||||
|
}
|
||||||
|
return inUse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether an option should show.
|
||||||
|
*
|
||||||
|
* @param option {HTMLLIElement} the option
|
||||||
|
* @param more {HTMLLIElement} the more element
|
||||||
|
* @param inUse {string[]} the account codes that are used in the form
|
||||||
|
* @param query {HTMLInputElement} the query element, if any
|
||||||
|
* @return {boolean} true if the option should show, or false otherwise
|
||||||
|
*/
|
||||||
|
#shouldOptionShow(option, more, inUse, query) {
|
||||||
|
const isQueryMatched = () => {
|
||||||
|
if (query.value === "") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||||
|
for (const queryValue of queryValues) {
|
||||||
|
if (queryValue.includes(query.value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const isMoreMatched = () => {
|
||||||
|
if (more.classList.contains("d-none")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
|
||||||
|
};
|
||||||
|
return isMoreMatched() && isQueryMatched();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback when the account selector is shown.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
onOpen() {
|
||||||
|
this.#query.value = "";
|
||||||
|
this.#more.classList.remove("d-none");
|
||||||
|
this.#filterOptions();
|
||||||
|
for (const option of this.#options) {
|
||||||
|
if (option.dataset.code === this.#entryEditor.accountCode) {
|
||||||
|
option.classList.add("active");
|
||||||
|
} else {
|
||||||
|
option.classList.remove("active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.#entryEditor.accountCode === null) {
|
||||||
|
this.#clearButton.classList.add("btn-secondary");
|
||||||
|
this.#clearButton.classList.remove("btn-danger");
|
||||||
|
this.#clearButton.disabled = true;
|
||||||
|
} else {
|
||||||
|
this.#clearButton.classList.add("btn-danger");
|
||||||
|
this.#clearButton.classList.remove("btn-secondary");
|
||||||
|
this.#clearButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the account selector instances.
|
||||||
|
*
|
||||||
|
* @param entryEditor {JournalEntryEditor} the journal entry editor
|
||||||
|
* @return {{debit: AccountSelector, credit: AccountSelector}}
|
||||||
|
*/
|
||||||
|
static getInstances(entryEditor) {
|
||||||
|
const selectors = {}
|
||||||
|
const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
|
||||||
|
for (const modal of modals) {
|
||||||
|
selectors[modal.dataset.entryType] = new AccountSelector(entryEditor, modal.dataset.entryType);
|
||||||
|
}
|
||||||
|
return selectors;
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
/* The Mia! Accounting Project
|
/* The Mia! Accounting Flask Project
|
||||||
* currency-form.js: The JavaScript for the currency form
|
* currency-form.js: The JavaScript for the currency form
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -81,7 +81,7 @@ class CurrencyForm {
|
|||||||
this.#validateName();
|
this.#validateName();
|
||||||
};
|
};
|
||||||
this.#formElement.onsubmit = () => {
|
this.#formElement.onsubmit = () => {
|
||||||
this.#validate().then((isValid) => {
|
this.#validateForm().then((isValid) => {
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
this.#formElement.submit();
|
this.#formElement.submit();
|
||||||
}
|
}
|
||||||
@ -95,7 +95,7 @@ class CurrencyForm {
|
|||||||
*
|
*
|
||||||
* @returns {Promise<boolean>} true if valid, or false otherwise
|
* @returns {Promise<boolean>} true if valid, or false otherwise
|
||||||
*/
|
*/
|
||||||
async #validate() {
|
async #validateForm() {
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
isValid = await this.#validateCode() && isValid;
|
isValid = await this.#validateCode() && isValid;
|
||||||
isValid = this.#validateName() && isValid;
|
isValid = this.#validateName() && isValid;
|
||||||
@ -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
@ -1,4 +1,4 @@
|
|||||||
/* The Mia! Accounting Project
|
/* The Mia! Accounting Flask Project
|
||||||
* drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
|
* drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user