diff --git a/src/accounting/report/reports/unapplied.py b/src/accounting/report/reports/unapplied.py new file mode 100644 index 0000000..2b00adb --- /dev/null +++ b/src/accounting/report/reports/unapplied.py @@ -0,0 +1,217 @@ +# The Mia! Accounting Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The unapplied original line items. + +""" +from datetime import date +from decimal import Decimal + +import sqlalchemy as sa +from flask import render_template, Response +from sqlalchemy.orm import selectinload + +from accounting import db +from accounting.journal_entry.utils.offset_alias import offset_alias +from accounting.locale import gettext +from accounting.models import Account, JournalEntry, \ + JournalEntryLineItem +from accounting.report.utils.base_page_params import BasePageParams +from accounting.report.utils.base_report import BaseReport +from accounting.report.utils.csv_export import BaseCSVRow, csv_download +from accounting.report.utils.option_link import OptionLink +from accounting.report.utils.report_chooser import ReportChooser +from accounting.report.utils.report_type import ReportType +from accounting.report.utils.unapplied import get_accounts_with_unapplied +from accounting.report.utils.urls import unapplied_url +from accounting.utils.cast import be +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, amount: str | Decimal, + net_balance: str | Decimal): + """Constructs a row in the CSV. + + :param journal_entry_date: The journal entry date. + :param currency: The currency. + :param description: The description. + :param amount: The amount. + :param net_balance: The net balance. + """ + self.date: str | date = journal_entry_date + """The date.""" + self.currency: str = currency + """The currency.""" + self.description: str | None = description + """The description.""" + self.amount: str | Decimal = amount + """The amount.""" + self.net_balance: str | Decimal = net_balance + """The net balance.""" + + @property + def values(self) -> list[str | date | Decimal | None]: + """Returns the values of the row. + + :return: The values of the row. + """ + return [self.date, self.currency, self.description, self.amount, + self.net_balance] + + +class PageParams(BasePageParams): + """The HTML page parameters.""" + + def __init__(self, account: Account, + pagination: Pagination[JournalEntryLineItem], + line_items: list[JournalEntryLineItem]): + """Constructs the HTML page parameters. + + :param account: The account. + :param pagination: The pagination. + :param line_items: The line items. + """ + self.account: Account = account + """The account.""" + self.pagination: Pagination[JournalEntryLineItem] = pagination + """The pagination.""" + self.line_items: list[JournalEntryLineItem] = line_items + """The line items.""" + + @property + def has_data(self) -> bool: + """Returns whether there is any data on the page. + + :return: True if there is any data, or False otherwise. + """ + return len(self.line_items) > 0 + + @property + def report_chooser(self) -> ReportChooser: + """Returns the report chooser. + + :return: The report chooser. + """ + return ReportChooser(ReportType.UNAPPLIED, + account=self.account) + + @property + def account_options(self) -> list[OptionLink]: + """Returns the account options. + + :return: The account options. + """ + return [OptionLink(str(x), + unapplied_url(x), + x.id == self.account.id) + for x in get_accounts_with_unapplied()] + + +def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]: + """Composes and returns the CSV rows from the line items. + + :param line_items: The line items. + :return: The CSV rows. + """ + rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"), + gettext("Description"), gettext("Amount"), + gettext("Net Balance"))] + rows.extend([CSVRow(x.journal_entry.date, x.currency.code, + x.description, x.amount, x.net_balance) + for x in line_items]) + return rows + + +class UnappliedOriginalLineItems(BaseReport): + """The unapplied original line items.""" + + def __init__(self, account: Account): + """Constructs the unapplied original line items. + + :param account: The account. + """ + self.__account: Account = account + """The account.""" + self.__line_items: list[JournalEntryLineItem] \ + = self.__query_line_items() + """The line items.""" + + def __query_line_items(self) -> list[JournalEntryLineItem]: + """Queries and returns the line items. + + :return: The line items. + """ + offset: sa.Alias = offset_alias() + net_balance: sa.Label \ + = (JournalEntryLineItem.amount + + sa.func.sum(sa.case( + (be(offset.c.is_debit == JournalEntryLineItem.is_debit), + offset.c.amount), + else_=-offset.c.amount))).label("net_balance") + select_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 csv(self) -> Response: + """Returns the report as CSV for download. + + :return: The response of the report for download. + """ + filename: str = f"unapplied-{self.__account.code}.csv" + return csv_download(filename, get_csv_rows(self.__line_items)) + + def html(self) -> str: + """Composes and returns the report as HTML. + + :return: The report as HTML. + """ + pagination: Pagination[JournalEntryLineItem] \ + = Pagination[JournalEntryLineItem](self.__line_items, + is_reversed=True) + params: PageParams = PageParams(account=self.__account, + pagination=pagination, + line_items=pagination.list) + return render_template("accounting/report/unapplied.html", + report=params) diff --git a/src/accounting/report/utils/report_chooser.py b/src/accounting/report/utils/report_chooser.py index 6c5baa8..8784217 100644 --- a/src/accounting/report/utils/report_chooser.py +++ b/src/accounting/report/utils/report_chooser.py @@ -33,8 +33,9 @@ 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 + trial_balance_url, income_statement_url, balance_sheet_url, unapplied_url class ReportChooser: @@ -74,6 +75,7 @@ class ReportChooser: self.__reports.append(self.__trial_balance) self.__reports.append(self.__income_statement) self.__reports.append(self.__balance_sheet) + self.__reports.append(self.__unapplied) for report in self.__reports: if report.is_active: self.current_report = report.title @@ -151,6 +153,20 @@ class ReportChooser: self.__active_report == ReportType.BALANCE_SHEET, fa_icon="fa-solid fa-scale-balanced") + @property + def __unapplied(self) -> OptionLink: + """Returns the unapplied original line items. + + :return: The unapplied original line items. + """ + account: Account = self.__account + if not account.is_need_offset: + account = get_accounts_with_unapplied()[0] + return OptionLink(gettext("Unapplied Original Line Items"), + unapplied_url(account), + self.__active_report == ReportType.UNAPPLIED, + fa_icon="fa-solid fa-link-slash") + def __iter__(self) -> t.Iterator[OptionLink]: """Returns the iteration of the reports. diff --git a/src/accounting/report/utils/report_type.py b/src/accounting/report/utils/report_type.py index ea0053b..35c054e 100644 --- a/src/accounting/report/utils/report_type.py +++ b/src/accounting/report/utils/report_type.py @@ -34,5 +34,7 @@ class ReportType(Enum): """The income statement.""" BALANCE_SHEET: str = "balance-sheet" """The balance sheet.""" + UNAPPLIED: str = "unapplied" + """The unapplied original line items.""" SEARCH: str = "search" """The search.""" diff --git a/src/accounting/report/utils/unapplied.py b/src/accounting/report/utils/unapplied.py new file mode 100644 index 0000000..ca5c160 --- /dev/null +++ b/src/accounting/report/utils/unapplied.py @@ -0,0 +1,61 @@ +# The Mia! Accounting Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The unapplied original line item utilities. + +""" +import sqlalchemy as sa + +from accounting.journal_entry.utils.offset_alias import offset_alias +from accounting.models import Account, JournalEntryLineItem +from accounting.utils.cast import be + + +def get_accounts_with_unapplied() -> list[Account]: + """Returns the accounts with unapplied original line items. + + :return: The accounts with unapplied original line items. + """ + offset: sa.Alias = offset_alias() + net_balance: sa.Label \ + = (JournalEntryLineItem.amount + + sa.func.sum(sa.case( + (be(offset.c.is_debit == JournalEntryLineItem.is_debit), + offset.c.amount), + else_=-offset.c.amount))).label("net_balance") + select_unapplied: sa.Select \ + = sa.select(JournalEntryLineItem.id)\ + .join(Account)\ + .join(offset, be(JournalEntryLineItem.id + == offset.c.original_line_item_id), + isouter=True)\ + .filter(Account.is_need_offset, + sa.or_(sa.and_(Account.base_code.startswith("2"), + sa.not_(JournalEntryLineItem.is_debit)), + sa.and_(Account.base_code.startswith("1"), + JournalEntryLineItem.is_debit)))\ + .group_by(JournalEntryLineItem.id)\ + .having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0)) + + count_func: sa.Function \ + = sa.func.count(JournalEntryLineItem.id) + select: sa.Select = sa.select(Account.id)\ + .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))\ + .order_by(Account.base_code, Account.no).all() diff --git a/src/accounting/report/utils/urls.py b/src/accounting/report/utils/urls.py index 9f2e683..5e56241 100644 --- a/src/accounting/report/utils/urls.py +++ b/src/accounting/report/utils/urls.py @@ -116,3 +116,12 @@ def balance_sheet_url(currency: Currency, period: Period) -> str: currency=currency) return url_for("accounting-report.balance-sheet", currency=currency, period=period) + + +def unapplied_url(account: Account) -> str: + """Returns the URL of the unapplied original line items. + + :param account: The account. + :return: The URL of the unapplied original line items. + """ + return url_for("accounting-report.unapplied", account=account) diff --git a/src/accounting/report/views.py b/src/accounting/report/views.py index 7e4a579..88d0e14 100644 --- a/src/accounting/report/views.py +++ b/src/accounting/report/views.py @@ -28,6 +28,7 @@ from accounting.utils.options import options from accounting.utils.permission import has_permission, can_view from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ IncomeStatement, BalanceSheet, Search +from .reports.unapplied import UnappliedOriginalLineItems from .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") +@has_permission(can_view) +def get_unapplied(account: Account) -> str | Response: + """Returns the unapplied original line items. + + :param account: The Account. + :return: The unapplied original line items. + """ + report: UnappliedOriginalLineItems = UnappliedOriginalLineItems(account) + if "as" in request.args and request.args["as"] == "csv": + return report.csv() + return report.html() + + @bp.get("search", endpoint="search") @has_permission(can_view) def search() -> str | Response: diff --git a/src/accounting/static/css/style.css b/src/accounting/static/css/style.css index 20ce9e8..db3c40c 100644 --- a/src/accounting/static/css/style.css +++ b/src/accounting/static/css/style.css @@ -309,6 +309,9 @@ a.accounting-report-table-row { .accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount { font-style: italic; } +.accounting-unapplied-table .accounting-report-table-row { + grid-template-columns: 1fr 1fr 5fr 1fr 1fr; +} /* The accounting report */ .accounting-mobile-journal-credit { diff --git a/src/accounting/templates/accounting/report/unapplied.html b/src/accounting/templates/accounting/report/unapplied.html new file mode 100644 index 0000000..b7d9a42 --- /dev/null +++ b/src/accounting/templates/accounting/report/unapplied.html @@ -0,0 +1,96 @@ +{# +The Mia! Accounting Project +unapplied.html: The unapplied original line items + + Copyright (c) 2023 imacat. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Author: imacat@mail.imacat.idv.tw (imacat) +First written: 2023/4/7 +#} +{% extends "accounting/base.html" %} + +{% block accounting_scripts %} + +{% endblock %} + +{% block header %}{% block title %}{{ A_("Unapplied Original Line Items of %(account)s", account=report.account.title|title) }}{% 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 %} + {% with pagination = report.pagination %} + {% include "accounting/include/pagination.html" %} + {% endwith %} + +
+
+
+
{{ A_("Date") }}
+
{{ A_("Currency") }}
+
{{ A_("Description") }}
+
{{ A_("Amount") }}
+
{{ A_("Net Balance") }}
+
+
+ +
+ +
+ {% for line_item in report.line_items %} + +
+
+ {{ 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 }}
+ {% endif %} +
+ +
+ {{ line_item.amount|accounting_format_amount }} + {{ line_item.net_balance|accounting_format_amount }} +
+
+ {% endfor %} +
+{% else %} +

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

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