From cca43c68a64b28f86cfcd9c1d8aba0f38d02311d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BE=9D=E7=91=AA=E8=B2=93?= Date: Mon, 6 Mar 2023 00:20:11 +0800 Subject: [PATCH] Added trial balance. --- src/accounting/report/period_choosers.py | 19 +++ src/accounting/report/report_chooser.py | 15 ++ src/accounting/report/report_rows.py | 37 +++++ src/accounting/report/report_type.py | 2 + src/accounting/report/reports.py | 137 +++++++++++++++- src/accounting/report/views.py | 42 ++++- src/accounting/static/css/style.css | 35 ++++ .../accounting/report/trial-balance.html | 155 ++++++++++++++++++ 8 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 src/accounting/templates/accounting/report/trial-balance.html diff --git a/src/accounting/report/period_choosers.py b/src/accounting/report/period_choosers.py index 561b117..3158b10 100644 --- a/src/accounting/report/period_choosers.py +++ b/src/accounting/report/period_choosers.py @@ -159,3 +159,22 @@ class IncomeExpensesPeriodChooser(PeriodChooser): return url_for("accounting.report.income-expenses", currency=self.currency, account=self.account, period=period) + + +class TrialBalancePeriodChooser(PeriodChooser): + """The trial balance period chooser.""" + + def __init__(self, currency: Currency): + """Constructs the trial balance period chooser.""" + self.currency: Currency = currency + """The currency.""" + first: Transaction | None \ + = Transaction.query.order_by(Transaction.date).first() + super().__init__(None if first is None else first.date) + + def _url_for(self, period: Period) -> str: + if period.is_default: + return url_for("accounting.report.trial-balance-default", + currency=self.currency) + return url_for("accounting.report.trial-balance", + currency=self.currency, period=period) diff --git a/src/accounting/report/report_chooser.py b/src/accounting/report/report_chooser.py index 6bc9ab2..c9a744f 100644 --- a/src/accounting/report/report_chooser.py +++ b/src/accounting/report/report_chooser.py @@ -67,6 +67,7 @@ class ReportChooser: self.__reports.append(self.__journal) self.__reports.append(self.__ledger) self.__reports.append(self.__income_expenses) + self.__reports.append(self.__trial_balance) for report in self.__reports: if report.is_active: self.current_report = report.title @@ -113,6 +114,20 @@ class ReportChooser: return OptionLink(gettext("Income and Expenses"), url, self.__active_report == ReportType.INCOME_EXPENSES) + @property + def __trial_balance(self) -> OptionLink: + """Returns the trial balance. + + :return: The trial balance. + """ + url: str = url_for("accounting.report.trial-balance-default", + currency=self.__currency) \ + if self.__period.is_default \ + else url_for("accounting.report.trial-balance", + currency=self.__currency, period=self.__period) + return OptionLink(gettext("Trial Balance"), url, + self.__active_report == ReportType.TRIAL_BALANCE) + def __iter__(self) -> t.Iterator[OptionLink]: """Returns the iteration of the reports. diff --git a/src/accounting/report/report_rows.py b/src/accounting/report/report_rows.py index fee5950..36677be 100644 --- a/src/accounting/report/report_rows.py +++ b/src/accounting/report/report_rows.py @@ -22,6 +22,7 @@ from abc import ABC, abstractmethod from datetime import date from decimal import Decimal +from accounting.locale import gettext from accounting.models import JournalEntry, Transaction, Account, Currency @@ -169,3 +170,39 @@ class IncomeExpensesRow(ReportRow): "Expense": self.expense, "Balance": self.balance, "Note": self.note} + + +class TrialBalanceRow(ReportRow): + """A row in the trial balance.""" + + def __init__(self, account: Account | None = None, + balance: Decimal | None = None): + """Constructs the row in the trial balance. + + :param account: The account. + :param balance: The balance. + """ + self.is_total: bool = False + """Whether this is the total row.""" + self.account: Account | None = account + """The date.""" + self.debit: Decimal | None = None + """The debit amount.""" + self.credit: Decimal | None = None + """The credit amount.""" + self.url: str | None = None + """The URL.""" + if balance is not None: + if balance > 0: + self.debit = balance + if balance < 0: + self.credit = -balance + + def as_dict(self) -> dict[str, t.Any]: + if self.is_total: + return {"Account": gettext("Total"), + "Debit": self.debit, + "Credit": self.credit} + return {"Account": str(self.account).title(), + "Debit": self.debit, + "Credit": self.credit} diff --git a/src/accounting/report/report_type.py b/src/accounting/report/report_type.py index e10c68d..873af81 100644 --- a/src/accounting/report/report_type.py +++ b/src/accounting/report/report_type.py @@ -28,3 +28,5 @@ class ReportType(Enum): """The ledger.""" INCOME_EXPENSES: str = "income-expenses" """The income and expenses.""" + TRIAL_BALANCE: str = "trial-balance" + """The trial balance.""" diff --git a/src/accounting/report/reports.py b/src/accounting/report/reports.py index 563fc8c..6b51ba9 100644 --- a/src/accounting/report/reports.py +++ b/src/accounting/report/reports.py @@ -36,9 +36,10 @@ from accounting.utils.txn_types import TransactionType from .option_link import OptionLink from .period import Period from .period_choosers import PeriodChooser, JournalPeriodChooser, \ - LedgerPeriodChooser, IncomeExpensesPeriodChooser + LedgerPeriodChooser, IncomeExpensesPeriodChooser, TrialBalancePeriodChooser from .report_chooser import ReportChooser -from .report_rows import ReportRow, JournalRow, LedgerRow, IncomeExpensesRow +from .report_rows import ReportRow, JournalRow, LedgerRow, IncomeExpensesRow, \ + TrialBalanceRow from .report_type import ReportType T = t.TypeVar("T", bound=ReportRow) @@ -629,3 +630,135 @@ class IncomeExpenses(JournalEntryReport[IncomeExpensesRow]): return [OptionLink(str(x), get_url(x), x.id == self.account.id) for x in Account.query.filter(Account.id.in_(in_use)) .order_by(Account.base_code, Account.no).all()] + + +class TrialBalance(JournalEntryReport[TrialBalanceRow]): + """The trial balance.""" + + def __init__(self, currency: Currency, period: Period): + """Constructs a trial balance. + + :param currency: The currency. + :param period: The period. + """ + super().__init__(period) + self.currency: Currency = currency + """The currency.""" + self.total_row: TrialBalanceRow | None = None + """The total row.""" + + def get_rows(self) -> list[TrialBalanceRow]: + rows: list[TrialBalanceRow] = self.__query_balances() + self.__populate_url(rows) + total_row: TrialBalanceRow = self.__get_total_row(rows) + rows.append(total_row) + return rows + + def __query_balances(self) -> list[TrialBalanceRow]: + """Queries and returns the balances. + + :return: The balances. + """ + conditions: list[sa.BinaryExpression] \ + = [JournalEntry.currency_code == self.currency.code] + if self.period.start is not None: + conditions.append(Transaction.date >= self.period.start) + if self.period.end is not None: + conditions.append(Transaction.date <= self.period.end) + balance_func: sa.Function = sa.func.sum(sa.case( + (JournalEntry.is_debit, JournalEntry.amount), + else_=-JournalEntry.amount)).label("balance") + select_trial_balance: sa.Select \ + = sa.select(JournalEntry.account_id, balance_func)\ + .join(Transaction)\ + .filter(*conditions)\ + .group_by(JournalEntry.account_id) + balances: list[sa.Row] = db.session.execute(select_trial_balance).all() + accounts: dict[int, Account] \ + = {x.id: x for x in Account.query + .filter(Account.id.in_([x.account_id for x in balances])).all()} + return [TrialBalanceRow(accounts[x.account_id], x.balance) + for x in balances] + + def __populate_url(self, rows: list[TrialBalanceRow]) -> None: + """Populates the URL of the trial balance rows. + + :param rows: The trial balance rows. + :return: None. + """ + def get_url(account: Account) -> str: + """Returns the ledger URL of an account. + + :param account: The account. + :return: The ledger URL of the account. + """ + if self.period.is_default: + return url_for("accounting.report.ledger-default", + currency=self.currency, account=account) + return url_for("accounting.report.ledger", + currency=self.currency, account=account, + period=self.period) + + for row in rows: + row.url = get_url(row.account) + + @staticmethod + def __get_total_row(rows: list[TrialBalanceRow]) -> TrialBalanceRow: + """Composes the total row. + + :param rows: The rows. + :return: None. + """ + row: TrialBalanceRow = TrialBalanceRow() + row.is_total = True + row.debit = sum([x.debit for x in rows if x.debit is not None]) + row.credit = sum([x.credit for x in rows if x.credit is not None]) + return row + + def populate_rows(self, rows: list[T]) -> None: + pass + + @property + def csv_field_names(self) -> list[str]: + return ["Account", "Debit", "Credit"] + + @property + def csv_filename(self) -> str: + return f"trial-balance-{self.period.spec}.csv" + + @property + def period_chooser(self) -> PeriodChooser: + return TrialBalancePeriodChooser(self.currency) + + @property + def report_chooser(self) -> ReportChooser: + return ReportChooser(ReportType.TRIAL_BALANCE, period=self.period) + + def as_html_page(self) -> str: + pagination: Pagination = Pagination[TrialBalanceRow](self.rows) + rows: list[TrialBalanceRow] = pagination.list + if len(rows) > 0 and rows[-1].is_total: + self.total_row = rows[-1] + rows = rows[:-1] + return render_template("accounting/report/trial-balance.html", + list=rows, pagination=pagination, report=self) + + @property + def currency_options(self) -> list[OptionLink]: + """Returns the currency options. + + :return: The currency options. + """ + def get_url(currency: Currency): + if self.period.is_default: + return url_for("accounting.report.trial-balance-default", + currency=currency) + return url_for("accounting.report.trial-balance", + currency=currency, period=self.period) + + in_use: set[str] = set(db.session.scalars( + sa.select(JournalEntry.currency_code) + .group_by(JournalEntry.currency_code)).all()) + return [OptionLink(str(x), get_url(x), x.code == self.currency.code) + for x in Currency.query.filter(Currency.code.in_(in_use)) + .order_by(Currency.code).all()] diff --git a/src/accounting/report/views.py b/src/accounting/report/views.py index 1a31c27..b10b585 100644 --- a/src/accounting/report/views.py +++ b/src/accounting/report/views.py @@ -22,7 +22,7 @@ from flask import Blueprint, request, Response from accounting.models import Currency, Account from accounting.utils.permission import has_permission, can_view from .period import Period -from .reports import Journal, Ledger, IncomeExpenses +from .reports import Journal, Ledger, IncomeExpenses, TrialBalance bp: Blueprint = Blueprint("report", __name__) """The view blueprint for the reports.""" @@ -148,3 +148,43 @@ def __get_income_expenses_list(currency: Currency, account: Account, if "as" in request.args and request.args["as"] == "csv": return report.as_csv_download() return report.as_html_page() + + +@bp.get("trial-balance/", + endpoint="trial-balance-default") +@has_permission(can_view) +def get_default_trial_balance_list(currency: Currency) -> str | Response: + """Returns the trial balance in the default period. + + :param currency: The currency. + :return: The trial balance in the default period. + """ + return __get_trial_balance_list(currency, Period.get_instance()) + + +@bp.get("trial-balance//", + endpoint="trial-balance") +@has_permission(can_view) +def get_trial_balance_list(currency: Currency, period: Period) \ + -> str | Response: + """Returns the trial balance. + + :param currency: The currency. + :param period: The period. + :return: The trial balance in the period. + """ + return __get_trial_balance_list(currency, period) + + +def __get_trial_balance_list(currency: Currency, period: Period) \ + -> str | Response: + """Returns the trial balance. + + :param currency: The currency. + :param period: The period. + :return: The trial balance in the period. + """ + report: TrialBalance = TrialBalance(currency, period) + if "as" in request.args and request.args["as"] == "csv": + return report.as_csv_download() + return report.as_html_page() diff --git a/src/accounting/static/css/style.css b/src/accounting/static/css/style.css index 8b756ee..a970503 100644 --- a/src/accounting/static/css/style.css +++ b/src/accounting/static/css/style.css @@ -127,6 +127,41 @@ td.accounting-amount { font-weight: bolder; font-style: italic; } +.accounting-report-card { + padding: 2em 1.5em; + margin: 1em; + background-color: #F8F9FA; + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); +} +.accounting-report-card h2 { + border-bottom: thick double slategray; +} +.accounting-report-row { + border: none; +} +a.accounting-report-row:hover { + color: inherit; +} +.accounting-trial-balance-row.accounting-trial-balance-header { + border-bottom: thick double slategray; + font-weight: bolder; + font-size: 1.2rem; +} +.accounting-trial-balance-row.accounting-trial-balance-header .accounting-amount { + font-style: normal; +} +.accounting-trial-balance-row > div { + width: 50%; +} +.accounting-trial-balance-row .accounting-amount { + width: 50%; + font-style: italic; +} +.accounting-trial-balance-row.accounting-trial-balance-total { + border-top: thick double slategray; + font-weight: bolder; + font-size: 1.2rem; +} /* The Material Design text field (floating form control in Bootstrap) */ .accounting-material-text-field { diff --git a/src/accounting/templates/accounting/report/trial-balance.html b/src/accounting/templates/accounting/report/trial-balance.html new file mode 100644 index 0000000..811773f --- /dev/null +++ b/src/accounting/templates/accounting/report/trial-balance.html @@ -0,0 +1,155 @@ +{# +The Mia! Accounting Flask Project +trial-balance.html: The trial balance + + 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/3/5 +#} +{% extends "accounting/base.html" %} + +{% block accounting_scripts %} + + +{% endblock %} + +{% block header %}{% block title %}{{ _("Trial Balance of %(currency)s %(period)s", currency=report.currency, period=report.period.desc|title) }}{% endblock %}{% endblock %} + +{% block content %} + +
+ {% if accounting_can_edit() %} + + {% endif %} + {% with report_chooser = report.report_chooser %} + {% include "accounting/report/include/report-chooser.html" %} + {% endwith %} +
+ + +
+ + + + {{ A_("Download") }} + +
+ +{% with txn_types = report.txn_types %} + {% include "accounting/include/add-txn-material-fab.html" %} +{% endwith %} + +
+ {% with report_chooser = report.report_chooser %} + {% include "accounting/report/include/report-chooser.html" %} + {% endwith %} +
+ + +
+ +
+ +{% with period = report.period, period_chooser = report.period_chooser %} + {% include "accounting/report/include/period-chooser.html" %} +{% endwith %} + +{% if list %} +
+
+

{{ _("Trial Balance of %(currency)s %(period)s", currency=report.currency, period=report.period.desc|title) }}

+
+ +
+
+
{{ A_("Account") }}
+
+
{{ A_("Debit") }}
+
{{ A_("Credit") }}
+
+
+ {% for item in list %} + +
{{ item.account|title }}
+
+
{{ "" if item.debit is none else item.debit|accounting_format_amount }}
+
{{ "" if item.credit is none else item.credit|accounting_format_amount }}
+
+
+ {% endfor %} +
+
{{ A_("Total") }}
+
+
{{ report.total_row.debit|accounting_format_amount }}
+
{{ report.total_row.credit|accounting_format_amount }}
+
+
+
+
+{% else %} +

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

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