37 Commits

Author SHA1 Message Date
370d2668e5 Advanced to version 1.1.0. 2023-04-09 00:48:57 +08:00
5e3e695e62 Updated the Sphinx documentation. 2023-04-09 00:41:14 +08:00
510d369e9c Updated the translation. 2023-04-09 00:39:46 +08:00
b65cae9252 Added the OffsetMatcherTestCase test case. 2023-04-09 00:39:46 +08:00
285c12406b Revised the property names in the TestData class in testlib_offset.py. 2023-04-09 00:39:46 +08:00
df240472a4 Changed the permission to the offset matcher so that editors can use it. 2023-04-09 00:39:45 +08:00
1218b224fc Renamed the "accounting.unmatched_offset.forms" module to "accounting.utils.offset_matcher". 2023-04-09 00:39:45 +08:00
79689ac0e5 Revised the unapplied original line item report to mark matched offsets for administrators when there are unmatched offsets. 2023-04-09 00:39:45 +08:00
1660e66766 Revised the background color of the report tables, for better look on non-white backgrounds. 2023-04-09 00:39:45 +08:00
12d00c9c7d Added the unmatched offset list and the offset matcher. 2023-04-09 00:39:11 +08:00
428018e4a9 Added the match pseudo property to the JournalEntryLineItem data model. 2023-04-08 18:12:57 +08:00
a8f318b0bb Reordered the methods in the JournalEntryLineItem data model. 2023-04-08 18:12:57 +08:00
a3507494e5 Added the refundable deposit accounts to the default list of accounts that need offset in the accounting-init-accounts console command. 2023-04-08 18:12:57 +08:00
3aa6c8d6f6 Removed the empty value in the __is_need_offset function in the "accounting.account.commands" console.command. 2023-04-08 18:12:56 +08:00
052b62cdd4 Moved the __query_line_items method in the UnappliedOriginalLineItems report to the new "accounting.utils.unapplied" module, to share this query. 2023-04-08 18:12:56 +08:00
3728a4037d Renamed the UnappliedAccountConverter path converter to NeedOffsetAccountConverter. 2023-04-08 18:12:56 +08:00
6eee17d44f Added the account list as the default page for the unapplied original line items. 2023-04-08 18:12:55 +08:00
e5cc2b5a2f Added the "count" pseudo property to the Account data model. 2023-04-08 18:12:55 +08:00
ac3b5523b1 Fixed the documentation of the default_currency and default_ie_account pseudo property in the Options class. 2023-04-08 18:12:55 +08:00
5af6fd9619 Moved the "accounting.journal_entry.utils.offset_alias" module to "accounting.utils.offset_alias". 2023-04-08 18:12:55 +08:00
71a20cba29 Replaced the "default_currency_text" pseudo property with the "default_currency" pseudo property in the Options class. 2023-04-08 18:12:54 +08:00
4a4cf1ea40 Removed the redundant "default_ie_account_code_text" pseudo property from the Options class. 2023-04-08 18:12:54 +08:00
e9824808ec Added the UnappliedAccountConverter path converter to only allow the accounts that need offsets. 2023-04-08 18:12:54 +08:00
c984d2d596 Renamed the IncomeExpensesAccountConverter path converter to CurrentAccountConverter. 2023-04-08 18:12:54 +08:00
720e77c814 Fixed the documentation of the PeriodConverter and IncomeExpensesAccountConverter path converters. 2023-04-08 18:12:54 +08:00
0f0412827d Added the unapplied original line item report. 2023-04-08 18:12:45 +08:00
3a0e978f76 Removed an unused import from the "accounting.journal_entry.forms.line_item" module. 2023-04-08 00:44:13 +08:00
8c10d42d7b Added documentation to the currency and account parameters of the CSVRow class, and the pagination parameter of the PageParams class in the "accounting.report.reports.journal" module. 2023-04-08 00:44:13 +08:00
04ec51afbe Changed the "offsets" relationship to a pseudo property, to apply the correct but complex ordering rules. 2023-04-07 16:04:54 +08:00
fe7a8842ce Fixed the query in the JournalEntryConverter converter. 2023-04-07 15:31:06 +08:00
66daa5c42c Fixed the query in the KeepAccountWhenHavingOffset validator. 2023-04-07 15:29:17 +08:00
27fb44937d Fixed the incorrect query in the "offsets" pseudo property of the LineItemForm form. 2023-04-07 15:11:04 +08:00
7026ed3a65 Fixed the order of the items in the "offsets" pseudo property of the LineItemForm form. 2023-04-07 15:01:22 +08:00
fdd3e93778 Fixed the net balance in the line items in the journal entry detail. 2023-04-07 14:57:24 +08:00
def7559457 Fixed the #filterOptions in the JavaScript JournalEntryAccountSelector to show the "more" option when there is no matches, but it is not showing all the accounts. 2023-04-07 12:34:24 +08:00
7905820d68 Revised the imports in the "accounting.base_account.views" and "accounting.currency.views" modules. 2023-04-06 16:09:36 +08:00
7ae332c975 Moved the "Test Site and Live Demonstration" section to the front of the documentation. 2023-04-06 10:00:24 +08:00
49 changed files with 2352 additions and 259 deletions

View File

@ -17,8 +17,6 @@ accounting reports:
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
receivables.
You may try the `live demonstration`_.
Installation
============
@ -33,6 +31,18 @@ You may also download the from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_.
Test Site and Live Demonstration
================================
You may find a working example in the `test site`_ in the
`source distribution`_. It is the simplest website that works with
*Mia! Accounting*. It is used in the automatic tests. It is the same
code run for `live demonstration`_.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Prerequisites
=============
@ -157,18 +167,6 @@ base template:
Check your Flask application and see how it works.
Test Site and Live Demonstration
================================
You may find a working example in the `test site`_ in the
`source distribution`_. It is the simplest website that works with
*Mia! Accounting*. It is used in the automatic tests. It is the same
code run for `live demonstration`_.
If you do not have a running Flask application, you may start with the
test site.
Documentation
=============
@ -203,6 +201,8 @@ Authors
.. _Flask: https://flask.palletsprojects.com
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _live demonstration: https://accounting.imacat.idv.tw
.. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases
@ -216,6 +216,4 @@ Authors
.. _init_app: https://mia-accounting.readthedocs.io/en/latest/accounting.html#accounting.init_app
.. _flask_sqlalchemy.SQLAlchemy.create_all: https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/api/#flask_sqlalchemy.SQLAlchemy.create_all
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io

View File

@ -20,14 +20,6 @@ accounting.journal\_entry.utils.description\_editor module
:undoc-members:
:show-inheritance:
accounting.journal\_entry.utils.offset\_alias module
----------------------------------------------------
.. automodule:: accounting.journal_entry.utils.offset_alias
:members:
:undoc-members:
:show-inheritance:
accounting.journal\_entry.utils.operators module
------------------------------------------------

View File

@ -60,6 +60,22 @@ accounting.report.reports.trial\_balance module
:undoc-members:
:show-inheritance:
accounting.report.reports.unapplied module
------------------------------------------
.. automodule:: accounting.report.reports.unapplied
:members:
:undoc-members:
:show-inheritance:
accounting.report.reports.unapplied\_accounts module
----------------------------------------------------
.. automodule:: accounting.report.reports.unapplied_accounts
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------

View File

@ -52,6 +52,14 @@ accounting.report.utils.report\_type module
:undoc-members:
:show-inheritance:
accounting.report.utils.unapplied module
----------------------------------------
.. automodule:: accounting.report.utils.unapplied
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.urls module
-----------------------------------

View File

@ -13,6 +13,7 @@ Subpackages
accounting.journal_entry
accounting.option
accounting.report
accounting.unmatched_offset
accounting.utils
Submodules

View File

@ -0,0 +1,29 @@
accounting.unmatched\_offset package
====================================
Submodules
----------
accounting.unmatched\_offset.queries module
-------------------------------------------
.. automodule:: accounting.unmatched_offset.queries
:members:
:undoc-members:
:show-inheritance:
accounting.unmatched\_offset.views module
-----------------------------------------
.. automodule:: accounting.unmatched_offset.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.unmatched_offset
:members:
:undoc-members:
:show-inheritance:

View File

@ -44,6 +44,22 @@ accounting.utils.next\_uri module
:undoc-members:
:show-inheritance:
accounting.utils.offset\_alias module
-------------------------------------
.. automodule:: accounting.utils.offset_alias
:members:
:undoc-members:
:show-inheritance:
accounting.utils.offset\_matcher module
---------------------------------------
.. automodule:: accounting.utils.offset_matcher
:members:
:undoc-members:
:show-inheritance:
accounting.utils.options module
-------------------------------
@ -92,6 +108,14 @@ accounting.utils.strip\_text module
:undoc-members:
:show-inheritance:
accounting.utils.unapplied module
---------------------------------
.. automodule:: accounting.utils.unapplied
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module
----------------------------

View File

@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Mia! Accounting'
copyright = '2023, imacat'
author = 'imacat'
release = '1.0.1'
release = '1.1.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -12,8 +12,6 @@ accounting reports:
In addition, *Mia! Accounting* tracks offsets for unpaid payables and
receivables.
You may try the `live demonstration`_.
Installation
------------
@ -28,6 +26,18 @@ You may also download the from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_.
Test Site and Live Demonstration
--------------------------------
You may find a working example in the `test site`_ in the
`source distribution`_. It is the simplest website that works with
*Mia! Accounting*. It is used in the automatic tests. It is the same
code run for `live demonstration`_.
If you do not have a running Flask application or do not know how to
start one, you may start with the test site.
Prerequisites
-------------
@ -102,18 +112,6 @@ base template:
Check your Flask application and see how it works.
Test Site and Live Demonstration
--------------------------------
You may find a working example in the `test site`_ in the
`source distribution`_. It is the simplest website that works with
*Mia! Accounting*. It is used in the automatic tests. It is the same
code run for `live demonstration`_.
If you do not have a running Flask application, you may start with the
test site.
Documentation
-------------
@ -122,6 +120,8 @@ Refer to the `documentation on Read the Docs`_.
.. _Flask: https://flask.palletsprojects.com
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _live demonstration: https://accounting.imacat.idv.tw
.. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases
@ -133,6 +133,4 @@ Refer to the `documentation on Read the Docs`_.
.. _Tempus-Dominus: https://getdatepicker.com
.. _flask_sqlalchemy.SQLAlchemy.create_all: https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/api/#flask_sqlalchemy.SQLAlchemy.create_all
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
.. _test site: https://github.com/imacat/mia-accounting/tree/main/tests/test_site
.. _source distribution: https://pypi.org/project/mia-accounting/#files
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io

View File

@ -17,7 +17,7 @@
[project]
name = "mia-accounting"
version = "1.0.1"
version = "1.1.0"
description = "A Flask accounting module."
readme = "README.rst"
requires-python = ">=3.11"

View File

@ -88,4 +88,7 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
from . import option
option.init_app(bp)
from . import unmatched_offset
unmatched_offset.init_app(bp)
app.register_blueprint(bp, url_prefix=url_prefix)

View File

