Replaced the report generators with a separated module for each report, to work with the diversity of the report formats without messing-up one another.
This commit is contained in:
parent
436a4c367f
commit
edb893ecd3
68
src/accounting/report/page_params.py
Normal file
68
src/accounting/report/page_params.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
|
||||||
|
|
||||||
|
# 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 page parameters of a report.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
|
||||||
|
urlunparse
|
||||||
|
|
||||||
|
from flask import request
|
||||||
|
import typing as t
|
||||||
|
|
||||||
|
from accounting.report.report_chooser import ReportChooser
|
||||||
|
from accounting.utils.txn_types import TransactionType
|
||||||
|
|
||||||
|
|
||||||
|
class PageParams(ABC):
|
||||||
|
"""The page parameters of a report."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def has_data(self) -> bool:
|
||||||
|
"""Returns whether there is any data on the page.
|
||||||
|
|
||||||
|
:return: True if there is any data, or False otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def txn_types(self) -> t.Type[TransactionType]:
|
||||||
|
"""Returns the transaction types.
|
||||||
|
|
||||||
|
:return: The transaction types.
|
||||||
|
"""
|
||||||
|
return TransactionType
|
||||||
|
|
||||||
|
@property
|
||||||
|
def csv_uri(self) -> str:
|
||||||
|
uri: str = request.full_path if request.query_string \
|
||||||
|
else request.path
|
||||||
|
uri_p: ParseResult = urlparse(uri)
|
||||||
|
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
||||||
|
params = [x for x in params if x[0] != "as"]
|
||||||
|
params.append(("as", "csv"))
|
||||||
|
parts: list[str] = list(uri_p)
|
||||||
|
parts[4] = urlencode(params)
|
||||||
|
return urlunparse(parts)
|
@ -333,7 +333,7 @@ class Period:
|
|||||||
"""
|
"""
|
||||||
if self.start is None:
|
if self.start is None:
|
||||||
return None
|
return None
|
||||||
return self.__class__(None, self.start - datetime.timedelta(days=1))
|
return Period(None, self.start - datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
|
||||||
class ThisMonth(Period):
|
class ThisMonth(Period):
|
||||||
|
@ -1,387 +0,0 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
|
|
||||||
|
|
||||||
# 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 page parameters of a report.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import typing as t
|
|
||||||
from abc import ABC
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from flask import url_for
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.models import Currency, Account, JournalEntry
|
|
||||||
from accounting.report.option_link import OptionLink
|
|
||||||
from accounting.report.period import Period
|
|
||||||
from accounting.report.period_choosers import PeriodChooser, \
|
|
||||||
JournalPeriodChooser, LedgerPeriodChooser, IncomeExpensesPeriodChooser, \
|
|
||||||
TrialBalancePeriodChooser, IncomeStatementPeriodChooser
|
|
||||||
from accounting.report.report_chooser import ReportChooser
|
|
||||||
from accounting.report.report_rows import JournalRow, LedgerRow, \
|
|
||||||
IncomeExpensesRow, TrialBalanceRow, IncomeStatementRow
|
|
||||||
from accounting.report.report_type import ReportType
|
|
||||||
from accounting.utils.pagination import Pagination
|
|
||||||
from accounting.utils.txn_types import TransactionType
|
|
||||||
|
|
||||||
T = t.TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
class ReportParams(t.Generic[T], ABC):
|
|
||||||
"""The parameters of a report page."""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
period_chooser: PeriodChooser,
|
|
||||||
report_chooser: ReportChooser,
|
|
||||||
data_rows: list[T],
|
|
||||||
is_paged: bool,
|
|
||||||
filler: t.Callable[[list[T]], None] | None = None,
|
|
||||||
brought_forward: T | None = None,
|
|
||||||
total: T | None = None):
|
|
||||||
"""Constructs the parameters of a report page.
|
|
||||||
|
|
||||||
:param period_chooser: The period chooser.
|
|
||||||
:param report_chooser: The report chooser.
|
|
||||||
:param filler: The callback to fill in the related data to the rows.
|
|
||||||
:param data_rows: The data rows.
|
|
||||||
:param is_paged: True to use pagination, or False otherwise.
|
|
||||||
:param brought_forward: The brought-forward row, if any.
|
|
||||||
:param total: The total row, if any.
|
|
||||||
"""
|
|
||||||
self.txn_types: t.Type[TransactionType] = TransactionType
|
|
||||||
"""The transaction types."""
|
|
||||||
self.period_chooser: PeriodChooser = period_chooser
|
|
||||||
"""The period chooser."""
|
|
||||||
self.report_chooser: ReportChooser = report_chooser
|
|
||||||
"""The report chooser."""
|
|
||||||
self.data_rows: list[T] = data_rows
|
|
||||||
"""The data rows"""
|
|
||||||
self.brought_forward: T | None = brought_forward
|
|
||||||
"""The brought-forward row."""
|
|
||||||
self.total: T | None = total
|
|
||||||
"""The total row."""
|
|
||||||
self.pagination: Pagination[T] | None = None
|
|
||||||
"""The pagination."""
|
|
||||||
self.has_data: bool = len(self.data_rows) > 0
|
|
||||||
"""True if there is any data in the page, or False otherwise."""
|
|
||||||
if is_paged:
|
|
||||||
all_rows: list[T] = []
|
|
||||||
if brought_forward is not None:
|
|
||||||
all_rows.append(brought_forward)
|
|
||||||
all_rows.extend(data_rows)
|
|
||||||
if self.total is not None:
|
|
||||||
all_rows.append(total)
|
|
||||||
self.pagination = Pagination[T](all_rows)
|
|
||||||
rows = self.pagination.list
|
|
||||||
self.has_data = len(rows) > 0
|
|
||||||
if len(rows) > 0 and rows[0] == brought_forward:
|
|
||||||
rows = rows[1:]
|
|
||||||
else:
|
|
||||||
self.brought_forward = None
|
|
||||||
if len(rows) > 0 and rows[-1] == total:
|
|
||||||
rows = rows[:-1]
|
|
||||||
else:
|
|
||||||
self.total = None
|
|
||||||
self.data_rows = rows
|
|
||||||
if filler is not None:
|
|
||||||
filler(self.data_rows)
|
|
||||||
|
|
||||||
|
|
||||||
class JournalParams(ReportParams[JournalRow]):
|
|
||||||
"""The parameters of a journal page."""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
period: Period,
|
|
||||||
data_rows: list[JournalRow],
|
|
||||||
filler: t.Callable[[list[JournalRow]], None]):
|
|
||||||
"""Constructs the parameters for the journal page.
|
|
||||||
|
|
||||||
:param period: The period.
|
|
||||||
:param data_rows: The data rows.
|
|
||||||
:param filler: The callback to fill in the related data to the rows.
|
|
||||||
"""
|
|
||||||
super().__init__(
|
|
||||||
period_chooser=JournalPeriodChooser(),
|
|
||||||
report_chooser=ReportChooser(ReportType.JOURNAL,
|
|
||||||
period=period),
|
|
||||||
data_rows=data_rows,
|
|
||||||
is_paged=True,
|
|
||||||
filler=filler)
|
|
||||||
self.period: Period | None = period
|
|
||||||
"""The period."""
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerParams(ReportParams[LedgerRow]):
|
|
||||||
"""The parameters of a ledger page."""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
currency: Currency,
|
|
||||||
account: Account,
|
|
||||||
period: Period,
|
|
||||||
data_rows: list[LedgerRow],
|
|
||||||
filler: t.Callable[[list[LedgerRow]], None],
|
|
||||||
brought_forward: LedgerRow | None,
|
|
||||||
total: LedgerRow):
|
|
||||||
"""Constructs the parameters for the ledger page.
|
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:param account: The account.
|
|
||||||
:param period: The period.
|
|
||||||
:param data_rows: The data rows.
|
|
||||||
:param filler: The callback to fill in the related data to the rows.
|
|
||||||
:param brought_forward: The brought-forward row, if any.
|
|
||||||
:param total: The total row, if any.
|
|
||||||
"""
|
|
||||||
super().__init__(
|
|
||||||
period_chooser=IncomeExpensesPeriodChooser(currency, account),
|
|
||||||
report_chooser=ReportChooser(ReportType.LEDGER,
|
|
||||||
currency=currency,
|
|
||||||
account=account,
|
|
||||||
period=period),
|
|
||||||
data_rows=data_rows,
|
|
||||||
is_paged=True,
|
|
||||||
filler=filler,
|
|
||||||
brought_forward=brought_forward,
|
|
||||||
total=total)
|
|
||||||
self.currency: Currency = currency
|
|
||||||
"""The currency."""
|
|
||||||
self.account: Account = account
|
|
||||||
"""The account."""
|
|
||||||
self.period: Period | None = period
|
|
||||||
"""The period."""
|
|
||||||
|
|
||||||
@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.ledger-default",
|
|
||||||
currency=currency, account=self.account)
|
|
||||||
return url_for("accounting.report.ledger",
|
|
||||||
currency=currency, account=self.account,
|
|
||||||
period=self.period)
|
|
||||||
|
|
||||||
in_use: sa.Select = sa.Select(JournalEntry.currency_code)\
|
|
||||||
.group_by(JournalEntry.currency_code)
|
|
||||||
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()]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def account_options(self) -> list[OptionLink]:
|
|
||||||
"""Returns the account options.
|
|
||||||
|
|
||||||
:return: The account options.
|
|
||||||
"""
|
|
||||||
def get_url(account: 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)
|
|
||||||
|
|
||||||
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
|
||||||
.filter(JournalEntry.currency_code == self.currency.code)\
|
|
||||||
.group_by(JournalEntry.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))
|
|
||||||
.order_by(Account.base_code, Account.no).all()]
|
|
||||||
|
|
||||||
|
|
||||||
class IncomeExpensesParams(ReportParams[IncomeExpensesRow]):
|
|
||||||
"""The parameters of an income and expenses page."""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
currency: Currency,
|
|
||||||
account: Account,
|
|
||||||
period: Period,
|
|
||||||
data_rows: list[IncomeExpensesRow],
|
|
||||||
filler: t.Callable[[list[IncomeExpensesRow]], None],
|
|
||||||
brought_forward: IncomeExpensesRow | None,
|
|
||||||
total: IncomeExpensesRow):
|
|
||||||
"""Constructs the parameters for the income and expenses page.
|
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:param account: The account.
|
|
||||||
:param period: The period.
|
|
||||||
:param data_rows: The data rows.
|
|
||||||
:param filler: The callback to fill in the related data to the rows.
|
|
||||||
:param brought_forward: The brought-forward row, if any.
|
|
||||||
:param total: The total row, if any.
|
|
||||||
"""
|
|
||||||
super().__init__(
|
|
||||||
period_chooser=LedgerPeriodChooser(currency, account),
|
|
||||||
report_chooser=ReportChooser(ReportType.INCOME_EXPENSES,
|
|
||||||
currency=currency,
|
|
||||||
account=account,
|
|
||||||
period=period),
|
|
||||||
data_rows=data_rows,
|
|
||||||
is_paged=True,
|
|
||||||
filler=filler,
|
|
||||||
brought_forward=brought_forward,
|
|
||||||
total=total)
|
|
||||||
self.currency: Currency = currency
|
|
||||||
"""The currency."""
|
|
||||||
self.account: Account = account
|
|
||||||
"""The account."""
|
|
||||||
self.period: Period = period
|
|
||||||
"""The period."""
|
|
||||||
|
|
||||||
@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.income-expenses-default",
|
|
||||||
currency=currency, account=self.account)
|
|
||||||
return url_for("accounting.report.income-expenses",
|
|
||||||
currency=currency, account=self.account,
|
|
||||||
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()]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def account_options(self) -> list[OptionLink]:
|
|
||||||
"""Returns the account options.
|
|
||||||
|
|
||||||
:return: The account options.
|
|
||||||
"""
|
|
||||||
def get_url(account: Account):
|
|
||||||
if self.period.is_default:
|
|
||||||
return url_for("accounting.report.income-expenses-default",
|
|
||||||
currency=self.currency, account=account)
|
|
||||||
return url_for("accounting.report.income-expenses",
|
|
||||||
currency=self.currency, account=account,
|
|
||||||
period=self.period)
|
|
||||||
|
|
||||||
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
|
||||||
.join(Account)\
|
|
||||||
.filter(JournalEntry.currency_code == self.currency.code,
|
|
||||||
sa.or_(Account.base_code.startswith("11"),
|
|
||||||
Account.base_code.startswith("12"),
|
|
||||||
Account.base_code.startswith("21"),
|
|
||||||
Account.base_code.startswith("22")))\
|
|
||||||
.group_by(JournalEntry.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))
|
|
||||||
.order_by(Account.base_code, Account.no).all()]
|
|
||||||
|
|
||||||
|
|
||||||
class TrialBalanceParams(ReportParams[TrialBalanceRow]):
|
|
||||||
"""The parameters of a trial balance page."""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
currency: Currency,
|
|
||||||
period: Period,
|
|
||||||
data_rows: list[TrialBalanceRow],
|
|
||||||
total: TrialBalanceRow):
|
|
||||||
"""Constructs the parameters for the trial balance page.
|
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:param period: The period.
|
|
||||||
:param data_rows: The data rows.
|
|
||||||
:param total: The total row, if any.
|
|
||||||
"""
|
|
||||||
super().__init__(
|
|
||||||
period_chooser=TrialBalancePeriodChooser(currency),
|
|
||||||
report_chooser=ReportChooser(ReportType.TRIAL_BALANCE,
|
|
||||||
currency=currency,
|
|
||||||
period=period),
|
|
||||||
data_rows=data_rows,
|
|
||||||
is_paged=False,
|
|
||||||
total=total)
|
|
||||||
self.currency: Currency = currency
|
|
||||||
"""The currency."""
|
|
||||||
self.period: Period | None = period
|
|
||||||
"""The period."""
|
|
||||||
|
|
||||||
@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()]
|
|
||||||
|
|
||||||
|
|
||||||
class IncomeStatementParams(ReportParams[IncomeStatementRow]):
|
|
||||||
"""The parameters of an income statement page."""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
currency: Currency,
|
|
||||||
period: Period,
|
|
||||||
data_rows: list[IncomeStatementRow]):
|
|
||||||
"""Constructs the parameters for the income statement page.
|
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:param period: The period.
|
|
||||||
:param data_rows: The data rows.
|
|
||||||
"""
|
|
||||||
super().__init__(
|
|
||||||
period_chooser=IncomeStatementPeriodChooser(currency),
|
|
||||||
report_chooser=ReportChooser(ReportType.INCOME_STATEMENT,
|
|
||||||
currency=currency,
|
|
||||||
period=period),
|
|
||||||
data_rows=data_rows,
|
|
||||||
is_paged=False)
|
|
||||||
self.currency: Currency = currency
|
|
||||||
"""The currency."""
|
|
||||||
self.period: Period | None = period
|
|
||||||
"""The period."""
|
|
||||||
|
|
||||||
@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.income-statement-default",
|
|
||||||
currency=currency)
|
|
||||||
return url_for("accounting.report.income-statement",
|
|
||||||
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()]
|
|
@ -1,261 +0,0 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
|
||||||
|
|
||||||
# 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 rows of the reports.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import typing as t
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class ReportRow(ABC):
|
|
||||||
"""A row in the report."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def as_dict(self) -> dict[str, t.Any]:
|
|
||||||
"""Returns the row as a dictionary.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class JournalRow(ReportRow):
|
|
||||||
"""A row in the journal."""
|
|
||||||
|
|
||||||
def __init__(self, entry: JournalEntry):
|
|
||||||
"""Constructs the row in the journal.
|
|
||||||
|
|
||||||
:param entry: The journal entry.
|
|
||||||
"""
|
|
||||||
self.entry: JournalEntry = entry
|
|
||||||
"""The journal entry."""
|
|
||||||
self.transaction: Transaction | None = None
|
|
||||||
"""The transaction."""
|
|
||||||
self.currency: Currency | None = None
|
|
||||||
"""The currency."""
|
|
||||||
self.account: Account | None = None
|
|
||||||
"""The account."""
|
|
||||||
self.summary: str | None = entry.summary
|
|
||||||
"""The summary."""
|
|
||||||
self.debit: Decimal | None = entry.amount if entry.is_debit else None
|
|
||||||
"""The debit amount."""
|
|
||||||
self.credit: Decimal | None = None if entry.is_debit else entry.amount
|
|
||||||
"""The credit amount."""
|
|
||||||
self.amount: Decimal = entry.amount
|
|
||||||
"""The amount."""
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, t.Any]:
|
|
||||||
return {"Date": self.transaction.date,
|
|
||||||
"Currency": self.currency.code,
|
|
||||||
"Account": str(self.account).title(),
|
|
||||||
"Summary": self.summary,
|
|
||||||
"Debit": self.debit,
|
|
||||||
"Credit": self.credit,
|
|
||||||
"Note": self.transaction.note}
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerRow(ReportRow):
|
|
||||||
"""A row in the ledger."""
|
|
||||||
|
|
||||||
def __init__(self, entry: JournalEntry | None = None):
|
|
||||||
"""Constructs the row in the ledger.
|
|
||||||
|
|
||||||
:param entry: The journal entry.
|
|
||||||
"""
|
|
||||||
self.entry: JournalEntry | None = None
|
|
||||||
"""The journal entry."""
|
|
||||||
self.transaction: Transaction | None = None
|
|
||||||
"""The transaction."""
|
|
||||||
self.is_total: bool = False
|
|
||||||
"""Whether this is the total row."""
|
|
||||||
self.date: date | None = None
|
|
||||||
"""The date."""
|
|
||||||
self.summary: str | None = None
|
|
||||||
"""The summary."""
|
|
||||||
self.debit: Decimal | None = None
|
|
||||||
"""The debit amount."""
|
|
||||||
self.credit: Decimal | None = None
|
|
||||||
"""The credit amount."""
|
|
||||||
self.balance: Decimal | None = None
|
|
||||||
"""The balance."""
|
|
||||||
self.note: str | None = None
|
|
||||||
"""The note."""
|
|
||||||
if entry is not None:
|
|
||||||
self.entry = entry
|
|
||||||
self.summary = entry.summary
|
|
||||||
self.debit = entry.amount if entry.is_debit else None
|
|
||||||
self.credit = None if entry.is_debit else entry.amount
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, t.Any]:
|
|
||||||
if self.is_total:
|
|
||||||
return {"Date": "Total",
|
|
||||||
"Summary": None,
|
|
||||||
"Debit": self.debit,
|
|
||||||
"Credit": self.credit,
|
|
||||||
"Balance": self.balance,
|
|
||||||
"Note": None}
|
|
||||||
return {"Date": self.date,
|
|
||||||
"Summary": self.summary,
|
|
||||||
"Debit": self.debit,
|
|
||||||
"Credit": self.credit,
|
|
||||||
"Balance": self.balance,
|
|
||||||
"Note": self.note}
|
|
||||||
|
|
||||||
|
|
||||||
class IncomeExpensesRow(ReportRow):
|
|
||||||
"""A row in the income and expenses."""
|
|
||||||
|
|
||||||
def __init__(self, entry: JournalEntry | None = None):
|
|
||||||
"""Constructs the row in the income and expenses.
|
|
||||||
|
|
||||||
:param entry: The journal entry.
|
|
||||||
"""
|
|
||||||
self.entry: JournalEntry | None = None
|
|
||||||
"""The journal entry."""
|
|
||||||
self.transaction: Transaction | None = None
|
|
||||||
"""The transaction."""
|
|
||||||
self.is_total: bool = False
|
|
||||||
"""Whether this is the total row."""
|
|
||||||
self.date: date | None = None
|
|
||||||
"""The date."""
|
|
||||||
self.account: Account | None = None
|
|
||||||
"""The date."""
|
|
||||||
self.summary: str | None = None
|
|
||||||
"""The summary."""
|
|
||||||
self.income: Decimal | None = None
|
|
||||||
"""The income amount."""
|
|
||||||
self.expense: Decimal | None = None
|
|
||||||
"""The expense amount."""
|
|
||||||
self.balance: Decimal | None = None
|
|
||||||
"""The balance."""
|
|
||||||
self.note: str | None = None
|
|
||||||
"""The note."""
|
|
||||||
if entry is not None:
|
|
||||||
self.entry = entry
|
|
||||||
self.summary = entry.summary
|
|
||||||
self.income = None if entry.is_debit else entry.amount
|
|
||||||
self.expense = entry.amount if entry.is_debit else None
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, t.Any]:
|
|
||||||
if self.is_total:
|
|
||||||
return {"Date": "Total",
|
|
||||||
"Account": None,
|
|
||||||
"Summary": None,
|
|
||||||
"Income": self.income,
|
|
||||||
"Expense": self.expense,
|
|
||||||
"Balance": self.balance,
|
|
||||||
"Note": None}
|
|
||||||
return {"Date": self.date,
|
|
||||||
"Account": str(self.account).title(),
|
|
||||||
"Summary": self.summary,
|
|
||||||
"Income": self.income,
|
|
||||||
"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 account."""
|
|
||||||
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}
|
|
||||||
|
|
||||||
|
|
||||||
class IncomeStatementRow(ReportRow):
|
|
||||||
"""A row in the income statement."""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
code: str | None = None,
|
|
||||||
title: str | None = None,
|
|
||||||
amount: Decimal | None = None,
|
|
||||||
is_category: bool = False,
|
|
||||||
is_total: bool = False,
|
|
||||||
is_subcategory: bool = False,
|
|
||||||
is_subtotal: bool = False,
|
|
||||||
url: str | None = None):
|
|
||||||
"""Constructs the row in the income statement.
|
|
||||||
|
|
||||||
:param code: The account code.
|
|
||||||
:param title: The account title.
|
|
||||||
:param amount: The amount.
|
|
||||||
:param is_category: True for a category, or False otherwise.
|
|
||||||
:param is_total: True for a total, or False otherwise.
|
|
||||||
:param is_subcategory: True for a subcategory, or False otherwise.
|
|
||||||
:param is_subtotal: True for a subtotal, or False otherwise.
|
|
||||||
:param url: The URL for the account.
|
|
||||||
"""
|
|
||||||
self.is_total: bool = False
|
|
||||||
"""Whether this is the total row."""
|
|
||||||
self.code: str | None = code
|
|
||||||
"""The account code."""
|
|
||||||
self.title: str | None = title
|
|
||||||
"""The account code."""
|
|
||||||
self.amount: Decimal | None = amount
|
|
||||||
"""The amount."""
|
|
||||||
self.is_category: bool = is_category
|
|
||||||
"""True if this row is a category, or False otherwise."""
|
|
||||||
self.is_total: bool = is_total
|
|
||||||
"""True if this row is a total, or False otherwise."""
|
|
||||||
self.is_subcategory: bool = is_subcategory
|
|
||||||
"""True if this row is a subcategory, or False otherwise."""
|
|
||||||
self.is_subtotal: bool = is_subtotal
|
|
||||||
"""True if this row is a subtotal, or False otherwise."""
|
|
||||||
self.url: str | None = url
|
|
||||||
"""The URL."""
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, t.Any]:
|
|
||||||
if self.is_subtotal:
|
|
||||||
return {"": "Total",
|
|
||||||
"Amount": self.amount}
|
|
||||||
if self.is_total:
|
|
||||||
return {"": self.title,
|
|
||||||
"Amount": self.amount}
|
|
||||||
return {"": f"{self.code} {self.title}",
|
|
||||||
"Amount": self.amount}
|
|
@ -1,726 +0,0 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
|
||||||
|
|
||||||
# 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 reports.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import csv
|
|
||||||
import typing as t
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from decimal import Decimal
|
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from flask import Response, render_template, url_for
|
|
||||||
|
|
||||||
from accounting import db
|
|
||||||
from accounting.locale import gettext
|
|
||||||
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
|
||||||
JournalEntry
|
|
||||||
from accounting.report.period import Period
|
|
||||||
from accounting.report.report_params import JournalParams, LedgerParams, \
|
|
||||||
IncomeExpensesParams, TrialBalanceParams, IncomeStatementParams
|
|
||||||
from accounting.report.report_rows import JournalRow, LedgerRow, \
|
|
||||||
IncomeExpensesRow, TrialBalanceRow, IncomeStatementRow
|
|
||||||
|
|
||||||
T = t.TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
class Report(t.Generic[T], ABC):
|
|
||||||
"""A report."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Constructs a report."""
|
|
||||||
self.data_rows: list[T]
|
|
||||||
"""The data rows."""
|
|
||||||
self.brought_forward: T | None
|
|
||||||
"""The brought-forward row."""
|
|
||||||
self.total: T | None
|
|
||||||
"""The total row."""
|
|
||||||
self.data_rows, self.brought_forward, self.total = self.get_rows()
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_rows(self) -> tuple[list[T], T | None, T | None]:
|
|
||||||
"""Returns the data rows, the brought-forward row, and the total row.
|
|
||||||
|
|
||||||
:return: The data rows, the brought-forward row, and the total row.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@abstractmethod
|
|
||||||
def populate_rows(rows: list[JournalRow]) -> None:
|
|
||||||
"""Fills in the related data to the data rows.
|
|
||||||
|
|
||||||
:param rows: The data rows.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def csv_field_names(self) -> list[str]:
|
|
||||||
"""Returns the CSV field names.
|
|
||||||
|
|
||||||
:return: The CSV field names.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def csv_filename(self) -> str:
|
|
||||||
"""Returns the CSV download file name.
|
|
||||||
|
|
||||||
:return: The CSV download file name.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def csv(self) -> Response:
|
|
||||||
"""Returns the report as CSV for download.
|
|
||||||
|
|
||||||
:return: The response of the report for download.
|
|
||||||
"""
|
|
||||||
rows: list[T] = []
|
|
||||||
if self.brought_forward is not None:
|
|
||||||
rows.append(self.brought_forward)
|
|
||||||
rows.extend(self.data_rows)
|
|
||||||
if self.total is not None:
|
|
||||||
rows.append(self.total)
|
|
||||||
self.populate_rows(rows)
|
|
||||||
with StringIO() as fp:
|
|
||||||
writer: csv.DictWriter = csv.DictWriter(
|
|
||||||
fp, fieldnames=self.csv_field_names)
|
|
||||||
writer.writeheader()
|
|
||||||
writer.writerows([x.as_dict() for x in rows])
|
|
||||||
fp.seek(0)
|
|
||||||
response: Response = Response(fp.read(), mimetype="text/csv")
|
|
||||||
response.headers["Content-Disposition"] \
|
|
||||||
= f"attachment; filename={self.csv_filename}"
|
|
||||||
return response
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def html(self) -> str:
|
|
||||||
"""Composes and returns the report as HTML.
|
|
||||||
|
|
||||||
:return: The report as HTML.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class Journal(Report[JournalRow]):
|
|
||||||
"""The journal."""
|
|
||||||
|
|
||||||
def __init__(self, period: Period):
|
|
||||||
"""Constructs a journal.
|
|
||||||
|
|
||||||
:param period: The period.
|
|
||||||
"""
|
|
||||||
self.period: Period = period
|
|
||||||
"""The period."""
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def get_rows(self) -> tuple[list[T], T | None, T | None]:
|
|
||||||
conditions: list[sa.BinaryExpression] = []
|
|
||||||
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)
|
|
||||||
rows: list[JournalRow] = [JournalRow(x) for x in db.session
|
|
||||||
.query(JournalEntry)
|
|
||||||
.join(Transaction)
|
|
||||||
.filter(*conditions)
|
|
||||||
.order_by(Transaction.date,
|
|
||||||
JournalEntry.is_debit.desc(),
|
|
||||||
JournalEntry.no).all()]
|
|
||||||
return rows, None, None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def populate_rows(rows: list[JournalRow]) -> None:
|
|
||||||
transactions: dict[int, Transaction] \
|
|
||||||
= {x.id: x for x in Transaction.query.filter(
|
|
||||||
Transaction.id.in_({x.entry.transaction_id for x in rows}))}
|
|
||||||
accounts: dict[int, Account] \
|
|
||||||
= {x.id: x for x in Account.query.filter(
|
|
||||||
Account.id.in_({x.entry.account_id for x in rows}))}
|
|
||||||
currencies: dict[int, Currency] \
|
|
||||||
= {x.code: x for x in Currency.query.filter(
|
|
||||||
Currency.code.in_({x.entry.currency_code for x in rows}))}
|
|
||||||
for row in rows:
|
|
||||||
row.transaction = transactions[row.entry.transaction_id]
|
|
||||||
row.account = accounts[row.entry.account_id]
|
|
||||||
row.currency = currencies[row.entry.currency_code]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def csv_field_names(self) -> list[str]:
|
|
||||||
return ["Date", "Currency", "Account", "Summary", "Debit", "Credit",
|
|
||||||
"Note"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def csv_filename(self) -> str:
|
|
||||||
return f"journal-{self.period.spec}.csv"
|
|
||||||
|
|
||||||
def html(self) -> str:
|
|
||||||
params: JournalParams = JournalParams(
|
|
||||||
period=self.period,
|
|
||||||
data_rows=self.data_rows,
|
|
||||||
filler=self.populate_rows)
|
|
||||||
return render_template("accounting/report/journal.html",
|
|
||||||
report=params)
|
|
||||||
|
|
||||||
|
|
||||||
class Ledger(Report[LedgerRow]):
|
|
||||||
"""The ledger."""
|
|
||||||
|
|
||||||
def __init__(self, currency: Currency, account: Account, period: Period):
|
|
||||||
"""Constructs a ledger.
|
|
||||||
|
|
||||||
: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."""
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def get_rows(self) -> tuple[list[T], T | None, T | None]:
|
|
||||||
brought_forward: LedgerRow | None = self.__get_brought_forward_row()
|
|
||||||
rows: list[LedgerRow] = [LedgerRow(x) for x in self.__query_entries()]
|
|
||||||
total: LedgerRow = self.__get_total_row(brought_forward, rows)
|
|
||||||
self.__populate_balance(brought_forward, rows)
|
|
||||||
return rows, brought_forward, total
|
|
||||||
|
|
||||||
def __get_brought_forward_row(self) -> LedgerRow | None:
|
|
||||||
"""Queries, composes and returns the brought-forward row.
|
|
||||||
|
|
||||||
:return: The brought-forward row, or None if the ledger starts from the
|
|
||||||
beginning.
|
|
||||||
"""
|
|
||||||
if self.period.start is None:
|
|
||||||
return None
|
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
|
||||||
(JournalEntry.is_debit, JournalEntry.amount),
|
|
||||||
else_=-JournalEntry.amount))
|
|
||||||
select: sa.Select = sa.Select(balance_func).join(Transaction)\
|
|
||||||
.filter(JournalEntry.currency_code == self.currency.code,
|
|
||||||
JournalEntry.account_id == self.account.id,
|
|
||||||
Transaction.date < self.period.start)
|
|
||||||
balance: int | None = db.session.scalar(select)
|
|
||||||
if balance is None:
|
|
||||||
return None
|
|
||||||
row: LedgerRow = LedgerRow()
|
|
||||||
row.date = self.period.start
|
|
||||||
row.summary = gettext("Brought forward")
|
|
||||||
if balance > 0:
|
|
||||||
row.debit = balance
|
|
||||||
elif balance < 0:
|
|
||||||
row.credit = -balance
|
|
||||||
row.balance = balance
|
|
||||||
return row
|
|
||||||
|
|
||||||
def __query_entries(self) -> list[JournalEntry]:
|
|
||||||
"""Queries and returns the journal entries.
|
|
||||||
|
|
||||||
:return: The journal entries.
|
|
||||||
"""
|
|
||||||
conditions: list[sa.BinaryExpression] \
|
|
||||||
= [JournalEntry.currency_code == self.currency.code,
|
|
||||||
JournalEntry.account_id == self.account.id]
|
|
||||||
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)
|
|
||||||
return db.session.query(JournalEntry).join(Transaction)\
|
|
||||||
.filter(*conditions)\
|
|
||||||
.order_by(Transaction.date,
|
|
||||||
JournalEntry.is_debit.desc(),
|
|
||||||
JournalEntry.no).all()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __get_total_row(brought_forward: LedgerRow | None,
|
|
||||||
rows: list[LedgerRow]) -> LedgerRow:
|
|
||||||
"""Composes the total row.
|
|
||||||
|
|
||||||
:param brought_forward: The brought-forward row.
|
|
||||||
:param rows: The rows.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
row: LedgerRow = LedgerRow()
|
|
||||||
row.is_total = True
|
|
||||||
row.summary = gettext("Total")
|
|
||||||
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])
|
|
||||||
row.balance = row.debit - row.credit
|
|
||||||
if brought_forward is not None:
|
|
||||||
row.balance = brought_forward.balance + row.balance
|
|
||||||
return row
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __populate_balance(brought_forward: LedgerRow | None,
|
|
||||||
rows: list[LedgerRow]) -> None:
|
|
||||||
"""Populates the balance of the rows.
|
|
||||||
|
|
||||||
:param brought_forward: The brought-forward row.
|
|
||||||
:param rows: The rows.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
balance: Decimal = 0 if brought_forward is None \
|
|
||||||
else brought_forward.balance
|
|
||||||
for row in rows:
|
|
||||||
if row.debit is not None:
|
|
||||||
balance = balance + row.debit
|
|
||||||
if row.credit is not None:
|
|
||||||
balance = balance - row.credit
|
|
||||||
row.balance = balance
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def populate_rows(rows: list[LedgerRow]) -> None:
|
|
||||||
transactions: dict[int, Transaction] \
|
|
||||||
= {x.id: x for x in Transaction.query.filter(
|
|
||||||
Transaction.id.in_({x.entry.transaction_id for x in rows
|
|
||||||
if x.entry is not None}))}
|
|
||||||
for row in rows:
|
|
||||||
if row.entry is not None:
|
|
||||||
row.transaction = transactions[row.entry.transaction_id]
|
|
||||||
row.date = row.transaction.date
|
|
||||||
row.note = row.transaction.note
|
|
||||||
|
|
||||||
@property
|
|
||||||
def csv_field_names(self) -> list[str]:
|
|
||||||
return ["Date", "Summary", "Debit", "Credit", "Balance", "Note"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def csv_filename(self) -> str:
|
|
||||||
return "ledger-{currency}-{account}-{period}.csv".format(
|
|
||||||
currency=self.currency.code, account=self.account.code,
|
|
||||||
period=self.period.spec)
|
|
||||||
|
|
||||||
def html(self) -> str:
|
|
||||||
params: LedgerParams = LedgerParams(
|
|
||||||
currency=self.currency,
|
|
||||||
account=self.account,
|
|
||||||
period=self.period,
|
|
||||||
data_rows=self.data_rows,
|
|
||||||
filler=self.populate_rows,
|
|
||||||
brought_forward=self.brought_forward,
|
|
||||||
total=self.total)
|
|
||||||
return render_template("accounting/report/ledger.html",
|
|
||||||
report=params)
|
|
||||||
|
|
||||||
|
|
||||||
class IncomeExpenses(Report[IncomeExpensesRow]):
|
|
||||||
"""The income and expenses."""
|
|
||||||
|
|
||||||
def __init__(self, currency: Currency, account: Account, period: Period):
|
|
||||||
"""Constructs an income and expenses.
|
|
||||||
|
|
||||||
: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."""
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def get_rows(self) -> tuple[list[T], T | None, T | None]:
|
|
||||||
brought_forward: IncomeExpensesRow | None \
|
|
||||||
= self.__get_brought_forward_row()
|
|
||||||
rows: list[IncomeExpensesRow] \
|
|
||||||
= [IncomeExpensesRow(x) for x in self.__query_entries()]
|
|
||||||
total: IncomeExpensesRow = self.__get_total_row(brought_forward, rows)
|
|
||||||
self.__populate_balance(brought_forward, rows)
|
|
||||||
return rows, brought_forward, total
|
|
||||||
|
|
||||||
def __get_brought_forward_row(self) -> IncomeExpensesRow | None:
|
|
||||||
"""Queries, composes and returns the brought-forward row.
|
|
||||||
|
|
||||||
:return: The brought-forward row, or None if the income-expenses starts
|
|
||||||
from the beginning.
|
|
||||||
"""
|
|
||||||
if self.period.start is None:
|
|
||||||
return None
|
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
|
||||||
(JournalEntry.is_debit, JournalEntry.amount),
|
|
||||||
else_=-JournalEntry.amount))
|
|
||||||
select: sa.Select = sa.Select(balance_func).join(Transaction)\
|
|
||||||
.filter(JournalEntry.currency_code == self.currency.code,
|
|
||||||
JournalEntry.account_id == self.account.id,
|
|
||||||
Transaction.date < self.period.start)
|
|
||||||
balance: int | None = db.session.scalar(select)
|
|
||||||
if balance is None:
|
|
||||||
return None
|
|
||||||
row: IncomeExpensesRow = IncomeExpensesRow()
|
|
||||||
row.date = self.period.start
|
|
||||||
row.account = Account.find_by_code("3351-001")
|
|
||||||
row.summary = gettext("Brought forward")
|
|
||||||
if balance > 0:
|
|
||||||
row.income = balance
|
|
||||||
elif balance < 0:
|
|
||||||
row.expense = -balance
|
|
||||||
row.balance = balance
|
|
||||||
return row
|
|
||||||
|
|
||||||
def __query_entries(self) -> list[JournalEntry]:
|
|
||||||
"""Queries and returns the journal entries.
|
|
||||||
|
|
||||||
:return: The journal entries.
|
|
||||||
"""
|
|
||||||
conditions: list[sa.BinaryExpression] \
|
|
||||||
= [JournalEntry.currency_code == self.currency.code,
|
|
||||||
JournalEntry.account_id == self.account.id]
|
|
||||||
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)
|
|
||||||
txn_with_account: sa.Select = sa.Select(Transaction.id).\
|
|
||||||
join(JournalEntry).filter(*conditions)
|
|
||||||
|
|
||||||
return JournalEntry.query.join(Transaction)\
|
|
||||||
.filter(JournalEntry.transaction_id.in_(txn_with_account),
|
|
||||||
JournalEntry.currency_code == self.currency.code,
|
|
||||||
JournalEntry.account_id != self.account.id)\
|
|
||||||
.order_by(Transaction.date,
|
|
||||||
sa.desc(JournalEntry.is_debit),
|
|
||||||
JournalEntry.no)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __get_total_row(brought_forward: IncomeExpensesRow | None,
|
|
||||||
rows: list[IncomeExpensesRow]) -> IncomeExpensesRow:
|
|
||||||
"""Composes the total row.
|
|
||||||
|
|
||||||
:param brought_forward: The brought-forward row.
|
|
||||||
:param rows: The rows.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
row: IncomeExpensesRow = IncomeExpensesRow()
|
|
||||||
row.is_total = True
|
|
||||||
row.summary = gettext("Total")
|
|
||||||
row.income = sum([x.income for x in rows if x.income is not None])
|
|
||||||
row.expense = sum([x.expense for x in rows if x.expense is not None])
|
|
||||||
row.balance = row.income - row.expense
|
|
||||||
if brought_forward is not None:
|
|
||||||
row.balance = brought_forward.balance + row.balance
|
|
||||||
return row
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __populate_balance(brought_forward: IncomeExpensesRow | None,
|
|
||||||
rows: list[IncomeExpensesRow]) -> None:
|
|
||||||
"""Populates the balance of the rows.
|
|
||||||
|
|
||||||
:param brought_forward: The brought-forward row.
|
|
||||||
:param rows: The rows.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
balance: Decimal = 0 if brought_forward is None \
|
|
||||||
else brought_forward.balance
|
|
||||||
for row in rows:
|
|
||||||
if row.income is not None:
|
|
||||||
balance = balance + row.income
|
|
||||||
if row.expense is not None:
|
|
||||||
balance = balance - row.expense
|
|
||||||
row.balance = balance
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def populate_rows(rows: list[IncomeExpensesRow]) -> None:
|
|
||||||
transactions: dict[int, Transaction] \
|
|
||||||
= {x.id: x for x in Transaction.query.filter(
|
|
||||||
Transaction.id.in_({x.entry.transaction_id for x in rows
|
|
||||||
if x.entry is not None}))}
|
|
||||||
accounts: dict[int, Account] \
|
|
||||||
= {x.id: x for x in Account.query.filter(
|
|
||||||
Account.id.in_({x.entry.account_id for x in rows
|
|
||||||
if x.entry is not None}))}
|
|
||||||
for row in rows:
|
|
||||||
if row.entry is not None:
|
|
||||||
row.transaction = transactions[row.entry.transaction_id]
|
|
||||||
row.date = row.transaction.date
|
|
||||||
row.note = row.transaction.note
|
|
||||||
row.account = accounts[row.entry.account_id]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def csv_field_names(self) -> list[str]:
|
|
||||||
return ["Date", "Account", "Summary", "Income", "Expense", "Balance",
|
|
||||||
"Note"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def csv_filename(self) -> str:
|
|
||||||
return "income-expenses-{currency}-{account}-{period}.csv".format(
|
|
||||||
currency=self.currency.code, account=self.account.code,
|
|
||||||
period=self.period.spec)
|
|
||||||
|
|
||||||
def html(self) -> str:
|
|
||||||
params: IncomeExpensesParams = IncomeExpensesParams(
|
|
||||||
currency=self.currency,
|
|
||||||
account=self.account,
|
|
||||||
period=self.period,
|
|
||||||
data_rows=self.data_rows,
|
|
||||||
filler=self.populate_rows,
|
|
||||||
brought_forward=self.brought_forward,
|
|
||||||
total=self.total)
|
|
||||||
return render_template("accounting/report/income-expenses.html",
|
|
||||||
report=params)
|
|
||||||
|
|
||||||
|
|
||||||
class TrialBalance(Report[TrialBalanceRow]):
|
|
||||||
"""The trial balance."""
|
|
||||||
|
|
||||||
def __init__(self, currency: Currency, period: Period):
|
|
||||||
"""Constructs a trial balance.
|
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:param period: The period.
|
|
||||||
"""
|
|
||||||
self.currency: Currency = currency
|
|
||||||
"""The currency."""
|
|
||||||
self.period: Period = period
|
|
||||||
"""The period."""
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def get_rows(self) -> tuple[list[T], T | None, T | None]:
|
|
||||||
rows: list[TrialBalanceRow] = self.__query_balances()
|
|
||||||
self.__populate_url(rows)
|
|
||||||
total_row: TrialBalanceRow = self.__get_total_row(rows)
|
|
||||||
return rows, None, total_row
|
|
||||||
|
|
||||||
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_balances: sa.Select \
|
|
||||||
= sa.select(JournalEntry.account_id, balance_func)\
|
|
||||||
.join(Transaction).join(Account)\
|
|
||||||
.filter(*conditions)\
|
|
||||||
.group_by(JournalEntry.account_id)\
|
|
||||||
.order_by(Account.base_code, Account.no)
|
|
||||||
balances: list[sa.Row] = db.session.execute(select_balances).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
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def populate_rows(rows: list[JournalRow]) -> 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"
|
|
||||||
|
|
||||||
def html(self) -> str:
|
|
||||||
params: TrialBalanceParams = TrialBalanceParams(
|
|
||||||
currency=self.currency,
|
|
||||||
period=self.period,
|
|
||||||
data_rows=self.data_rows,
|
|
||||||
total=self.total)
|
|
||||||
return render_template("accounting/report/trial-balance.html",
|
|
||||||
report=params)
|
|
||||||
|
|
||||||
|
|
||||||
class IncomeStatement(Report[IncomeStatementRow]):
|
|
||||||
"""The income statement."""
|
|
||||||
|
|
||||||
def __init__(self, currency: Currency, period: Period):
|
|
||||||
"""Constructs an income statement.
|
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:param period: The period.
|
|
||||||
"""
|
|
||||||
self.currency: Currency = currency
|
|
||||||
"""The currency."""
|
|
||||||
self.period: Period = period
|
|
||||||
"""The period."""
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def get_rows(self) -> tuple[list[T], T | None, T | None]:
|
|
||||||
rows: list[IncomeStatementRow] = self.__query_balances()
|
|
||||||
rows = self.__get_income_statement_rows(rows)
|
|
||||||
return rows, None, None
|
|
||||||
|
|
||||||
def __query_balances(self) -> list[IncomeStatementRow]:
|
|
||||||
"""Queries and returns the balances.
|
|
||||||
|
|
||||||
:return: The balances.
|
|
||||||
"""
|
|
||||||
sub_conditions: list[sa.BinaryExpression] \
|
|
||||||
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
|
|
||||||
conditions: list[sa.BinaryExpression] \
|
|
||||||
= [JournalEntry.currency_code == self.currency.code,
|
|
||||||
sa.or_(*sub_conditions)]
|
|
||||||
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_balance: sa.Select \
|
|
||||||
= sa.select(JournalEntry.account_id, balance_func)\
|
|
||||||
.join(Transaction).join(Account)\
|
|
||||||
.filter(*conditions)\
|
|
||||||
.group_by(JournalEntry.account_id)\
|
|
||||||
.order_by(Account.base_code, Account.no)
|
|
||||||
balances: list[sa.Row] = db.session.execute(select_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()}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
return [IncomeStatementRow(code=accounts[x.account_id].code,
|
|
||||||
title=accounts[x.account_id].title,
|
|
||||||
amount=x.balance,
|
|
||||||
url=get_url(accounts[x.account_id]))
|
|
||||||
for x in balances]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __get_income_statement_rows(balances: list[IncomeStatementRow]) \
|
|
||||||
-> list[IncomeStatementRow]:
|
|
||||||
"""Composes the categories and totals from the balance rows.
|
|
||||||
|
|
||||||
:param balances: The balance rows.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
categories: list[BaseAccount] \
|
|
||||||
= BaseAccount.query\
|
|
||||||
.filter(BaseAccount.code.in_([str(x) for x in range(4, 10)]))\
|
|
||||||
.order_by(BaseAccount.code).all()
|
|
||||||
subcategory_codes: set[str] = {x.code[:2] for x in balances}
|
|
||||||
subcategory_dict: dict[str, BaseAccount] \
|
|
||||||
= {x.code: x for x in BaseAccount.query
|
|
||||||
.filter(BaseAccount.code.in_({x.code[:2] for x in balances}))}
|
|
||||||
|
|
||||||
balances_by_subcategory: dict[str, list[IncomeStatementRow]] \
|
|
||||||
= {x: [] for x in subcategory_codes}
|
|
||||||
for balance in balances:
|
|
||||||
balances_by_subcategory[balance.code[:2]].append(balance)
|
|
||||||
|
|
||||||
subcategories_by_category: dict[str, list[BaseAccount]] \
|
|
||||||
= {x.code: [] for x in categories}
|
|
||||||
for subcategory in subcategory_dict.values():
|
|
||||||
subcategories_by_category[subcategory.code[0]].append(subcategory)
|
|
||||||
|
|
||||||
total_titles: dict[str, str] \
|
|
||||||
= {"4": "total revenue",
|
|
||||||
"5": "gross income",
|
|
||||||
"6": "operating income",
|
|
||||||
"7": "before tax income",
|
|
||||||
"8": "after tax income",
|
|
||||||
"9": "net income or loss for current period"}
|
|
||||||
|
|
||||||
rows: list[IncomeStatementRow] = []
|
|
||||||
total: Decimal = Decimal(0)
|
|
||||||
for category in categories:
|
|
||||||
rows.append(IncomeStatementRow(code=category.code,
|
|
||||||
title=category.title,
|
|
||||||
is_category=True))
|
|
||||||
for subcategory in subcategories_by_category[category.code]:
|
|
||||||
rows.append(IncomeStatementRow(code=subcategory.code,
|
|
||||||
title=subcategory.title,
|
|
||||||
is_subcategory=True))
|
|
||||||
subtotal: Decimal = Decimal(0)
|
|
||||||
for balance in balances_by_subcategory[subcategory.code]:
|
|
||||||
rows.append(balance)
|
|
||||||
subtotal = subtotal + balance.amount
|
|
||||||
rows.append(IncomeStatementRow(amount=subtotal,
|
|
||||||
is_subtotal=True))
|
|
||||||
total = total + subtotal
|
|
||||||
rows.append(IncomeStatementRow(title=total_titles[category.code],
|
|
||||||
amount=total,
|
|
||||||
is_total=True))
|
|
||||||
return rows
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def populate_rows(rows: list[JournalRow]) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
def csv_field_names(self) -> list[str]:
|
|
||||||
return ["", "Amount"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def csv_filename(self) -> str:
|
|
||||||
return f"income-statement-{self.period.spec}.csv"
|
|
||||||
|
|
||||||
def html(self) -> str:
|
|
||||||
params: IncomeStatementParams = IncomeStatementParams(
|
|
||||||
currency=self.currency,
|
|
||||||
period=self.period,
|
|
||||||
data_rows=self.data_rows)
|
|
||||||
return render_template("accounting/report/income-statement.html",
|
|
||||||
report=params)
|
|
25
src/accounting/report/reports/__init__.py
Normal file
25
src/accounting/report/reports/__init__.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/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 reports.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from .balance_sheet import BalanceSheet
|
||||||
|
from .income_expenses import IncomeExpenses
|
||||||
|
from .income_statement import IncomeStatement
|
||||||
|
from .journal import Journal
|
||||||
|
from .ledger import Ledger
|
||||||
|
from .trial_balance import TrialBalance
|
@ -1,5 +1,5 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
# The Mia! Accounting Flask Project.
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
# Copyright (c) 2023 imacat.
|
||||||
#
|
#
|
||||||
@ -18,24 +18,22 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import csv
|
import csv
|
||||||
import typing as t
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
|
|
||||||
urlunparse
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import url_for, render_template, Response, request
|
from flask import url_for, render_template, Response
|
||||||
|
|
||||||
from accounting import db
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
||||||
JournalEntry
|
JournalEntry
|
||||||
from accounting.utils.txn_types import TransactionType
|
from accounting.report.option_link import OptionLink
|
||||||
from .option_link import OptionLink
|
from accounting.report.page_params import PageParams
|
||||||
from .period import Period
|
from accounting.report.period import Period
|
||||||
from .period_choosers import BalanceSheetPeriodChooser
|
from accounting.report.period_choosers import BalanceSheetPeriodChooser
|
||||||
from .report_chooser import ReportChooser
|
from accounting.report.report_chooser import ReportChooser
|
||||||
from .report_type import ReportType
|
from accounting.report.report_type import ReportType
|
||||||
|
|
||||||
|
|
||||||
class BalanceSheetAccount:
|
class BalanceSheetAccount:
|
||||||
@ -100,6 +98,168 @@ class BalanceSheetSection:
|
|||||||
return sum([x.total for x in self.subsections])
|
return sum([x.total for x in self.subsections])
|
||||||
|
|
||||||
|
|
||||||
|
class AccountCollector:
|
||||||
|
"""The balance sheet account collector."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, period: Period):
|
||||||
|
"""Constructs the balance sheet account collector.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.accounts: list[BalanceSheetAccount] = self.__query_balances()
|
||||||
|
"""The balance sheet accounts."""
|
||||||
|
|
||||||
|
def __query_balances(self) -> list[BalanceSheetAccount]:
|
||||||
|
"""Queries and returns the balances.
|
||||||
|
|
||||||
|
:return: The balances.
|
||||||
|
"""
|
||||||
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
|
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
|
sa.or_(*sub_conditions)]
|
||||||
|
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_balance: sa.Select \
|
||||||
|
= sa.select(Account.id, Account.base_code, Account.no,
|
||||||
|
balance_func)\
|
||||||
|
.join(Transaction).join(Account)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.group_by(Account.id, Account.base_code, Account.no)\
|
||||||
|
.order_by(Account.base_code, Account.no)
|
||||||
|
account_balances: list[sa.Row] \
|
||||||
|
= db.session.execute(select_balance).all()
|
||||||
|
self.__all_accounts: list[Account] = Account.query\
|
||||||
|
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
|
||||||
|
Account.base_code == "3351",
|
||||||
|
Account.base_code == "3353")).all()
|
||||||
|
account_by_id: dict[int, Account] \
|
||||||
|
= {x.id: x for x in self.__all_accounts}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
self.accounts: list[BalanceSheetAccount] \
|
||||||
|
= [BalanceSheetAccount(account=account_by_id[x.id],
|
||||||
|
amount=x.balance,
|
||||||
|
url=get_url(account_by_id[x.id]))
|
||||||
|
for x in account_balances]
|
||||||
|
self.__add_accumulated()
|
||||||
|
self.__add_current_period()
|
||||||
|
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
|
||||||
|
for balance in self.accounts:
|
||||||
|
if not balance.account.base_code.startswith("1"):
|
||||||
|
balance.amount = -balance.amount
|
||||||
|
return self.accounts
|
||||||
|
|
||||||
|
def __add_accumulated(self) -> None:
|
||||||
|
"""Adds the accumulated profit or loss to the balances.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
code: str = "3351-001"
|
||||||
|
amount: Decimal | None = self.__query_accumulated()
|
||||||
|
url: str = url_for("accounting.report.income-statement",
|
||||||
|
currency=self.__currency,
|
||||||
|
period=self.__period.before)
|
||||||
|
self.__add_owner_s_equity(code, amount, url)
|
||||||
|
|
||||||
|
def __query_accumulated(self) -> Decimal | None:
|
||||||
|
"""Queries and returns the accumulated profit or loss.
|
||||||
|
|
||||||
|
:return: The accumulated profit or loss.
|
||||||
|
"""
|
||||||
|
if self.__period.start is None:
|
||||||
|
return None
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
|
Transaction.date < self.__period.start]
|
||||||
|
conditions.extend([sa.not_(Account.base_code.startswith(x))
|
||||||
|
for x in {"1", "2"}])
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
|
else_=-JournalEntry.amount)).label("balance")
|
||||||
|
select_balance: sa.Select = sa.select(balance_func)\
|
||||||
|
.join(Transaction).join(Account).filter(*conditions)
|
||||||
|
return db.session.scalar(select_balance)
|
||||||
|
|
||||||
|
def __add_current_period(self) -> None:
|
||||||
|
"""Adds the accumulated profit or loss to the balances.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
code: str = "3353-001"
|
||||||
|
amount: Decimal | None = self.__query_currency_period()
|
||||||
|
url: str = url_for("accounting.report.income-statement",
|
||||||
|
currency=self.__currency, period=self.__period)
|
||||||
|
self.__add_owner_s_equity(code, amount, url)
|
||||||
|
|
||||||
|
def __query_currency_period(self) -> Decimal | None:
|
||||||
|
"""Queries and returns the net income or loss for current period.
|
||||||
|
|
||||||
|
:return: The net income or loss for current period.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
conditions.extend([sa.not_(Account.base_code.startswith(x))
|
||||||
|
for x in {"1", "2"}])
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
|
else_=-JournalEntry.amount)).label("balance")
|
||||||
|
select_balance: sa.Select = sa.select(balance_func)\
|
||||||
|
.join(Transaction).join(Account).filter(*conditions)
|
||||||
|
return db.session.scalar(select_balance)
|
||||||
|
|
||||||
|
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
|
||||||
|
url: str) -> None:
|
||||||
|
"""Adds an owner's equity balance.
|
||||||
|
|
||||||
|
:param code: The code of the account to add.
|
||||||
|
:param amount: The amount.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
# There is an existing balance.
|
||||||
|
account_balance_by_code: dict[str, BalanceSheetAccount] \
|
||||||
|
= {x.account.code: x for x in self.accounts}
|
||||||
|
if code in account_balance_by_code:
|
||||||
|
balance: BalanceSheetAccount = account_balance_by_code[code]
|
||||||
|
balance.url = url
|
||||||
|
if amount is not None:
|
||||||
|
balance.amount = balance.amount + amount
|
||||||
|
return
|
||||||
|
# Add a new balance
|
||||||
|
if amount is None:
|
||||||
|
return
|
||||||
|
account_by_code: dict[str, Account] \
|
||||||
|
= {x.code: x for x in self.__all_accounts}
|
||||||
|
self.accounts.append(BalanceSheetAccount(account=account_by_code[code],
|
||||||
|
amount=amount,
|
||||||
|
url=url))
|
||||||
|
|
||||||
|
|
||||||
class CSVHalfRow:
|
class CSVHalfRow:
|
||||||
"""A half row in the CSV balance sheet."""
|
"""A half row in the CSV balance sheet."""
|
||||||
|
|
||||||
@ -139,7 +299,7 @@ class CSVRow:
|
|||||||
self.liability_title, self.liability_amount]
|
self.liability_title, self.liability_amount]
|
||||||
|
|
||||||
|
|
||||||
class BalanceSheetPageParams:
|
class BalanceSheetPageParams(PageParams):
|
||||||
"""The HTML parameters of the balance sheet."""
|
"""The HTML parameters of the balance sheet."""
|
||||||
|
|
||||||
def __init__(self, currency: Currency,
|
def __init__(self, currency: Currency,
|
||||||
@ -161,7 +321,7 @@ class BalanceSheetPageParams:
|
|||||||
"""The currency."""
|
"""The currency."""
|
||||||
self.period: Period = period
|
self.period: Period = period
|
||||||
"""The period."""
|
"""The period."""
|
||||||
self.has_data: bool = has_data
|
self.__has_data: bool = has_data
|
||||||
"""True if there is any data, or False otherwise."""
|
"""True if there is any data, or False otherwise."""
|
||||||
self.assets: BalanceSheetSection = assets
|
self.assets: BalanceSheetSection = assets
|
||||||
"""The assets."""
|
"""The assets."""
|
||||||
@ -172,25 +332,24 @@ class BalanceSheetPageParams:
|
|||||||
self.period_chooser: BalanceSheetPeriodChooser \
|
self.period_chooser: BalanceSheetPeriodChooser \
|
||||||
= BalanceSheetPeriodChooser(currency)
|
= BalanceSheetPeriodChooser(currency)
|
||||||
"""The period chooser."""
|
"""The period chooser."""
|
||||||
self.report_chooser: ReportChooser \
|
|
||||||
= ReportChooser(ReportType.BALANCE_SHEET,
|
|
||||||
currency=currency,
|
|
||||||
period=period)
|
|
||||||
"""The report chooser."""
|
|
||||||
self.txn_types: t.Type[TransactionType] = TransactionType
|
|
||||||
"""The transaction types."""
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def csv_uri(self) -> str:
|
def has_data(self) -> bool:
|
||||||
uri: str = request.full_path if request.query_string \
|
"""Returns whether there is any data on the page.
|
||||||
else request.path
|
|
||||||
uri_p: ParseResult = urlparse(uri)
|
:return: True if there is any data, or False otherwise.
|
||||||
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
"""
|
||||||
params = [x for x in params if x[0] != "as"]
|
return self.__has_data
|
||||||
params.append(("as", "csv"))
|
|
||||||
parts: list[str] = list(uri_p)
|
@property
|
||||||
parts[4] = urlencode(params)
|
def report_chooser(self) -> ReportChooser:
|
||||||
return urlunparse(parts)
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
return ReportChooser(ReportType.BALANCE_SHEET,
|
||||||
|
currency=self.currency,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def currency_options(self) -> list[OptionLink]:
|
def currency_options(self) -> list[OptionLink]:
|
||||||
@ -222,19 +381,28 @@ class BalanceSheet:
|
|||||||
:param currency: The currency.
|
:param currency: The currency.
|
||||||
:param period: The period.
|
:param period: The period.
|
||||||
"""
|
"""
|
||||||
self.currency: Currency = currency
|
self.__currency: Currency = currency
|
||||||
"""The currency."""
|
"""The currency."""
|
||||||
self.period: Period = period
|
self.__period: Period = period
|
||||||
"""The period."""
|
"""The period."""
|
||||||
|
self.__has_data: bool
|
||||||
|
"""True if there is any data, or False otherwise."""
|
||||||
|
self.__assets: BalanceSheetSection
|
||||||
|
"""The assets."""
|
||||||
|
self.__liabilities: BalanceSheetSection
|
||||||
|
"""The liabilities."""
|
||||||
|
self.__owner_s_equity: BalanceSheetSection
|
||||||
|
"""The owner's equity."""
|
||||||
self.__set_data()
|
self.__set_data()
|
||||||
|
|
||||||
def __set_data(self) -> None:
|
def __set_data(self) -> None:
|
||||||
"""Queries and sets assets, the liabilities, and the owner's equity
|
"""Queries and sets assets, the liabilities, and the owner's equity
|
||||||
sections in the balance sheet.
|
sections in the balance sheet.
|
||||||
|
|
||||||
:return: The assets, the liabilities, and the owner's equity sections.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
balances: list[BalanceSheetAccount] = self.__query_balances()
|
balances: list[BalanceSheetAccount] = AccountCollector(
|
||||||
|
self.__currency, self.__period).accounts
|
||||||
|
|
||||||
titles: list[BaseAccount] = BaseAccount.query\
|
titles: list[BaseAccount] = BaseAccount.query\
|
||||||
.filter(BaseAccount.code.in_({"1", "2", "3"})).all()
|
.filter(BaseAccount.code.in_({"1", "2", "3"})).all()
|
||||||
@ -251,164 +419,10 @@ class BalanceSheet:
|
|||||||
for balance in balances:
|
for balance in balances:
|
||||||
subsections[balance.account.base_code[:2]].accounts.append(balance)
|
subsections[balance.account.base_code[:2]].accounts.append(balance)
|
||||||
|
|
||||||
self.__has_data: bool = len(balances) > 0
|
self.__has_data = len(balances) > 0
|
||||||
self.__assets: BalanceSheetSection = sections["1"]
|
self.__assets = sections["1"]
|
||||||
self.__liabilities: BalanceSheetSection = sections["2"]
|
self.__liabilities = sections["2"]
|
||||||
self.__owner_s_equity: BalanceSheetSection = sections["3"]
|
self.__owner_s_equity = sections["3"]
|
||||||
|
|
||||||
def __query_balances(self) -> list[BalanceSheetAccount]:
|
|
||||||
"""Queries and returns the balances.
|
|
||||||
|
|
||||||
:return: The balances.
|
|
||||||
"""
|
|
||||||
sub_conditions: list[sa.BinaryExpression] \
|
|
||||||
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
|
|
||||||
conditions: list[sa.BinaryExpression] \
|
|
||||||
= [JournalEntry.currency_code == self.currency.code,
|
|
||||||
sa.or_(*sub_conditions)]
|
|
||||||
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_balance: sa.Select \
|
|
||||||
= sa.select(Account.id, Account.base_code, Account.no,
|
|
||||||
balance_func)\
|
|
||||||
.join(Transaction).join(Account)\
|
|
||||||
.filter(*conditions)\
|
|
||||||
.group_by(Account.id, Account.base_code, Account.no)\
|
|
||||||
.order_by(Account.base_code, Account.no)
|
|
||||||
account_balances: list[sa.Row] \
|
|
||||||
= db.session.execute(select_balance).all()
|
|
||||||
account_by_id: dict[int, Account] \
|
|
||||||
= {x.id: x for x in Account.query
|
|
||||||
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
|
|
||||||
Account.base_code == "3351",
|
|
||||||
Account.base_code == "3353")).all()}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
balances: list[BalanceSheetAccount] \
|
|
||||||
= [BalanceSheetAccount(account=account_by_id[x.id],
|
|
||||||
amount=x.balance,
|
|
||||||
url=get_url(account_by_id[x.id]))
|
|
||||||
for x in account_balances]
|
|
||||||
self.__add_accumulated(balances, list(account_by_id.values()))
|
|
||||||
self.__add_current_period(balances, list(account_by_id.values()))
|
|
||||||
for balance in balances:
|
|
||||||
if not balance.account.base_code.startswith("1"):
|
|
||||||
balance.amount = -balance.amount
|
|
||||||
return balances
|
|
||||||
|
|
||||||
def __add_accumulated(self, balances: list[BalanceSheetAccount],
|
|
||||||
accounts: list[Account]) -> None:
|
|
||||||
"""Adds the accumulated profit or loss to the balances.
|
|
||||||
|
|
||||||
:param balances: The accounts on the balance sheet.
|
|
||||||
:param accounts: The accounts.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
code: str = "3351-001"
|
|
||||||
amount: Decimal | None = self.__query_accumulated()
|
|
||||||
url: str = url_for("accounting.report.income-statement",
|
|
||||||
currency=self.currency, period=self.period.before)
|
|
||||||
self.__add_owner_s_equity(balances, accounts, code, amount, url)
|
|
||||||
|
|
||||||
def __query_accumulated(self) -> Decimal | None:
|
|
||||||
"""Queries and returns the accumulated profit or loss.
|
|
||||||
|
|
||||||
:return: The accumulated profit or loss.
|
|
||||||
"""
|
|
||||||
if self.period.start is None:
|
|
||||||
return None
|
|
||||||
conditions: list[sa.BinaryExpression] \
|
|
||||||
= [JournalEntry.currency_code == self.currency.code,
|
|
||||||
Transaction.date < self.period.start]
|
|
||||||
conditions.extend([sa.not_(Account.base_code.startswith(x))
|
|
||||||
for x in {"1", "2"}])
|
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
|
||||||
(JournalEntry.is_debit, JournalEntry.amount),
|
|
||||||
else_=-JournalEntry.amount)).label("balance")
|
|
||||||
select_balance: sa.Select = sa.select(balance_func)\
|
|
||||||
.join(Transaction).join(Account).filter(*conditions)
|
|
||||||
return db.session.scalar(select_balance)
|
|
||||||
|
|
||||||
def __add_current_period(self, balances: list[BalanceSheetAccount],
|
|
||||||
accounts: list[Account]) -> None:
|
|
||||||
"""Adds the accumulated profit or loss to the balances.
|
|
||||||
|
|
||||||
:param balances: The accounts on the balance sheet.
|
|
||||||
:param accounts: The accounts.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
code: str = "3353-001"
|
|
||||||
amount: Decimal | None = self.__query_currency_period()
|
|
||||||
url: str = url_for("accounting.report.income-statement",
|
|
||||||
currency=self.currency, period=self.period)
|
|
||||||
self.__add_owner_s_equity(balances, accounts, code, amount, url)
|
|
||||||
|
|
||||||
def __query_currency_period(self) -> Decimal | None:
|
|
||||||
"""Queries and returns the net income or loss for current period.
|
|
||||||
|
|
||||||
:return: The net income or loss for current period.
|
|
||||||
"""
|
|
||||||
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)
|
|
||||||
conditions.extend([sa.not_(Account.base_code.startswith(x))
|
|
||||||
for x in {"1", "2"}])
|
|
||||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
|
||||||
(JournalEntry.is_debit, JournalEntry.amount),
|
|
||||||
else_=-JournalEntry.amount)).label("balance")
|
|
||||||
select_balance: sa.Select = sa.select(balance_func)\
|
|
||||||
.join(Transaction).join(Account).filter(*conditions)
|
|
||||||
return db.session.scalar(select_balance)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __add_owner_s_equity(balances: list[BalanceSheetAccount],
|
|
||||||
accounts: list[Account],
|
|
||||||
code: str,
|
|
||||||
amount: Decimal | None,
|
|
||||||
url: str):
|
|
||||||
"""Adds an owner's equity balance.
|
|
||||||
|
|
||||||
:param balances: The accounts on the balance sheet.
|
|
||||||
:param accounts: The accounts.
|
|
||||||
:param code: The code of the account to add.
|
|
||||||
:param amount: The amount.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
# There is an existing balance.
|
|
||||||
balance_by_code: dict[str, BalanceSheetAccount] \
|
|
||||||
= {x.account.code: x for x in balances}
|
|
||||||
if code in balance_by_code:
|
|
||||||
balance: BalanceSheetAccount = balance_by_code[code]
|
|
||||||
balance.url = url
|
|
||||||
if amount is not None:
|
|
||||||
balance.amount = balance.amount + amount
|
|
||||||
return
|
|
||||||
# Add a new balance
|
|
||||||
if amount is None:
|
|
||||||
return
|
|
||||||
account_by_code: dict[str, Account] = {x.code: x for x in accounts}
|
|
||||||
balances.append(BalanceSheetAccount(
|
|
||||||
account=account_by_code[code],
|
|
||||||
amount=amount,
|
|
||||||
url=url))
|
|
||||||
|
|
||||||
def csv(self) -> Response:
|
def csv(self) -> Response:
|
||||||
"""Returns the report as CSV for download.
|
"""Returns the report as CSV for download.
|
||||||
@ -416,7 +430,7 @@ class BalanceSheet:
|
|||||||
:return: The response of the report for download.
|
:return: The response of the report for download.
|
||||||
"""
|
"""
|
||||||
filename: str = "balance-sheet-{currency}-{period}.csv"\
|
filename: str = "balance-sheet-{currency}-{period}.csv"\
|
||||||
.format(currency=self.currency.code, period=self.period.spec)
|
.format(currency=self.__currency.code, period=self.__period.spec)
|
||||||
rows: list[CSVRow] = self.__get_csv_rows()
|
rows: list[CSVRow] = self.__get_csv_rows()
|
||||||
with StringIO() as fp:
|
with StringIO() as fp:
|
||||||
writer = csv.writer(fp)
|
writer = csv.writer(fp)
|
||||||
@ -435,10 +449,12 @@ class BalanceSheet:
|
|||||||
asset_rows: list[CSVHalfRow] = self.__section_csv_rows(self.__assets)
|
asset_rows: list[CSVHalfRow] = self.__section_csv_rows(self.__assets)
|
||||||
liability_rows: list[CSVHalfRow] = []
|
liability_rows: list[CSVHalfRow] = []
|
||||||
liability_rows.extend(self.__section_csv_rows(self.__liabilities))
|
liability_rows.extend(self.__section_csv_rows(self.__liabilities))
|
||||||
liability_rows.append(CSVHalfRow("Total", self.__liabilities.total))
|
liability_rows.append(CSVHalfRow(gettext("Total"),
|
||||||
|
self.__liabilities.total))
|
||||||
liability_rows.append(CSVHalfRow(None, None))
|
liability_rows.append(CSVHalfRow(None, None))
|
||||||
liability_rows.extend(self.__section_csv_rows(self.__owner_s_equity))
|
liability_rows.extend(self.__section_csv_rows(self.__owner_s_equity))
|
||||||
liability_rows.append(CSVHalfRow("Total", self.__owner_s_equity.total))
|
liability_rows.append(CSVHalfRow(gettext("Total"),
|
||||||
|
self.__owner_s_equity.total))
|
||||||
rows: list[CSVRow] = [CSVRow() for _ in
|
rows: list[CSVRow] = [CSVRow() for _ in
|
||||||
range(max(len(asset_rows), len(liability_rows)))]
|
range(max(len(asset_rows), len(liability_rows)))]
|
||||||
for i in range(len(rows)):
|
for i in range(len(rows)):
|
||||||
@ -449,9 +465,9 @@ class BalanceSheet:
|
|||||||
rows[i].liability_title = liability_rows[i].title
|
rows[i].liability_title = liability_rows[i].title
|
||||||
rows[i].liability_amount = liability_rows[i].amount
|
rows[i].liability_amount = liability_rows[i].amount
|
||||||
total: CSVRow = CSVRow()
|
total: CSVRow = CSVRow()
|
||||||
total.asset_title = "Total"
|
total.asset_title = gettext("Total")
|
||||||
total.asset_amount = self.__assets.total
|
total.asset_amount = self.__assets.total
|
||||||
total.liability_title = "Total"
|
total.liability_title = gettext("Total")
|
||||||
total.liability_amount \
|
total.liability_amount \
|
||||||
= self.__liabilities.total + self.__owner_s_equity.total
|
= self.__liabilities.total + self.__owner_s_equity.total
|
||||||
rows.append(total)
|
rows.append(total)
|
||||||
@ -479,8 +495,8 @@ class BalanceSheet:
|
|||||||
:return: The report as HTML.
|
:return: The report as HTML.
|
||||||
"""
|
"""
|
||||||
params: BalanceSheetPageParams = BalanceSheetPageParams(
|
params: BalanceSheetPageParams = BalanceSheetPageParams(
|
||||||
currency=self.currency,
|
currency=self.__currency,
|
||||||
period=self.period,
|
period=self.__period,
|
||||||
has_data=self.__has_data,
|
has_data=self.__has_data,
|
||||||
assets=self.__assets,
|
assets=self.__assets,
|
||||||
liabilities=self.__liabilities,
|
liabilities=self.__liabilities,
|
466
src/accounting/report/reports/income_expenses.py
Normal file
466
src/accounting/report/reports/income_expenses.py
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/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 income and expenses log.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import url_for, render_template, Response
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||||
|
from accounting.report.option_link import OptionLink
|
||||||
|
from accounting.report.page_params import PageParams
|
||||||
|
from accounting.report.period import Period
|
||||||
|
from accounting.report.period_choosers import IncomeExpensesPeriodChooser
|
||||||
|
from accounting.report.report_chooser import ReportChooser
|
||||||
|
from accounting.report.report_type import ReportType
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class Entry:
|
||||||
|
"""An entry in the income and expenses log."""
|
||||||
|
|
||||||
|
def __init__(self, entry: JournalEntry | None = None):
|
||||||
|
"""Constructs the entry in the income and expenses log.
|
||||||
|
|
||||||
|
:param entry: The journal entry.
|
||||||
|
"""
|
||||||
|
self.entry: JournalEntry | None = None
|
||||||
|
"""The journal entry."""
|
||||||
|
self.transaction: Transaction | None = None
|
||||||
|
"""The transaction."""
|
||||||
|
self.is_brought_forward: bool = False
|
||||||
|
"""Whether this is the brought-forward entry."""
|
||||||
|
self.is_total: bool = False
|
||||||
|
"""Whether this is the total entry."""
|
||||||
|
self.date: date | None = None
|
||||||
|
"""The date."""
|
||||||
|
self.account: Account | None = None
|
||||||
|
"""The account."""
|
||||||
|
self.summary: str | None = None
|
||||||
|
"""The summary."""
|
||||||
|
self.income: Decimal | None = None
|
||||||
|
"""The income amount."""
|
||||||
|
self.expense: Decimal | None = None
|
||||||
|
"""The expense amount."""
|
||||||
|
self.balance: Decimal | None = None
|
||||||
|
"""The balance."""
|
||||||
|
self.note: str | None = None
|
||||||
|
"""The note."""
|
||||||
|
if entry is not None:
|
||||||
|
self.entry = entry
|
||||||
|
self.summary = entry.summary
|
||||||
|
self.income = None if entry.is_debit else entry.amount
|
||||||
|
self.expense = entry.amount if entry.is_debit else None
|
||||||
|
|
||||||
|
|
||||||
|
class EntryCollector:
|
||||||
|
"""The income and expenses log entry collector."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||||
|
"""Constructs the income and expenses log entry collector.
|
||||||
|
|
||||||
|
: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"""
|
||||||
|
self.brought_forward: Entry | None
|
||||||
|
"""The brought-forward entry."""
|
||||||
|
self.entries: list[Entry]
|
||||||
|
"""The log entries."""
|
||||||
|
self.total: Entry
|
||||||
|
"""The total entry."""
|
||||||
|
self.brought_forward = self.__get_brought_forward_entry()
|
||||||
|
self.entries = self.__query_entries()
|
||||||
|
self.total = self.__get_total_entry()
|
||||||
|
self.__populate_balance()
|
||||||
|
|
||||||
|
def __get_brought_forward_entry(self) -> Entry | None:
|
||||||
|
"""Queries, composes and returns the brought-forward entry.
|
||||||
|
|
||||||
|
:return: The brought-forward entry, or None if the period starts from
|
||||||
|
the beginning.
|
||||||
|
"""
|
||||||
|
if self.__period.start is None:
|
||||||
|
return None
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
|
else_=-JournalEntry.amount))
|
||||||
|
select: sa.Select = sa.Select(balance_func).join(Transaction)\
|
||||||
|
.filter(JournalEntry.currency_code == self.__currency.code,
|
||||||
|
JournalEntry.account_id == self.__account.id,
|
||||||
|
Transaction.date < self.__period.start)
|
||||||
|
balance: int | None = db.session.scalar(select)
|
||||||
|
if balance is None:
|
||||||
|
return None
|
||||||
|
entry: Entry = Entry()
|
||||||
|
entry.is_brought_forward = True
|
||||||
|
entry.date = self.__period.start
|
||||||
|
entry.account = Account.find_by_code("3351-001")
|
||||||
|
entry.summary = gettext("Brought forward")
|
||||||
|
if balance > 0:
|
||||||
|
entry.income = balance
|
||||||
|
elif balance < 0:
|
||||||
|
entry.expense = -balance
|
||||||
|
entry.balance = balance
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def __query_entries(self) -> list[Entry]:
|
||||||
|
"""Queries and returns the log entries.
|
||||||
|
|
||||||
|
:return: The log entries.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
|
JournalEntry.account_id == self.__account.id]
|
||||||
|
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)
|
||||||
|
txn_with_account: sa.Select = sa.Select(Transaction.id).\
|
||||||
|
join(JournalEntry).filter(*conditions)
|
||||||
|
|
||||||
|
return [Entry(x)
|
||||||
|
for x in JournalEntry.query.join(Transaction)
|
||||||
|
.filter(JournalEntry.transaction_id.in_(txn_with_account),
|
||||||
|
JournalEntry.currency_code == self.__currency.code,
|
||||||
|
JournalEntry.account_id != self.__account.id)
|
||||||
|
.order_by(Transaction.date,
|
||||||
|
JournalEntry.is_debit,
|
||||||
|
JournalEntry.no)]
|
||||||
|
|
||||||
|
def __get_total_entry(self) -> Entry:
|
||||||
|
"""Composes the total entry.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
entry: Entry = Entry()
|
||||||
|
entry.is_total = True
|
||||||
|
entry.summary = gettext("Total")
|
||||||
|
entry.income = sum([x.income for x in self.entries
|
||||||
|
if x.income is not None])
|
||||||
|
entry.expense = sum([x.expense for x in self.entries
|
||||||
|
if x.expense is not None])
|
||||||
|
entry.balance = entry.income - entry.expense
|
||||||
|
if self.brought_forward is not None:
|
||||||
|
entry.balance = self.brought_forward.balance + entry.balance
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def __populate_balance(self) -> None:
|
||||||
|
"""Populates the balance of the entries.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
balance: Decimal = 0 if self.brought_forward is None \
|
||||||
|
else self.brought_forward.balance
|
||||||
|
for entry in self.entries:
|
||||||
|
if entry.income is not None:
|
||||||
|
balance = balance + entry.income
|
||||||
|
if entry.expense is not None:
|
||||||
|
balance = balance - entry.expense
|
||||||
|
entry.balance = balance
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow:
|
||||||
|
"""A row in the CSV income and expenses log."""
|
||||||
|
|
||||||
|
def __init__(self, txn_date: date | str | None,
|
||||||
|
account: str | None,
|
||||||
|
summary: str | None,
|
||||||
|
income: str | Decimal | None,
|
||||||
|
expense: str | Decimal | None,
|
||||||
|
balance: str | Decimal | None,
|
||||||
|
note: str | None):
|
||||||
|
"""Constructs a row in the CSV income and expenses log.
|
||||||
|
|
||||||
|
:param txn_date: The transaction date.
|
||||||
|
:param account: The account.
|
||||||
|
:param summary: The summary.
|
||||||
|
:param income: The income.
|
||||||
|
:param expense: The expense.
|
||||||
|
:param balance: The balance.
|
||||||
|
:param note: The note.
|
||||||
|
"""
|
||||||
|
self.date: date | str | None = txn_date
|
||||||
|
"""The date."""
|
||||||
|
self.account: str | None = account
|
||||||
|
"""The account."""
|
||||||
|
self.summary: str | None = summary
|
||||||
|
"""The summary."""
|
||||||
|
self.income: str | Decimal | None = income
|
||||||
|
"""The income."""
|
||||||
|
self.expense: str | Decimal | None = expense
|
||||||
|
"""The expense."""
|
||||||
|
self.balance: str | Decimal | None = balance
|
||||||
|
"""The balance."""
|
||||||
|
self.note: str | None = note
|
||||||
|
"""The note."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.date, self.account, self.summary,
|
||||||
|
self.income, self.expense, self.balance, self.note]
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeExpensesPageParams(PageParams):
|
||||||
|
"""The HTML parameters of the income and expenses log."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
account: Account,
|
||||||
|
period: Period,
|
||||||
|
has_data: bool,
|
||||||
|
pagination: Pagination[Entry],
|
||||||
|
brought_forward: Entry | None,
|
||||||
|
entries: list[Entry],
|
||||||
|
total: Entry | None):
|
||||||
|
"""Constructs the HTML parameters of the income and expenses log.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
:param has_data: True if there is any data, or False otherwise.
|
||||||
|
:param brought_forward: The brought-forward entry.
|
||||||
|
:param entries: The log entries.
|
||||||
|
:param total: The total entry.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__has_data: bool = has_data
|
||||||
|
"""True if there is any data, or False otherwise."""
|
||||||
|
self.pagination: Pagination[Entry] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.brought_forward: Entry | None = brought_forward
|
||||||
|
"""The brought-forward entry."""
|
||||||
|
self.entries: list[Entry] = entries
|
||||||
|
"""The entries."""
|
||||||
|
self.total: Entry | None = total
|
||||||
|
"""The total entry."""
|
||||||
|
self.period_chooser: IncomeExpensesPeriodChooser \
|
||||||
|
= IncomeExpensesPeriodChooser(currency, account)
|
||||||
|
"""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 self.__has_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
return ReportChooser(ReportType.INCOME_EXPENSES,
|
||||||
|
currency=self.currency,
|
||||||
|
account=self.account,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
|
@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.income-expenses-default",
|
||||||
|
currency=currency, account=self.account)
|
||||||
|
return url_for("accounting.report.income-expenses",
|
||||||
|
currency=currency, account=self.account,
|
||||||
|
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()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the account options.
|
||||||
|
|
||||||
|
:return: The account options.
|
||||||
|
"""
|
||||||
|
def get_url(account: Account):
|
||||||
|
if self.period.is_default:
|
||||||
|
return url_for("accounting.report.income-expenses-default",
|
||||||
|
currency=self.currency, account=account)
|
||||||
|
return url_for("accounting.report.income-expenses",
|
||||||
|
currency=self.currency, account=account,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
|
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
||||||
|
.join(Account)\
|
||||||
|
.filter(JournalEntry.currency_code == self.currency.code,
|
||||||
|
sa.or_(Account.base_code.startswith("11"),
|
||||||
|
Account.base_code.startswith("12"),
|
||||||
|
Account.base_code.startswith("21"),
|
||||||
|
Account.base_code.startswith("22")))\
|
||||||
|
.group_by(JournalEntry.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))
|
||||||
|
.order_by(Account.base_code, Account.no).all()]
|
||||||
|
|
||||||
|
|
||||||
|
def _populate_entries(entries: list[Entry]) -> None:
|
||||||
|
"""Populates the income and expenses entries with relative data.
|
||||||
|
|
||||||
|
:param entries: The income and expenses entries.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
transactions: dict[int, Transaction] \
|
||||||
|
= {x.id: x for x in Transaction.query.filter(
|
||||||
|
Transaction.id.in_({x.entry.transaction_id for x in entries
|
||||||
|
if x.entry is not None}))}
|
||||||
|
accounts: dict[int, Account] \
|
||||||
|
= {x.id: x for x in Account.query.filter(
|
||||||
|
Account.id.in_({x.entry.account_id for x in entries
|
||||||
|
if x.entry is not None}))}
|
||||||
|
for entry in entries:
|
||||||
|
if entry.entry is not None:
|
||||||
|
entry.transaction = transactions[entry.entry.transaction_id]
|
||||||
|
entry.date = entry.transaction.date
|
||||||
|
entry.note = entry.transaction.note
|
||||||
|
entry.account = accounts[entry.entry.account_id]
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeExpenses:
|
||||||
|
"""The income and expenses log."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||||
|
"""Constructs an income and expenses log.
|
||||||
|
|
||||||
|
: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."""
|
||||||
|
collector: EntryCollector = EntryCollector(
|
||||||
|
self.__currency, self.__account, self.__period)
|
||||||
|
self.__brought_forward: Entry | None = collector.brought_forward
|
||||||
|
"""The brought-forward entry."""
|
||||||
|
self.__entries: list[Entry] = collector.entries
|
||||||
|
"""The log entries."""
|
||||||
|
self.__total: Entry = collector.total
|
||||||
|
"""The total entry."""
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "income-expenses-{currency}-{account}-{period}.csv"\
|
||||||
|
.format(currency=self.__currency.code, account=self.__account.code,
|
||||||
|
period=self.__period.spec)
|
||||||
|
rows: list[CSVRow] = self.__get_csv_rows()
|
||||||
|
with StringIO() as fp:
|
||||||
|
writer = csv.writer(fp)
|
||||||
|
writer.writerows([x.values for x in rows])
|
||||||
|
fp.seek(0)
|
||||||
|
response: Response = Response(fp.read(), mimetype="text/csv")
|
||||||
|
response.headers["Content-Disposition"] \
|
||||||
|
= f"attachment; filename={filename}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
def __get_csv_rows(self) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows.
|
||||||
|
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
_populate_entries(self.__entries)
|
||||||
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Account"),
|
||||||
|
gettext("Summary"), gettext("Income"),
|
||||||
|
gettext("Expense"), gettext("Balance"),
|
||||||
|
gettext("Note"))]
|
||||||
|
if self.__brought_forward is not None:
|
||||||
|
rows.append(CSVRow(self.__brought_forward.date,
|
||||||
|
str(self.__brought_forward.account).title(),
|
||||||
|
self.__brought_forward.summary,
|
||||||
|
self.__brought_forward.income,
|
||||||
|
self.__brought_forward.expense,
|
||||||
|
self.__brought_forward.balance,
|
||||||
|
None))
|
||||||
|
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
|
||||||
|
x.income, x.expense, x.balance, x.note)
|
||||||
|
for x in self.__entries])
|
||||||
|
rows.append(CSVRow(gettext("Total"), None, None,
|
||||||
|
self.__total.income, self.__total.expense,
|
||||||
|
self.__total.balance, None))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
all_entries: list[Entry] = []
|
||||||
|
if self.__brought_forward is not None:
|
||||||
|
all_entries.append(self.__brought_forward)
|
||||||
|
all_entries.extend(self.__entries)
|
||||||
|
all_entries.append(self.__total)
|
||||||
|
pagination: Pagination[Entry] = Pagination[Entry](all_entries)
|
||||||
|
page_entries: list[Entry] = pagination.list
|
||||||
|
has_data: bool = len(page_entries) > 0
|
||||||
|
_populate_entries(page_entries)
|
||||||
|
brought_forward: Entry | None = None
|
||||||
|
if page_entries[0].is_brought_forward:
|
||||||
|
brought_forward = page_entries[0]
|
||||||
|
page_entries = page_entries[1:]
|
||||||
|
total: Entry | None = None
|
||||||
|
if page_entries[-1].is_total:
|
||||||
|
total = page_entries[-1]
|
||||||
|
page_entries = page_entries[:-1]
|
||||||
|
params: IncomeExpensesPageParams = IncomeExpensesPageParams(
|
||||||
|
currency=self.__currency,
|
||||||
|
account=self.__account,
|
||||||
|
period=self.__period,
|
||||||
|
has_data=has_data,
|
||||||
|
pagination=pagination,
|
||||||
|
brought_forward=brought_forward,
|
||||||
|
entries=page_entries,
|
||||||
|
total=total)
|
||||||
|
return render_template("accounting/report/income-expenses.html",
|
||||||
|
report=params)
|
356
src/accounting/report/reports/income_statement.py
Normal file
356
src/accounting/report/reports/income_statement.py
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/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 income statement.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
from decimal import Decimal
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import url_for, render_template, Response
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, BaseAccount, Account, Transaction, \
|
||||||
|
JournalEntry
|
||||||
|
from accounting.report.option_link import OptionLink
|
||||||
|
from accounting.report.page_params import PageParams
|
||||||
|
from accounting.report.period import Period
|
||||||
|
from accounting.report.period_choosers import IncomeStatementPeriodChooser
|
||||||
|
from accounting.report.report_chooser import ReportChooser
|
||||||
|
from accounting.report.report_type import ReportType
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeStatementAccount:
|
||||||
|
"""An account in the income statement."""
|
||||||
|
|
||||||
|
def __init__(self, account: Account, amount: Decimal, url: str):
|
||||||
|
"""Constructs an account in the income statement.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:param amount: The amount.
|
||||||
|
:param url: The URL to the ledger of the account.
|
||||||
|
"""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.amount: Decimal = amount
|
||||||
|
"""The amount of the account."""
|
||||||
|
self.url: str = url
|
||||||
|
"""The URL to the ledger of the account."""
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeStatementAccumulatedTotal:
|
||||||
|
"""An accumulated total in the income statement."""
|
||||||
|
|
||||||
|
def __init__(self, title: str):
|
||||||
|
"""Constructs an accumulated total in the income statement.
|
||||||
|
|
||||||
|
:param title: The title.
|
||||||
|
"""
|
||||||
|
self.title: str = title
|
||||||
|
"""The account."""
|
||||||
|
self.amount: Decimal = Decimal("0")
|
||||||
|
"""The amount of the account."""
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeStatementSubsection:
|
||||||
|
"""A subsection in the income statement."""
|
||||||
|
|
||||||
|
def __init__(self, title: BaseAccount):
|
||||||
|
"""Constructs a subsection in the income statement.
|
||||||
|
|
||||||
|
:param title: The title account.
|
||||||
|
"""
|
||||||
|
self.title: BaseAccount = title
|
||||||
|
"""The title account."""
|
||||||
|
self.accounts: list[IncomeStatementAccount] = []
|
||||||
|
"""The accounts in the subsection."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> Decimal:
|
||||||
|
"""Returns the total of the subsection.
|
||||||
|
|
||||||
|
:return: The total of the subsection.
|
||||||
|
"""
|
||||||
|
return sum([x.amount for x in self.accounts])
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeStatementSection:
|
||||||
|
"""A section in the income statement."""
|
||||||
|
|
||||||
|
def __init__(self, title: BaseAccount, accumulated_title: str):
|
||||||
|
"""Constructs a section in the income statement.
|
||||||
|
|
||||||
|
:param title: The title account.
|
||||||
|
:param accumulated_title: The title for the accumulated total.
|
||||||
|
"""
|
||||||
|
self.title: BaseAccount = title
|
||||||
|
"""The title account."""
|
||||||
|
self.subsections: list[IncomeStatementSubsection] = []
|
||||||
|
"""The subsections in the section."""
|
||||||
|
self.accumulated: IncomeStatementAccumulatedTotal \
|
||||||
|
= IncomeStatementAccumulatedTotal(accumulated_title)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> Decimal:
|
||||||
|
"""Returns the total of the section.
|
||||||
|
|
||||||
|
:return: The total of the section.
|
||||||
|
"""
|
||||||
|
return sum([x.total for x in self.subsections])
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow:
|
||||||
|
"""A row in the CSV income statement."""
|
||||||
|
|
||||||
|
def __init__(self, text: str | None, amount: str | Decimal | None):
|
||||||
|
"""Constructs a row in the CSV income statement.
|
||||||
|
|
||||||
|
:param text: The text.
|
||||||
|
:param amount: The amount.
|
||||||
|
"""
|
||||||
|
self.text: str | None = text
|
||||||
|
"""The text."""
|
||||||
|
self.amount: str | Decimal | None = amount
|
||||||
|
"""The amount."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.text, self.amount]
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeStatementPageParams(PageParams):
|
||||||
|
"""The HTML parameters of the income statement."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
period: Period,
|
||||||
|
has_data: bool,
|
||||||
|
sections: list[IncomeStatementSection],):
|
||||||
|
"""Constructs the HTML parameters of the income statement.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:param has_data: True if there is any data, or False otherwise.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__has_data: bool = has_data
|
||||||
|
"""True if there is any data, or False otherwise."""
|
||||||
|
self.sections: list[IncomeStatementSection] = sections
|
||||||
|
self.period_chooser: IncomeStatementPeriodChooser \
|
||||||
|
= IncomeStatementPeriodChooser(currency)
|
||||||
|
"""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 self.__has_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
return ReportChooser(ReportType.INCOME_STATEMENT,
|
||||||
|
currency=self.currency,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
|
@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.income-statement-default",
|
||||||
|
currency=currency)
|
||||||
|
return url_for("accounting.report.income-statement",
|
||||||
|
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()]
|
||||||
|
|
||||||
|
|
||||||
|
class IncomeStatement:
|
||||||
|
"""The income statement."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, period: Period):
|
||||||
|
"""Constructs an income statement.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__has_data: bool
|
||||||
|
"""True if there is any data, or False otherwise."""
|
||||||
|
self.__sections: list[IncomeStatementSection]
|
||||||
|
"""The sections."""
|
||||||
|
self.__set_data()
|
||||||
|
|
||||||
|
def __set_data(self) -> None:
|
||||||
|
"""Queries and sets data sections in the income statement.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
balances: list[IncomeStatementAccount] = self.__query_balances()
|
||||||
|
|
||||||
|
titles: list[BaseAccount] = BaseAccount.query\
|
||||||
|
.filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all()
|
||||||
|
subtitles: list[BaseAccount] = BaseAccount.query\
|
||||||
|
.filter(BaseAccount.code.in_({x.account.base_code[:2]
|
||||||
|
for x in balances})).all()
|
||||||
|
|
||||||
|
total_titles: dict[str, str] \
|
||||||
|
= {"4": gettext("total revenue"),
|
||||||
|
"5": gettext("gross income"),
|
||||||
|
"6": gettext("operating income"),
|
||||||
|
"7": gettext("before tax income"),
|
||||||
|
"8": gettext("after tax income"),
|
||||||
|
"9": gettext("net income or loss for current period")}
|
||||||
|
|
||||||
|
sections: dict[str, IncomeStatementSection] \
|
||||||
|
= {x.code: IncomeStatementSection(x, total_titles[x.code])
|
||||||
|
for x in titles}
|
||||||
|
subsections: dict[str, IncomeStatementSubsection] \
|
||||||
|
= {x.code: IncomeStatementSubsection(x) for x in subtitles}
|
||||||
|
for subsection in subsections.values():
|
||||||
|
sections[subsection.title.code[0]].subsections.append(subsection)
|
||||||
|
for balance in balances:
|
||||||
|
subsections[balance.account.base_code[:2]].accounts.append(balance)
|
||||||
|
|
||||||
|
self.__has_data = len(balances) > 0
|
||||||
|
self.__sections = sorted(sections.values(), key=lambda x: x.title.code)
|
||||||
|
total: Decimal = Decimal("0")
|
||||||
|
for section in self.__sections:
|
||||||
|
total = total + section.total
|
||||||
|
section.accumulated.amount = total
|
||||||
|
|
||||||
|
def __query_balances(self) -> list[IncomeStatementAccount]:
|
||||||
|
"""Queries and returns the balances.
|
||||||
|
|
||||||
|
:return: The balances.
|
||||||
|
"""
|
||||||
|
sub_conditions: list[sa.BinaryExpression] \
|
||||||
|
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
|
sa.or_(*sub_conditions)]
|
||||||
|
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_balance: sa.Select \
|
||||||
|
= sa.select(JournalEntry.account_id, balance_func)\
|
||||||
|
.join(Transaction).join(Account)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.group_by(JournalEntry.account_id)\
|
||||||
|
.order_by(Account.base_code, Account.no)
|
||||||
|
balances: list[sa.Row] = db.session.execute(select_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()}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
return [IncomeStatementAccount(account=accounts[x.account_id],
|
||||||
|
amount=x.balance,
|
||||||
|
url=get_url(accounts[x.account_id]))
|
||||||
|
for x in balances]
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "income-statement-{currency}-{period}.csv"\
|
||||||
|
.format(currency=self.__currency.code, period=self.__period.spec)
|
||||||
|
rows: list[CSVRow] = self.__get_csv_rows()
|
||||||
|
with StringIO() as fp:
|
||||||
|
writer = csv.writer(fp)
|
||||||
|
writer.writerows([x.values for x in rows])
|
||||||
|
fp.seek(0)
|
||||||
|
response: Response = Response(fp.read(), mimetype="text/csv")
|
||||||
|
response.headers["Content-Disposition"] \
|
||||||
|
= f"attachment; filename={filename}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
def __get_csv_rows(self) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows.
|
||||||
|
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
total_str: str = gettext("Total")
|
||||||
|
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
|
||||||
|
for section in self.__sections:
|
||||||
|
rows.append(CSVRow(str(section.title).title(), None))
|
||||||
|
for subsection in section.subsections:
|
||||||
|
rows.append(CSVRow(f" {str(subsection.title).title()}", None))
|
||||||
|
for account in subsection.accounts:
|
||||||
|
rows.append(CSVRow(f" {str(account.account).title()}",
|
||||||
|
account.amount))
|
||||||
|
rows.append(CSVRow(f" {total_str}", subsection.total))
|
||||||
|
rows.append(CSVRow(section.accumulated.title.title(),
|
||||||
|
section.accumulated.amount))
|
||||||
|
rows.append(CSVRow(None, None))
|
||||||
|
rows = rows[:-1]
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
params: IncomeStatementPageParams = IncomeStatementPageParams(
|
||||||
|
currency=self.__currency,
|
||||||
|
period=self.__period,
|
||||||
|
has_data=self.__has_data,
|
||||||
|
sections=self.__sections)
|
||||||
|
return render_template("accounting/report/income-statement.html",
|
||||||
|
report=params)
|
251
src/accounting/report/reports/journal.py
Normal file
251
src/accounting/report/reports/journal.py
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/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 journal.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import render_template, Response
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||||
|
from accounting.report.page_params import PageParams
|
||||||
|
from accounting.report.period import Period
|
||||||
|
from accounting.report.period_choosers import JournalPeriodChooser
|
||||||
|
from accounting.report.report_chooser import ReportChooser
|
||||||
|
from accounting.report.report_type import ReportType
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class Entry:
|
||||||
|
"""An entry in the journal."""
|
||||||
|
|
||||||
|
def __init__(self, entry: JournalEntry | None = None):
|
||||||
|
"""Constructs the entry in the journal.
|
||||||
|
|
||||||
|
:param entry: The journal entry.
|
||||||
|
"""
|
||||||
|
self.entry: JournalEntry | None = None
|
||||||
|
"""The journal entry."""
|
||||||
|
self.transaction: Transaction | None = None
|
||||||
|
"""The transaction."""
|
||||||
|
self.is_total: bool = False
|
||||||
|
"""Whether this is the total entry."""
|
||||||
|
self.currency: Currency | None = None
|
||||||
|
"""The account."""
|
||||||
|
self.account: Account | None = None
|
||||||
|
"""The account."""
|
||||||
|
self.summary: str | None = None
|
||||||
|
"""The summary."""
|
||||||
|
self.debit: Decimal | None = None
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: Decimal | None = None
|
||||||
|
"""The credit amount."""
|
||||||
|
self.amount: Decimal | None = None
|
||||||
|
"""The amount."""
|
||||||
|
if entry is not None:
|
||||||
|
self.entry = entry
|
||||||
|
self.summary = entry.summary
|
||||||
|
self.debit = entry.amount if entry.is_debit else None
|
||||||
|
self.credit = None if entry.is_debit else entry.amount
|
||||||
|
self.amount = entry.amount
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow:
|
||||||
|
"""A row in the CSV journal."""
|
||||||
|
|
||||||
|
def __init__(self, txn_date: str | date,
|
||||||
|
currency: str,
|
||||||
|
account: str,
|
||||||
|
summary: str | None,
|
||||||
|
debit: str | Decimal | None,
|
||||||
|
credit: str | Decimal | None,
|
||||||
|
note: str | None):
|
||||||
|
"""Constructs a row in the CSV journal.
|
||||||
|
|
||||||
|
:param txn_date: The transaction date.
|
||||||
|
:param summary: The summary.
|
||||||
|
:param debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
:param note: The note.
|
||||||
|
"""
|
||||||
|
self.date: str | date = txn_date
|
||||||
|
"""The date."""
|
||||||
|
self.currency: str = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.account: str = account
|
||||||
|
"""The account."""
|
||||||
|
self.summary: str | None = summary
|
||||||
|
"""The summary."""
|
||||||
|
self.debit: str | Decimal | None = debit
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: str | Decimal | None = credit
|
||||||
|
"""The credit amount."""
|
||||||
|
self.note: str | None = note
|
||||||
|
"""The note."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.date, self.currency, self.account, self.summary,
|
||||||
|
self.debit, self.credit, self.note]
|
||||||
|
|
||||||
|
|
||||||
|
class JournalPageParams(PageParams):
|
||||||
|
"""The HTML parameters of the journal."""
|
||||||
|
|
||||||
|
def __init__(self, period: Period,
|
||||||
|
pagination: Pagination[Entry],
|
||||||
|
entries: list[Entry]):
|
||||||
|
"""Constructs the HTML parameters of the journal.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
:param entries: The journal entries.
|
||||||
|
"""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.pagination: Pagination[Entry] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.entries: list[Entry] = entries
|
||||||
|
"""The entries."""
|
||||||
|
self.period_chooser: JournalPeriodChooser \
|
||||||
|
= JournalPeriodChooser()
|
||||||
|
"""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.entries) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
return ReportChooser(ReportType.JOURNAL,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
|
|
||||||
|
def _populate_entries(entries: list[Entry]) -> None:
|
||||||
|
"""Populates the journal entries with relative data.
|
||||||
|
|
||||||
|
:param entries: The journal entries.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
transactions: dict[int, Transaction] \
|
||||||
|
= {x.id: x for x in Transaction.query.filter(
|
||||||
|
Transaction.id.in_({x.entry.transaction_id for x in entries}))}
|
||||||
|
accounts: dict[int, Account] \
|
||||||
|
= {x.id: x for x in Account.query.filter(
|
||||||
|
Account.id.in_({x.entry.account_id for x in entries}))}
|
||||||
|
currencies: dict[int, Currency] \
|
||||||
|
= {x.code: x for x in Currency.query.filter(
|
||||||
|
Currency.code.in_({x.entry.currency_code for x in entries}))}
|
||||||
|
for entry in entries:
|
||||||
|
entry.transaction = transactions[entry.entry.transaction_id]
|
||||||
|
entry.account = accounts[entry.entry.account_id]
|
||||||
|
entry.currency = currencies[entry.entry.currency_code]
|
||||||
|
|
||||||
|
|
||||||
|
class Journal:
|
||||||
|
"""The journal."""
|
||||||
|
|
||||||
|
def __init__(self, period: Period):
|
||||||
|
"""Constructs a journal.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
"""The account."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__entries: list[Entry] = self.__query_entries()
|
||||||
|
"""The journal entries."""
|
||||||
|
|
||||||
|
def __query_entries(self) -> list[Entry]:
|
||||||
|
"""Queries and returns the journal entries.
|
||||||
|
|
||||||
|
:return: The journal entries.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] = []
|
||||||
|
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)
|
||||||
|
return [Entry(x) for x in db.session
|
||||||
|
.query(JournalEntry).join(Transaction).filter(*conditions)
|
||||||
|
.order_by(Transaction.date,
|
||||||
|
JournalEntry.is_debit.desc(),
|
||||||
|
JournalEntry.no).all()]
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = f"journal-{self.__period.spec}.csv"
|
||||||
|
rows: list[CSVRow] = self.__get_csv_rows()
|
||||||
|
with StringIO() as fp:
|
||||||
|
writer = csv.writer(fp)
|
||||||
|
writer.writerows([x.values for x in rows])
|
||||||
|
fp.seek(0)
|
||||||
|
response: Response = Response(fp.read(), mimetype="text/csv")
|
||||||
|
response.headers["Content-Disposition"] \
|
||||||
|
= f"attachment; filename={filename}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
def __get_csv_rows(self) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows.
|
||||||
|
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
_populate_entries(self.__entries)
|
||||||
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
|
||||||
|
gettext("Account"), gettext("Summary"),
|
||||||
|
gettext("Debit"), gettext("Credit"),
|
||||||
|
gettext("Note"))]
|
||||||
|
rows.extend([CSVRow(x.transaction.date, x.currency.code,
|
||||||
|
str(x.account).title(), x.summary,
|
||||||
|
x.debit, x.credit, x.transaction.note)
|
||||||
|
for x in self.__entries])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
pagination: Pagination[Entry] = Pagination[Entry](self.__entries)
|
||||||
|
page_entries: list[Entry] = pagination.list
|
||||||
|
_populate_entries(page_entries)
|
||||||
|
params: JournalPageParams = JournalPageParams(
|
||||||
|
period=self.__period,
|
||||||
|
pagination=pagination,
|
||||||
|
entries=page_entries)
|
||||||
|
return render_template("accounting/report/journal.html",
|
||||||
|
report=params)
|
443
src/accounting/report/reports/ledger.py
Normal file
443
src/accounting/report/reports/ledger.py
Normal file
@ -0,0 +1,443 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/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 ledger.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import url_for, render_template, Response
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||||
|
from accounting.report.option_link import OptionLink
|
||||||
|
from accounting.report.page_params import PageParams
|
||||||
|
from accounting.report.period import Period
|
||||||
|
from accounting.report.period_choosers import LedgerPeriodChooser
|
||||||
|
from accounting.report.report_chooser import ReportChooser
|
||||||
|
from accounting.report.report_type import ReportType
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class Entry:
|
||||||
|
"""An entry in the ledger."""
|
||||||
|
|
||||||
|
def __init__(self, entry: JournalEntry | None = None):
|
||||||
|
"""Constructs the entry in the ledger.
|
||||||
|
|
||||||
|
:param entry: The journal entry.
|
||||||
|
"""
|
||||||
|
self.entry: JournalEntry | None = None
|
||||||
|
"""The journal entry."""
|
||||||
|
self.transaction: Transaction | None = None
|
||||||
|
"""The transaction."""
|
||||||
|
self.is_brought_forward: bool = False
|
||||||
|
"""Whether this is the brought-forward entry."""
|
||||||
|
self.is_total: bool = False
|
||||||
|
"""Whether this is the total entry."""
|
||||||
|
self.date: date | None = None
|
||||||
|
"""The date."""
|
||||||
|
self.account: Account | None = None
|
||||||
|
"""The account."""
|
||||||
|
self.summary: str | None = None
|
||||||
|
"""The summary."""
|
||||||
|
self.debit: Decimal | None = None
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: Decimal | None = None
|
||||||
|
"""The credit amount."""
|
||||||
|
self.balance: Decimal | None = None
|
||||||
|
"""The balance."""
|
||||||
|
self.note: str | None = None
|
||||||
|
"""The note."""
|
||||||
|
if entry is not None:
|
||||||
|
self.entry = entry
|
||||||
|
self.summary = entry.summary
|
||||||
|
self.debit = entry.amount if entry.is_debit else None
|
||||||
|
self.credit = None if entry.is_debit else entry.amount
|
||||||
|
|
||||||
|
|
||||||
|
class EntryCollector:
|
||||||
|
"""The ledger entry collector."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||||
|
"""Constructs the ledger entry collector.
|
||||||
|
|
||||||
|
: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"""
|
||||||
|
self.brought_forward: Entry | None
|
||||||
|
"""The brought-forward entry."""
|
||||||
|
self.entries: list[Entry]
|
||||||
|
"""The ledger entries."""
|
||||||
|
self.total: Entry
|
||||||
|
"""The total entry."""
|
||||||
|
self.brought_forward = self.__get_brought_forward_entry()
|
||||||
|
self.entries = self.__query_entries()
|
||||||
|
self.total = self.__get_total_entry()
|
||||||
|
self.__populate_balance()
|
||||||
|
|
||||||
|
def __get_brought_forward_entry(self) -> Entry | None:
|
||||||
|
"""Queries, composes and returns the brought-forward entry.
|
||||||
|
|
||||||
|
:return: The brought-forward entry, or None if the ledger starts from
|
||||||
|
the beginning.
|
||||||
|
"""
|
||||||
|
if self.__period.start is None:
|
||||||
|
return None
|
||||||
|
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||||
|
(JournalEntry.is_debit, JournalEntry.amount),
|
||||||
|
else_=-JournalEntry.amount))
|
||||||
|
select: sa.Select = sa.Select(balance_func).join(Transaction)\
|
||||||
|
.filter(JournalEntry.currency_code == self.__currency.code,
|
||||||
|
JournalEntry.account_id == self.__account.id,
|
||||||
|
Transaction.date < self.__period.start)
|
||||||
|
balance: int | None = db.session.scalar(select)
|
||||||
|
if balance is None:
|
||||||
|
return None
|
||||||
|
entry: Entry = Entry()
|
||||||
|
entry.is_brought_forward = True
|
||||||
|
entry.date = self.__period.start
|
||||||
|
entry.summary = gettext("Brought forward")
|
||||||
|
if balance > 0:
|
||||||
|
entry.debit = balance
|
||||||
|
elif balance < 0:
|
||||||
|
entry.credit = -balance
|
||||||
|
entry.balance = balance
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def __query_entries(self) -> list[Entry]:
|
||||||
|
"""Queries and returns the ledger entries.
|
||||||
|
|
||||||
|
:return: The ledger entries.
|
||||||
|
"""
|
||||||
|
conditions: list[sa.BinaryExpression] \
|
||||||
|
= [JournalEntry.currency_code == self.__currency.code,
|
||||||
|
JournalEntry.account_id == self.__account.id]
|
||||||
|
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)
|
||||||
|
return [Entry(x) for x in JournalEntry.query.join(Transaction)
|
||||||
|
.filter(*conditions)
|
||||||
|
.order_by(Transaction.date,
|
||||||
|
JournalEntry.is_debit.desc(),
|
||||||
|
JournalEntry.no).all()]
|
||||||
|
|
||||||
|
def __get_total_entry(self) -> Entry:
|
||||||
|
"""Composes the total entry.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
entry: Entry = Entry()
|
||||||
|
entry.is_total = True
|
||||||
|
entry.summary = gettext("Total")
|
||||||
|
entry.debit = sum([x.debit for x in self.entries
|
||||||
|
if x.debit is not None])
|
||||||
|
entry.credit = sum([x.credit for x in self.entries
|
||||||
|
if x.credit is not None])
|
||||||
|
entry.balance = entry.debit - entry.credit
|
||||||
|
if self.brought_forward is not None:
|
||||||
|
entry.balance = self.brought_forward.balance + entry.balance
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def __populate_balance(self) -> None:
|
||||||
|
"""Populates the balance of the entries.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
balance: Decimal = 0 if self.brought_forward is None \
|
||||||
|
else self.brought_forward.balance
|
||||||
|
for entry in self.entries:
|
||||||
|
if entry.debit is not None:
|
||||||
|
balance = balance + entry.debit
|
||||||
|
if entry.credit is not None:
|
||||||
|
balance = balance - entry.credit
|
||||||
|
entry.balance = balance
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow:
|
||||||
|
"""A row in the CSV ledger."""
|
||||||
|
|
||||||
|
def __init__(self, txn_date: date | str | None,
|
||||||
|
summary: str | None,
|
||||||
|
debit: str | Decimal | None,
|
||||||
|
credit: str | Decimal | None,
|
||||||
|
balance: str | Decimal | None,
|
||||||
|
note: str | None):
|
||||||
|
"""Constructs a row in the CSV ledger.
|
||||||
|
|
||||||
|
:param txn_date: The transaction date.
|
||||||
|
:param summary: The summary.
|
||||||
|
:param debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
:param balance: The balance.
|
||||||
|
:param note: The note.
|
||||||
|
"""
|
||||||
|
self.date: date | str | None = txn_date
|
||||||
|
"""The date."""
|
||||||
|
self.summary: str | None = summary
|
||||||
|
"""The summary."""
|
||||||
|
self.debit: str | Decimal | None = debit
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: str | Decimal | None = credit
|
||||||
|
"""The credit amount."""
|
||||||
|
self.balance: str | Decimal | None = balance
|
||||||
|
"""The balance."""
|
||||||
|
self.note: str | None = note
|
||||||
|
"""The note."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.date, self.summary,
|
||||||
|
self.debit, self.credit, self.balance, self.note]
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerPageParams(PageParams):
|
||||||
|
"""The HTML parameters of the ledger."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
account: Account,
|
||||||
|
period: Period,
|
||||||
|
has_data: bool,
|
||||||
|
pagination: Pagination[Entry],
|
||||||
|
brought_forward: Entry | None,
|
||||||
|
entries: list[Entry],
|
||||||
|
total: Entry | None):
|
||||||
|
"""Constructs the HTML parameters of the ledger.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param account: The account.
|
||||||
|
:param period: The period.
|
||||||
|
:param has_data: True if there is any data, or False otherwise.
|
||||||
|
:param brought_forward: The brought-forward entry.
|
||||||
|
:param entries: The ledger entries.
|
||||||
|
:param total: The total entry.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__has_data: bool = has_data
|
||||||
|
"""True if there is any data, or False otherwise."""
|
||||||
|
self.pagination: Pagination[Entry] = pagination
|
||||||
|
"""The pagination."""
|
||||||
|
self.brought_forward: Entry | None = brought_forward
|
||||||
|
"""The brought-forward entry."""
|
||||||
|
self.entries: list[Entry] = entries
|
||||||
|
"""The entries."""
|
||||||
|
self.total: Entry | None = total
|
||||||
|
"""The total entry."""
|
||||||
|
self.period_chooser: LedgerPeriodChooser \
|
||||||
|
= LedgerPeriodChooser(currency, account)
|
||||||
|
"""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 self.__has_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
return ReportChooser(ReportType.LEDGER,
|
||||||
|
currency=self.currency,
|
||||||
|
account=self.account,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
|
@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.ledger-default",
|
||||||
|
currency=currency, account=self.account)
|
||||||
|
return url_for("accounting.report.ledger",
|
||||||
|
currency=currency, account=self.account,
|
||||||
|
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()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def account_options(self) -> list[OptionLink]:
|
||||||
|
"""Returns the account options.
|
||||||
|
|
||||||
|
:return: The account options.
|
||||||
|
"""
|
||||||
|
def get_url(account: 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)
|
||||||
|
|
||||||
|
in_use: sa.Select = sa.Select(JournalEntry.account_id)\
|
||||||
|
.filter(JournalEntry.currency_code == self.currency.code)\
|
||||||
|
.group_by(JournalEntry.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))
|
||||||
|
.order_by(Account.base_code, Account.no).all()]
|
||||||
|
|
||||||
|
|
||||||
|
def _populate_entries(entries: list[Entry]) -> None:
|
||||||
|
"""Populates the ledger entries with relative data.
|
||||||
|
|
||||||
|
:param entries: The ledger entries.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
transactions: dict[int, Transaction] \
|
||||||
|
= {x.id: x for x in Transaction.query.filter(
|
||||||
|
Transaction.id.in_({x.entry.transaction_id for x in entries
|
||||||
|
if x.entry is not None}))}
|
||||||
|
for entry in entries:
|
||||||
|
if entry.entry is not None:
|
||||||
|
entry.transaction = transactions[entry.entry.transaction_id]
|
||||||
|
entry.date = entry.transaction.date
|
||||||
|
entry.note = entry.transaction.note
|
||||||
|
|
||||||
|
|
||||||
|
class Ledger:
|
||||||
|
"""The ledger."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||||
|
"""Constructs a ledger.
|
||||||
|
|
||||||
|
: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."""
|
||||||
|
collector: EntryCollector = EntryCollector(
|
||||||
|
self.__currency, self.__account, self.__period)
|
||||||
|
self.__brought_forward: Entry | None = collector.brought_forward
|
||||||
|
"""The brought-forward entry."""
|
||||||
|
self.__entries: list[Entry] = collector.entries
|
||||||
|
"""The ledger entries."""
|
||||||
|
self.__total: Entry = collector.total
|
||||||
|
"""The total entry."""
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "ledger-{currency}-{account}-{period}.csv"\
|
||||||
|
.format(currency=self.__currency.code, account=self.__account.code,
|
||||||
|
period=self.__period.spec)
|
||||||
|
rows: list[CSVRow] = self.__get_csv_rows()
|
||||||
|
with StringIO() as fp:
|
||||||
|
writer = csv.writer(fp)
|
||||||
|
writer.writerows([x.values for x in rows])
|
||||||
|
fp.seek(0)
|
||||||
|
response: Response = Response(fp.read(), mimetype="text/csv")
|
||||||
|
response.headers["Content-Disposition"] \
|
||||||
|
= f"attachment; filename={filename}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
def __get_csv_rows(self) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows.
|
||||||
|
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
_populate_entries(self.__entries)
|
||||||
|
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Summary"),
|
||||||
|
gettext("Debit"), gettext("Credit"),
|
||||||
|
gettext("Balance"), gettext("Note"))]
|
||||||
|
if self.__brought_forward is not None:
|
||||||
|
rows.append(CSVRow(self.__brought_forward.date,
|
||||||
|
self.__brought_forward.summary,
|
||||||
|
self.__brought_forward.debit,
|
||||||
|
self.__brought_forward.credit,
|
||||||
|
self.__brought_forward.balance,
|
||||||
|
None))
|
||||||
|
rows.extend([CSVRow(x.date, x.summary,
|
||||||
|
x.debit, x.credit, x.balance, x.note)
|
||||||
|
for x in self.__entries])
|
||||||
|
rows.append(CSVRow(gettext("Total"), None,
|
||||||
|
self.__total.debit, self.__total.credit,
|
||||||
|
self.__total.balance, None))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
all_entries: list[Entry] = []
|
||||||
|
if self.__brought_forward is not None:
|
||||||
|
all_entries.append(self.__brought_forward)
|
||||||
|
all_entries.extend(self.__entries)
|
||||||
|
all_entries.append(self.__total)
|
||||||
|
pagination: Pagination[Entry] = Pagination[Entry](all_entries)
|
||||||
|
page_entries: list[Entry] = pagination.list
|
||||||
|
has_data: bool = len(page_entries) > 0
|
||||||
|
_populate_entries(page_entries)
|
||||||
|
brought_forward: Entry | None = None
|
||||||
|
if page_entries[0].is_brought_forward:
|
||||||
|
brought_forward = page_entries[0]
|
||||||
|
page_entries = page_entries[1:]
|
||||||
|
total: Entry | None = None
|
||||||
|
if page_entries[-1].is_total:
|
||||||
|
total = page_entries[-1]
|
||||||
|
page_entries = page_entries[:-1]
|
||||||
|
params: LedgerPageParams = LedgerPageParams(
|
||||||
|
currency=self.__currency,
|
||||||
|
account=self.__account,
|
||||||
|
period=self.__period,
|
||||||
|
has_data=has_data,
|
||||||
|
pagination=pagination,
|
||||||
|
brought_forward=brought_forward,
|
||||||
|
entries=page_entries,
|
||||||
|
total=total)
|
||||||
|
return render_template("accounting/report/ledger.html",
|
||||||
|
report=params)
|
272
src/accounting/report/reports/trial_balance.py
Normal file
272
src/accounting/report/reports/trial_balance.py
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/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 trial balance.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
from decimal import Decimal
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import url_for, Response, render_template
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency, Account, Transaction, JournalEntry
|
||||||
|
from accounting.report.option_link import OptionLink
|
||||||
|
from accounting.report.page_params import PageParams
|
||||||
|
from accounting.report.period import Period
|
||||||
|
from accounting.report.period_choosers import TrialBalancePeriodChooser
|
||||||
|
from accounting.report.report_chooser import ReportChooser
|
||||||
|
from accounting.report.report_type import ReportType
|
||||||
|
|
||||||
|
|
||||||
|
class TrialBalanceAccount:
|
||||||
|
"""An account in the trial balance."""
|
||||||
|
|
||||||
|
def __init__(self, account: Account, amount: Decimal, url: str):
|
||||||
|
"""Constructs an account in the trial balance.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
|
:param amount: The amount.
|
||||||
|
:param url: The URL to the ledger of the account.
|
||||||
|
"""
|
||||||
|
self.account: Account = account
|
||||||
|
"""The account."""
|
||||||
|
self.debit: Decimal | None = amount if amount > 0 else None
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: Decimal | None = -amount if amount < 0 else None
|
||||||
|
"""The credit amount."""
|
||||||
|
self.url: str = url
|
||||||
|
"""The URL to the ledger of the account."""
|
||||||
|
|
||||||
|
|
||||||
|
class TrialBalanceTotal:
|
||||||
|
"""The total in the trial balance."""
|
||||||
|
|
||||||
|
def __init__(self, debit: Decimal, credit: Decimal):
|
||||||
|
"""Constructs the total in the trial balance.
|
||||||
|
|
||||||
|
:param debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
"""
|
||||||
|
self.debit: Decimal | None = debit
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: Decimal | None = credit
|
||||||
|
"""The credit amount."""
|
||||||
|
|
||||||
|
|
||||||
|
class CSVRow:
|
||||||
|
"""A row in the CSV trial balance."""
|
||||||
|
|
||||||
|
def __init__(self, text: str | None,
|
||||||
|
debit: str | Decimal | None,
|
||||||
|
credit: str | Decimal | None):
|
||||||
|
"""Constructs a row in the CSV trial balance.
|
||||||
|
|
||||||
|
:param text: The text.
|
||||||
|
:param debit: The debit amount.
|
||||||
|
:param credit: The credit amount.
|
||||||
|
"""
|
||||||
|
self.text: str | None = text
|
||||||
|
"""The text."""
|
||||||
|
self.debit: str | Decimal | None = debit
|
||||||
|
"""The debit amount."""
|
||||||
|
self.credit: str | Decimal | None = credit
|
||||||
|
"""The credit amount."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def values(self) -> list[str | Decimal | None]:
|
||||||
|
"""Returns the values of the row.
|
||||||
|
|
||||||
|
:return: The values of the row.
|
||||||
|
"""
|
||||||
|
return [self.text, self.debit, self.credit]
|
||||||
|
|
||||||
|
|
||||||
|
class TrialBalancePageParams(PageParams):
|
||||||
|
"""The HTML parameters of the trial balance."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency,
|
||||||
|
period: Period,
|
||||||
|
accounts: list[TrialBalanceAccount],
|
||||||
|
total: TrialBalanceTotal):
|
||||||
|
"""Constructs the HTML parameters of the trial balance.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
:param accounts: The accounts in the trial balance.
|
||||||
|
:param total: The total of the trial balance.
|
||||||
|
"""
|
||||||
|
self.currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.accounts: list[TrialBalanceAccount] = accounts
|
||||||
|
"""The accounts in the trial balance."""
|
||||||
|
self.total: TrialBalanceTotal = total
|
||||||
|
"""The total of the trial balance."""
|
||||||
|
self.period_chooser: TrialBalancePeriodChooser \
|
||||||
|
= TrialBalancePeriodChooser(currency)
|
||||||
|
"""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.TRIAL_BALANCE,
|
||||||
|
currency=self.currency,
|
||||||
|
period=self.period)
|
||||||
|
|
||||||
|
@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()]
|
||||||
|
|
||||||
|
|
||||||
|
class TrialBalance:
|
||||||
|
"""The trial balance."""
|
||||||
|
|
||||||
|
def __init__(self, currency: Currency, period: Period):
|
||||||
|
"""Constructs a trial balance.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.__currency: Currency = currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self.__accounts: list[TrialBalanceAccount]
|
||||||
|
"""The accounts in the trial balance."""
|
||||||
|
self.__total: TrialBalanceTotal
|
||||||
|
"""The total of the trial balance."""
|
||||||
|
self.__set_data()
|
||||||
|
|
||||||
|
def __set_data(self) -> None:
|
||||||
|
"""Queries and sets data sections in the trial balance.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
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_balances: sa.Select \
|
||||||
|
= sa.select(Account.id, balance_func)\
|
||||||
|
.join(Transaction).join(Account)\
|
||||||
|
.filter(*conditions)\
|
||||||
|
.group_by(JournalEntry.account_id)\
|
||||||
|
.order_by(Account.base_code, Account.no)
|
||||||
|
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
||||||
|
accounts: dict[int, Account] \
|
||||||
|
= {x.id: x for x in Account.query
|
||||||
|
.filter(Account.id.in_([x.id for x in balances])).all()}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
self.__accounts = [TrialBalanceAccount(account=accounts[x.id],
|
||||||
|
amount=x.balance,
|
||||||
|
url=get_url(accounts[x.id]))
|
||||||
|
for x in balances]
|
||||||
|
self.__total = TrialBalanceTotal(
|
||||||
|
sum([x.debit for x in self.__accounts if x.debit is not None]),
|
||||||
|
sum([x.credit for x in self.__accounts if x.credit is not None]))
|
||||||
|
|
||||||
|
def csv(self) -> Response:
|
||||||
|
"""Returns the report as CSV for download.
|
||||||
|
|
||||||
|
:return: The response of the report for download.
|
||||||
|
"""
|
||||||
|
filename: str = "trial-balance-{currency}-{period}.csv"\
|
||||||
|
.format(currency=self.__currency.code, period=self.__period.spec)
|
||||||
|
rows: list[CSVRow] = self.__get_csv_rows()
|
||||||
|
with StringIO() as fp:
|
||||||
|
writer = csv.writer(fp)
|
||||||
|
writer.writerows([x.values for x in rows])
|
||||||
|
fp.seek(0)
|
||||||
|
response: Response = Response(fp.read(), mimetype="text/csv")
|
||||||
|
response.headers["Content-Disposition"] \
|
||||||
|
= f"attachment; filename={filename}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
def __get_csv_rows(self) -> list[CSVRow]:
|
||||||
|
"""Composes and returns the CSV rows.
|
||||||
|
|
||||||
|
:return: The CSV rows.
|
||||||
|
"""
|
||||||
|
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
|
||||||
|
gettext("Credit"))]
|
||||||
|
rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit)
|
||||||
|
for x in self.__accounts])
|
||||||
|
rows.append(CSVRow(gettext("Total"), self.__total.debit,
|
||||||
|
self.__total.credit))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def html(self) -> str:
|
||||||
|
"""Composes and returns the report as HTML.
|
||||||
|
|
||||||
|
:return: The report as HTML.
|
||||||
|
"""
|
||||||
|
params: TrialBalancePageParams = TrialBalancePageParams(
|
||||||
|
currency=self.__currency,
|
||||||
|
period=self.__period,
|
||||||
|
accounts=self.__accounts,
|
||||||
|
total=self.__total)
|
||||||
|
return render_template("accounting/report/trial-balance.html",
|
||||||
|
report=params)
|
@ -21,10 +21,9 @@ 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 .balance_sheet import BalanceSheet
|
|
||||||
from .period import Period
|
from .period import Period
|
||||||
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
|
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
|
||||||
IncomeStatement
|
IncomeStatement, BalanceSheet
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("report", __name__)
|
bp: Blueprint = Blueprint("report", __name__)
|
||||||
"""The view blueprint for the reports."""
|
"""The view blueprint for the reports."""
|
||||||
|
@ -175,18 +175,21 @@ a.accounting-report-table-row {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.accounting-income-statement-category, .accounting-income-statement-total {
|
.accounting-income-statement-table .accounting-report-table-header .accounting-report-table-row {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.accounting-income-statement-section, .accounting-income-statement-total {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
}
|
}
|
||||||
.accounting-income-statement-subcategory, .accounting-income-statement-subtotal {
|
.accounting-income-statement-subsection, .accounting-income-statement-subtotal {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
.accounting-income-statement-subtotal {
|
.accounting-income-statement-subtotal {
|
||||||
border-top: thin solid darkslategray;
|
border-top: thin solid darkslategray;
|
||||||
}
|
}
|
||||||
/* Indents */
|
/* Indents */
|
||||||
.accounting-income-statement-subcategory {
|
.accounting-income-statement-subsection {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
@ -195,10 +198,10 @@ a.accounting-report-table-row {
|
|||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
}
|
}
|
||||||
/* A visual blank line between categories */
|
/* A visual blank line between categories */
|
||||||
.accounting-income-statement-category {
|
.accounting-income-statement-section {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
.accounting-income-statement-category:first-child {
|
.accounting-income-statement-section:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.accounting-income-statement-total {
|
.accounting-income-statement-total {
|
||||||
|
@ -20,31 +20,31 @@ Author: imacat@mail.imacat.idv.tw (imacat)
|
|||||||
First written: 2023/3/5
|
First written: 2023/3/5
|
||||||
#}
|
#}
|
||||||
<div>
|
<div>
|
||||||
{% if item.date is not none or item.account is not none %}
|
{% if entry.date or entry.account %}
|
||||||
<div class="text-muted small">
|
<div class="text-muted small">
|
||||||
{% if item.date is not none %}
|
{% if entry.date %}
|
||||||
{{ item.date|accounting_format_date }}
|
{{ entry.date|accounting_format_date }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.account is not none %}
|
{% if entry.account %}
|
||||||
{{ item.account.title|title }}
|
{{ entry.account.title|title }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.summary is not none %}
|
{% if entry.summary %}
|
||||||
<div>{{ item.summary }}</div>
|
<div>{{ entry.summary }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-nowrap">
|
<div class="text-nowrap">
|
||||||
{% if item.income is not none %}
|
{% if entry.income %}
|
||||||
<span class="badge rounded-pill bg-success">+{{ item.income|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-success">+{{ entry.income|accounting_format_amount }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.expense is not none %}
|
{% if entry.expense %}
|
||||||
<span class="badge rounded-pill bg-warning">-{{ item.expense|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-warning">-{{ entry.expense|accounting_format_amount }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.balance < 0 %}
|
{% if entry.balance < 0 %}
|
||||||
<span class="badge rounded-pill bg-danger">{{ item.balance|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-danger">{{ entry.balance|accounting_format_amount }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge rounded-pill bg-primary">{{ item.balance|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -20,22 +20,22 @@ Author: imacat@mail.imacat.idv.tw (imacat)
|
|||||||
First written: 2023/3/5
|
First written: 2023/3/5
|
||||||
#}
|
#}
|
||||||
<div>
|
<div>
|
||||||
{% if item.date is not none %}
|
{% if entry.date %}
|
||||||
<div class="text-muted small">
|
<div class="text-muted small">
|
||||||
{{ item.date|accounting_format_date }}
|
{{ entry.date|accounting_format_date }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.summary is not none %}
|
{% if entry.summary %}
|
||||||
<div>{{ item.summary }}</div>
|
<div>{{ entry.summary }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{% if item.debit is not none %}
|
{% if entry.debit %}
|
||||||
<span class="badge rounded-pill bg-success">+{{ item.debit|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-success">+{{ entry.debit|accounting_format_amount }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.credit is not none %}
|
{% if entry.credit %}
|
||||||
<span class="badge rounded-pill bg-warning">-{{ item.credit|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="badge rounded-pill bg-primary">{{ item.balance|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -164,36 +164,36 @@ First written: 2023/3/5
|
|||||||
</div>
|
</div>
|
||||||
<div class="accounting-report-table-body">
|
<div class="accounting-report-table-body">
|
||||||
{% if report.brought_forward %}
|
{% if report.brought_forward %}
|
||||||
{% with item = report.brought_forward %}
|
{% with entry = report.brought_forward %}
|
||||||
<div class="accounting-report-table-row">
|
<div class="accounting-report-table-row">
|
||||||
<div>{{ item.date|accounting_format_date }}</div>
|
<div>{{ entry.date|accounting_format_date }}</div>
|
||||||
<div>{{ item.account.title|title }}</div>
|
<div>{{ entry.account.title|title }}</div>
|
||||||
<div>{{ item.summary|accounting_default }}</div>
|
<div>{{ entry.summary|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.income|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.expense|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.balance|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ entry.balance|accounting_format_amount }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for item in report.data_rows %}
|
{% for entry in report.entries %}
|
||||||
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
|
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||||
<div>{{ item.date|accounting_format_date }}</div>
|
<div>{{ entry.date|accounting_format_date }}</div>
|
||||||
<div>{{ item.account.title|title }}</div>
|
<div>{{ entry.account.title|title }}</div>
|
||||||
<div>{{ item.summary|accounting_default }}</div>
|
<div>{{ entry.summary|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.income|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.expense|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.balance|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ entry.balance|accounting_format_amount }}</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if report.total %}
|
{% if report.total %}
|
||||||
{% with item = report.total %}
|
{% with entry = report.total %}
|
||||||
<div class="accounting-report-table-footer">
|
<div class="accounting-report-table-footer">
|
||||||
<div class="accounting-report-table-row">
|
<div class="accounting-report-table-row">
|
||||||
<div>{{ A_("Total") }}</div>
|
<div>{{ A_("Total") }}</div>
|
||||||
<div class="accounting-amount">{{ item.income|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ entry.income|accounting_format_amount }}</div>
|
||||||
<div class="accounting-amount">{{ item.expense|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ entry.expense|accounting_format_amount }}</div>
|
||||||
<div class="accounting-amount">{{ item.balance|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ entry.balance|accounting_format_amount }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
@ -202,19 +202,19 @@ First written: 2023/3/5
|
|||||||
|
|
||||||
<div class="list-group d-md-none">
|
<div class="list-group d-md-none">
|
||||||
{% if report.brought_forward %}
|
{% if report.brought_forward %}
|
||||||
{% with item = report.brought_forward %}
|
{% with entry = report.brought_forward %}
|
||||||
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||||
{% include "accounting/report/include/income-expenses-mobile-row.html" %}
|
{% include "accounting/report/include/income-expenses-mobile-row.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for item in report.data_rows %}
|
{% for entry in report.entries %}
|
||||||
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
|
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||||
{% include "accounting/report/include/income-expenses-mobile-row.html" %}
|
{% include "accounting/report/include/income-expenses-mobile-row.html" %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if report.total is not none %}
|
{% if report.total %}
|
||||||
{% with item = report.total %}
|
{% with entry = report.total %}
|
||||||
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||||
{% include "accounting/report/include/income-expenses-mobile-row.html" %}
|
{% include "accounting/report/include/income-expenses-mobile-row.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -129,40 +129,38 @@ First written: 2023/3/7
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="accounting-report-table-body">
|
<div class="accounting-report-table-body">
|
||||||
{% for item in report.data_rows %}
|
{% for section in report.sections %}
|
||||||
{% if item.is_category %}
|
<div class="accounting-report-table-row accounting-income-statement-section">
|
||||||
<div class="accounting-report-table-row accounting-income-statement-category">
|
<div>
|
||||||
|
<span class="d-none d-md-inline">{{ section.title.code }}</span>
|
||||||
|
{{ section.title.title|title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for subsection in section.subsections %}
|
||||||
|
<div class="accounting-report-table-row accounting-income-statement-subsection">
|
||||||
<div>
|
<div>
|
||||||
<span class="d-none d-md-inline">{{ item.code }}</span>
|
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
|
||||||
{{ item.title|title }}
|
{{ subsection.title.title|title }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% elif item.is_total %}
|
{% for account in subsection.accounts %}
|
||||||
<div class="accounting-report-table-row accounting-income-statement-total">
|
<a class="accounting-report-table-row accounting-income-statement-account" href="{{ account.url }}">
|
||||||
<div>{{ item.title|title }}</div>
|
<div>
|
||||||
<div class="accounting-amount">{{ item.amount|accounting_format_amount }}</div>
|
<span class="d-none d-md-inline">{{ account.account.code }}</span>
|
||||||
</div>
|
{{ account.account.title|title }}
|
||||||
{% elif item.is_subcategory %}
|
</div>
|
||||||
<div class="accounting-report-table-row accounting-income-statement-subcategory">
|
<div class="accounting-amount">{{ account.amount|accounting_format_amount }}</div>
|
||||||
<div>
|
</a>
|
||||||
<span class="d-none d-md-inline">{{ item.code }}</span>
|
{% endfor %}
|
||||||
{{ item.title|title }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% elif item.is_subtotal %}
|
|
||||||
<div class="accounting-report-table-row accounting-income-statement-subtotal">
|
<div class="accounting-report-table-row accounting-income-statement-subtotal">
|
||||||
<div>{{ A_("Total") }}</div>
|
<div>{{ A_("Total") }}</div>
|
||||||
<div class="accounting-amount">{{ item.amount|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ subsection.total|accounting_format_amount }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% endfor %}
|
||||||
<a class="accounting-report-table-row accounting-income-statement-account" href="{{ item.url }}">
|
<div class="accounting-report-table-row accounting-income-statement-total">
|
||||||
<div>
|
<div>{{ section.accumulated.title|title }}</div>
|
||||||
<span class="d-none d-md-inline">{{ item.code }}</span>
|
<div class="accounting-amount">{{ section.accumulated.amount|accounting_format_amount }}</div>
|
||||||
{{ item.title|title }}
|
</div>
|
||||||
</div>
|
|
||||||
<div class="accounting-amount">{{ item.amount|accounting_format_amount }}</div>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -105,38 +105,41 @@ First written: 2023/3/4
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="accounting-report-table-body">
|
<div class="accounting-report-table-body">
|
||||||
{% for item in report.data_rows %}
|
{% for entry in report.entries %}
|
||||||
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
|
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||||
<div>{{ item.transaction.date|accounting_format_date }}</div>
|
<div>{{ entry.transaction.date|accounting_format_date }}</div>
|
||||||
<div>{{ item.currency.name }}</div>
|
<div>{{ entry.currency.name }}</div>
|
||||||
<div>{{ item.account.title|title }}</div>
|
<div>
|
||||||
<div>{{ item.summary|accounting_default }}</div>
|
<span class="d-none d-md-inline">{{ entry.account.code }}</span>
|
||||||
<div class="accounting-amount">{{ item.debit|accounting_format_amount|accounting_default }}</div>
|
{{ entry.account.title|title }}
|
||||||
<div class="accounting-amount">{{ item.credit|accounting_format_amount|accounting_default }}</div>
|
</div>
|
||||||
|
<div>{{ entry.summary|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
||||||
|
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="list-group d-md-none">
|
<div class="list-group d-md-none">
|
||||||
{% for item in report.data_rows %}
|
{% for entry in report.entries %}
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
|
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||||
<div class="d-flex justify-content-between">
|
<div class="d-flex justify-content-between">
|
||||||
<div {% if not item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
|
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
|
||||||
<div class="text-muted small">
|
<div class="text-muted small">
|
||||||
{{ item.transaction.date|accounting_format_date }}
|
{{ entry.transaction.date|accounting_format_date }}
|
||||||
{{ item.account.title|title }}
|
{{ entry.account.title|title }}
|
||||||
{% if item.currency_code != accounting_default_currency_code() %}
|
{% if entry.currency.code != accounting_default_currency_code() %}
|
||||||
<span class="badge rounded-pill bg-info">{{ item.currency_code }}</span>
|
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if item.summary is not none %}
|
{% if entry.summary is not none %}
|
||||||
<div>{{ item.summary }}</div>
|
<div>{{ entry.summary }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="badge rounded-pill bg-info">{{ item.amount|accounting_format_amount }}</span>
|
<span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
@ -163,34 +163,34 @@ First written: 2023/3/5
|
|||||||
</div>
|
</div>
|
||||||
<div class="accounting-report-table-body">
|
<div class="accounting-report-table-body">
|
||||||
{% if report.brought_forward %}
|
{% if report.brought_forward %}
|
||||||
{% with item = report.brought_forward %}
|
{% with entry = report.brought_forward %}
|
||||||
<div class="accounting-report-table-row">
|
<div class="accounting-report-table-row">
|
||||||
<div>{{ item.date|accounting_format_date }}</div>
|
<div>{{ entry.date|accounting_format_date }}</div>
|
||||||
<div>{{ item.summary|accounting_default }}</div>
|
<div>{{ entry.summary|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.debit|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.credit|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.balance|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ entry.balance|accounting_format_amount }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for item in report.data_rows %}
|
{% for entry in report.entries %}
|
||||||
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
|
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||||
<div>{{ item.date|accounting_format_date }}</div>
|
<div>{{ entry.date|accounting_format_date }}</div>
|
||||||
<div>{{ item.summary|accounting_default }}</div>
|
<div>{{ entry.summary|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.debit|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.credit|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.balance|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ entry.balance|accounting_format_amount }}</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if report.total %}
|
{% if report.total %}
|
||||||
{% with item = report.total %}
|
{% with entry = report.total %}
|
||||||
<div class="accounting-report-table-footer">
|
<div class="accounting-report-table-footer">
|
||||||
<div class="accounting-report-table-row">
|
<div class="accounting-report-table-row">
|
||||||
<div>{{ A_("Total") }}</div>
|
<div>{{ A_("Total") }}</div>
|
||||||
<div class="accounting-amount">{{ item.debit|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ entry.debit|accounting_format_amount }}</div>
|
||||||
<div class="accounting-amount">{{ item.credit|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ entry.credit|accounting_format_amount }}</div>
|
||||||
<div class="accounting-amount">{{ item.balance|accounting_format_amount }}</div>
|
<div class="accounting-amount">{{ entry.balance|accounting_format_amount }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
@ -199,19 +199,19 @@ First written: 2023/3/5
|
|||||||
|
|
||||||
<div class="list-group d-md-none">
|
<div class="list-group d-md-none">
|
||||||
{% if report.brought_forward %}
|
{% if report.brought_forward %}
|
||||||
{% with item = report.brought_forward %}
|
{% with entry = report.brought_forward %}
|
||||||
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||||
{% include "accounting/report/include/ledger-mobile-row.html" %}
|
{% include "accounting/report/include/ledger-mobile-row.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for item in report.data_rows %}
|
{% for entry in report.entries %}
|
||||||
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
|
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
|
||||||
{% include "accounting/report/include/ledger-mobile-row.html" %}
|
{% include "accounting/report/include/ledger-mobile-row.html" %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if report.total is not none %}
|
{% if report.total %}
|
||||||
{% with item = report.total %}
|
{% with entry = report.total %}
|
||||||
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
<div class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||||
{% include "accounting/report/include/ledger-mobile-row.html" %}
|
{% include "accounting/report/include/ledger-mobile-row.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -131,14 +131,14 @@ First written: 2023/3/5
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="accounting-report-table-body">
|
<div class="accounting-report-table-body">
|
||||||
{% for item in report.data_rows %}
|
{% for account in report.accounts %}
|
||||||
<a class="accounting-report-table-row" href="{{ item.url }}">
|
<a class="accounting-report-table-row" href="{{ account.url }}">
|
||||||
<div>
|
<div>
|
||||||
<span class="d-none d-md-inline">{{ item.account.code }}</span>
|
<span class="d-none d-md-inline">{{ account.account.code }}</span>
|
||||||
{{ item.account.title|title }}
|
{{ account.account.title|title }}
|
||||||
</div>
|
</div>
|
||||||
<div class="accounting-amount">{{ item.debit|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ account.debit|accounting_format_amount|accounting_default }}</div>
|
||||||
<div class="accounting-amount">{{ item.credit|accounting_format_amount|accounting_default }}</div>
|
<div class="accounting-amount">{{ account.credit|accounting_format_amount|accounting_default }}</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user