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