Compare commits

...

25 Commits

Author SHA1 Message Date
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
38 changed files with 2290 additions and 199 deletions

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

@ -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

@ -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

@ -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.
@ -703,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.
@ -725,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.
@ -769,6 +788,25 @@ class JournalEntryLineItem(db.Model):
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

@ -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

@ -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

@ -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: