diff --git a/src/accounting/report/reports/unapplied.py b/src/accounting/report/reports/unapplied.py index c4dd78d..0b9a029 100644 --- a/src/accounting/report/reports/unapplied.py +++ b/src/accounting/report/reports/unapplied.py @@ -118,10 +118,14 @@ class PageParams(BasePageParams): :return: The account options. """ - return [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(None), + False)] + options.extend([OptionLink(str(x), + unapplied_url(x), + x.id == self.account.id) + for x in get_accounts_with_unapplied()]) + return options def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]: diff --git a/src/accounting/report/reports/unapplied_accounts.py b/src/accounting/report/reports/unapplied_accounts.py new file mode 100644 index 0000000..6da0e42 --- /dev/null +++ b/src/accounting/report/reports/unapplied_accounts.py @@ -0,0 +1,137 @@ +# The Mia! Accounting Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The accounts with unapplied original line items. + +""" +from datetime import date +from decimal import Decimal + +from flask import render_template, Response + +from accounting.locale import gettext +from accounting.models import Account +from accounting.report.utils.base_page_params import BasePageParams +from accounting.report.utils.base_report import BaseReport +from accounting.report.utils.csv_export import BaseCSVRow, csv_download +from accounting.report.utils.option_link import OptionLink +from accounting.report.utils.report_chooser import ReportChooser +from accounting.report.utils.report_type import ReportType +from accounting.report.utils.unapplied import get_accounts_with_unapplied +from accounting.report.utils.urls import unapplied_url + + +class CSVRow(BaseCSVRow): + """A row in the CSV.""" + + def __init__(self, account: str, count: int | str): + """Constructs a row in the CSV. + + :param account: The account. + :param count: The number of unapplied original line items. + """ + self.account: str = account + """The currency.""" + self.count: int | str = count + """The number of unapplied original line items.""" + + @property + def values(self) -> list[str | date | Decimal | None]: + """Returns the values of the row. + + :return: The values of the row. + """ + return [self.account, self.count] + + +class PageParams(BasePageParams): + """The HTML page parameters.""" + + def __init__(self, accounts: list[Account]): + """Constructs the HTML page parameters. + + :param accounts: The accounts. + """ + self.accounts: list[Account] = accounts + """The accounts.""" + + @property + def has_data(self) -> bool: + """Returns whether there is any data on the page. + + :return: True if there is any data, or False otherwise. + """ + return len(self.accounts) > 0 + + @property + def report_chooser(self) -> ReportChooser: + """Returns the report chooser. + + :return: The report chooser. + """ + return ReportChooser(ReportType.UNAPPLIED) + + @property + def account_options(self) -> list[OptionLink]: + """Returns the account options. + + :return: The account options. + """ + options: list[OptionLink] = [OptionLink(gettext("Accounts"), + unapplied_url(None), + True)] + options.extend([OptionLink(str(x), + unapplied_url(x), + False) + for x in self.accounts]) + return options + + +def get_csv_rows(accounts: list[Account]) -> list[CSVRow]: + """Composes and returns the CSV rows from the line items. + + :param accounts: The accounts. + :return: The CSV rows. + """ + rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))] + rows.extend([CSVRow(str(x).title(), x.count) + for x in accounts]) + return rows + + +class AccountsWithUnappliedOriginalLineItems(BaseReport): + """The accounts with unapplied original line items.""" + + def __init__(self): + """Constructs the outstanding balances.""" + self.__accounts: list[Account] = get_accounts_with_unapplied() + """The accounts.""" + + def csv(self) -> Response: + """Returns the report as CSV for download. + + :return: The response of the report for download. + """ + filename: str = f"unapplied-accounts.csv" + return csv_download(filename, get_csv_rows(self.__accounts)) + + def html(self) -> str: + """Composes and returns the report as HTML. + + :return: The report as HTML. + """ + return render_template("accounting/report/unapplied-accounts.html", + report=PageParams(accounts=self.__accounts)) diff --git a/src/accounting/report/utils/report_chooser.py b/src/accounting/report/utils/report_chooser.py index 8784217..c30329f 100644 --- a/src/accounting/report/utils/report_chooser.py +++ b/src/accounting/report/utils/report_chooser.py @@ -33,7 +33,6 @@ from accounting.template_globals import default_currency_code from accounting.utils.current_account import CurrentAccount from .option_link import OptionLink from .report_type import ReportType -from .unapplied import get_accounts_with_unapplied from .urls import journal_url, ledger_url, income_expenses_url, \ trial_balance_url, income_statement_url, balance_sheet_url, unapplied_url @@ -161,7 +160,10 @@ class ReportChooser: """ account: Account = self.__account if not account.is_need_offset: - account = get_accounts_with_unapplied()[0] + return OptionLink(gettext("Unapplied Original Line Items"), + unapplied_url(None), + self.__active_report == ReportType.UNAPPLIED, + fa_icon="fa-solid fa-link-slash") return OptionLink(gettext("Unapplied Original Line Items"), unapplied_url(account), self.__active_report == ReportType.UNAPPLIED, diff --git a/src/accounting/report/utils/unapplied.py b/src/accounting/report/utils/unapplied.py index 3c73402..7cbb6cf 100644 --- a/src/accounting/report/utils/unapplied.py +++ b/src/accounting/report/utils/unapplied.py @@ -19,6 +19,7 @@ """ import sqlalchemy as sa +from accounting import db from accounting.models import Account, JournalEntryLineItem from accounting.utils.cast import be from accounting.utils.offset_alias import offset_alias @@ -50,12 +51,17 @@ def get_accounts_with_unapplied() -> list[Account]: .group_by(JournalEntryLineItem.id)\ .having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0)) - count_func: sa.Function \ - = sa.func.count(JournalEntryLineItem.id) - select: sa.Select = sa.select(Account.id)\ + count_func: sa.Label \ + = sa.func.count(JournalEntryLineItem.id).label("count") + select: sa.Select = sa.select(Account.id, count_func)\ .join(JournalEntryLineItem, isouter=True)\ .filter(JournalEntryLineItem.id.in_(select_unapplied))\ .group_by(Account.id)\ .having(count_func > 0) - return Account.query.filter(Account.id.in_(select))\ + counts: dict[int, int] \ + = {x.id: x.count for x in db.session.execute(select)} + accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\ .order_by(Account.base_code, Account.no).all() + for account in accounts: + account.count = counts[account.id] + return accounts diff --git a/src/accounting/report/utils/urls.py b/src/accounting/report/utils/urls.py index 5e56241..7271adb 100644 --- a/src/accounting/report/utils/urls.py +++ b/src/accounting/report/utils/urls.py @@ -118,10 +118,13 @@ def balance_sheet_url(currency: Currency, period: Period) -> str: currency=currency, period=period) -def unapplied_url(account: Account) -> str: +def unapplied_url(account: Account | None) -> str: """Returns the URL of the unapplied original line items. - :param account: The account. + :param account: The account, or None to list the accounts with unapplied + original line items. :return: The URL of the unapplied original line items. """ + if account is None: + return url_for("accounting-report.unapplied-default") return url_for("accounting-report.unapplied", account=account) diff --git a/src/accounting/report/views.py b/src/accounting/report/views.py index 08ca2ae..ee14888 100644 --- a/src/accounting/report/views.py +++ b/src/accounting/report/views.py @@ -29,6 +29,7 @@ from accounting.utils.permission import has_permission, can_view from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ IncomeStatement, BalanceSheet, Search from .reports.unapplied import UnappliedOriginalLineItems +from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems from .template_filters import format_amount bp: Blueprint = Blueprint("accounting-report", __name__) @@ -286,6 +287,20 @@ def __get_balance_sheet(currency: Currency, period: Period) \ return report.html() +@bp.get("unapplied", endpoint="unapplied-default") +@has_permission(can_view) +def get_default_unapplied() -> str | Response: + """Returns the accounts with unapplied original line items. + + :return: The accounts with unapplied original line items. + """ + report: AccountsWithUnappliedOriginalLineItems \ + = AccountsWithUnappliedOriginalLineItems() + if "as" in request.args and request.args["as"] == "csv": + return report.csv() + return report.html() + + @bp.get("unapplied/", endpoint="unapplied") @has_permission(can_view) def get_unapplied(account: Account) -> str | Response: diff --git a/src/accounting/static/css/style.css b/src/accounting/static/css/style.css index db3c40c..01d0e6e 100644 --- a/src/accounting/static/css/style.css +++ b/src/accounting/static/css/style.css @@ -312,6 +312,13 @@ a.accounting-report-table-row { .accounting-unapplied-table .accounting-report-table-row { grid-template-columns: 1fr 1fr 5fr 1fr 1fr; } +.accounting-unapplied-account-table .accounting-report-table-row { + display: flex; + justify-content: space-between; +} +.accounting-unapplied-account-table .accounting-report-table-header .accounting-report-table-row { + display: block; +} /* The accounting report */ .accounting-mobile-journal-credit { diff --git a/src/accounting/templates/accounting/report/unapplied-accounts.html b/src/accounting/templates/accounting/report/unapplied-accounts.html new file mode 100644 index 0000000..0118547 --- /dev/null +++ b/src/accounting/templates/accounting/report/unapplied-accounts.html @@ -0,0 +1,64 @@ +{# +The Mia! Accounting Project +unapplied-accounts.html: The account list with unapplied original line items + + Copyright (c) 2023 imacat. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Author: imacat@mail.imacat.idv.tw (imacat) +First written: 2023/4/8 +#} +{% extends "accounting/base.html" %} + +{% block header %}{% block title %}{{ A_("Accounts with Unapplied Original Line Items") }}{% endblock %}{% endblock %} + +{% block content %} + +
+ {% with use_account_chooser = true %} + {% include "accounting/report/include/toolbar-buttons.html" %} + {% endwith %} +
+ +{% include "accounting/report/include/add-journal-entry-material-fab.html" %} + +{% include "accounting/report/include/search-modal.html" %} + +{% if report.has_data %} +
+
+

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

+
+ + +
+{% else %} +

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

+{% endif %} + +{% endblock %}