diff --git a/src/accounting/__init__.py b/src/accounting/__init__.py index 0bea353..d2dd5b8 100644 --- a/src/accounting/__init__.py +++ b/src/accounting/__init__.py @@ -91,7 +91,4 @@ 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) diff --git a/src/accounting/models.py b/src/accounting/models.py index d5ab607..2a6b98b 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -742,7 +742,18 @@ class JournalEntryLineItem(db.Model): :return: The debit amount, or None if this is not a debit line item. """ - return self.amount if self.is_debit else None + if not hasattr(self, "__debit"): + setattr(self, "__debit", self.amount if self.is_debit else None) + return getattr(self, "__debit") + + @debit.setter + def debit(self, debit: Decimal | None) -> None: + """Sets the debit amount. + + :param debit: The debit amount. + :return: None. + """ + setattr(self, "__debit", debit) @property def credit(self) -> Decimal | None: @@ -750,7 +761,18 @@ class JournalEntryLineItem(db.Model): :return: The credit amount, or None if this is not a credit line item. """ - return None if self.is_debit else self.amount + if not hasattr(self, "__credit"): + setattr(self, "__credit", None if self.is_debit else self.amount) + return getattr(self, "__credit") + + @credit.setter + def credit(self, credit: Decimal | None) -> None: + """Sets the credit amount. + + :param credit: The credit amount. + :return: None. + """ + setattr(self, "__credit", credit) @property def net_balance(self) -> Decimal: @@ -773,6 +795,25 @@ class JournalEntryLineItem(db.Model): """ setattr(self, "__net_balance", net_balance) + @property + def balance(self) -> Decimal: + """Returns the net balance. + + :return: The net balance. + """ + if not hasattr(self, "__balance"): + setattr(self, "__balance", Decimal("0")) + return getattr(self, "__balance") + + @balance.setter + def balance(self, balance: Decimal) -> None: + """Sets the net balance. + + :param balance: The net balance. + :return: None. + """ + setattr(self, "__balance", balance) + @property def offsets(self) -> list[t.Self]: """Returns the offset items. @@ -788,6 +829,26 @@ class JournalEntryLineItem(db.Model): setattr(self, "__offsets", offsets) return getattr(self, "__offsets") + @property + def is_offset(self) -> bool: + """Returns whether the line item is an offset. + + :return: True if the line item is an offset, or False otherwise. + """ + if not hasattr(self, "__is_offset"): + setattr(self, "__is_offset", False) + return getattr(self, "__is_offset") + + @is_offset.setter + def is_offset(self, is_offset: bool) -> None: + """Sets whether the line item is an offset. + + :param is_offset: True if the line item is an offset, or False + otherwise. + :return: None. + """ + setattr(self, "__is_offset", is_offset) + @property def match(self) -> t.Self | None: """Returns the match of the line item. diff --git a/src/accounting/report/reports/unapplied.py b/src/accounting/report/reports/unapplied.py index 63fb4f0..4913dcf 100644 --- a/src/accounting/report/reports/unapplied.py +++ b/src/accounting/report/reports/unapplied.py @@ -23,18 +23,19 @@ from decimal import Decimal from flask import render_template, Response from accounting.locale import gettext -from accounting.models import Account, JournalEntryLineItem +from accounting.models import Currency, Account, JournalEntryLineItem +from accounting.report.period import Period, PeriodChooser 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.csv_export import BaseCSVRow, csv_download, \ + period_spec +from accounting.report.utils.offset_matcher import OffsetMatcher 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): @@ -75,25 +76,32 @@ class CSVRow(BaseCSVRow): class PageParams(BasePageParams): """The HTML page parameters.""" - def __init__(self, account: Account, - is_mark_matches: bool, + def __init__(self, currency: Currency, + account: Account, + period: Period, pagination: Pagination[JournalEntryLineItem], line_items: list[JournalEntryLineItem]): """Constructs the HTML page parameters. + :param currency: The currency. :param account: The account. - :param is_mark_matches: Whether to mark the matched offsets. + :param period: The period. :param pagination: The pagination. :param line_items: The line items. """ + self.currency: Currency = currency + """The currency.""" self.account: Account = account """The account.""" + self.period: Period = period + """The period.""" 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.""" + self.period_chooser: PeriodChooser = PeriodChooser( + lambda x: unapplied_url(currency, account, x)) + """The period chooser.""" @property def has_data(self) -> bool: @@ -109,8 +117,18 @@ class PageParams(BasePageParams): :return: The report chooser. """ - return ReportChooser(ReportType.UNAPPLIED, - account=self.account) + return ReportChooser(ReportType.UNAPPLIED, currency=self.currency, + account=self.account, period=self.period) + + @property + def currency_options(self) -> list[OptionLink]: + """Returns the currency options. + + :return: The currency options. + """ + return self._get_currency_options( + lambda x: unapplied_url(x, self.account, self.period), + self.currency) @property def account_options(self) -> list[OptionLink]: @@ -118,13 +136,15 @@ class PageParams(BasePageParams): :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()]) + options: list[OptionLink] \ + = [OptionLink(gettext("Accounts"), + unapplied_url(self.currency, None, self.period), + False)] + options.extend( + [OptionLink(str(x), + unapplied_url(self.currency, x, self.period), + x.id == self.account.id) + for x in get_accounts_with_unapplied(self.currency, self.period)]) return options @@ -146,27 +166,33 @@ def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]: class UnappliedOriginalLineItems(BaseReport): """The unapplied original line items.""" - def __init__(self, account: Account): + def __init__(self, currency: Currency, account: Account, period: Period): """Constructs the unapplied original line items. + :param currency: The currency. :param account: The account. + :param period: The period. """ + self.__currency: Currency = currency + """The currency.""" self.__account: Account = account """The account.""" - offset_matcher: OffsetMatcher = OffsetMatcher(self.__account) + self.__period: Period = period + """The period.""" + offset_matcher: OffsetMatcher \ + = OffsetMatcher(self.__currency, self.__account, self.__period) 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" + filename: str = "unapplied-{currency}-{account}-{period}.csv"\ + .format(currency=self.__currency.code, account=self.__account.code, + period=period_spec(self.__period)) return csv_download(filename, get_csv_rows(self.__line_items)) def html(self) -> str: @@ -177,8 +203,9 @@ class UnappliedOriginalLineItems(BaseReport): pagination: Pagination[JournalEntryLineItem] \ = Pagination[JournalEntryLineItem](self.__line_items, is_reversed=True) - params: PageParams = PageParams(account=self.__account, - is_mark_matches=self.__is_mark_matches, + params: PageParams = PageParams(currency=self.__currency, + account=self.__account, + period=self.__period, pagination=pagination, line_items=pagination.list) return render_template("accounting/report/unapplied.html", diff --git a/src/accounting/report/reports/unapplied_accounts.py b/src/accounting/report/reports/unapplied_accounts.py index 6da0e42..1097298 100644 --- a/src/accounting/report/reports/unapplied_accounts.py +++ b/src/accounting/report/reports/unapplied_accounts.py @@ -23,7 +23,8 @@ from decimal import Decimal from flask import render_template, Response from accounting.locale import gettext -from accounting.models import Account +from accounting.models import Currency, Account +from accounting.report.period import Period, PeriodChooser 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 @@ -60,13 +61,24 @@ class CSVRow(BaseCSVRow): class PageParams(BasePageParams): """The HTML page parameters.""" - def __init__(self, accounts: list[Account]): + def __init__(self, currency: Currency, + period: Period, + accounts: list[Account]): """Constructs the HTML page parameters. + :param currency: The currency. + :param period: The period. :param accounts: The accounts. """ + self.currency: Currency = currency + """The currency.""" + self.period: Period = period + """The period.""" self.accounts: list[Account] = accounts """The accounts.""" + self.period_chooser: PeriodChooser = PeriodChooser( + lambda x: unapplied_url(currency, None, x)) + """The period chooser.""" @property def has_data(self) -> bool: @@ -82,7 +94,18 @@ class PageParams(BasePageParams): :return: The report chooser. """ - return ReportChooser(ReportType.UNAPPLIED) + return ReportChooser(ReportType.UNAPPLIED, currency=self.currency, + account=None, period=self.period) + + @property + def currency_options(self) -> list[OptionLink]: + """Returns the currency options. + + :return: The currency options. + """ + return self._get_currency_options( + lambda x: unapplied_url(x, None, self.period), + self.currency) @property def account_options(self) -> list[OptionLink]: @@ -90,13 +113,15 @@ class PageParams(BasePageParams): :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]) + options: list[OptionLink] \ + = [OptionLink(gettext("Accounts"), + unapplied_url(self.currency, None, self.period), + True)] + options.extend( + [OptionLink(str(x), + unapplied_url(self.currency, x, self.period), + False) + for x in self.accounts]) return options @@ -115,9 +140,18 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]: 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() + def __init__(self, currency: Currency, period: Period): + """Constructs the outstanding balances. + + :param currency: The currency. + :param period: The period. + """ + self.__currency: Currency = currency + """The currency.""" + self.__period: Period = period + """The period.""" + self.__accounts: list[Account] \ + = get_accounts_with_unapplied(currency, period) """The accounts.""" def csv(self) -> Response: @@ -134,4 +168,6 @@ class AccountsWithUnappliedOriginalLineItems(BaseReport): :return: The report as HTML. """ return render_template("accounting/report/unapplied-accounts.html", - report=PageParams(accounts=self.__accounts)) + report=PageParams(currency=self.__currency, + period=self.__period, + accounts=self.__accounts)) diff --git a/src/accounting/report/reports/unmatched.py b/src/accounting/report/reports/unmatched.py new file mode 100644 index 0000000..7f778fc --- /dev/null +++ b/src/accounting/report/reports/unmatched.py @@ -0,0 +1,230 @@ +# The Mia! Accounting Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The unmatched offsets. + +""" +from datetime import date +from decimal import Decimal + +from flask import render_template, Response +from flask_babel import LazyString + +from accounting.locale import gettext +from accounting.models import Currency, Account, JournalEntryLineItem +from accounting.report.period import Period, PeriodChooser +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, \ + period_spec +from accounting.report.utils.option_link import OptionLink +from accounting.report.utils.report_chooser import ReportChooser +from accounting.report.utils.report_type import ReportType +from accounting.report.utils.unmatched import get_accounts_with_unmatched +from accounting.report.utils.urls import unmatched_url +from accounting.report.utils.offset_matcher import OffsetMatcher, OffsetPair +from accounting.utils.pagination import Pagination + + +class CSVRow(BaseCSVRow): + """A row in the CSV.""" + + def __init__(self, journal_entry_date: str | date, currency: str, + description: str | None, debit: str | Decimal, + credit: str | Decimal, balance: str | Decimal): + """Constructs a row in the CSV. + + :param journal_entry_date: The journal entry date. + :param currency: The currency. + :param description: The description. + :param debit: The debit amount. + :param credit: The credit amount. + :param balance: The balance. + """ + self.date: str | date = journal_entry_date + """The date.""" + self.currency: str = currency + """The currency.""" + self.description: str | None = description + """The description.""" + self.debit: str | Decimal | None = debit + """The debit amount.""" + self.credit: str | Decimal | None = credit + """The credit amount.""" + self.balance: str | Decimal = balance + """The balance.""" + + @property + def values(self) -> list[str | date | Decimal | None]: + """Returns the values of the row. + + :return: The values of the row. + """ + return [self.date, self.currency, self.description, self.debit, + self.credit, self.balance] + + +class PageParams(BasePageParams): + """The HTML page parameters.""" + + def __init__(self, currency: Currency, + account: Account, + period: Period, + match_status: str | LazyString, + matched_pairs: list[OffsetPair], + pagination: Pagination[JournalEntryLineItem], + line_items: list[JournalEntryLineItem]): + """Constructs the HTML page parameters. + + :param currency: The currency. + :param account: The account. + :param period: The period. + :param match_status: The match status message. + :param matched_pairs: A list of matched pairs. + :param pagination: The pagination. + :param line_items: The line items. + """ + self.currency: Currency = currency + """The currency.""" + self.account: Account = account + """The account.""" + self.period: Period = period + """The period.""" + self.match_status: str | LazyString = match_status + """The match status message.""" + self.matched_pairs: list[OffsetPair] = matched_pairs + """A list of matched pairs.""" + self.pagination: Pagination[JournalEntryLineItem] = pagination + """The pagination.""" + self.line_items: list[JournalEntryLineItem] = line_items + """The line items.""" + self.period_chooser: PeriodChooser = PeriodChooser( + lambda x: unmatched_url(currency, account, x)) + """The period chooser.""" + + @property + def has_data(self) -> bool: + """Returns whether there is any data on the page. + + :return: True if there is any data, or False otherwise. + """ + return len(self.line_items) > 0 + + @property + def report_chooser(self) -> ReportChooser: + """Returns the report chooser. + + :return: The report chooser. + """ + return ReportChooser(ReportType.UNMATCHED, currency=self.currency, + account=self.account) + + @property + def currency_options(self) -> list[OptionLink]: + """Returns the currency options. + + :return: The currency options. + """ + return self._get_currency_options( + lambda x: unmatched_url(x, self.account, self.period), + self.currency) + + @property + def account_options(self) -> list[OptionLink]: + """Returns the account options. + + :return: The account options. + """ + options: list[OptionLink] \ + = [OptionLink(gettext("Accounts"), + unmatched_url(self.currency, None, self.period), + False)] + options.extend( + [OptionLink(str(x), + unmatched_url(self.currency, x, self.period), + x.id == self.account.id) + for x in get_accounts_with_unmatched(self.currency, self.period)]) + return options + + +def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]: + """Composes and returns the CSV rows from the line items. + + :param line_items: The line items. + :return: The CSV rows. + """ + rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"), + gettext("Description"), gettext("Debit"), + gettext("Credit"), gettext("Balance"))] + rows.extend([CSVRow(x.journal_entry.date, x.currency.code, + x.description, x.debit, x.credit, x.balance) + for x in line_items]) + return rows + + +class UnmatchedOffsets(BaseReport): + """The unmatched offsets.""" + + def __init__(self, currency: Currency, account: Account, period: Period): + """Constructs the unmatched offsets. + + :param currency: The currency. + :param account: The account. + :param period: The period. + """ + self.__currency: Currency = currency + """The currency.""" + self.__account: Account = account + """The account.""" + self.__period: Period = period + """The period.""" + offset_matcher: OffsetMatcher \ + = OffsetMatcher(self.__currency, self.__account, self.__period) + self.__line_items: list[JournalEntryLineItem] \ + = offset_matcher.line_items + """The line items.""" + self.__match_status: str | LazyString = offset_matcher.status + """The match status message.""" + self.__matched_pairs: list[OffsetPair] = offset_matcher.matched_pairs + """A list of matched pairs.""" + + def csv(self) -> Response: + """Returns the report as CSV for download. + + :return: The response of the report for download. + """ + filename: str = "unmatched-{currency}-{account}-{period}.csv"\ + .format(currency=self.__currency.code, account=self.__account.code, + period=period_spec(self.__period)) + return csv_download(filename, get_csv_rows(self.__line_items)) + + def html(self) -> str: + """Composes and returns the report as HTML. + + :return: The report as HTML. + """ + pagination: Pagination[JournalEntryLineItem] \ + = Pagination[JournalEntryLineItem](self.__line_items, + is_reversed=True) + params: PageParams = PageParams(currency=self.__currency, + account=self.__account, + period=self.__period, + match_status=self.__match_status, + matched_pairs=self.__matched_pairs, + pagination=pagination, + line_items=pagination.list) + return render_template("accounting/report/unmatched.html", + report=params) diff --git a/src/accounting/report/reports/unmatched_accounts.py b/src/accounting/report/reports/unmatched_accounts.py new file mode 100644 index 0000000..f9b5387 --- /dev/null +++ b/src/accounting/report/reports/unmatched_accounts.py @@ -0,0 +1,173 @@ +# The Mia! Accounting Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The accounts with unmatched offsets. + +""" +from datetime import date +from decimal import Decimal + +from flask import render_template, Response + +from accounting.locale import gettext +from accounting.models import Currency, Account +from accounting.report.period import Period, PeriodChooser +from accounting.report.utils.base_page_params import BasePageParams +from accounting.report.utils.base_report import BaseReport +from accounting.report.utils.csv_export import BaseCSVRow, csv_download +from accounting.report.utils.option_link import OptionLink +from accounting.report.utils.report_chooser import ReportChooser +from accounting.report.utils.report_type import ReportType +from accounting.report.utils.unmatched import get_accounts_with_unmatched +from accounting.report.utils.urls import unmatched_url + + +class CSVRow(BaseCSVRow): + """A row in the CSV.""" + + def __init__(self, account: str, count: int | str): + """Constructs a row in the CSV. + + :param account: The account. + :param count: The number of unapplied original line items. + """ + self.account: str = account + """The currency.""" + self.count: int | str = count + """The number of unapplied original line items.""" + + @property + def values(self) -> list[str | date | Decimal | None]: + """Returns the values of the row. + + :return: The values of the row. + """ + return [self.account, self.count] + + +class PageParams(BasePageParams): + """The HTML page parameters.""" + + def __init__(self, currency: Currency, + period: Period, + accounts: list[Account]): + """Constructs the HTML page parameters. + + :param currency: The currency. + :param period: The period. + :param accounts: The accounts. + """ + self.currency: Currency = currency + """The currency.""" + self.period: Period = period + """The period.""" + self.accounts: list[Account] = accounts + """The accounts.""" + self.period_chooser: PeriodChooser = PeriodChooser( + lambda x: unmatched_url(currency, None, x)) + """The period chooser.""" + + @property + def has_data(self) -> bool: + """Returns whether there is any data on the page. + + :return: True if there is any data, or False otherwise. + """ + return len(self.accounts) > 0 + + @property + def report_chooser(self) -> ReportChooser: + """Returns the report chooser. + + :return: The report chooser. + """ + return ReportChooser(ReportType.UNMATCHED, currency=self.currency, + account=None, period=self.period) + + @property + def currency_options(self) -> list[OptionLink]: + """Returns the currency options. + + :return: The currency options. + """ + return self._get_currency_options( + lambda x: unmatched_url(x, None, self.period), + self.currency) + + @property + def account_options(self) -> list[OptionLink]: + """Returns the account options. + + :return: The account options. + """ + options: list[OptionLink] \ + = [OptionLink(gettext("Accounts"), + unmatched_url(self.currency, None, self.period), + True)] + options.extend( + [OptionLink(str(x), + unmatched_url(self.currency, x, self.period), + 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 AccountsWithUnmatchedOffsets(BaseReport): + """The accounts with unmatched offsets.""" + + def __init__(self, currency: Currency, period: Period): + """Constructs the outstanding balances. + + :param currency: The currency. + :param period: The period. + """ + self.__currency: Currency = currency + """The currency.""" + self.__period: Period = period + """The period.""" + self.__accounts: list[Account] \ + = get_accounts_with_unmatched(currency, period) + """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/unmatched-accounts.html", + report=PageParams(currency=self.__currency, + period=self.__period, + accounts=self.__accounts)) diff --git a/src/accounting/report/utils/offset_matcher.py b/src/accounting/report/utils/offset_matcher.py new file mode 100644 index 0000000..80b187b --- /dev/null +++ b/src/accounting/report/utils/offset_matcher.py @@ -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 diff --git a/src/accounting/report/utils/report_chooser.py b/src/accounting/report/utils/report_chooser.py index c30329f..b4d7f9b 100644 --- a/src/accounting/report/utils/report_chooser.py +++ b/src/accounting/report/utils/report_chooser.py @@ -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. diff --git a/src/accounting/report/utils/report_type.py b/src/accounting/report/utils/report_type.py index 35c054e..e4a68ea 100644 --- a/src/accounting/report/utils/report_type.py +++ b/src/accounting/report/utils/report_type.py @@ -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.""" diff --git a/src/accounting/report/utils/unapplied.py b/src/accounting/report/utils/unapplied.py index 7cbb6cf..75ccaa6 100644 --- a/src/accounting/report/utils/unapplied.py +++ b/src/accounting/report/utils/unapplied.py @@ -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)) diff --git a/src/accounting/unmatched_offset/queries.py b/src/accounting/report/utils/unmatched.py similarity index 55% rename from src/accounting/unmatched_offset/queries.py rename to src/accounting/report/utils/unmatched.py index 3375ef0..8acbfb1 100644 --- a/src/accounting/unmatched_offset/queries.py +++ b/src/accounting/report/utils/unmatched.py @@ -14,31 +14,45 @@ # 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. +"""The unmatched offset utilities. """ 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 -def get_accounts_with_unmatched_offsets() -> list[Account]: +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)\ - .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))))\ + .select_from(Account)\ + .join(JournalEntryLineItem, isouter=True).join(JournalEntry)\ + .filter(*conditions)\ .group_by(Account.id)\ .having(count_func > 0) counts: dict[int, int] \ diff --git a/src/accounting/report/utils/urls.py b/src/accounting/report/utils/urls.py index 0a00d9f..5d583f7 100644 --- a/src/accounting/report/utils/urls.py +++ b/src/accounting/report/utils/urls.py @@ -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) diff --git a/src/accounting/report/views.py b/src/accounting/report/views.py index 6b552ce..8e1a3df 100644 --- a/src/accounting/report/views.py +++ b/src/accounting/report/views.py @@ -17,20 +17,27 @@ """The views for the report management. """ -from flask import Blueprint, request, Response +from flask import Blueprint, request, Response, redirect, flash from accounting import db +from accounting.locale import lazy_gettext 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.cast import s from accounting.utils.current_account import CurrentAccount +from accounting.utils.next_uri import or_next from accounting.utils.options import options -from accounting.utils.permission import has_permission, can_view +from accounting.utils.permission import has_permission, can_view, can_edit +from .period import Period, get_period from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ IncomeStatement, BalanceSheet, Search from .reports.unapplied import UnappliedOriginalLineItems from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems +from .reports.unmatched import UnmatchedOffsets +from .reports.unmatched_accounts import AccountsWithUnmatchedOffsets from .template_filters import format_amount +from .utils.offset_matcher import OffsetMatcher +from .utils.urls import unmatched_url bp: Blueprint = Blueprint("accounting-report", __name__) """The view blueprint for the reports.""" @@ -278,34 +285,144 @@ def __get_balance_sheet(currency: Currency, period: Period) \ return report.html() -@bp.get("unapplied", endpoint="unapplied-default") +@bp.get("unapplied", endpoint="unapplied-accounts-default") @has_permission(can_view) -def get_default_unapplied() -> str | Response: +def get_default_unapplied_accounts() -> str | Response: """Returns the accounts with unapplied original line items. + :return: The accounts with unapplied original line items. + """ + return __get_unapplied_accounts( + db.session.get(Currency, default_currency_code()), get_period()) + + +@bp.get("unapplied//", + endpoint="unapplied-accounts") +@has_permission(can_view) +def get_unapplied_accounts(currency: Currency, + period: Period) -> str | Response: + """Returns the accounts with unapplied original line items. + + :param currency: The currency. + :param period: The period. + :return: The accounts with unapplied original line items. + """ + return __get_unapplied_accounts(currency, period) + + +def __get_unapplied_accounts(currency: Currency, + period: Period) -> str | Response: + """Returns the accounts with unapplied original line items. + + :param currency: The currency. + :param period: The period. :return: The accounts with unapplied original line items. """ report: AccountsWithUnappliedOriginalLineItems \ - = AccountsWithUnappliedOriginalLineItems() + = AccountsWithUnappliedOriginalLineItems(currency, period) if "as" in request.args and request.args["as"] == "csv": return report.csv() return report.html() -@bp.get("unapplied/", endpoint="unapplied") +@bp.get("unapplied///" + "", endpoint="unapplied") @has_permission(can_view) -def get_unapplied(account: Account) -> str | Response: +def get_unapplied(currency: Currency, account: Account, + period: Period) -> str | Response: """Returns the unapplied original line items. + :param currency: The currency. :param account: The Account. - :return: The unapplied original line items. + :param period: The period. + :return: The unapplied original line items in the period. """ - report: UnappliedOriginalLineItems = UnappliedOriginalLineItems(account) + report: UnappliedOriginalLineItems \ + = UnappliedOriginalLineItems(currency, account, period) if "as" in request.args and request.args["as"] == "csv": return report.csv() return report.html() +@bp.get("unmatched", endpoint="unmatched-accounts-default") +@has_permission(can_edit) +def get_default_unmatched_accounts() -> str | Response: + """Returns the accounts with unmatched offsets. + + :return: The accounts with unmatched offsets. + """ + return __get_unmatched_accounts( + db.session.get(Currency, default_currency_code()), get_period()) + + +@bp.get("unmatched//", + endpoint="unmatched-accounts") +@has_permission(can_edit) +def get_unmatched_accounts(currency: Currency, + period: Period) -> str | Response: + """Returns the accounts with unmatched offsets. + + :param currency: The currency. + :param period: The period. + :return: The accounts with unmatched offsets. + """ + return __get_unmatched_accounts(currency, period) + + +def __get_unmatched_accounts(currency: Currency, + period: Period) -> str | Response: + """Returns the accounts with unmatched offsets. + + :param currency: The currency. + :param period: The period. + :return: The accounts with unmatched offsets. + """ + report: AccountsWithUnmatchedOffsets \ + = AccountsWithUnmatchedOffsets(currency, period) + if "as" in request.args and request.args["as"] == "csv": + return report.csv() + return report.html() + + +@bp.get("unmatched///" + "", endpoint="unmatched") +@has_permission(can_edit) +def get_unmatched(currency: Currency, account: Account, + period: Period) -> str | Response: + """Returns the unmatched offsets. + + :param currency: The currency. + :param account: The Account. + :param period: The period. + :return: The unmatched offsets in the period. + """ + report: UnmatchedOffsets = UnmatchedOffsets(currency, account, period) + if "as" in request.args and request.args["as"] == "csv": + return report.csv() + return report.html() + + +@bp.post("match-offsets//", + endpoint="match-offsets") +@has_permission(can_edit) +def match_offsets(currency: Currency, account: Account) -> redirect: + """Matches the original line items with their offsets. + + :return: Redirection to the view of the unmatched offsets. + """ + matcher: OffsetMatcher = OffsetMatcher(currency, account, None) + if len(matcher.matched_pairs) == 0: + flash(s(lazy_gettext("No more offset to match automatically.")), + "success") + return redirect(or_next( + unmatched_url(currency, account, get_period()))) + matcher.match() + db.session.commit() + flash(s(lazy_gettext("Matched %(matches)s offsets.", + matches=len(matcher.matched_pairs))), "success") + return redirect(or_next(unmatched_url(currency, account, get_period()))) + + @bp.get("search", endpoint="search") @has_permission(can_view) def search() -> str | Response: diff --git a/src/accounting/static/css/style.css b/src/accounting/static/css/style.css index 789ef39..eab5632 100644 --- a/src/accounting/static/css/style.css +++ b/src/accounting/static/css/style.css @@ -322,7 +322,7 @@ a.accounting-report-table-row { font-style: italic; } .accounting-unapplied-table .accounting-report-table-row { - grid-template-columns: 1fr 1fr 5fr 1fr 1fr; + grid-template-columns: 1fr 5fr 1fr 1fr; } .accounting-unapplied-account-table .accounting-report-table-row { display: flex; @@ -331,6 +331,16 @@ a.accounting-report-table-row { .accounting-unapplied-account-table .accounting-report-table-header .accounting-report-table-row { display: block; } +.accounting-unmatched-table .accounting-report-table-row { + grid-template-columns: 1fr 5fr 1fr 1fr 1fr; +} +.accounting-unmatched-account-table .accounting-report-table-row { + display: flex; + justify-content: space-between; +} +.accounting-unmatched-account-table .accounting-report-table-header .accounting-report-table-row { + display: block; +} /* The accounting report */ .accounting-mobile-journal-credit { diff --git a/src/accounting/templates/accounting/include/nav.html b/src/accounting/templates/accounting/include/nav.html index 7b9a017..01b83e1 100644 --- a/src/accounting/templates/accounting/include/nav.html +++ b/src/accounting/templates/accounting/include/nav.html @@ -51,14 +51,6 @@ First written: 2023/1/26 {{ A_("Currencies") }} - {% if accounting_can_edit() %} -
  • - - - {{ A_("Unmatched Offsets") }} - -
  • - {% endif %} {% if accounting_can_admin() %}
  • diff --git a/src/accounting/templates/accounting/report/unapplied-accounts.html b/src/accounting/templates/accounting/report/unapplied-accounts.html index 0118547..04e0db8 100644 --- a/src/accounting/templates/accounting/report/unapplied-accounts.html +++ b/src/accounting/templates/accounting/report/unapplied-accounts.html @@ -21,24 +21,33 @@ First written: 2023/4/8 #} {% extends "accounting/base.html" %} -{% block header %}{% block title %}{{ A_("Accounts with Unapplied Original Line Items") }}{% endblock %}{% endblock %} +{% block accounting_scripts %} + + +{% endblock %} + +{% block header %}{% block title %}{{ A_("Accounts with Unapplied Items in %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %} {% block content %}
    - {% with use_account_chooser = true %} + {% with use_currency_chooser = true, + use_account_chooser = true, + use_period_chooser = true %} {% include "accounting/report/include/toolbar-buttons.html" %} {% endwith %}
    {% include "accounting/report/include/add-journal-entry-material-fab.html" %} +{% include "accounting/report/include/period-chooser.html" %} + {% include "accounting/report/include/search-modal.html" %} {% if report.has_data %}
    -

    {{ A_("Accounts with Unapplied Original Line Items") }}

    +

    {{ A_("Accounts with Unapplied Items in %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}

    {% for account in report.accounts %} - +
    {{ account }}
    {{ account.count }}
    diff --git a/src/accounting/templates/accounting/report/unapplied.html b/src/accounting/templates/accounting/report/unapplied.html index e1ec33e..2bf769a 100644 --- a/src/accounting/templates/accounting/report/unapplied.html +++ b/src/accounting/templates/accounting/report/unapplied.html @@ -23,20 +23,25 @@ First written: 2023/4/7 {% block accounting_scripts %} + {% endblock %} -{% block header %}{% block title %}{{ A_("Unapplied Original Line Items of %(account)s", account=report.account.title|title) }}{% endblock %}{% endblock %} +{% block header %}{% block title %}{{ A_("Unapplied Items of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %} {% block content %}
    - {% with use_account_chooser = true %} + {% with use_currency_chooser = true, + use_account_chooser = true, + use_period_chooser = true %} {% include "accounting/report/include/toolbar-buttons.html" %} {% endwith %}
    {% include "accounting/report/include/add-journal-entry-material-fab.html" %} +{% include "accounting/report/include/period-chooser.html" %} + {% include "accounting/report/include/search-modal.html" %} {% if report.has_data %} @@ -48,7 +53,6 @@ First written: 2023/4/7
    {{ A_("Date") }}
    -
    {{ A_("Currency") }}
    {{ A_("Description") }}
    {{ A_("Amount") }}
    {{ A_("Net Balance") }}
    @@ -56,15 +60,9 @@ First written: 2023/4/7
    {% for line_item in report.line_items %} - +
    {{ line_item.journal_entry.date|accounting_format_date }}
    -
    {{ line_item.currency.name }}
    -
    - {{ line_item.description|accounting_default }} - {% if report.is_mark_matches and line_item.match %} -
    {{ A_("Can match %(offset)s", offset=line_item.match) }}
    - {% endif %} -
    +
    {{ line_item.description|accounting_default }}
    {{ line_item.amount|accounting_format_amount }}
    {{ line_item.net_balance|accounting_format_amount }}
    @@ -78,9 +76,6 @@ First written: 2023/4/7
    {{ line_item.journal_entry.date|accounting_format_date }} - {% if line_item.currency.code != accounting_default_currency_code() %} - {{ line_item.currency.code }} - {% endif %}
    {% if line_item.description is not none %}
    {{ line_item.description }}
    diff --git a/src/accounting/templates/accounting/report/unmatched-accounts.html b/src/accounting/templates/accounting/report/unmatched-accounts.html new file mode 100644 index 0000000..f65add5 --- /dev/null +++ b/src/accounting/templates/accounting/report/unmatched-accounts.html @@ -0,0 +1,73 @@ +{# +The Mia! Accounting Project +unmatched-accounts.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/17 +#} +{% extends "accounting/base.html" %} + +{% block accounting_scripts %} + + +{% endblock %} + +{% block header %}{% block title %}{{ A_("Accounts with Unmatched Offsets in %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %} + +{% block content %} + +
    + {% with use_currency_chooser = true, + use_account_chooser = true, + use_period_chooser = true %} + {% include "accounting/report/include/toolbar-buttons.html" %} + {% endwith %} +
    + +{% include "accounting/report/include/add-journal-entry-material-fab.html" %} + +{% include "accounting/report/include/period-chooser.html" %} + +{% include "accounting/report/include/search-modal.html" %} + +{% if report.has_data %} +
    +
    +

    {{ A_("Accounts with Unmatched Offsets in %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}

    +
    + + +
    +{% else %} +

    {{ A_("There is no data.") }}

    +{% endif %} + +{% endblock %} diff --git a/src/accounting/templates/accounting/report/unmatched.html b/src/accounting/templates/accounting/report/unmatched.html new file mode 100644 index 0000000..5f91e62 --- /dev/null +++ b/src/accounting/templates/accounting/report/unmatched.html @@ -0,0 +1,153 @@ +{# +The Mia! Accounting Project +unmatched.html: The 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/17 +#} +{% extends "accounting/base.html" %} + +{% block accounting_scripts %} + + +{% endblock %} + +{% block header %}{% block title %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %} + +{% block content %} + +
    + {% with use_currency_chooser = true, + use_account_chooser = true, + use_period_chooser = true %} + {% include "accounting/report/include/toolbar-buttons.html" %} + {% endwith %} +
    + +{% include "accounting/report/include/add-journal-entry-material-fab.html" %} + +{% include "accounting/report/include/period-chooser.html" %} + +{% include "accounting/report/include/search-modal.html" %} + +{% if report.matched_pairs %} + + +
    + + + +
    +{% endif %} + +

    {{ report.match_status }}

    + +{% if report.has_data %} + {% with pagination = report.pagination %} + {% include "accounting/include/pagination.html" %} + {% endwith %} + + + + +{% else %} +

    {{ A_("There is no data.") }}

    +{% endif %} + +{% endblock %} diff --git a/src/accounting/templates/accounting/unmatched-offset/dashboard.html b/src/accounting/templates/accounting/unmatched-offset/dashboard.html deleted file mode 100644 index c137458..0000000 --- a/src/accounting/templates/accounting/unmatched-offset/dashboard.html +++ /dev/null @@ -1,40 +0,0 @@ -{# -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 %} -
    - {% for account in list %} - - {{ account }} ({{ account.count }}) - - {% endfor %} -
    -{% else %} -

    {{ A_("There is no data.") }}

    -{% endif %} - -{% endblock %} diff --git a/src/accounting/templates/accounting/unmatched-offset/list.html b/src/accounting/templates/accounting/unmatched-offset/list.html deleted file mode 100644 index e6f5885..0000000 --- a/src/accounting/templates/accounting/unmatched-offset/list.html +++ /dev/null @@ -1,107 +0,0 @@ -{# -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 %} - -
    - - - {{ A_("Back") }} - - {% if matcher.is_having_matches %} - - {% else %} - - {% endif %} -
    - -{% if matcher.is_having_matches %} -
    - - -
    -{% endif %} - -{% if matcher.total %} - {% if matcher.is_having_matches %} -

    {{ A_("%(matches)s unapplied original line items out of %(total)s can match with their offsets.", matches=matcher.matches, total=matcher.total) }}

    - {% else %} -

    {{ A_("%(total)s unapplied original line items without matching offsets.", total=matcher.total) }}

    - {% endif %} -

    {{ A_("Go to unapplied original line items.") }}

    -{% else %} -

    {{ A_("All original line items are fully offset.") }}

    -{% endif %} - -{% if list %} - {% include "accounting/include/pagination.html" %} - - -{% else %} -

    {{ A_("There is no data.") }}

    -{% endif %} - -{% endblock %} diff --git a/src/accounting/unmatched_offset/__init__.py b/src/accounting/unmatched_offset/__init__.py deleted file mode 100644 index f803446..0000000 --- a/src/accounting/unmatched_offset/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -# The Mia! Accounting Project. -# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8 - -# Copyright (c) 2023 imacat. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""The unmatched offset 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") diff --git a/src/accounting/unmatched_offset/views.py b/src/accounting/unmatched_offset/views.py deleted file mode 100644 index ea22eab..0000000 --- a/src/accounting/unmatched_offset/views.py +++ /dev/null @@ -1,81 +0,0 @@ -# The Mia! Accounting Project. -# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8 - -# Copyright (c) 2023 imacat. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""The 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("", 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("", 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)) diff --git a/src/accounting/utils/offset_matcher.py b/src/accounting/utils/offset_matcher.py deleted file mode 100644 index cf945f7..0000000 --- a/src/accounting/utils/offset_matcher.py +++ /dev/null @@ -1,173 +0,0 @@ -# The Mia! Accounting Project. -# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8 - -# Copyright (c) 2023 imacat. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -"""The forms for the unmatched offset management. - -""" -from decimal import Decimal - -import sqlalchemy as sa -from 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 - - -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] = self.__get_unapplied() - 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_unapplied(self) -> list[JournalEntryLineItem]: - """Returns the unapplied original line items of the account. - - :return: The unapplied original 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), - 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 - - def __get_unmatched_offsets(self) -> list[JournalEntryLineItem]: - """Returns the unmatched offsets of the 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 diff --git a/tests/test_report.py b/tests/test_report.py index b16f1ca..239dafb 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -107,10 +107,26 @@ class ReportTestCase(unittest.TestCase): response = client.get(f"{PREFIX}/unapplied?as=csv") self.assertEqual(response.status_code, 403) - response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}") + response = client.get( + f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time") self.assertEqual(response.status_code, 403) - response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv") + response = client.get( + f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time?as=csv") + self.assertEqual(response.status_code, 403) + + response = client.get(f"{PREFIX}/unmatched") + self.assertEqual(response.status_code, 403) + + response = client.get(f"{PREFIX}/unmatched?as=csv") + self.assertEqual(response.status_code, 403) + + response = client.get( + f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time") + self.assertEqual(response.status_code, 403) + + response = client.get( + f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time?as=csv") self.assertEqual(response.status_code, 403) response = client.get(f"{PREFIX}/search?q=Salary") @@ -190,13 +206,29 @@ class ReportTestCase(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.headers["Content-Type"], CSV_MIME) - response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}") + response = client.get( + f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time") self.assertEqual(response.status_code, 200) - response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv") + response = client.get( + f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time?as=csv") self.assertEqual(response.status_code, 200) self.assertEqual(response.headers["Content-Type"], CSV_MIME) + response = client.get(f"{PREFIX}/unmatched") + self.assertEqual(response.status_code, 403) + + response = client.get(f"{PREFIX}/unmatched?as=csv") + self.assertEqual(response.status_code, 403) + + response = client.get( + f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time") + self.assertEqual(response.status_code, 403) + + response = client.get( + f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time?as=csv") + self.assertEqual(response.status_code, 403) + response = client.get(f"{PREFIX}/search?q=Salary") self.assertEqual(response.status_code, 200) @@ -275,11 +307,28 @@ class ReportTestCase(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.headers["Content-Type"], CSV_MIME) - response = self.client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}") + response = self.client.get( + f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time") self.assertEqual(response.status_code, 200) response = self.client.get( - f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv") + f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time?as=csv") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], CSV_MIME) + + response = self.client.get(f"{PREFIX}/unmatched") + self.assertEqual(response.status_code, 200) + + response = self.client.get(f"{PREFIX}/unmatched?as=csv") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], CSV_MIME) + + response = self.client.get( + f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time") + self.assertEqual(response.status_code, 200) + + response = self.client.get( + f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time?as=csv") self.assertEqual(response.status_code, 200) self.assertEqual(response.headers["Content-Type"], CSV_MIME) @@ -360,11 +409,28 @@ class ReportTestCase(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.headers["Content-Type"], CSV_MIME) - response = self.client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}") + response = self.client.get( + f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time") self.assertEqual(response.status_code, 200) response = self.client.get( - f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv") + f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time?as=csv") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], CSV_MIME) + + response = self.client.get(f"{PREFIX}/unmatched") + self.assertEqual(response.status_code, 200) + + response = self.client.get(f"{PREFIX}/unmatched?as=csv") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["Content-Type"], CSV_MIME) + + response = self.client.get( + f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time") + self.assertEqual(response.status_code, 200) + + response = self.client.get( + f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time?as=csv") self.assertEqual(response.status_code, 200) self.assertEqual(response.headers["Content-Type"], CSV_MIME) diff --git a/tests/test_unmatched_offset.py b/tests/test_unmatched_offset.py index 07f07b1..6f1fd33 100644 --- a/tests/test_unmatched_offset.py +++ b/tests/test_unmatched_offset.py @@ -25,9 +25,9 @@ from flask import Flask from test_site import db from test_site.lib import JournalEntryCurrencyData, JournalEntryData, \ BaseTestData -from testlib import create_test_app, get_client, Accounts +from testlib import NEXT_URI, create_test_app, get_client, Accounts -PREFIX: str = "/accounting/unmatched-offsets" +PREFIX: str = "/accounting/match-offsets/USD" """The URL prefix for the unmatched offset management.""" @@ -58,14 +58,9 @@ class UnmatchedOffsetTestCase(unittest.TestCase): DifferentTestData(self.app, "nobody").populate() response: httpx.Response - response = client.get(PREFIX) - self.assertEqual(response.status_code, 403) - - response = client.get(f"{PREFIX}/{Accounts.PAYABLE}") - self.assertEqual(response.status_code, 403) - response = client.post(f"{PREFIX}/{Accounts.PAYABLE}", - data={"csrf_token": csrf_token}) + data={"csrf_token": csrf_token, + "next": NEXT_URI}) self.assertEqual(response.status_code, 403) def test_viewer(self) -> None: @@ -77,14 +72,9 @@ class UnmatchedOffsetTestCase(unittest.TestCase): DifferentTestData(self.app, "viewer").populate() response: httpx.Response - response = client.get(PREFIX) - self.assertEqual(response.status_code, 403) - - response = client.get(f"{PREFIX}/{Accounts.PAYABLE}") - self.assertEqual(response.status_code, 403) - response = client.post(f"{PREFIX}/{Accounts.PAYABLE}", - data={"csrf_token": csrf_token}) + data={"csrf_token": csrf_token, + "next": NEXT_URI}) self.assertEqual(response.status_code, 403) def test_editor(self) -> None: @@ -95,17 +85,11 @@ class UnmatchedOffsetTestCase(unittest.TestCase): DifferentTestData(self.app, "editor").populate() response: httpx.Response - response = self.client.get(PREFIX) - self.assertEqual(response.status_code, 200) - - response = self.client.get(f"{PREFIX}/{Accounts.PAYABLE}") - self.assertEqual(response.status_code, 200) - response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}", - data={"csrf_token": self.csrf_token}) + data={"csrf_token": self.csrf_token, + "next": NEXT_URI}) self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers["Location"], - f"{PREFIX}/{Accounts.PAYABLE}") + self.assertEqual(response.headers["Location"], NEXT_URI) def test_empty_db(self) -> None: """Test the empty database. @@ -114,43 +98,42 @@ class UnmatchedOffsetTestCase(unittest.TestCase): """ response: httpx.Response - response = self.client.get(PREFIX) - self.assertEqual(response.status_code, 200) - - response = self.client.get(f"{PREFIX}/{Accounts.PAYABLE}") - self.assertEqual(response.status_code, 200) - response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}", - data={"csrf_token": self.csrf_token}) + data={"csrf_token": self.csrf_token, + "next": NEXT_URI}) self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers["Location"], - f"{PREFIX}/{Accounts.PAYABLE}") + self.assertEqual(response.headers["Location"], NEXT_URI) 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 + from accounting.models import Currency, Account, JournalEntryLineItem + from accounting.report.utils.offset_matcher import OffsetMatcher + from accounting.template_globals import default_currency_code data: DifferentTestData = DifferentTestData(self.app, "editor") data.populate() account: Account | None line_item: JournalEntryLineItem | None matcher: OffsetMatcher - list_uri: str match_uri: str response: httpx.Response + with self.app.app_context(): + currency: Currency | None \ + = db.session.get(Currency, default_currency_code()) + assert currency is not None + # The receivables with self.app.app_context(): account = Account.find_by_code(Accounts.RECEIVABLE) assert account is not None - matcher = OffsetMatcher(account) + matcher = OffsetMatcher(currency, account, None) 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}, + self.assertEqual({x.id for x in matcher.unmatched}, {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}) @@ -164,24 +147,24 @@ class UnmatchedOffsetTestCase(unittest.TestCase): self.assertIsNotNone(line_item) self.assertIsNone(line_item.original_line_item_id) - list_uri = f"{PREFIX}/{Accounts.RECEIVABLE}" match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}" response = self.client.post(match_uri, - data={"csrf_token": self.csrf_token}) + data={"csrf_token": self.csrf_token, + "next": NEXT_URI}) self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers["Location"], list_uri) + self.assertEqual(response.headers["Location"], NEXT_URI) with self.app.app_context(): account = Account.find_by_code(Accounts.RECEIVABLE) assert account is not None - matcher = OffsetMatcher(account) + matcher = OffsetMatcher(currency, account, None) 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}, + self.assertEqual({x.id for x in matcher.unmatched}, {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) + self.assertEqual(len(matcher.matched_pairs), 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) @@ -196,11 +179,11 @@ class UnmatchedOffsetTestCase(unittest.TestCase): with self.app.app_context(): account = Account.find_by_code(Accounts.PAYABLE) assert account is not None - matcher = OffsetMatcher(account) + matcher = OffsetMatcher(currency, account, None) 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}, + self.assertEqual({x.id for x in matcher.unmatched}, {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}) @@ -214,24 +197,24 @@ class UnmatchedOffsetTestCase(unittest.TestCase): self.assertIsNotNone(line_item) self.assertIsNone(line_item.original_line_item_id) - list_uri = f"{PREFIX}/{Accounts.PAYABLE}" match_uri = f"{PREFIX}/{Accounts.PAYABLE}" response = self.client.post(match_uri, - data={"csrf_token": self.csrf_token}) + data={"csrf_token": self.csrf_token, + "next": NEXT_URI}) self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers["Location"], list_uri) + self.assertEqual(response.headers["Location"], NEXT_URI) with self.app.app_context(): account = Account.find_by_code(Accounts.PAYABLE) assert account is not None - matcher = OffsetMatcher(account) + matcher = OffsetMatcher(currency, account, None) 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}, + self.assertEqual({x.id for x in matcher.unmatched}, {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) + self.assertEqual(len(matcher.matched_pairs), 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) @@ -247,27 +230,32 @@ class UnmatchedOffsetTestCase(unittest.TestCase): :return: None. """ - from accounting.models import Account, JournalEntryLineItem - from accounting.utils.offset_matcher import OffsetMatcher + from accounting.models import Currency, Account, JournalEntryLineItem + from accounting.report.utils.offset_matcher import OffsetMatcher + from accounting.template_globals import default_currency_code data: SameTestData = SameTestData(self.app, "editor") data.populate() account: Account | None line_item: JournalEntryLineItem | None matcher: OffsetMatcher - list_uri: str match_uri: str response: httpx.Response + with self.app.app_context(): + currency: Currency | None \ + = db.session.get(Currency, default_currency_code()) + assert currency is not None + # The receivables with self.app.app_context(): account = Account.find_by_code(Accounts.RECEIVABLE) assert account is not None - matcher = OffsetMatcher(account) + matcher = OffsetMatcher(currency, account, None) 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}, + self.assertEqual({x.id for x in matcher.unmatched}, {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}) @@ -287,22 +275,22 @@ class UnmatchedOffsetTestCase(unittest.TestCase): self.assertIsNotNone(line_item.original_line_item_id) self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id) - list_uri = f"{PREFIX}/{Accounts.RECEIVABLE}" match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}" response = self.client.post(match_uri, - data={"csrf_token": self.csrf_token}) + data={"csrf_token": self.csrf_token, + "next": NEXT_URI}) self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers["Location"], list_uri) + self.assertEqual(response.headers["Location"], NEXT_URI) with self.app.app_context(): account = Account.find_by_code(Accounts.RECEIVABLE) assert account is not None - matcher = OffsetMatcher(account) + matcher = OffsetMatcher(currency, account, None) 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}, + self.assertEqual({x.id for x in matcher.unmatched}, {data.l_r_of1c.id, data.l_r_of5c.id}) - self.assertEqual(matcher.matches, 0) + self.assertEqual(len(matcher.matched_pairs), 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) @@ -328,12 +316,12 @@ class UnmatchedOffsetTestCase(unittest.TestCase): with self.app.app_context(): account = Account.find_by_code(Accounts.PAYABLE) assert account is not None - matcher = OffsetMatcher(account) + matcher = OffsetMatcher(currency, account, None) 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}, + self.assertEqual({x.id for x in matcher.unmatched}, {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}) @@ -353,22 +341,22 @@ class UnmatchedOffsetTestCase(unittest.TestCase): self.assertIsNotNone(line_item.original_line_item_id) self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id) - list_uri = f"{PREFIX}/{Accounts.PAYABLE}" match_uri = f"{PREFIX}/{Accounts.PAYABLE}" response = self.client.post(match_uri, - data={"csrf_token": self.csrf_token}) + data={"csrf_token": self.csrf_token, + "next": NEXT_URI}) self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers["Location"], list_uri) + self.assertEqual(response.headers["Location"], NEXT_URI) with self.app.app_context(): account = Account.find_by_code(Accounts.PAYABLE) assert account is not None - matcher = OffsetMatcher(account) + matcher = OffsetMatcher(currency, account, None) 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}, + self.assertEqual({x.id for x in matcher.unmatched}, {data.l_p_of1d.id, data.l_p_of5d.id}) - self.assertEqual(matcher.matches, 0) + self.assertEqual(len(matcher.matched_pairs), 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)