@ -108,15 +108,15 @@ def __is_need_offset(base_code: str) -> bool:
"""
# Assets
if base_code[0] == "1":
if base_code[:3] in {"113", "114", "118", "184"}:
if base_code[:3] in {"113", "114", "118", "184", "186"}:
return True
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
"1581", "1611", "1851", ""}:
if base_code in {"1286", "1411", "1421", "1431", "1441", "1511",
"1521", "1581", "1611", "1851"}:
return True
return False
# Liabilities
if base_code[0] == "2":
if base_code in {"2111", "2114", "2284", "2293"}:
if base_code in {"2111", "2114", "2284", "2293", "2861"}:
return False
return True
# Only assets and liabilities need offset

View File

@ -22,6 +22,7 @@ from flask import Blueprint, render_template
from accounting.models import BaseAccount
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view
from .queries import get_base_account_query
bp: Blueprint = Blueprint("base-account", __name__)
"""The view blueprint for the base account management."""
@ -34,7 +35,6 @@ def list_accounts() -> str:
:return: The account list.
"""
from .queries import get_base_account_query
accounts: list[BaseAccount] = get_base_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts)
return render_template("accounting/base-account/list.html",

View File

@ -34,6 +34,7 @@ from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import CurrencyForm
from .queries import get_currency_query
bp: Blueprint = Blueprint("currency", __name__)
"""The view blueprint for the currency management."""
@ -48,7 +49,6 @@ def list_currencies() -> str:
:return: The currency list.
"""
from .queries import get_currency_query
currencies: list[Currency] = get_currency_query()
pagination: Pagination = Pagination[Currency](currencies)
return render_template("accounting/currency/list.html",

View File

@ -23,6 +23,7 @@ from flask import abort
from sqlalchemy.orm import selectinload
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import JournalEntry, JournalEntryLineItem
from accounting.utils.journal_entry_types import JournalEntryType
@ -37,13 +38,7 @@ class JournalEntryConverter(BaseConverter):
:param value: The journal entry ID.
:return: The corresponding journal entry.
"""
journal_entry: JournalEntry | None = JournalEntry.query\
.join(JournalEntryLineItem)\
.filter(JournalEntry.id == value)\
.options(selectinload(JournalEntry.line_items)
.selectinload(JournalEntryLineItem.offsets)
.selectinload(JournalEntryLineItem.journal_entry))\
.first()
journal_entry: JournalEntry | None = db.session.get(JournalEntry, value)
if journal_entry is None:
abort(404)
return journal_entry

View File

@ -28,14 +28,13 @@ from wtforms.validators import DataRequired
from accounting import db
from accounting.forms import CurrencyExists
from accounting.journal_entry.utils.offset_alias import offset_alias
from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem
from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias
from accounting.utils.strip_text import strip_text
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency."))
"""The validator to check if the currency code is empty."""

View File

@ -31,7 +31,7 @@ from accounting import db
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
IsCreditAccount
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntryLineItem
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.template_filters import format_amount
from accounting.utils.cast import be
from accounting.utils.random_id import new_id
@ -127,10 +127,8 @@ class KeepAccountWhenHavingOffset:
assert isinstance(form, LineItemForm)
if field.data is None or form.id.data is None:
return
line_item: JournalEntryLineItem | None = db.session\
.query(JournalEntryLineItem)\
.filter(JournalEntryLineItem.id == form.id.data)\
.options(selectinload(JournalEntryLineItem.offsets)).first()
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:
@ -344,14 +342,13 @@ class LineItemForm(FlaskForm):
def get_offsets() -> list[JournalEntryLineItem]:
if not self.is_need_offset or self.id.data is None:
return []
return JournalEntryLineItem.query\
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),
selectinload(JournalEntryLineItem.offsets)
.selectinload(
JournalEntryLineItem.journal_entry)).all()
selectinload(JournalEntryLineItem.account)).all()
setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets")

View File

@ -25,7 +25,7 @@ from sqlalchemy.orm import selectinload
from accounting import db
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.cast import be
from .offset_alias import offset_alias
from accounting.utils.offset_alias import offset_alias
def get_selectable_original_line_items(

View File

@ -214,6 +214,25 @@ class Account(db.Model):
"""
return not self.is_real
@property
def count(self) -> int:
"""Returns the number of items in the account.
:return: The number of items in the account.
"""
if not hasattr(self, "__count"):
setattr(self, "__count", 0)
return getattr(self, "__count")
@count.setter
def count(self, count: int) -> None:
"""Sets the number of items in the account.
:param count: The number of items in the account.
:return: None.
"""
setattr(self, "__count", count)
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.
@ -660,12 +679,8 @@ class JournalEntryLineItem(db.Model):
nullable=True)
"""The ID of the original line item."""
original_line_item = db.relationship("JournalEntryLineItem",
back_populates="offsets",
remote_side=id, passive_deletes=True)
"""The original line item."""
offsets = db.relationship("JournalEntryLineItem",
back_populates="original_line_item")
"""The offset items."""
currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"),
nullable=False)
@ -707,14 +722,6 @@ class JournalEntryLineItem(db.Model):
"""
return self.account.code
@property
def debit(self) -> Decimal | None:
"""Returns the debit amount.
:return: The debit amount, or None if this is not a debit line item.
"""
return self.amount if self.is_debit else None
@property
def is_need_offset(self) -> bool:
"""Returns whether the line item needs offset.
@ -729,6 +736,14 @@ class JournalEntryLineItem(db.Model):
return False
return True
@property
def debit(self) -> Decimal | None:
"""Returns the debit amount.
:return: The debit amount, or None if this is not a debit line item.
"""
return self.amount if self.is_debit else None
@property
def credit(self) -> Decimal | None:
"""Returns the credit amount.
@ -758,6 +773,40 @@ class JournalEntryLineItem(db.Model):
"""
setattr(self, "__net_balance", net_balance)
@property
def offsets(self) -> list[t.Self]:
"""Returns the offset items.
:return: The offset items.
"""
if not hasattr(self, "__offsets"):
cls: t.Type[t.Self] = self.__class__
offsets: list[t.Self] = cls.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id == self.id)\
.order_by(JournalEntry.date, JournalEntry.no,
cls.is_debit, cls.no).all()
setattr(self, "__offsets", offsets)
return getattr(self, "__offsets")
@property
def match(self) -> t.Self | None:
"""Returns the match of the line item.
:return: The match of the line item.
"""
if not hasattr(self, "__match"):
setattr(self, "__match", None)
return getattr(self, "__match")
@match.setter
def match(self, match: t.Self) -> None:
"""Sets the match of the line item.
:param match: The matcho of the line item.
:return: None.
"""
setattr(self, "__match", match)
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.

View File

@ -27,9 +27,11 @@ def init_app(app: Flask, url_prefix: str) -> None:
:param url_prefix: The URL prefix of the accounting application.
:return: None.
"""
from .converters import PeriodConverter, IncomeExpensesAccountConverter
from .converters import PeriodConverter, CurrentAccountConverter, \
NeedOffsetAccountConverter
app.url_map.converters["period"] = PeriodConverter
app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter
app.url_map.converters["currentAccount"] = CurrentAccountConverter
app.url_map.converters["needOffsetAccount"] = NeedOffsetAccountConverter
from .views import bp as report_bp
app.register_blueprint(report_bp, url_prefix=url_prefix)

View File

@ -28,8 +28,8 @@ from .period import Period, get_period
class PeriodConverter(BaseConverter):
"""The supplier converter to convert the period specification from and to
the corresponding period in the routes."""
"""The converter to convert the period specification from and to the
corresponding period in the routes."""
def to_python(self, value: str) -> Period:
"""Converts a period specification to a period.
@ -51,9 +51,9 @@ class PeriodConverter(BaseConverter):
return value.spec
class IncomeExpensesAccountConverter(BaseConverter):
"""The supplier converter to convert the income and expenses log pseudo
account code from and to the corresponding pseudo account in the routes."""
class CurrentAccountConverter(BaseConverter):
"""The converter to convert the current account code from and to the
corresponding account in the routes."""
def to_python(self, value: str) -> CurrentAccount:
"""Converts an account code to an account.
@ -77,3 +77,29 @@ class IncomeExpensesAccountConverter(BaseConverter):
:return: Its code.
"""
return value.code
class NeedOffsetAccountConverter(BaseConverter):
"""The converter to convert the unapplied original line item account code
from and to the corresponding account in the routes."""
def to_python(self, value: str) -> Account:
"""Converts an account code to an account.
:param value: The account code.
:return: The corresponding account.
"""
account: Account | None = Account.find_by_code(value)
if account is None:
abort(404)
if not account.is_need_offset:
abort(404)
return account
def to_url(self, value: Account) -> str:
"""Converts an account to account code.
:param value: The account.
:return: Its code.
"""
return value.code

View File

@ -77,6 +77,8 @@ class CSVRow(BaseCSVRow):
"""Constructs a row in the CSV.
:param journal_entry_date: The journal entry date.
:param currency: The currency.
:param account: The account.
:param description: The description.
:param debit: The debit amount.
:param credit: The credit amount.
@ -116,6 +118,7 @@ class PageParams(BasePageParams):
"""Constructs the HTML page parameters.
:param period: The period.
:param pagination: The pagination.
:param line_items: The line items.
"""
self.period: Period = period

View File

@ -0,0 +1,185 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The unapplied original line items.
"""
from datetime import date
from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Account, JournalEntryLineItem
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unapplied import get_accounts_with_unapplied
from accounting.report.utils.urls import unapplied_url
from accounting.utils.offset_matcher import OffsetMatcher
from accounting.utils.pagination import Pagination
from accounting.utils.permission import can_edit
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, journal_entry_date: str | date, currency: str,
description: str | None, amount: str | Decimal,
net_balance: str | Decimal):
"""Constructs a row in the CSV.
:param journal_entry_date: The journal entry date.
:param currency: The currency.
:param description: The description.
:param amount: The amount.
:param net_balance: The net balance.
"""
self.date: str | date = journal_entry_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.description: str | None = description
"""The description."""
self.amount: str | Decimal = amount
"""The amount."""
self.net_balance: str | Decimal = net_balance
"""The net balance."""
@property
def values(self) -> list[str | date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.currency, self.description, self.amount,
self.net_balance]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, account: Account,
is_mark_matches: bool,
pagination: Pagination[JournalEntryLineItem],
line_items: list[JournalEntryLineItem]):
"""Constructs the HTML page parameters.
:param account: The account.
:param is_mark_matches: Whether to mark the matched offsets.
:param pagination: The pagination.
:param line_items: The line items.
"""
self.account: Account = account
"""The account."""
self.pagination: Pagination[JournalEntryLineItem] = pagination
"""The pagination."""
self.line_items: list[JournalEntryLineItem] = line_items
"""The line items."""
self.is_mark_matches: bool = is_mark_matches
"""Whether to mark the matched offsets."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.line_items) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.UNAPPLIED,
account=self.account)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] = [OptionLink(gettext("Accounts"),
unapplied_url(None),
False)]
options.extend([OptionLink(str(x),
unapplied_url(x),
x.id == self.account.id)
for x in get_accounts_with_unapplied()])
return options
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param line_items: The line items.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Description"), gettext("Amount"),
gettext("Net Balance"))]
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
x.description, x.amount, x.net_balance)
for x in line_items])
return rows
class UnappliedOriginalLineItems(BaseReport):
"""The unapplied original line items."""
def __init__(self, account: Account):
"""Constructs the unapplied original line items.
:param account: The account.
"""
self.__account: Account = account
"""The account."""
offset_matcher: OffsetMatcher = OffsetMatcher(self.__account)
self.__line_items: list[JournalEntryLineItem] \
= offset_matcher.unapplied
"""The line items."""
self.__is_mark_matches: bool \
= can_edit() and len(offset_matcher.unmatched_offsets) > 0
"""Whether to mark the matched offsets."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = f"unapplied-{self.__account.code}.csv"
return csv_download(filename, get_csv_rows(self.__line_items))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[JournalEntryLineItem] \
= Pagination[JournalEntryLineItem](self.__line_items,
is_reversed=True)
params: PageParams = PageParams(account=self.__account,
is_mark_matches=self.__is_mark_matches,
pagination=pagination,
line_items=pagination.list)
return render_template("accounting/report/unapplied.html",
report=params)

View File

@ -0,0 +1,137 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The accounts with unapplied original line items.
"""
from datetime import date
from decimal import Decimal
from flask import render_template, Response
from accounting.locale import gettext
from accounting.models import Account
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.unapplied import get_accounts_with_unapplied
from accounting.report.utils.urls import unapplied_url
class CSVRow(BaseCSVRow):
"""A row in the CSV."""
def __init__(self, account: str, count: int | str):
"""Constructs a row in the CSV.
:param account: The account.
:param count: The number of unapplied original line items.
"""
self.account: str = account
"""The currency."""
self.count: int | str = count
"""The number of unapplied original line items."""
@property
def values(self) -> list[str | date | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.account, self.count]
class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, accounts: list[Account]):
"""Constructs the HTML page parameters.
:param accounts: The accounts.
"""
self.accounts: list[Account] = accounts
"""The accounts."""
@property
def has_data(self) -> bool:
"""Returns whether there is any data on the page.
:return: True if there is any data, or False otherwise.
"""
return len(self.accounts) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.UNAPPLIED)
@property
def account_options(self) -> list[OptionLink]:
"""Returns the account options.
:return: The account options.
"""
options: list[OptionLink] = [OptionLink(gettext("Accounts"),
unapplied_url(None),
True)]
options.extend([OptionLink(str(x),
unapplied_url(x),
False)
for x in self.accounts])
return options
def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the line items.
:param accounts: The accounts.
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x).title(), x.count)
for x in accounts])
return rows
class AccountsWithUnappliedOriginalLineItems(BaseReport):
"""The accounts with unapplied original line items."""
def __init__(self):
"""Constructs the outstanding balances."""
self.__accounts: list[Account] = get_accounts_with_unapplied()
"""The accounts."""
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = f"unapplied-accounts.csv"
return csv_download(filename, get_csv_rows(self.__accounts))
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
return render_template("accounting/report/unapplied-accounts.html",
report=PageParams(accounts=self.__accounts))

