Changed the unmatched offsets from a module to a report, and to show both the unapplied original line items and the unmatched offsets instead of only the unmatched offsets, and added the accumulated balance, in order for ease of use. Removed the match information from the unapplied original line item report. Added the currency and period filters to both the unapplied original line item report and unmatched offset reports.

This commit is contained in:
2023-04-18 01:12:04 +08:00
parent f8895e3bff
commit e2f854b5cc
26 changed files with 1436 additions and 619 deletions

View File

@ -0,0 +1,242 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The forms for the unmatched offset management.
"""
from decimal import Decimal
import sqlalchemy as sa
from flask_babel import LazyString
from sqlalchemy.orm import selectinload
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period
from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias
class OffsetPair:
"""A pair of an original line item and its offset."""
def __init__(self, original_line_item: JournalEntryLineItem,
offset: JournalEntryLineItem):
"""Constructs a pair of an original line item and its offset.
:param original_line_item: The original line item.
:param offset: The offset.
"""
self.original_line_item: JournalEntryLineItem = original_line_item
"""The original line item."""
self.offset: JournalEntryLineItem = offset
"""The offset."""
class OffsetMatcher:
"""The offset matcher."""
def __init__(self, currency: Currency, account: Account,
period: Period | None):
"""Constructs the offset matcher.
:param currency: The currency.
:param account: The account.
:param period: The period, or None for all time.
"""
self.__currency: Account = currency
"""The currency."""
self.__account: Account = account
"""The account."""
self.__period: Period | None = period
"""The period."""
self.matched_pairs: list[OffsetPair] = []
"""A list of matched pairs."""
self.__all_line_items: list[JournalEntryLineItem] = []
"""The unapplied debits or credits and unmatched offsets."""
self.line_items: list[JournalEntryLineItem] = []
"""The unapplied debits or credits and unmatched offsets in the
period."""
self.__all_unapplied: list[JournalEntryLineItem] = []
"""The unapplied debits or credits."""
self.unapplied: list[JournalEntryLineItem] = []
"""The unapplied debits or credits in the period."""
self.__all_unmatched: list[JournalEntryLineItem] = []
"""The unmatched offsets."""
self.unmatched: list[JournalEntryLineItem] = []
"""The unmatched offsets in the period."""
self.__find_matches()
self.__filter_by_period()
def __find_matches(self) -> None:
"""Finds the matched original line items and their offsets.
:return: None.
"""
self.__get_line_items()
if len(self.__all_unapplied) == 0 or len(self.__all_unmatched) == 0:
return
remains: list[JournalEntryLineItem] = self.__all_unmatched.copy()
for original_item in self.__all_unapplied:
offset_candidates: list[JournalEntryLineItem] \
= [x for x in remains
if (x.journal_entry.date > original_item.journal_entry.date
or (x.journal_entry.date
== original_item.journal_entry.date
and x.journal_entry.no
> original_item.journal_entry.no))
and x.currency_code == original_item.currency_code
and x.description == original_item.description
and x.amount == original_item.net_balance]
if len(offset_candidates) == 0:
continue
self.matched_pairs.append(
OffsetPair(original_item, offset_candidates[0]))
original_item.match = offset_candidates[0]
offset_candidates[0].match = original_item
remains.remove(offset_candidates[0])
def __get_line_items(self) -> None:
"""Returns the unapplied original line items and unmatched offsets of
the account.
:return: The unapplied original line items and unmatched offsets of the
account.
"""
net_balances: dict[int, Decimal | None] = self.__get_net_balances()
unmatched_offset_condition: sa.BinaryExpression \
= sa.and_(Account.id == self.__account.id,
JournalEntryLineItem.currency_code
== self.__currency.code,
JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"),
JournalEntryLineItem.is_debit),
sa.and_(Account.base_code.startswith("1"),
sa.not_(JournalEntryLineItem.is_debit))))
self.__all_line_items = JournalEntryLineItem.query \
.join(Account).join(JournalEntry) \
.filter(sa.or_(JournalEntryLineItem.id.in_(net_balances),
unmatched_offset_condition)) \
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
for line_item in self.__all_line_items:
line_item.is_offset = line_item.id in net_balances
self.__all_unapplied = [x for x in self.__all_line_items
if x.is_offset]
for line_item in self.__all_unapplied:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
else net_balances[line_item.id]
self.__all_unmatched = [x for x in self.__all_line_items
if not x.is_offset]
self.__populate_accumulated_balances()
def __get_net_balances(self) -> dict[int, Decimal | None]:
"""Returns the net balances of the unapplied line items of the account.
:return: The net balances of the unapplied line items of the account.
"""
offset: sa.Alias = offset_alias()
net_balance: sa.Label \
= (JournalEntryLineItem.amount
+ sa.func.sum(sa.case(
(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 == self.__account.id),
be(JournalEntryLineItem.currency_code
== self.__currency.code),
sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)),
sa.and_(Account.base_code.startswith("1"),
JournalEntryLineItem.is_debit))) \
.group_by(JournalEntryLineItem.id) \
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
return {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()}
def __populate_accumulated_balances(self) -> None:
"""Populates the accumulated balances of the line items.
:return: None.
"""
balance: Decimal = Decimal("0")
for line_item in self.__all_line_items:
amount: Decimal = line_item.amount if line_item.is_offset \
else line_item.net_balance
if line_item.is_debit:
line_item.debit = amount
line_item.credit = None
balance = balance + amount
else:
line_item.debit = None
line_item.credit = amount
balance = balance - amount
line_item.balance = balance
def __filter_by_period(self) -> None:
"""Filters the line items by the period.
:return: None.
"""
self.line_items = self.__all_line_items.copy()
if self.__period is not None:
if self.__period.start is not None:
self.line_items \
= [x for x in self.line_items
if x.journal_entry.date >= self.__period.start]
if self.__period.end is not None:
self.line_items \
= [x for x in self.line_items
if x.journal_entry.date <= self.__period.end]
self.unapplied = [x for x in self.line_items if x.is_offset]
self.unmatched = [x for x in self.line_items if not x.is_offset]
@property
def status(self) -> str | LazyString:
"""Returns the match status message.
:return: The match status message.
"""
if len(self.__all_unmatched) == 0:
return lazy_gettext("There is no unmatched offset.")
if len(self.matched_pairs) == 0:
return lazy_gettext(
"%(total)s unmatched offsets without original items.",
total=len(self.__all_unmatched))
return lazy_gettext(
"%(matches)s unmatched offsets out of %(total)s"
" can match with their original items.",
matches=len(self.matched_pairs),
total=len(self.__all_unmatched))
def match(self) -> None:
"""Matches the original line items with offsets.
:return: None.
"""
for pair in self.matched_pairs:
pair.offset.original_line_item_id = pair.original_line_item.id

View File

@ -31,10 +31,12 @@ from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount
from accounting.utils.permission import can_edit
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, unapplied_url
trial_balance_url, income_statement_url, balance_sheet_url, \
unapplied_url, unmatched_url
class ReportChooser:
@ -75,6 +77,8 @@ class ReportChooser:
self.__reports.append(self.__income_statement)
self.__reports.append(self.__balance_sheet)
self.__reports.append(self.__unapplied)
if can_edit():
self.__reports.append(self.__unmatched)
for report in self.__reports:
if report.is_active:
self.current_report = report.title
@ -160,15 +164,36 @@ class ReportChooser:
"""
account: Account = self.__account
if not account.is_need_offset:
return OptionLink(gettext("Unapplied Original Line Items"),
unapplied_url(None),
return OptionLink(gettext("Unapplied Items"),
unapplied_url(self.__currency, None,
self.__period),
self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash")
return OptionLink(gettext("Unapplied Original Line Items"),
unapplied_url(account),
return OptionLink(gettext("Unapplied Items"),
unapplied_url(self.__currency, self.__account,
self.__period),
self.__active_report == ReportType.UNAPPLIED,
fa_icon="fa-solid fa-link-slash")
@property
def __unmatched(self) -> OptionLink:
"""Returns the unmatched offsets.
:return: The unmatched offsets.
"""
account: Account = self.__account
if not account.is_need_offset:
return OptionLink(gettext("Unmatched Offsets"),
unmatched_url(self.__currency, None,
self.__period),
self.__active_report == ReportType.UNMATCHED,
fa_icon="fa-solid fa-link-slash")
return OptionLink(gettext("Unmatched Offsets"),
unmatched_url(self.__currency, self.__account,
self.__period),
self.__active_report == ReportType.UNMATCHED,
fa_icon="fa-solid fa-link-slash")
def __iter__(self) -> t.Iterator[OptionLink]:
"""Returns the iteration of the reports.

View File

@ -36,5 +36,7 @@ class ReportType(Enum):
"""The balance sheet."""
UNAPPLIED: str = "unapplied"
"""The unapplied original line items."""
UNMATCHED: str = "unmatched"
"""The unmatched offsets."""
SEARCH: str = "search"
"""The search."""

View File

@ -20,14 +20,19 @@
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntryLineItem
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period
from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias
def get_accounts_with_unapplied() -> list[Account]:
def get_accounts_with_unapplied(currency: Currency,
period: Period) -> list[Account]:
"""Returns the accounts with unapplied original line items.
:param currency: The currency.
:param period: The period.
:return: The accounts with unapplied original line items.
"""
offset: sa.Alias = offset_alias()
@ -37,17 +42,24 @@ def get_accounts_with_unapplied() -> list[Account]:
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] \
= [Account.is_need_offset,
be(JournalEntryLineItem.currency_code == currency.code),
sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)),
sa.and_(Account.base_code.startswith("1"),
JournalEntryLineItem.is_debit))]
if period.start is not None:
conditions.append(JournalEntry.date >= period.start)
if period.end is not None:
conditions.append(JournalEntry.date <= period.end)
select_unapplied: sa.Select \
= sa.select(JournalEntryLineItem.id)\
.join(Account)\
.join(JournalEntry).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)))\
.filter(*conditions)\
.group_by(JournalEntryLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))

View File

@ -0,0 +1,64 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The unmatched offset utilities.
"""
import sqlalchemy as sa
from accounting import db
from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem
from accounting.report.period import Period
from accounting.utils.cast import be
def get_accounts_with_unmatched(currency: Currency,
period: Period) -> list[Account]:
"""Returns the accounts with unmatched offsets.
:param currency: The currency.
:param period: The period.
: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")
conditions: list[sa.BinaryExpression] \
= [Account.is_need_offset,
be(JournalEntryLineItem.currency_code == currency.code),
JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"),
JournalEntryLineItem.is_debit),
sa.and_(Account.base_code.startswith("1"),
sa.not_(JournalEntryLineItem.is_debit)))]
if period.start is not None:
conditions.append(JournalEntry.date >= period.start)
if period.end is not None:
conditions.append(JournalEntry.date <= period.end)
select: sa.Select = sa.select(Account.id, count_func)\
.select_from(Account)\
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
.filter(*conditions)\
.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

@ -113,13 +113,39 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
currency=currency, period=period)
def unapplied_url(account: Account | None) -> str:
def unapplied_url(currency: Currency, account: Account | None,
period: Period) -> str:
"""Returns the URL of the unapplied original line items.
:param currency: The currency.
:param account: The account, or None to list the accounts with unapplied
original line items.
:param period: The period.
: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)
if currency.code == default_currency_code() and period.is_default:
return url_for("accounting-report.unapplied-accounts-default")
return url_for("accounting-report.unapplied-accounts",
currency=currency, period=period)
return url_for("accounting-report.unapplied",
currency=currency, account=account, period=period)
def unmatched_url(currency: Currency, account: Account | None,
period: Period) -> str:
"""Returns the URL of the unmatched offset line items.
:param currency: The currency.
:param account: The account, or None to list the accounts with unmatched
offset line items.
:param period: The period.
:return: The URL of the unmatched offset line items.
"""
if account is None:
if currency.code == default_currency_code() and period.is_default:
return url_for("accounting-report.unmatched-accounts-default")
return url_for("accounting-report.unmatched-accounts",
currency=currency, period=period)
return url_for("accounting-report.unmatched",
currency=currency, account=account, period=period)