Added trial balance.

This commit is contained in:
依瑪貓 2023-03-06 00:20:11 +08:00
parent 480e2d2d8f
commit cca43c68a6
8 changed files with 439 additions and 3 deletions

View File

@ -159,3 +159,22 @@ class IncomeExpensesPeriodChooser(PeriodChooser):
return url_for("accounting.report.income-expenses", return url_for("accounting.report.income-expenses",
currency=self.currency, account=self.account, currency=self.currency, account=self.account,
period=period) 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)

View File

@ -67,6 +67,7 @@ class ReportChooser:
self.__reports.append(self.__journal) self.__reports.append(self.__journal)
self.__reports.append(self.__ledger) self.__reports.append(self.__ledger)
self.__reports.append(self.__income_expenses) self.__reports.append(self.__income_expenses)
self.__reports.append(self.__trial_balance)
for report in self.__reports: for report in self.__reports:
if report.is_active: if report.is_active:
self.current_report = report.title self.current_report = report.title
@ -113,6 +114,20 @@ class ReportChooser:
return OptionLink(gettext("Income and Expenses"), url, return OptionLink(gettext("Income and Expenses"), url,
self.__active_report == ReportType.INCOME_EXPENSES) 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]: def __iter__(self) -> t.Iterator[OptionLink]:
"""Returns the iteration of the reports. """Returns the iteration of the reports.

View File

@ -22,6 +22,7 @@ from abc import ABC, abstractmethod
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from accounting.locale import gettext
from accounting.models import JournalEntry, Transaction, Account, Currency from accounting.models import JournalEntry, Transaction, Account, Currency
@ -169,3 +170,39 @@ class IncomeExpensesRow(ReportRow):
"Expense": self.expense, "Expense": self.expense,
"Balance": self.balance, "Balance": self.balance,
"Note": self.note} "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}

View File

@ -28,3 +28,5 @@ class ReportType(Enum):
"""The ledger.""" """The ledger."""
INCOME_EXPENSES: str = "income-expenses" INCOME_EXPENSES: str = "income-expenses"
"""The income and expenses.""" """The income and expenses."""
TRIAL_BALANCE: str = "trial-balance"
"""The trial balance."""

View File

@ -36,9 +36,10 @@ from accounting.utils.txn_types import TransactionType
from .option_link import OptionLink from .option_link import OptionLink
from .period import Period from .period import Period
from .period_choosers import PeriodChooser, JournalPeriodChooser, \ from .period_choosers import PeriodChooser, JournalPeriodChooser, \
LedgerPeriodChooser, IncomeExpensesPeriodChooser LedgerPeriodChooser, IncomeExpensesPeriodChooser, TrialBalancePeriodChooser
from .report_chooser import ReportChooser 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 from .report_type import ReportType
T = t.TypeVar("T", bound=ReportRow) 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) return [OptionLink(str(x), get_url(x), x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use)) for x in Account.query.filter(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no).all()] .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()]

View File

@ -22,7 +22,7 @@ from flask import Blueprint, request, Response
from accounting.models import Currency, Account from accounting.models import Currency, Account
from accounting.utils.permission import has_permission, can_view from accounting.utils.permission import has_permission, can_view
from .period import Period from .period import Period
from .reports import Journal, Ledger, IncomeExpenses from .reports import Journal, Ledger, IncomeExpenses, TrialBalance
bp: Blueprint = Blueprint("report", __name__) bp: Blueprint = Blueprint("report", __name__)
"""The view blueprint for the reports.""" """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": if "as" in request.args and request.args["as"] == "csv":
return report.as_csv_download() return report.as_csv_download()
return report.as_html_page() return report.as_html_page()
@bp.get("trial-balance/<currency:currency>",
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/<currency:currency>/<period:period>",
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()

View File

@ -127,6 +127,41 @@ td.accounting-amount {
font-weight: bolder; font-weight: bolder;
font-style: italic; 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) */ /* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field { .accounting-material-text-field {

View File

@ -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 %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ _("Trial Balance of %(currency)s %(period)s", currency=report.currency, period=report.period.desc|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
{{ report.currency.name|title }}
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
<i class="fa-solid fa-calendar-day"></i>
{{ report.period.desc|title }}
</button>
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
{{ A_("Download") }}
</a>
</div>
{% with txn_types = report.txn_types %}
{% include "accounting/include/add-txn-material-fab.html" %}
{% endwith %}
<div class="btn-group btn-actions mb-3 d-md-none">
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-money-bill-wave"></i>
</button>
<ul class="dropdown-menu">
{% for currency in report.currency_options %}
<li>
<a class="dropdown-item {% if currency.is_active %} active {% endif %}" href="{{ currency.url }}">
{{ currency.title|title }}
</a>
</li>
{% endfor %}
</ul>
</div>
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
<i class="fa-solid fa-calendar-day"></i>
{{ A_("Period") }}
</button>
</div>
{% with period = report.period, period_chooser = report.period_chooser %}
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% if list %}
<div class="accounting-report-card">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ _("Trial Balance of %(currency)s %(period)s", currency=report.currency, period=report.period.desc|title) }}</h2>
</div>
<div class="list-group accounting-list-group-stripped accounting-list-group-hover">
<div class="list-group-item d-flex justify-content-between accounting-report-row accounting-trial-balance-row accounting-trial-balance-header">
<div>{{ A_("Account") }}</div>
<div class="d-flex justify-content-between">
<div class="accounting-amount">{{ A_("Debit") }}</div>
<div class="accounting-amount">{{ A_("Credit") }}</div>
</div>
</div>
{% for item in list %}
<a class="list-group-item d-flex justify-content-between accounting-report-row accounting-trial-balance-row" href="{{ item.url }}">
<div>{{ item.account|title }}</div>
<div class="d-flex justify-content-between">
<div class="accounting-amount">{{ "" if item.debit is none else item.debit|accounting_format_amount }}</div>
<div class="accounting-amount">{{ "" if item.credit is none else item.credit|accounting_format_amount }}</div>
</div>
</a>
{% endfor %}
<div class="list-group-item d-flex justify-content-between accounting-report-row accounting-trial-balance-row accounting-trial-balance-total">
<div>{{ A_("Total") }}</div>
<div class="d-flex justify-content-between">
<div class="accounting-amount">{{ report.total_row.debit|accounting_format_amount }}</div>
<div class="accounting-amount">{{ report.total_row.credit|accounting_format_amount }}</div>
</div>
</div>
</div>
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}