View File

@ -34,7 +34,7 @@ from accounting.utils.current_account import CurrentAccount
from .option_link import OptionLink
from .report_type import ReportType
from .urls import journal_url, ledger_url, income_expenses_url, \
trial_balance_url, income_statement_url, balance_sheet_url
trial_balance_url, income_statement_url, balance_sheet_url, unapplied_url
class ReportChooser:
@ -74,6 +74,7 @@ class ReportChooser:
self.__reports.append(self.__trial_balance)
self.__reports.append(self.__income_statement)
self.__reports.append(self.__balance_sheet)
self.__reports.append(self.__unapplied)
for report in self.__reports:
if report.is_active:
self.current_report = report.title
@ -151,6 +152,23 @@ class ReportChooser:
self.__active_report == ReportType.BALANCE_SHEET,
fa_icon="fa-solid fa-scale-balanced")
@property
def __unapplied(self) -> OptionLink:
"""Returns the unapplied original line items.
:return: The unapplied original line items.
"""
account: Account = self.__account
if not account.is_need_offset:
return OptionLink(gettext("Unapplied Original Line Items"),
unapplied_url(None),
self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash")
return OptionLink(gettext("Unapplied Original Line Items"),
unapplied_url(account),
self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash")
def __iter__(self) -> t.Iterator[OptionLink]:
"""Returns the iteration of the reports.

View File

@ -34,5 +34,7 @@ class ReportType(Enum):
"""The income statement."""
BALANCE_SHEET: str = "balance-sheet"
"""The balance sheet."""
UNAPPLIED: str = "unapplied"
"""The unapplied original line items."""
SEARCH: str = "search"
"""The search."""

View File

@ -0,0 +1,67 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The unapplied original line item utilities.
"""
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias
def get_accounts_with_unapplied() -> list[Account]:
"""Returns the accounts with unapplied original line items.
:return: The accounts with unapplied original line items.
"""
offset: sa.Alias = offset_alias()
net_balance: sa.Label \
= (JournalEntryLineItem.amount
+ sa.func.sum(sa.case(
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
select_unapplied: sa.Select \
= sa.select(JournalEntryLineItem.id)\
.join(Account)\
.join(offset, be(JournalEntryLineItem.id
== offset.c.original_line_item_id),
isouter=True)\
.filter(Account.is_need_offset,
sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)),
sa.and_(Account.base_code.startswith("1"),
JournalEntryLineItem.is_debit)))\
.group_by(JournalEntryLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
count_func: sa.Label \
= sa.func.count(JournalEntryLineItem.id).label("count")
select: sa.Select = sa.select(Account.id, count_func)\
.join(JournalEntryLineItem, isouter=True)\
.filter(JournalEntryLineItem.id.in_(select_unapplied))\
.group_by(Account.id)\
.having(count_func > 0)
counts: dict[int, int] \
= {x.id: x.count for x in db.session.execute(select)}
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
.order_by(Account.base_code, Account.no).all()
for account in accounts:
account.count = counts[account.id]
return accounts

View File

@ -116,3 +116,15 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
currency=currency)
return url_for("accounting-report.balance-sheet",
currency=currency, period=period)
def unapplied_url(account: Account | None) -> str:
"""Returns the URL of the unapplied original line items.
:param account: The account, or None to list the accounts with unapplied
original line items.
:return: The URL of the unapplied original line items.
"""
if account is None:
return url_for("accounting-report.unapplied-default")
return url_for("accounting-report.unapplied", account=account)

View File

@ -28,6 +28,8 @@ from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_view
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement, BalanceSheet, Search
from .reports.unapplied import UnappliedOriginalLineItems
from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems
from .template_filters import format_amount
bp: Blueprint = Blueprint("accounting-report", __name__)
@ -124,7 +126,7 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
return report.html()
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
@bp.get("income-expenses/<currency:currency>/<currentAccount:account>",
endpoint="income-expenses-default")
@has_permission(can_view)
def get_default_income_expenses(currency: Currency, account: CurrentAccount) \
@ -138,9 +140,8 @@ def get_default_income_expenses(currency: Currency, account: CurrentAccount) \
return __get_income_expenses(currency, account, get_period())
@bp.get(
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
endpoint="income-expenses")
@bp.get("income-expenses/<currency:currency>/<currentAccount:account>/"
"<period:period>", endpoint="income-expenses")
@has_permission(can_view)
def get_income_expenses(currency: Currency, account: CurrentAccount,
period: Period) -> str | Response:
@ -286,6 +287,34 @@ def __get_balance_sheet(currency: Currency, period: Period) \
return report.html()
@bp.get("unapplied", endpoint="unapplied-default")
@has_permission(can_view)
def get_default_unapplied() -> str | Response:
"""Returns the accounts with unapplied original line items.
:return: The accounts with unapplied original line items.
"""
report: AccountsWithUnappliedOriginalLineItems \
= AccountsWithUnappliedOriginalLineItems()
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("unapplied/<needOffsetAccount:account>", endpoint="unapplied")
@has_permission(can_view)
def get_unapplied(account: Account) -> str | Response:
"""Returns the unapplied original line items.
:param account: The Account.
:return: The unapplied original line items.
"""
report: UnappliedOriginalLineItems = UnappliedOriginalLineItems(account)
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("search", endpoint="search")
@has_permission(can_view)
def search() -> str | Response:

View File

@ -209,11 +209,23 @@ a.accounting-report-table-row {
.accounting-report-table-body .accounting-amount {
font-style: italic;
}
.accounting-report-table-body .accounting-report-table-row {
background-color: #f8f9fa;
}
.accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) {
background-color: #f2f2f2;
background-color: #ecedee;
}
.accounting-report-table-body .accounting-report-table-row:hover {
background-color: rgba(0, 0, 0, 0.075);
background-color: #e5e6e7;
}
.accounting-report-table-body .accounting-report-table-row-danger {
background-color: #f8d7da;
}
.accounting-report-table-body .accounting-report-table-row-danger:nth-child(2n+1) {
background-color: #eccccf;
}
.accounting-report-table-body .accounting-report-table-row-danger:hover {
background-color: #e5c7ca;
}
.accounting-journal-table .accounting-report-table-row {
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
@ -309,6 +321,16 @@ a.accounting-report-table-row {
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
font-style: italic;
}
.accounting-unapplied-table .accounting-report-table-row {
grid-template-columns: 1fr 1fr 5fr 1fr 1fr;
}
.accounting-unapplied-account-table .accounting-report-table-row {
display: flex;
justify-content: space-between;
}
.accounting-unapplied-account-table .accounting-report-table-header .accounting-report-table-row {
display: block;
}
/* The accounting report */
.accounting-mobile-journal-credit {
@ -343,6 +365,12 @@ a.accounting-report-table-row {
margin: 0;
}
/* The unmatched offsets */
.accounting-unmatched-offset-pair-list {
height: 20rem;
overflow-y: scroll;
}
/* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field {
position: relative;

View File

@ -123,7 +123,7 @@ class JournalEntryAccountSelector {
option.setShown(false);
}
}
if (!isAnyMatched) {
if (!isAnyMatched && this.#isShowMore) {
this.#optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none");
} else {

View File

@ -51,6 +51,12 @@ First written: 2023/1/26
{{ A_("Currencies") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.unmatched-offset.") %} active {% endif %}" href="{{ url_for("accounting.unmatched-offset.dashboard") }}">
<i class="fa-solid fa-link-slash"></i>
{{ A_("Unmatched Offsets") }}
</a>
</li>
{% if accounting_can_admin() %}
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.option.") %} active {% endif %}" href="{{ url_for("accounting.option.detail") }}">

View File

@ -50,10 +50,10 @@ First written: 2023/3/14
{% endfor %}
</ul>
</div>
{% if line_item.balance %}
{% if line_item.net_balance %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ line_item.balance|accounting_format_amount }}</div>
<div>{{ line_item.net_balance|accounting_format_amount }}</div>
</div>
{% else %}
<div class="d-flex justify-content-between">

View File

@ -42,11 +42,11 @@ First written: 2023/3/22
<tbody>
<tr>
<th scope="row">{{ A_("Default Currency") }}</th>
<td>{{ obj.default_currency_text }}</td>
<td>{{ obj.default_currency }}</td>
</tr>
<tr>
<th scope="row">{{ A_("Default Account for the Income and Expenses Log") }}</th>
<td>{{ obj.default_ie_account_code_text }}</td>
<td>{{ obj.default_ie_account }}</td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,64 @@
{#
The Mia! Accounting Project
unapplied-accounts.html: The account list with unapplied original line items
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/4/8
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Accounts with Unapplied Original Line Items") }}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
{% with use_account_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
</div>
{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
<div class="accounting-sheet">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ A_("Accounts with Unapplied Original Line Items") }}</h2>
</div>
<div class="accounting-report-table accounting-unapplied-account-table">
<div class="accounting-report-table-header">
<div class="accounting-report-table-row">
<div class="accounting-amount">{{ A_("Count") }}</div>
</div>
</div>
<div class="accounting-report-table-body">
{% for account in report.accounts %}
<a class="accounting-report-table-row" href="{{ url_for("accounting-report.unapplied", account=account) }}">
<div>{{ account }}</div>
<div class="accounting-amount">{{ account.count }}</div>
</a>
{% endfor %}
</div>
</div>
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,101 @@
{#
The Mia! Accounting Project
unapplied.html: The unapplied original line items
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/4/7
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Unapplied Original Line Items of %(account)s", account=report.account.title|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
{% with use_account_chooser = true %}
{% include "accounting/report/include/toolbar-buttons.html" %}
{% endwith %}
</div>
{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
{% with pagination = report.pagination %}
{% include "accounting/include/pagination.html" %}
{% endwith %}
<div class="d-none d-md-block accounting-report-table accounting-unapplied-table">
<div class="accounting-report-table-header">
<div class="accounting-report-table-row">
<div>{{ A_("Date") }}</div>
<div>{{ A_("Currency") }}</div>
<div>{{ A_("Description") }}</div>
<div class="accounting-amount">{{ A_("Amount") }}</div>
<div class="accounting-amount">{{ A_("Net Balance") }}</div>
</div>
</div>
<div class="accounting-report-table-body">
{% for line_item in report.line_items %}
<a class="accounting-report-table-row {% if report.is_mark_matches and not line_item.match %} accounting-report-table-row-danger {% endif %}" href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.journal_entry)|accounting_append_next }}">
<div>{{ line_item.journal_entry.date|accounting_format_date }}</div>
<div>{{ line_item.currency.name }}</div>
<div>
{{ line_item.description|accounting_default }}
{% if report.is_mark_matches and line_item.match %}
<div>{{ A_("Can match %(offset)s", offset=line_item.match) }}</div>
{% endif %}
</div>
<div class="accounting-amount">{{ line_item.amount|accounting_format_amount }}</div>
<div class="accounting-amount">{{ line_item.net_balance|accounting_format_amount }}</div>
</a>
{% endfor %}
</div>
</div>
<div class="list-group d-md-none">
{% for line_item in report.line_items %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.journal_entry)|accounting_append_next }}">
<div>
<div class="text-muted small">
{{ line_item.journal_entry.date|accounting_format_date }}
{% if line_item.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
{% endif %}
</div>
{% if line_item.description is not none %}
<div>{{ line_item.description }}</div>
{% endif %}
</div>
<div>
<span class="badge rounded-pill bg-info">{{ line_item.amount|accounting_format_amount }}</span>
<span class="badge rounded-pill bg-info">{{ line_item.net_balance|accounting_format_amount }}</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,40 @@
{#
The Mia! Accounting Project
dashboard.html: The account list with unmatched offsets
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/4/8
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Unmatched Offsets") }}{% endblock %}{% endblock %}
{% block content %}
{% if list %}
<div>
{% for account in list %}
<a class="btn btn-primary mb-1" role="button" href="{{ url_for("accounting.unmatched-offset.list", account=account) }}">
{{ account }} ({{ account.count }})
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,107 @@
{#
The Mia! Accounting Project
list.html: The unmatched offset list
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/4/8
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Unmatched Offsets in %(account)s", account=matcher.account.title|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3" role="group" aria-label="{{ A_("Toolbar") }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.unmatched-offset.dashboard") }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if matcher.is_having_matches %}
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-match-modal">
<i class="fa-solid fa-link"></i>
{{ A_("Match") }}
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-link"></i>
{{ A_("Match") }}
</button>
{% endif %}
</div>
{% if matcher.is_having_matches %}
<form action="{{ url_for("accounting.unmatched-offset.match", account=matcher.account) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal fade" id="accounting-match-modal" tabindex="-1" aria-labelledby="accounting-match-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-match-modal-label">{{ A_("Confirm Match Offsets") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<p>{{ A_("Do you really want to match the following original line items with their offsets? This cannot be undone. Please backup your database first, and review before you confirm.") }}</p>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover accounting-unmatched-offset-pair-list">
{% for pair in matcher.matched_pairs %}
<li class="list-group-item">
{{ pair.offset.description|accounting_default }}
<span class="badge bg-info">{{ pair.offset.amount|accounting_format_amount }}</span>
{{ pair.original_line_item.journal_entry.date|accounting_format_date }} &rarr; {{ pair.offset.journal_entry.date|accounting_format_date }}
</li>
{% endfor %}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
{% if matcher.total %}
{% if matcher.is_having_matches %}
<p>{{ A_("%(matches)s unapplied original line items out of %(total)s can match with their offsets.", matches=matcher.matches, total=matcher.total) }}</p>
{% else %}
<p>{{ A_("%(total)s unapplied original line items without matching offsets.", total=matcher.total) }}</p>
{% endif %}
<p><a href="{{ url_for("accounting-report.unapplied", account=matcher.account) }}">{{ A_("Go to unapplied original line items.") }}</a></p>
{% else %}
<p>{{ A_("All original line items are fully offset.") }}</p>
{% endif %}
{% if list %}
{% include "accounting/include/pagination.html" %}
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action {% if not item.match %} list-group-item-danger {% endif %}" href="{{ url_for("accounting.journal-entry.detail", journal_entry=item.journal_entry)|accounting_append_next }}">
{{ item }}
{% if item.match %}
<div class="small">{{ A_("Can match %(item)s", item=item.match) }}</div>
{% endif %}
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -6,10 +6,10 @@
#
msgid ""
msgstr ""
"Project-Id-Version: mia-accounting 1.0.0\n"
"Project-Id-Version: mia-accounting 1.1.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-04-06 02:34+0800\n"
"PO-Revision-Date: 2023-04-06 02:34+0800\n"
"POT-Creation-Date: 2023-04-09 00:22+0800\n"
"PO-Revision-Date: 2023-04-09 00:37+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -35,22 +35,22 @@ msgstr "沒有這個貨幣。"
msgid "The account does not exist."
msgstr "沒有這個科目。"
#: src/accounting/models.py:562
#: src/accounting/models.py:581
#, python-format
msgid "Cash Disbursement Journal Entry#%(id)s"
msgstr "現金支出傳票#%(id)s"
#: src/accounting/models.py:565
#: src/accounting/models.py:584
#, python-format
msgid "Cash Receipt Journal Entry#%(id)s"
msgstr "現金收入傳票#%(id)s"
#: src/accounting/models.py:566
#: src/accounting/models.py:585
#, python-format
msgid "Transfer Journal Entry#%(id)s"
msgstr "轉帳傳票#%(id)s"
#: src/accounting/models.py:699
#: src/accounting/models.py:714
#, python-format
msgid "%(date)s %(description)s %(amount)s"
msgstr "%(date)s %(description)s %(amount)s"
@ -205,24 +205,24 @@ msgstr "傳票不可刪除。"
msgid "The journal entry is deleted successfully."
msgstr "傳票刪掉了"
#: src/accounting/journal_entry/forms/currency.py:40
#: src/accounting/journal_entry/forms/currency.py:39
msgid "Please select the currency."
msgstr "請選擇貨幣。"
#: src/accounting/journal_entry/forms/currency.py:63
#: src/accounting/journal_entry/forms/currency.py:62
msgid "The currency must be the same as the original line item."
msgstr "貨幣需和原始分錄相同。"
#: src/accounting/journal_entry/forms/currency.py:90
#: src/accounting/journal_entry/forms/currency.py:89
msgid "The currency must not be changed when there is offset."
msgstr "抵銷過不可變更貨幣。"
#: src/accounting/journal_entry/forms/currency.py:99
#: src/accounting/journal_entry/forms/currency.py:98
#: src/accounting/static/js/journal-entry-form.js:773
msgid "Please add some line items."
msgstr "請加上分錄。"
#: src/accounting/journal_entry/forms/currency.py:112
#: src/accounting/journal_entry/forms/currency.py:111
#: src/accounting/static/js/journal-entry-form.js:522
msgid "The totals of the debit and credit amounts do not match."
msgstr "借方貸方合計不符。 "
@ -271,24 +271,24 @@ msgstr "原始分錄不可以是抵銷分錄。"
msgid "The account must be the same as the original line item."
msgstr "科目需和原始分錄相同。"
#: src/accounting/journal_entry/forms/line_item.py:137
#: src/accounting/journal_entry/forms/line_item.py:135
msgid "The account must not be changed when there is offset."
msgstr "抵銷過不可變更科目。"
#: src/accounting/journal_entry/forms/line_item.py:153
#: src/accounting/journal_entry/forms/line_item.py:151
msgid "A payable line item cannot start from debit."
msgstr "不可由借方新建應付款。"
#: src/accounting/journal_entry/forms/line_item.py:169
#: src/accounting/journal_entry/forms/line_item.py:167
msgid "A receivable line item cannot start from credit."
msgstr "不可由貸方新建應收款。"
#: src/accounting/journal_entry/forms/line_item.py:180
#: src/accounting/journal_entry/forms/line_item.py:178
#: src/accounting/static/js/journal-entry-line-item-editor.js:436
msgid "Please fill in a positive amount."
msgstr "金額請填正數。"
#: src/accounting/journal_entry/forms/line_item.py:222
#: src/accounting/journal_entry/forms/line_item.py:220
#: src/accounting/static/js/journal-entry-line-item-editor.js:442
#, python-format
msgid ""
@ -296,17 +296,17 @@ msgid ""
"line item."
msgstr "金額不可超過原始分錄凈額 %(balance)s 。"
#: src/accounting/journal_entry/forms/line_item.py:243
#: src/accounting/journal_entry/forms/line_item.py:241
#: src/accounting/static/js/journal-entry-line-item-editor.js:450
#, python-format
msgid "The amount must not be less than the offset total %(total)s."
msgstr "金額不可低於抵銷總額 %(total)s 。"
#: src/accounting/journal_entry/forms/line_item.py:416
#: src/accounting/journal_entry/forms/line_item.py:413
msgid "This account is not for debit line items."
msgstr "科目不是借方科目。"
#: src/accounting/journal_entry/forms/line_item.py:468
#: src/accounting/journal_entry/forms/line_item.py:465
msgid "This account is not for credit line items."
msgstr "科目不是貸方科目。"
@ -442,20 +442,23 @@ msgid "Brought forward"
msgstr "前期轉入"
#: src/accounting/report/reports/income_expenses.py:407
#: src/accounting/report/reports/journal.py:155
#: src/accounting/report/reports/journal.py:158
#: src/accounting/report/reports/ledger.py:366
#: src/accounting/report/reports/unapplied.py:137
#: src/accounting/templates/accounting/journal-entry/include/form.html:50
#: src/accounting/templates/accounting/report/include/period-chooser.html:111
#: src/accounting/templates/accounting/report/income-expenses.html:55
#: src/accounting/templates/accounting/report/journal.html:53
#: src/accounting/templates/accounting/report/ledger.html:55
#: src/accounting/templates/accounting/report/search.html:50
#: src/accounting/templates/accounting/report/unapplied.html:50
msgid "Date"
msgstr "日期"
#: src/accounting/report/reports/income_expenses.py:407
#: src/accounting/report/reports/journal.py:156
#: src/accounting/report/reports/journal.py:159
#: src/accounting/report/reports/trial_balance.py:225
#: src/accounting/report/reports/unapplied_accounts.py:109
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:57
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:39
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:90
@ -467,14 +470,16 @@ msgid "Account"
msgstr "科目"
#: src/accounting/report/reports/income_expenses.py:408
#: src/accounting/report/reports/journal.py:156
#: src/accounting/report/reports/journal.py:159
#: src/accounting/report/reports/ledger.py:366
#: src/accounting/report/reports/unapplied.py:138
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:28
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:49
#: src/accounting/templates/accounting/report/income-expenses.html:57
#: src/accounting/templates/accounting/report/journal.html:56
#: src/accounting/templates/accounting/report/ledger.html:56
#: src/accounting/templates/accounting/report/search.html:53
#: src/accounting/templates/accounting/report/unapplied.html:52
msgid "Description"
msgstr "摘要"
@ -496,7 +501,7 @@ msgid "Balance"
msgstr "餘額"
#: src/accounting/report/reports/income_expenses.py:410
#: src/accounting/report/reports/journal.py:158
#: src/accounting/report/reports/journal.py:161
#: src/accounting/report/reports/ledger.py:368
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:178
#: src/accounting/templates/accounting/journal-entry/include/form.html:73
@ -528,20 +533,24 @@ msgid "net income or loss for current period"
msgstr "本期損益"
#: src/accounting/report/reports/income_statement.py:301
#: src/accounting/report/reports/unapplied.py:138
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:65
#: src/accounting/templates/accounting/report/income-statement.html:55
#: src/accounting/templates/accounting/report/unapplied.html:53
msgid "Amount"
msgstr "金額"
#: src/accounting/report/reports/journal.py:155
#: src/accounting/report/reports/journal.py:158
#: src/accounting/report/reports/unapplied.py:137
#: src/accounting/templates/accounting/journal-entry/include/form-currency.html:33
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:73
#: src/accounting/templates/accounting/report/journal.html:54
#: src/accounting/templates/accounting/report/search.html:51
#: src/accounting/templates/accounting/report/unapplied.html:51
msgid "Currency"
msgstr "貨幣"
#: src/accounting/report/reports/journal.py:157
#: src/accounting/report/reports/journal.py:160
#: src/accounting/report/reports/ledger.py:367
#: src/accounting/report/reports/trial_balance.py:225
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33
@ -553,7 +562,7 @@ msgstr "貨幣"
msgid "Debit"
msgstr "借方"
#: src/accounting/report/reports/journal.py:157
#: src/accounting/report/reports/journal.py:160
#: src/accounting/report/reports/ledger.py:367
#: src/accounting/report/reports/trial_balance.py:226
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49
@ -565,7 +574,23 @@ msgstr "借方"
msgid "Credit"
msgstr "貸方"
#: src/accounting/report/utils/report_chooser.py:81
#: src/accounting/report/reports/unapplied.py:121
#: src/accounting/report/reports/unapplied_accounts.py:93
#: src/accounting/templates/accounting/include/nav.html:39
msgid "Accounts"
msgstr "科目"
#: src/accounting/report/reports/unapplied.py:139
#: src/accounting/templates/accounting/report/unapplied.html:54
msgid "Net Balance"
msgstr "淨額"
#: src/accounting/report/reports/unapplied_accounts.py:109
#: src/accounting/templates/accounting/report/unapplied-accounts.html:47
msgid "Count"
msgstr "數量"
#: src/accounting/report/utils/report_chooser.py:82
#: src/accounting/templates/accounting/account/include/form.html:98
#: src/accounting/templates/accounting/account/list.html:40
#: src/accounting/templates/accounting/base-account/list.html:34
@ -581,30 +606,35 @@ msgstr "貸方"
msgid "Search"
msgstr "搜尋"
#: src/accounting/report/utils/report_chooser.py:92
#: src/accounting/report/utils/report_chooser.py:93
msgid "Income and Expenses Log"
msgstr "收支帳"
#: src/accounting/report/utils/report_chooser.py:105
#: src/accounting/report/utils/report_chooser.py:106
msgid "Ledger"
msgstr "分類帳"
#: src/accounting/report/utils/report_chooser.py:117
#: src/accounting/report/utils/report_chooser.py:118
msgid "Journal"
msgstr "日記簿"
#: src/accounting/report/utils/report_chooser.py:127
#: src/accounting/report/utils/report_chooser.py:128
msgid "Trial Balance"
msgstr "試算表"
#: src/accounting/report/utils/report_chooser.py:138
#: src/accounting/report/utils/report_chooser.py:139
msgid "Income Statement"
msgstr "損益表"
#: src/accounting/report/utils/report_chooser.py:149
#: src/accounting/report/utils/report_chooser.py:150
msgid "Balance Sheet"
msgstr "資產負債表"
#: src/accounting/report/utils/report_chooser.py:163
#: src/accounting/report/utils/report_chooser.py:167
msgid "Unapplied Original Line Items"
msgstr "未抵銷原始分錄"
#: src/accounting/static/js/account-form.js:206
msgid "Please fill in the title."
msgstr "請填上標題。"
@ -726,6 +756,7 @@ msgstr "新增科目"
#: src/accounting/templates/accounting/journal-entry/include/form.html:38
#: src/accounting/templates/accounting/journal-entry/order.html:36
#: src/accounting/templates/accounting/option/form.html:36
#: src/accounting/templates/accounting/unmatched-offset/list.html:31
msgid "Back"
msgstr "回上頁"
@ -766,6 +797,7 @@ msgstr "確認刪除科目"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:28
#: src/accounting/templates/accounting/report/include/period-chooser.html:27
#: src/accounting/templates/accounting/report/include/search-modal.html:28
#: src/accounting/templates/accounting/unmatched-offset/list.html:54
msgid "Close"
msgstr "關閉"
@ -783,6 +815,7 @@ msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:48
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:65
#: src/accounting/templates/accounting/report/include/search-modal.html:37
#: src/accounting/templates/accounting/unmatched-offset/list.html:70
msgid "Cancel"
msgstr "取消"
@ -790,6 +823,7 @@ msgstr "取消"
#: src/accounting/templates/accounting/currency/detail.html:80
#: src/accounting/templates/accounting/journal-entry/include/detail.html:85
#: src/accounting/templates/accounting/report/include/period-chooser.html:141
#: src/accounting/templates/accounting/unmatched-offset/list.html:71
msgid "Confirm"
msgstr "確定"
@ -849,6 +883,10 @@ msgstr "新增"
#: src/accounting/templates/accounting/report/ledger.html:116
#: src/accounting/templates/accounting/report/search.html:100
#: src/accounting/templates/accounting/report/trial-balance.html:82
#: src/accounting/templates/accounting/report/unapplied-accounts.html:61
#: src/accounting/templates/accounting/report/unapplied.html:98
#: src/accounting/templates/accounting/unmatched-offset/dashboard.html:37
#: src/accounting/templates/accounting/unmatched-offset/list.html:104
msgid "There is no data."
msgstr "沒有資料。"
@ -938,10 +976,6 @@ msgstr "記帳"
msgid "Reports"
msgstr "報表"
#: src/accounting/templates/accounting/include/nav.html:39
msgid "Accounts"
msgstr "科目"
#: src/accounting/templates/accounting/include/nav.html:45
msgid "Base Accounts"
msgstr "基本科目"
@ -950,7 +984,12 @@ msgstr "基本科目"
msgid "Currencies"
msgstr "貨幣"
#: src/accounting/templates/accounting/include/nav.html:58
#: src/accounting/templates/accounting/include/nav.html:57
#: src/accounting/templates/accounting/unmatched-offset/dashboard.html:24
msgid "Unmatched Offsets"
msgstr "遺漏的抵銷分錄"
#: src/accounting/templates/accounting/include/nav.html:64
#: src/accounting/templates/accounting/option/detail.html:24
#: src/accounting/templates/accounting/option/detail.html:41
#: src/accounting/templates/accounting/option/form.html:29
@ -1210,6 +1249,21 @@ msgstr "%(period)s%(currency)s%(account)s分類帳"
msgid "Trial Balance of %(currency)s %(period)s"
msgstr "%(period)s%(currency)s試算表"
#: src/accounting/templates/accounting/report/unapplied-accounts.html:24
#: src/accounting/templates/accounting/report/unapplied-accounts.html:41x
msgid "Accounts with Unapplied Original Line Items"
msgstr "有未抵銷原始分錄的科目"
#: src/accounting/templates/accounting/report/unapplied.html:28
#, python-format
msgid "Unapplied Original Line Items of %(account)s"
msgstr "%(account)s未抵銷原始分錄"
#: src/accounting/templates/accounting/report/unapplied.html:65
#, python-format
msgid "Can match %(offset)s"
msgstr "可抵銷 %(offset)s"
#: src/accounting/templates/accounting/report/include/period-chooser.html:26
msgid "Period Chooser"
msgstr "選擇日期範圍"
@ -1243,6 +1297,66 @@ msgstr "期間"
msgid "Download"
msgstr "下載"
#: src/accounting/templates/accounting/unmatched-offset/list.html:24
#, python-format
msgid "Unmatched Offsets in %(account)s"
msgstr "%(account)s遺漏的抵銷分錄"
#: src/accounting/templates/accounting/unmatched-offset/list.html:28
msgid "Toolbar"
msgstr "工具列"
#: src/accounting/templates/accounting/unmatched-offset/list.html:36
#: src/accounting/templates/accounting/unmatched-offset/list.html:41
msgid "Match"
msgstr "抵銷"
#: src/accounting/templates/accounting/unmatched-offset/list.html:53
msgid "Confirm Match Offsets"
msgstr "確認抵銷"
#: src/accounting/templates/accounting/unmatched-offset/list.html:57
msgid ""
"Do you really want to match the following original line items with their "
"offsets? This cannot be undone. Please backup your database first, and "
"review before you confirm."
msgstr "你確定要抵銷下列原始分錄與抵銷分錄嗎?結果無法復原。請先備份資料庫,並仔細核對抵銷分錄是否正確。"
#: src/accounting/templates/accounting/unmatched-offset/list.html:81
#, python-format
msgid ""
"%(matches)s unapplied original line items out of %(total)s can match with"
" their offsets."
msgstr ""
"%(total)s 筆未抵銷原始分錄中,可配對抵銷掉 %(matches)s 筆。"
#: src/accounting/templates/accounting/unmatched-offset/list.html:83
#, python-format
msgid "%(total)s unapplied original line items without matching offsets."
msgstr "%(total)s 筆未抵銷原始分錄,無法自動配對抵銷。"
#: src/accounting/templates/accounting/unmatched-offset/list.html:85
msgid "Go to unapplied original line items."
msgstr "查閱未抵銷原始分錄。"
#: src/accounting/templates/accounting/unmatched-offset/list.html:87
msgid "All original line items are fully offset."
msgstr "原始分錄已全部抵銷。"
#: src/accounting/templates/accounting/unmatched-offset/list.html:98
#, python-format
msgid "Can match %(item)s"
msgstr "可抵銷 %(item)s"
#: src/accounting/unmatched_offset/views.py:71
msgid "No more offset to match automatically."
msgstr "無法自動配對抵銷。"
#: src/accounting/unmatched_offset/views.py:77
#, python-format
msgid "Matches %(matches)s from %(total)s unapplied line items."
msgstr "%(total)s 筆未抵銷原始分錄中,配對抵銷掉 %(matches) 筆。"
#: src/accounting/utils/current_account.py:65
msgid "current assets and liabilities"
msgstr "流動資產與負債"

View File

@ -0,0 +1,30 @@
# 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 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 unmatched_offset_bp
bp.register_blueprint(unmatched_offset_bp, url_prefix="/unmatched-offsets")

View File

@ -0,0 +1,50 @@
# 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 queries for the unmatched offset management.
"""
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntryLineItem
def get_accounts_with_unmatched_offsets() -> list[Account]:
"""Returns the accounts with unmatched offsets.
: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)\
.filter(Account.is_need_offset,
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

View File

@ -0,0 +1,81 @@
# 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 views for the unmatched offset management.
"""
from flask import Blueprint, render_template, redirect, url_for, flash
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem, Account
from accounting.utils.cast import s
from accounting.utils.offset_matcher import OffsetMatcher
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_edit
from .queries import get_accounts_with_unmatched_offsets
bp: Blueprint = Blueprint("unmatched-offset", __name__)
"""The view blueprint for the unmatched offset management."""
@bp.get("", endpoint="dashboard")
@has_permission(can_edit)
def show_offset_dashboard() -> str:
"""Shows the dashboard about offsets.
:return: The dashboard about offsets.
"""
return render_template("accounting/unmatched-offset/dashboard.html",
list=get_accounts_with_unmatched_offsets())
@bp.get("<needOffsetAccount:account>", endpoint="list")
@has_permission(can_edit)
def show_unmatched_offsets(account: Account) -> str:
"""Shows the unmatched offsets in an account.
:return: The unmatched offsets in an account.
"""
matcher: OffsetMatcher = OffsetMatcher(account)
pagination: Pagination \
= Pagination[JournalEntryLineItem](matcher.unmatched_offsets,
is_reversed=True)
return render_template("accounting/unmatched-offset/list.html",
matcher=matcher,
list=pagination.list, pagination=pagination)
@bp.post("<needOffsetAccount:account>", endpoint="match")
@has_permission(can_edit)
def match_offsets(account: Account) -> redirect:
"""Matches the original line items with their offsets.
:return: Redirection to the view of the unmatched offsets.
"""
matcher: OffsetMatcher = OffsetMatcher(account)
if not matcher.is_having_matches:
flash(s(lazy_gettext("No more offset to match automatically.")),
"success")
return redirect(url_for("accounting.unmatched-offset.list",
account=account))
matcher.match()
db.session.commit()
flash(s(lazy_gettext(
"Matches %(matches)s from %(total)s unapplied line items.",
matches=matcher.matches, total=matcher.total)), "success")
return redirect(url_for("accounting.unmatched-offset.list",
account=account))

View File

@ -0,0 +1,129 @@
# 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.
"""
import sqlalchemy as sa
from sqlalchemy.orm import selectinload
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.unapplied import get_unapplied_original_line_items
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, account: Account):
"""Constructs the offset matcher.
:param account: The account.
"""
self.account: Account = account
"""The account."""
self.matched_pairs: list[OffsetPair] = []
"""A list of matched pairs."""
self.is_having_matches: bool = False
"""Whether there is any matches."""
self.total: int = 0
"""The total number of unapplied debits or credits."""
self.unapplied: list[JournalEntryLineItem] = []
"""The unapplied debits or credits."""
self.unmatched_offsets: 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.unapplied: list[JournalEntryLineItem] \
= get_unapplied_original_line_items(self.account)
self.total = len(self.unapplied)
if self.total == 0:
self.is_having_matches = False
return
self.unmatched_offsets = self.__get_unmatched_offsets()
remains: list[JournalEntryLineItem] = self.unmatched_offsets.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])
self.is_having_matches = len(self.matched_pairs) > 0
def __get_unmatched_offsets(self) -> list[JournalEntryLineItem]:
"""Returns the unmatched offsets of an account.
:return: The unmatched offsets of the account.
"""
return JournalEntryLineItem.query.join(Account).join(JournalEntry)\
.filter(Account.id == self.account.id,
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))))\
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
@property
def matches(self) -> int:
"""Returns the number of matches.
:return: The number of matches.
"""
return len(self.matched_pairs)
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

View File

@ -99,12 +99,12 @@ class Options:
self.__set_option("default_currency_code", value)
@property
def default_currency_text(self) -> str:
"""Returns the text of the default currency code.
def default_currency(self) -> Currency:
"""Returns the default currency.
:return: The text of the default currency code.
:return: The default currency.
"""
return str(db.session.get(Currency, self.default_currency_code))
return db.session.get(Currency, self.default_currency_code)
@property
def default_ie_account_code(self) -> str:
@ -123,22 +123,11 @@ class Options:
"""
self.__set_option("default_ie_account", value)
@property
def default_ie_account_code_text(self) -> str:
"""Returns the text of the default currency code.
:return: The text of the default currency code.
"""
code: str = self.default_ie_account_code
if code == CurrentAccount.CURRENT_AL_CODE:
return str(CurrentAccount.current_assets_and_liabilities())
return str(CurrentAccount(Account.find_by_code(code)))
@property
def default_ie_account(self) -> CurrentAccount:
"""Returns the default account code for the income and expenses log.
"""Returns the default account for the income and expenses log.
:return: The default account code for the income and expenses log.
:return: The default account for the income and expenses log.
"""
if self.default_ie_account_code \
== CurrentAccount.CURRENT_AL_CODE:

View File

@ -0,0 +1,72 @@
# 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 unapplied original line item utilities.
"""
from decimal import Decimal
import sqlalchemy as sa
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias
def get_unapplied_original_line_items(account: Account) \
-> list[JournalEntryLineItem]:
"""Queries and returns the unapplied original line items in an account.
:param account: The account.
:return: The unapplied original line items in the account.
"""
offset: sa.Alias = offset_alias()
net_balance: sa.Label \
= (JournalEntryLineItem.amount
+ sa.func.sum(sa.case(
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
select_net_balances: sa.Select \
= sa.select(JournalEntryLineItem.id, net_balance) \
.join(Account) \
.join(offset, be(JournalEntryLineItem.id
== offset.c.original_line_item_id),
isouter=True) \
.filter(be(Account.id == account.id),
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))
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.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

View File

@ -81,21 +81,21 @@ class OffsetTestCase(unittest.TestCase):
response: httpx.Response
journal_entry_data: JournalEntryData = JournalEntryData(
self.data.e_r_or3d.journal_entry.days, [CurrencyData(
self.data.l_r_or3d.journal_entry.days, [CurrencyData(
"USD",
[],
[JournalEntryLineItemData(
Accounts.RECEIVABLE,
self.data.e_r_or1d.description, "300",
original_line_item=self.data.e_r_or1d),
self.data.l_r_or1d.description, "300",
original_line_item=self.data.l_r_or1d),
JournalEntryLineItemData(
Accounts.RECEIVABLE,
self.data.e_r_or1d.description, "100",
original_line_item=self.data.e_r_or1d),
self.data.l_r_or1d.description, "100",
original_line_item=self.data.l_r_or1d),
JournalEntryLineItemData(
Accounts.RECEIVABLE,
self.data.e_r_or3d.description, "100",
original_line_item=self.data.e_r_or3d)])])
self.data.l_r_or3d.description, "100",
original_line_item=self.data.l_r_or3d)])])
# Non-existing original line item ID
form = journal_entry_data.new_form(self.csrf_token)
@ -107,8 +107,8 @@ class OffsetTestCase(unittest.TestCase):
# The same debit or credit
form = journal_entry_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_line_item_id"] \
= self.data.e_p_or1c.id
form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account
= self.data.l_p_or1c.id
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
form["currency-1-credit-1-amount"] = "100"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
@ -131,8 +131,8 @@ class OffsetTestCase(unittest.TestCase):
# The original line item is also an offset
form = journal_entry_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_line_item_id"] \
= self.data.e_p_of1d.id
form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account
= self.data.l_p_of1d.id
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
@ -195,13 +195,13 @@ class OffsetTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account
journal_entry_data: JournalEntryData = self.data.v_r_of2
journal_entry_data: JournalEntryData = self.data.j_r_of2
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str]
response: httpx.Response
journal_entry_data.days = self.data.v_r_or2.days
journal_entry_data.days = self.data.j_r_or2.days
journal_entry_data.currencies[0].debit[0].amount = Decimal("600")
journal_entry_data.currencies[0].credit[0].amount = Decimal("600")
journal_entry_data.currencies[0].debit[2].amount = Decimal("600")
@ -217,8 +217,8 @@ class OffsetTestCase(unittest.TestCase):
# The same debit or credit
form = journal_entry_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_line_item_id"] \
= self.data.e_p_or1c.id
form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account
= self.data.l_p_or1c.id
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
form["currency-1-debit-1-amount"] = "100"
form["currency-1-credit-1-amount"] = "100"
response = self.client.post(update_uri, data=form)
@ -242,8 +242,8 @@ class OffsetTestCase(unittest.TestCase):
# The original line item is also an offset
form = journal_entry_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_line_item_id"] \
= self.data.e_p_of1d.id
form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account
= self.data.l_p_of1d.id
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -308,13 +308,13 @@ class OffsetTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import JournalEntry
journal_entry_data: JournalEntryData = self.data.v_r_or1
journal_entry_data: JournalEntryData = self.data.j_r_or1
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str]
response: httpx.Response
journal_entry_data.days = self.data.v_r_of1.days
journal_entry_data.days = self.data.j_r_of1.days
journal_entry_data.currencies[0].debit[0].amount = Decimal("800")
journal_entry_data.currencies[0].credit[0].amount = Decimal("800")
journal_entry_data.currencies[0].debit[1].amount = Decimal("3.4")
@ -388,7 +388,7 @@ class OffsetTestCase(unittest.TestCase):
JournalEntry, journal_entry_data.id)
self.assertIsNotNone(journal_entry_or)
journal_entry_of: JournalEntry | None = db.session.get(
JournalEntry, self.data.v_r_of1.id)
JournalEntry, self.data.j_r_of1.id)
self.assertIsNotNone(journal_entry_of)
self.assertEqual(journal_entry_or.date, journal_entry_of.date)
self.assertLess(journal_entry_or.no, journal_entry_of.no)
@ -405,20 +405,20 @@ class OffsetTestCase(unittest.TestCase):
response: httpx.Response
journal_entry_data: JournalEntryData = JournalEntryData(
self.data.e_p_or3c.journal_entry.days, [CurrencyData(
self.data.l_p_or3c.journal_entry.days, [CurrencyData(
"USD",
[JournalEntryLineItemData(
Accounts.PAYABLE,
self.data.e_p_or1c.description, "500",
original_line_item=self.data.e_p_or1c),
self.data.l_p_or1c.description, "500",
original_line_item=self.data.l_p_or1c),
JournalEntryLineItemData(
Accounts.PAYABLE,
self.data.e_p_or1c.description, "300",
original_line_item=self.data.e_p_or1c),
self.data.l_p_or1c.description, "300",
original_line_item=self.data.l_p_or1c),
JournalEntryLineItemData(
Accounts.PAYABLE,
self.data.e_p_or3c.description, "120",
original_line_item=self.data.e_p_or3c)],
self.data.l_p_or3c.description, "120",
original_line_item=self.data.l_p_or3c)],
[])])
# Non-existing original line item ID
@ -431,8 +431,8 @@ class OffsetTestCase(unittest.TestCase):
# The same debit or credit
form = journal_entry_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_line_item_id"] \
= self.data.e_r_or1d.id
form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account
= self.data.l_r_or1d.id
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
form["currency-1-debit-1-amount"] = "100"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
@ -455,8 +455,8 @@ class OffsetTestCase(unittest.TestCase):
# The original line item is also an offset
form = journal_entry_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_line_item_id"] \
= self.data.e_r_of1c.id
form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account
= self.data.l_r_of1c.id
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
@ -519,13 +519,13 @@ class OffsetTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account, JournalEntry
journal_entry_data: JournalEntryData = self.data.v_p_of2
journal_entry_data: JournalEntryData = self.data.j_p_of2
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str]
response: httpx.Response
journal_entry_data.days = self.data.v_p_or2.days
journal_entry_data.days = self.data.j_p_or2.days
journal_entry_data.currencies[0].debit[0].amount = Decimal("1100")
journal_entry_data.currencies[0].credit[0].amount = Decimal("1100")
journal_entry_data.currencies[0].debit[2].amount = Decimal("900")
@ -541,8 +541,8 @@ class OffsetTestCase(unittest.TestCase):
# The same debit or credit
form = journal_entry_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_line_item_id"] \
= self.data.e_r_or1d.id
form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account
= self.data.l_r_or1d.id
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
form["currency-1-debit-1-amount"] = "100"
form["currency-1-credit-1-amount"] = "100"
response = self.client.post(update_uri, data=form)
@ -566,8 +566,8 @@ class OffsetTestCase(unittest.TestCase):
# The original line item is also an offset
form = journal_entry_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_line_item_id"] \
= self.data.e_r_of1c.id
form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account
= self.data.l_r_of1c.id
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -636,13 +636,13 @@ class OffsetTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import JournalEntry
journal_entry_data: JournalEntryData = self.data.v_p_or1
journal_entry_data: JournalEntryData = self.data.j_p_or1
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str]
response: httpx.Response
journal_entry_data.days = self.data.v_p_of1.days
journal_entry_data.days = self.data.j_p_of1.days
journal_entry_data.currencies[0].debit[0].amount = Decimal("1200")
journal_entry_data.currencies[0].credit[0].amount = Decimal("1200")
journal_entry_data.currencies[0].debit[1].amount = Decimal("0.9")
@ -716,7 +716,7 @@ class OffsetTestCase(unittest.TestCase):
JournalEntry, journal_entry_data.id)
self.assertIsNotNone(journal_entry_or)
journal_entry_of: JournalEntry | None = db.session.get(
JournalEntry, self.data.v_p_of1.id)
JournalEntry, self.data.j_p_of1.id)
self.assertIsNotNone(journal_entry_of)
self.assertEqual(journal_entry_or.date, journal_entry_of.date)
self.assertLess(journal_entry_or.no, journal_entry_of.no)

View File

@ -0,0 +1,692 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/11
# 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 test for the offset matcher.
"""
import unittest
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db
from testlib import create_test_app, get_client, Accounts
from testlib_journal_entry import match_journal_entry_detail
from testlib_offset import JournalEntryData, CurrencyData, \
JournalEntryLineItemData
class OffsetMatcherTestCase(unittest.TestCase):
"""The offset matcher test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
def test_different(self) -> None:
"""Tests to match against different descriptions and amounts.
:return: None.
"""
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.offset_matcher import OffsetMatcher
data: DifferentTestData \
= DifferentTestData(self.app, self.client, self.csrf_token)
account: Account | None
line_item: JournalEntryLineItem | None
matcher: OffsetMatcher
list_uri: str
response: httpx.Response
# The receivables
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or1d.id, data.l_r_or2d.id,
data.l_r_or3d.id, data.l_r_or4d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id,
data.l_r_of5c.id})
self.assertEqual({(x.original_line_item.id, x.offset.id)
for x in matcher.matched_pairs},
{(data.l_r_or4d.id, data.l_r_of5c.id)})
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id,
data.l_r_of5c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
list_uri = f"/accounting/unmatched-offsets/{Accounts.RECEIVABLE}"
response = self.client.post(list_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or1d.id, data.l_r_or2d.id,
data.l_r_or3d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id})
self.assertEqual(matcher.matches, 0)
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of3c.id, data.l_r_of4c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of5c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
# The payables
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or1c.id, data.l_p_or2c.id,
data.l_p_or3c.id, data.l_p_or4c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id,
data.l_p_of5d.id})
self.assertEqual({(x.original_line_item.id, x.offset.id)
for x in matcher.matched_pairs},
{(data.l_p_or4c.id, data.l_p_of5d.id)})
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id,
data.l_p_of5d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
list_uri = f"/accounting/unmatched-offsets/{Accounts.PAYABLE}"
response = self.client.post(list_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or1c.id, data.l_p_or2c.id,
data.l_p_or3c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id})
self.assertEqual(matcher.matches, 0)
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of3d.id, data.l_p_of4d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of5d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or4c.id)
def test_same(self) -> None:
"""Tests to match against same descriptions and amounts.
:return: None.
"""
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.offset_matcher import OffsetMatcher
data: SameTestData \
= SameTestData(self.app, self.client, self.csrf_token)
account: Account | None
line_item: JournalEntryLineItem | None
matcher: OffsetMatcher
list_uri: str
response: httpx.Response
# The receivables
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or1d.id, data.l_r_or3d.id,
data.l_r_or4d.id, data.l_r_or5d.id,
data.l_r_or6d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of4c.id, data.l_r_of5c.id,
data.l_r_of6c.id})
self.assertEqual({(x.original_line_item.id, x.offset.id)
for x in matcher.matched_pairs},
{(data.l_r_or1d.id, data.l_r_of2c.id),
(data.l_r_or3d.id, data.l_r_of4c.id),
(data.l_r_or4d.id, data.l_r_of6c.id)})
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
data.l_r_of4c.id, data.l_r_of5c.id,
data.l_r_of6c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of3c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
list_uri = f"/accounting/unmatched-offsets/{Accounts.RECEIVABLE}"
response = self.client.post(list_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_r_or5d.id, data.l_r_or6d.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_r_of1c.id, data.l_r_of5c.id})
self.assertEqual(matcher.matches, 0)
for line_item_id in {data.l_r_of1c.id, data.l_r_of5c.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of2c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or1d.id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of3c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of4c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or3d.id)
line_item = db.session.get(JournalEntryLineItem, data.l_r_of6c.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
# The payables
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or1c.id, data.l_p_or3c.id,
data.l_p_or4c.id, data.l_p_or5c.id,
data.l_p_or6c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of4d.id, data.l_p_of5d.id,
data.l_p_of6d.id})
self.assertEqual({(x.original_line_item.id, x.offset.id)
for x in matcher.matched_pairs},
{(data.l_p_or1c.id, data.l_p_of2d.id),
(data.l_p_or3c.id, data.l_p_of4d.id),
(data.l_p_or4c.id, data.l_p_of6d.id)})
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
data.l_p_of4d.id, data.l_p_of5d.id,
data.l_p_of6d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of3d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
list_uri = f"/accounting/unmatched-offsets/{Accounts.PAYABLE}"
response = self.client.post(list_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(account)
self.assertEqual({x.id for x in matcher.unapplied},
{data.l_p_or5c.id, data.l_p_or6c.id})
self.assertEqual({x.id for x in matcher.unmatched_offsets},
{data.l_p_of1d.id, data.l_p_of5d.id})
self.assertEqual(matcher.matches, 0)
for line_item_id in {data.l_p_of1d.id, data.l_p_of5d.id}:
line_item = db.session.get(JournalEntryLineItem, line_item_id)
self.assertIsNotNone(line_item)
self.assertIsNone(line_item.original_line_item_id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of2d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or1c.id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of3d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of4d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or3c.id)
line_item = db.session.get(JournalEntryLineItem, data.l_p_of6d.id)
self.assertIsNotNone(line_item)
self.assertIsNotNone(line_item.original_line_item_id)
self.assertEqual(line_item.original_line_item_id, data.l_p_or4c.id)
class DifferentTestData:
"""The test data for different descriptions and amounts."""
def __init__(self, app: Flask, client: httpx.Client, csrf_token: str):
"""Constructs the test data.
:param app: The Flask application.
:param client: The client.
:param csrf_token: The CSRF token.
"""
self.app: Flask = app
self.client: httpx.Client = client
self.csrf_token: str = csrf_token
def couple(description: str, amount: str, debit: str, credit: str) \
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
"""Returns a couple of debit-credit line items.
:param description: The description.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit line item and credit line item.
"""
return JournalEntryLineItemData(debit, description, amount),\
JournalEntryLineItemData(credit, description, amount)
# Receivable original line items
self.l_r_or1d, self.l_r_or1c = couple(
"Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
self.l_r_or2d, self.l_r_or2c = couple(
"Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or3d, self.l_r_or3c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or4d, self.l_r_or4c = couple(
"Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
# Payable original line items
self.l_p_or1d, self.l_p_or1c = couple(
"Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
self.l_p_or2d, self.l_p_or2c = couple(
"Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
self.l_p_or3d, self.l_p_or3c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or4d, self.l_p_or4c = couple(
"Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
# Original journal entries
self.j_r_or1: JournalEntryData = JournalEntryData(
50, [CurrencyData("USD", [self.l_r_or1d, self.l_r_or4d],
[self.l_r_or1c, self.l_r_or4c])])
self.j_r_or2: JournalEntryData = JournalEntryData(
30, [CurrencyData("USD", [self.l_r_or2d, self.l_r_or3d],
[self.l_r_or2c, self.l_r_or3c])])
self.j_p_or1: JournalEntryData = JournalEntryData(
40, [CurrencyData("USD", [self.l_p_or1d, self.l_p_or4d],
[self.l_p_or1c, self.l_p_or4c])])
self.j_p_or2: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD", [self.l_p_or2d, self.l_p_or3d],
[self.l_p_or2c, self.l_p_or3c])])
self.__add_journal_entry(self.j_r_or1)
self.__add_journal_entry(self.j_r_or2)
self.__add_journal_entry(self.j_p_or1)
self.__add_journal_entry(self.j_p_or2)
# Receivable offset items
self.l_r_of1d, self.l_r_of1c = couple(
"Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of2d, self.l_r_of2c = couple(
"Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of3d, self.l_r_of3c = couple(
"Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of4d, self.l_r_of4c = couple(
"Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of5d, self.l_r_of5c = couple(
"Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
# Payable offset items
self.l_p_of1d, self.l_p_of1c = couple(
"Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of2d, self.l_p_of2c = couple(
"Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of3d, self.l_p_of3c = couple(
"Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of4d, self.l_p_of4c = couple(
"Phone", "400", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of5d, self.l_p_of5c = couple(
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
# Offset journal entries
self.j_r_of1: JournalEntryData = JournalEntryData(
25, [CurrencyData("USD", [self.l_r_of1d], [self.l_r_of1c])])
self.j_r_of2: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD",
[self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
[self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
self.j_r_of3: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.l_r_of5d], [self.l_r_of5c])])
self.j_p_of1: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.l_p_of1d], [self.l_p_of1c])])
self.j_p_of2: JournalEntryData = JournalEntryData(
10, [CurrencyData("USD",
[self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
[self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
self.j_p_of3: JournalEntryData = JournalEntryData(
5, [CurrencyData("USD", [self.l_p_of5d], [self.l_p_of5c])])
self.__set_is_need_offset(False)
self.__add_journal_entry(self.j_r_of1)
self.__add_journal_entry(self.j_r_of2)
self.__add_journal_entry(self.j_r_of3)
self.__add_journal_entry(self.j_p_of1)
self.__add_journal_entry(self.j_p_of2)
self.__add_journal_entry(self.j_p_of3)
self.__set_is_need_offset(True)
def __set_is_need_offset(self, is_need_offset: bool) -> None:
"""Sets whether the payables and receivables need offset.
:param is_need_offset: True if payables and receivables need offset, or
False otherwise.
:return:
"""
from accounting.models import Account
with self.app.app_context():
for code in {Accounts.RECEIVABLE, Accounts.PAYABLE}:
account: Account | None = Account.find_by_code(code)
assert account is not None
account.is_need_offset = is_need_offset
db.session.commit()
def __add_journal_entry(self, journal_entry_data: JournalEntryData) \
-> None:
"""Adds a journal entry.
:param journal_entry_data: The journal entry data.
:return: None.
"""
from accounting.models import JournalEntry
store_uri: str = "/accounting/journal-entries/store/transfer"
response: httpx.Response = self.client.post(
store_uri, data=journal_entry_data.new_form(self.csrf_token))
assert response.status_code == 302
journal_entry_id: int \
= match_journal_entry_detail(response.headers["Location"])
journal_entry_data.id = journal_entry_id
with self.app.app_context():
journal_entry: JournalEntry | None \
= db.session.get(JournalEntry, journal_entry_id)
assert journal_entry is not None
for i in range(len(journal_entry.currencies)):
for j in range(len(journal_entry.currencies[i].debit)):
journal_entry_data.currencies[i].debit[j].id \
= journal_entry.currencies[i].debit[j].id
for j in range(len(journal_entry.currencies[i].credit)):
journal_entry_data.currencies[i].credit[j].id \
= journal_entry.currencies[i].credit[j].id
class SameTestData:
"""The test data with same descriptions and amounts."""
def __init__(self, app: Flask, client: httpx.Client, csrf_token: str):
"""Constructs the test data.
:param app: The Flask application.
:param client: The client.
:param csrf_token: The CSRF token.
"""
self.app: Flask = app
self.client: httpx.Client = client
self.csrf_token: str = csrf_token
def couple(description: str, amount: str, debit: str, credit: str) \
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
"""Returns a couple of debit-credit line items.
:param description: The description.
:param amount: The amount.
:param debit: The debit account code.
:param credit: The credit account code.
:return: The debit line item and credit line item.
"""
return JournalEntryLineItemData(debit, description, amount),\
JournalEntryLineItemData(credit, description, amount)
# Receivable original line items
self.l_r_or1d, self.l_r_or1c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or2d, self.l_r_or2c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or3d, self.l_r_or3c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or4d, self.l_r_or4c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or5d, self.l_r_or5c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.l_r_or6d, self.l_r_or6c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
# Payable original line items
self.l_p_or1d, self.l_p_or1c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or2d, self.l_p_or2c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or3d, self.l_p_or3c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or4d, self.l_p_or4c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or5d, self.l_p_or5c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.l_p_or6d, self.l_p_or6c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
# Original journal entries
self.j_r_or1: JournalEntryData = JournalEntryData(
60, [CurrencyData("USD", [self.l_r_or1d], [self.l_r_or1c])])
self.j_r_or2: JournalEntryData = JournalEntryData(
50, [CurrencyData("USD", [self.l_r_or2d], [self.l_r_or2c])])
self.j_r_or3: JournalEntryData = JournalEntryData(
40, [CurrencyData("USD", [self.l_r_or3d], [self.l_r_or3c])])
self.j_r_or4: JournalEntryData = JournalEntryData(
30, [CurrencyData("USD", [self.l_r_or4d], [self.l_r_or4c])])
self.j_r_or5: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD", [self.l_r_or5d], [self.l_r_or5c])])
self.j_r_or6: JournalEntryData = JournalEntryData(
10, [CurrencyData("USD", [self.l_r_or6d], [self.l_r_or6c])])
self.j_p_or1: JournalEntryData = JournalEntryData(
60, [CurrencyData("USD", [self.l_p_or1d], [self.l_p_or1c])])
self.j_p_or2: JournalEntryData = JournalEntryData(
50, [CurrencyData("USD", [self.l_p_or2d], [self.l_p_or2c])])
self.j_p_or3: JournalEntryData = JournalEntryData(
40, [CurrencyData("USD", [self.l_p_or3d], [self.l_p_or3c])])
self.j_p_or4: JournalEntryData = JournalEntryData(
30, [CurrencyData("USD", [self.l_p_or4d], [self.l_p_or4c])])
self.j_p_or5: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD", [self.l_p_or5d], [self.l_p_or5c])])
self.j_p_or6: JournalEntryData = JournalEntryData(
10, [CurrencyData("USD", [self.l_p_or6d], [self.l_p_or6c])])
self.__add_journal_entry(self.j_r_or1)
self.__add_journal_entry(self.j_r_or2)
self.__add_journal_entry(self.j_r_or3)
self.__add_journal_entry(self.j_r_or4)
self.__add_journal_entry(self.j_r_or5)
self.__add_journal_entry(self.j_r_or6)
self.__add_journal_entry(self.j_p_or1)
self.__add_journal_entry(self.j_p_or2)
self.__add_journal_entry(self.j_p_or3)
self.__add_journal_entry(self.j_p_or4)
self.__add_journal_entry(self.j_p_or5)
self.__add_journal_entry(self.j_p_or6)
# Receivable offset items
self.l_r_of1d, self.l_r_of1c = couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of2d, self.l_r_of2c = couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of3d, self.l_r_of3c = couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of3c.original_line_item = self.l_r_or2d
self.l_r_of4d, self.l_r_of4c = couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of5d, self.l_r_of5c = couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.l_r_of6d, self.l_r_of6c = couple(
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
# Payable offset items
self.l_p_of1d, self.l_p_of1c = couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of2d, self.l_p_of2c = couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of3d, self.l_p_of3c = couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of3d.original_line_item = self.l_p_or2c
self.l_p_of4d, self.l_p_of4c = couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of5d, self.l_p_of5c = couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
self.l_p_of6d, self.l_p_of6c = couple(
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
# Offset journal entries
self.j_r_of1: JournalEntryData = JournalEntryData(
65, [CurrencyData("USD", [self.l_r_of1d], [self.l_r_of1c])])
self.j_r_of2: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_r_of2d], [self.l_r_of2c])])
self.j_r_of3: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_r_of3d], [self.l_r_of3c])])
self.j_r_of4: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_r_of4d], [self.l_r_of4c])])
self.j_r_of5: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_r_of5d], [self.l_r_of5c])])
self.j_r_of6: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.l_r_of6d], [self.l_r_of6c])])
self.j_p_of1: JournalEntryData = JournalEntryData(
65, [CurrencyData("USD", [self.l_p_of1d], [self.l_p_of1c])])
self.j_p_of2: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_p_of2d], [self.l_p_of2c])])
self.j_p_of3: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_p_of3d], [self.l_p_of3c])])
self.j_p_of4: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_p_of4d], [self.l_p_of4c])])
self.j_p_of5: JournalEntryData = JournalEntryData(
35, [CurrencyData("USD", [self.l_p_of5d], [self.l_p_of5c])])
self.j_p_of6: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.l_p_of6d], [self.l_p_of6c])])
self.__set_is_need_offset(False)
self.__add_journal_entry(self.j_r_of1)
self.__add_journal_entry(self.j_r_of2)
self.__add_journal_entry(self.j_r_of4)
self.__add_journal_entry(self.j_r_of5)
self.__add_journal_entry(self.j_r_of6)
self.__add_journal_entry(self.j_p_of1)
self.__add_journal_entry(self.j_p_of2)
self.__add_journal_entry(self.j_p_of4)
self.__add_journal_entry(self.j_p_of5)
self.__add_journal_entry(self.j_p_of6)
self.__set_is_need_offset(True)
self.__add_journal_entry(self.j_r_of3)
self.__add_journal_entry(self.j_p_of3)
def __set_is_need_offset(self, is_need_offset: bool) -> None:
"""Sets whether the payables and receivables need offset.
:param is_need_offset: True if payables and receivables need offset, or
False otherwise.
:return:
"""
from accounting.models import Account
with self.app.app_context():
for code in {Accounts.RECEIVABLE, Accounts.PAYABLE}:
account: Account | None = Account.find_by_code(code)
assert account is not None
account.is_need_offset = is_need_offset
db.session.commit()
def __add_journal_entry(self, journal_entry_data: JournalEntryData) \
-> None:
"""Adds a journal entry.
:param journal_entry_data: The journal entry data.
:return: None.
"""
from accounting.models import JournalEntry
store_uri: str = "/accounting/journal-entries/store/transfer"
response: httpx.Response = self.client.post(
store_uri, data=journal_entry_data.new_form(self.csrf_token))
assert response.status_code == 302
journal_entry_id: int \
= match_journal_entry_detail(response.headers["Location"])
journal_entry_data.id = journal_entry_id
with self.app.app_context():
journal_entry: JournalEntry | None \
= db.session.get(JournalEntry, journal_entry_id)
assert journal_entry is not None
for i in range(len(journal_entry.currencies)):
for j in range(len(journal_entry.currencies[i].debit)):
journal_entry_data.currencies[i].debit[j].id \
= journal_entry.currencies[i].debit[j].id
for j in range(len(journal_entry.currencies[i].credit)):
journal_entry_data.currencies[i].credit[j].id \
= journal_entry.currencies[i].credit[j].id

View File

@ -189,102 +189,102 @@ class TestData:
JournalEntryLineItemData(credit, description, amount)
# Receivable original line items
self.e_r_or1d, self.e_r_or1c = couple(
self.l_r_or1d, self.l_r_or1c = couple(
"Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
self.e_r_or2d, self.e_r_or2c = couple(
self.l_r_or2d, self.l_r_or2c = couple(
"Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
self.e_r_or3d, self.e_r_or3c = couple(
self.l_r_or3d, self.l_r_or3c = couple(
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
self.e_r_or4d, self.e_r_or4c = couple(
self.l_r_or4d, self.l_r_or4c = couple(
"Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
# Payable original line items
self.e_p_or1d, self.e_p_or1c = couple(
self.l_p_or1d, self.l_p_or1c = couple(
"Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
self.e_p_or2d, self.e_p_or2c = couple(
self.l_p_or2d, self.l_p_or2c = couple(
"Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
self.e_p_or3d, self.e_p_or3c = couple(
self.l_p_or3d, self.l_p_or3c = couple(
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
self.e_p_or4d, self.e_p_or4c = couple(
self.l_p_or4d, self.l_p_or4c = couple(
"Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
# Original journal entries
self.v_r_or1: JournalEntryData = JournalEntryData(
50, [CurrencyData("USD", [self.e_r_or1d, self.e_r_or4d],
[self.e_r_or1c, self.e_r_or4c])])
self.v_r_or2: JournalEntryData = JournalEntryData(
30, [CurrencyData("USD", [self.e_r_or2d, self.e_r_or3d],
[self.e_r_or2c, self.e_r_or3c])])
self.v_p_or1: JournalEntryData = JournalEntryData(
40, [CurrencyData("USD", [self.e_p_or1d, self.e_p_or4d],
[self.e_p_or1c, self.e_p_or4c])])
self.v_p_or2: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD", [self.e_p_or2d, self.e_p_or3d],
[self.e_p_or2c, self.e_p_or3c])])
self.j_r_or1: JournalEntryData = JournalEntryData(
50, [CurrencyData("USD", [self.l_r_or1d, self.l_r_or4d],
[self.l_r_or1c, self.l_r_or4c])])
self.j_r_or2: JournalEntryData = JournalEntryData(
30, [CurrencyData("USD", [self.l_r_or2d, self.l_r_or3d],
[self.l_r_or2c, self.l_r_or3c])])
self.j_p_or1: JournalEntryData = JournalEntryData(
40, [CurrencyData("USD", [self.l_p_or1d, self.l_p_or4d],
[self.l_p_or1c, self.l_p_or4c])])
self.j_p_or2: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD", [self.l_p_or2d, self.l_p_or3d],
[self.l_p_or2c, self.l_p_or3c])])
self.__add_journal_entry(self.v_r_or1)
self.__add_journal_entry(self.v_r_or2)
self.__add_journal_entry(self.v_p_or1)
self.__add_journal_entry(self.v_p_or2)
self.__add_journal_entry(self.j_r_or1)
self.__add_journal_entry(self.j_r_or2)
self.__add_journal_entry(self.j_p_or1)
self.__add_journal_entry(self.j_p_or2)
# Receivable offset items
self.e_r_of1d, self.e_r_of1c = couple(
self.l_r_of1d, self.l_r_of1c = couple(
"Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of1c.original_line_item = self.e_r_or1d
self.e_r_of2d, self.e_r_of2c = couple(
self.l_r_of1c.original_line_item = self.l_r_or1d
self.l_r_of2d, self.l_r_of2c = couple(
"Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of2c.original_line_item = self.e_r_or1d
self.e_r_of3d, self.e_r_of3c = couple(
self.l_r_of2c.original_line_item = self.l_r_or1d
self.l_r_of3d, self.l_r_of3c = couple(
"Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of3c.original_line_item = self.e_r_or1d
self.e_r_of4d, self.e_r_of4c = couple(
self.l_r_of3c.original_line_item = self.l_r_or1d
self.l_r_of4d, self.l_r_of4c = couple(
"Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of4c.original_line_item = self.e_r_or2d
self.e_r_of5d, self.e_r_of5c = couple(
self.l_r_of4c.original_line_item = self.l_r_or2d
self.l_r_of5d, self.l_r_of5c = couple(
"Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of5c.original_line_item = self.e_r_or4d
self.l_r_of5c.original_line_item = self.l_r_or4d
# Payable offset items
self.e_p_of1d, self.e_p_of1c = couple(
self.l_p_of1d, self.l_p_of1c = couple(
"Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of1d.original_line_item = self.e_p_or1c
self.e_p_of2d, self.e_p_of2c = couple(
self.l_p_of1d.original_line_item = self.l_p_or1c
self.l_p_of2d, self.l_p_of2c = couple(
"Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of2d.original_line_item = self.e_p_or1c
self.e_p_of3d, self.e_p_of3c = couple(
self.l_p_of2d.original_line_item = self.l_p_or1c
self.l_p_of3d, self.l_p_of3c = couple(
"Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of3d.original_line_item = self.e_p_or1c
self.e_p_of4d, self.e_p_of4c = couple(
self.l_p_of3d.original_line_item = self.l_p_or1c
self.l_p_of4d, self.l_p_of4c = couple(
"Phone", "400", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of4d.original_line_item = self.e_p_or2c
self.e_p_of5d, self.e_p_of5c = couple(
self.l_p_of4d.original_line_item = self.l_p_or2c
self.l_p_of5d, self.l_p_of5c = couple(
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of5d.original_line_item = self.e_p_or4c
self.l_p_of5d.original_line_item = self.l_p_or4c
# Offset journal entries
self.v_r_of1: JournalEntryData = JournalEntryData(
25, [CurrencyData("USD", [self.e_r_of1d], [self.e_r_of1c])])
self.v_r_of2: JournalEntryData = JournalEntryData(
self.j_r_of1: JournalEntryData = JournalEntryData(
25, [CurrencyData("USD", [self.l_r_of1d], [self.l_r_of1c])])
self.j_r_of2: JournalEntryData = JournalEntryData(
20, [CurrencyData("USD",
[self.e_r_of2d, self.e_r_of3d, self.e_r_of4d],
[self.e_r_of2c, self.e_r_of3c, self.e_r_of4c])])
self.v_r_of3: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.e_r_of5d], [self.e_r_of5c])])
self.v_p_of1: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.e_p_of1d], [self.e_p_of1c])])
self.v_p_of2: JournalEntryData = JournalEntryData(
[self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
[self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
self.j_r_of3: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.l_r_of5d], [self.l_r_of5c])])
self.j_p_of1: JournalEntryData = JournalEntryData(
15, [CurrencyData("USD", [self.l_p_of1d], [self.l_p_of1c])])
self.j_p_of2: JournalEntryData = JournalEntryData(
10, [CurrencyData("USD",
[self.e_p_of2d, self.e_p_of3d, self.e_p_of4d],
[self.e_p_of2c, self.e_p_of3c, self.e_p_of4c])])
self.v_p_of3: JournalEntryData = JournalEntryData(
5, [CurrencyData("USD", [self.e_p_of5d], [self.e_p_of5c])])
[self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
[self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
self.j_p_of3: JournalEntryData = JournalEntryData(
5, [CurrencyData("USD", [self.l_p_of5d], [self.l_p_of5c])])
self.__add_journal_entry(self.v_r_of1)
self.__add_journal_entry(self.v_r_of2)
self.__add_journal_entry(self.v_r_of3)
self.__add_journal_entry(self.v_p_of1)
self.__add_journal_entry(self.v_p_of2)
self.__add_journal_entry(self.v_p_of3)
self.__add_journal_entry(self.j_r_of1)
self.__add_journal_entry(self.j_r_of2)
self.__add_journal_entry(self.j_r_of3)
self.__add_journal_entry(self.j_p_of1)
self.__add_journal_entry(self.j_p_of2)
self.__add_journal_entry(self.j_p_of3)
def __add_journal_entry(self, journal_entry_data: JournalEntryData) \
-> None: