Renamed "journal entry" to "voucher line item", and "entry type" to "side".

This commit is contained in:
依瑪貓 2023-03-19 21:00:11 +08:00
parent 25c45b16ae
commit c1235608d8
58 changed files with 1961 additions and 1926 deletions

View File

@ -100,10 +100,11 @@ def init_accounts_command(username: str) -> None:
def __is_need_offset(base_code: str) -> bool: def __is_need_offset(base_code: str) -> bool:
"""Checks that whether entries in the account need offset. """Checks that whether voucher line items in the account need offset.
:param base_code: The code of the base account. :param base_code: The code of the base account.
:return: True if entries in the account need offset, or False otherwise. :return: True if voucher line items in the account need offset, or False
otherwise.
""" """
# Assets # Assets
if base_code[0] == "1": if base_code[0] == "1":

View File

@ -82,7 +82,7 @@ class AccountForm(FlaskForm):
"""The title.""" """The title."""
is_need_offset = BooleanField( is_need_offset = BooleanField(
validators=[NoOffsetNominalAccount()]) validators=[NoOffsetNominalAccount()])
"""Whether the the entries of this account need offset.""" """Whether the the voucher line items of this account need offset."""
def populate_obj(self, obj: Account) -> None: def populate_obj(self, obj: Account) -> None:
"""Populates the form data into an account object. """Populates the form data into an account object.

View File

@ -115,7 +115,7 @@ class Account(db.Model):
title_l10n = db.Column("title", db.String, nullable=False) title_l10n = db.Column("title", db.String, nullable=False)
"""The title.""" """The title."""
is_need_offset = db.Column(db.Boolean, nullable=False, default=False) is_need_offset = db.Column(db.Boolean, nullable=False, default=False)
"""Whether the entries of this account need offset.""" """Whether the voucher line items of this account need offset."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now()) server_default=db.func.now())
"""The time of creation.""" """The time of creation."""
@ -139,8 +139,8 @@ class Account(db.Model):
l10n = db.relationship("AccountL10n", back_populates="account", l10n = db.relationship("AccountL10n", back_populates="account",
lazy=False) lazy=False)
"""The localized titles.""" """The localized titles."""
entries = db.relationship("JournalEntry", back_populates="account") line_items = db.relationship("VoucherLineItem", back_populates="account")
"""The journal entries.""" """The voucher line items."""
CASH_CODE: str = "1111-001" CASH_CODE: str = "1111-001"
"""The code of the cash account,""" """The code of the cash account,"""
@ -363,8 +363,8 @@ class Currency(db.Model):
l10n = db.relationship("CurrencyL10n", back_populates="currency", l10n = db.relationship("CurrencyL10n", back_populates="currency",
lazy=False) lazy=False)
"""The localized names.""" """The localized names."""
entries = db.relationship("JournalEntry", back_populates="currency") line_items = db.relationship("VoucherLineItem", back_populates="currency")
"""The journal entries.""" """The voucher line items."""
def __str__(self) -> str: def __str__(self) -> str:
"""Returns the string representation of the currency. """Returns the string representation of the currency.
@ -450,20 +450,20 @@ class CurrencyL10n(db.Model):
class VoucherCurrency: class VoucherCurrency:
"""A currency in a voucher.""" """A currency in a voucher."""
def __init__(self, code: str, debit: list[JournalEntry], def __init__(self, code: str, debit: list[VoucherLineItem],
credit: list[JournalEntry]): credit: list[VoucherLineItem]):
"""Constructs the currency in the voucher. """Constructs the currency in the voucher.
:param code: The currency code. :param code: The currency code.
:param debit: The debit entries. :param debit: The debit line items.
:param credit: The credit entries. :param credit: The credit line items.
""" """
self.code: str = code self.code: str = code
"""The currency code.""" """The currency code."""
self.debit: list[JournalEntry] = debit self.debit: list[VoucherLineItem] = debit
"""The debit entries.""" """The debit line items."""
self.credit: list[JournalEntry] = credit self.credit: list[VoucherLineItem] = credit
"""The credit entries.""" """The credit line items."""
@property @property
def name(self) -> str: def name(self) -> str:
@ -475,17 +475,17 @@ class VoucherCurrency:
@property @property
def debit_total(self) -> Decimal: def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries. """Returns the total amount of the debit line items.
:return: The total amount of the debit journal entries. :return: The total amount of the debit line items.
""" """
return sum([x.amount for x in self.debit]) return sum([x.amount for x in self.debit])
@property @property
def credit_total(self) -> str: def credit_total(self) -> str:
"""Returns the total amount of the credit journal entries. """Returns the total amount of the credit line items.
:return: The total amount of the credit journal entries. :return: The total amount of the credit line items.
""" """
return sum([x.amount for x in self.credit]) return sum([x.amount for x in self.credit])
@ -523,8 +523,8 @@ class Voucher(db.Model):
"""The ID of the updator.""" """The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator.""" """The updator."""
entries = db.relationship("JournalEntry", back_populates="voucher") line_items = db.relationship("VoucherLineItem", back_populates="voucher")
"""The journal entries.""" """The line items."""
def __str__(self) -> str: def __str__(self) -> str:
"""Returns the string representation of this voucher. """Returns the string representation of this voucher.
@ -539,18 +539,19 @@ class Voucher(db.Model):
@property @property
def currencies(self) -> list[VoucherCurrency]: def currencies(self) -> list[VoucherCurrency]:
"""Returns the journal entries categorized by their currencies. """Returns the line items categorized by their currencies.
:return: The currency categories. :return: The currency categories.
""" """
entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no) line_items: list[VoucherLineItem] = sorted(self.line_items,
key=lambda x: x.no)
codes: list[str] = [] codes: list[str] = []
by_currency: dict[str, list[JournalEntry]] = {} by_currency: dict[str, list[VoucherLineItem]] = {}
for entry in entries: for line_item in line_items:
if entry.currency_code not in by_currency: if line_item.currency_code not in by_currency:
codes.append(entry.currency_code) codes.append(line_item.currency_code)
by_currency[entry.currency_code] = [] by_currency[line_item.currency_code] = []
by_currency[entry.currency_code].append(entry) by_currency[line_item.currency_code].append(line_item)
return [VoucherCurrency(code=x, return [VoucherCurrency(code=x,
debit=[y for y in by_currency[x] debit=[y for y in by_currency[x]
if y.is_debit], if y.is_debit],
@ -593,8 +594,8 @@ class Voucher(db.Model):
""" """
if not hasattr(self, "__can_delete"): if not hasattr(self, "__can_delete"):
def has_offset() -> bool: def has_offset() -> bool:
for entry in self.entries: for line_item in self.line_items:
if len(entry.offsets) > 0: if len(line_item.offsets) > 0:
return True return True
return False return False
setattr(self, "__can_delete", not has_offset()) setattr(self, "__can_delete", not has_offset())
@ -605,50 +606,52 @@ class Voucher(db.Model):
:return: None. :return: None.
""" """
JournalEntry.query\ VoucherLineItem.query\
.filter(JournalEntry.voucher_id == self.id).delete() .filter(VoucherLineItem.voucher_id == self.id).delete()
db.session.delete(self) db.session.delete(self)
class JournalEntry(db.Model): class VoucherLineItem(db.Model):
"""An accounting journal entry.""" """A line item in the voucher."""
__tablename__ = "accounting_journal_entries" __tablename__ = "accounting_voucher_line_items"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False) autoincrement=False)
"""The entry ID.""" """The line item ID."""
voucher_id = db.Column(db.Integer, voucher_id = db.Column(db.Integer,
db.ForeignKey(Voucher.id, onupdate="CASCADE", db.ForeignKey(Voucher.id, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False) nullable=False)
"""The voucher ID.""" """The voucher ID."""
voucher = db.relationship(Voucher, back_populates="entries") voucher = db.relationship(Voucher, back_populates="line_items")
"""The voucher.""" """The voucher."""
is_debit = db.Column(db.Boolean, nullable=False) is_debit = db.Column(db.Boolean, nullable=False)
"""True for a debit entry, or False for a credit entry.""" """True for a debit line item, or False for a credit line item."""
no = db.Column(db.Integer, nullable=False) no = db.Column(db.Integer, nullable=False)
"""The entry number under the voucher and debit or credit.""" """The line item number under the voucher and debit or credit."""
original_entry_id = db.Column(db.Integer, original_line_item_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"), db.ForeignKey(id, onupdate="CASCADE"),
nullable=True) nullable=True)
"""The ID of the original entry.""" """The ID of the original line item."""
original_entry = db.relationship("JournalEntry", back_populates="offsets", original_line_item = db.relationship("VoucherLineItem",
remote_side=id, passive_deletes=True) back_populates="offsets",
"""The original entry.""" remote_side=id, passive_deletes=True)
offsets = db.relationship("JournalEntry", back_populates="original_entry") """The original line item."""
"""The offset entries.""" offsets = db.relationship("VoucherLineItem",
back_populates="original_line_item")
"""The offset items."""
currency_code = db.Column(db.String, currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"), db.ForeignKey(Currency.code, onupdate="CASCADE"),
nullable=False) nullable=False)
"""The currency code.""" """The currency code."""
currency = db.relationship(Currency, back_populates="entries") currency = db.relationship(Currency, back_populates="line_items")
"""The currency.""" """The currency."""
account_id = db.Column(db.Integer, account_id = db.Column(db.Integer,
db.ForeignKey(Account.id, db.ForeignKey(Account.id,
onupdate="CASCADE"), onupdate="CASCADE"),
nullable=False) nullable=False)
"""The account ID.""" """The account ID."""
account = db.relationship(Account, back_populates="entries", lazy=False) account = db.relationship(Account, back_populates="line_items", lazy=False)
"""The account.""" """The account."""
summary = db.Column(db.String, nullable=True) summary = db.Column(db.String, nullable=True)
"""The summary.""" """The summary."""
@ -656,9 +659,9 @@ class JournalEntry(db.Model):
"""The amount.""" """The amount."""
def __str__(self) -> str: def __str__(self) -> str:
"""Returns the string representation of the journal entry. """Returns the string representation of the line item.
:return: The string representation of the journal entry. :return: The string representation of the line item.
""" """
if not hasattr(self, "__str"): if not hasattr(self, "__str"):
from accounting.template_filters import format_date, format_amount from accounting.template_filters import format_date, format_amount
@ -672,10 +675,10 @@ class JournalEntry(db.Model):
@property @property
def eid(self) -> int | None: def eid(self) -> int | None:
"""Returns the journal entry ID. This is the alternative name of the """Returns the line item ID. This is the alternative name of the
ID field, to work with WTForms. ID field, to work with WTForms.
:return: The journal entry ID. :return: The line item ID.
""" """
return self.id return self.id
@ -691,15 +694,15 @@ class JournalEntry(db.Model):
def debit(self) -> Decimal | None: def debit(self) -> Decimal | None:
"""Returns the debit amount. """Returns the debit amount.
:return: The debit amount, or None if this is not a debit entry. :return: The debit amount, or None if this is not a debit line item.
""" """
return self.amount if self.is_debit else None return self.amount if self.is_debit else None
@property @property
def is_need_offset(self) -> bool: def is_need_offset(self) -> bool:
"""Returns whether the entry needs offset. """Returns whether the line item needs offset.
:return: True if the entry needs offset, or False otherwise. :return: True if the line item needs offset, or False otherwise.
""" """
if not self.account.is_need_offset: if not self.account.is_need_offset:
return False return False
@ -713,7 +716,7 @@ class JournalEntry(db.Model):
def credit(self) -> Decimal | None: def credit(self) -> Decimal | None:
"""Returns the credit amount. """Returns the credit amount.
:return: The credit amount, or None if this is not a credit entry. :return: The credit amount, or None if this is not a credit line item.
""" """
return None if self.is_debit else self.amount return None if self.is_debit else self.amount

View File

@ -25,7 +25,7 @@ from flask import render_template, Response
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Voucher, \ from accounting.models import Currency, BaseAccount, Account, Voucher, \
JournalEntry VoucherLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -124,13 +124,13 @@ class AccountCollector:
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}] = [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [VoucherLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)] sa.or_(*sub_conditions)]
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Voucher.date <= self.__period.end) conditions.append(Voucher.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (VoucherLineItem.is_debit, VoucherLineItem.amount),
else_=-JournalEntry.amount)).label("balance") else_=-VoucherLineItem.amount)).label("balance")
select_balance: sa.Select \ select_balance: sa.Select \
= sa.select(Account.id, Account.base_code, Account.no, = sa.select(Account.id, Account.base_code, Account.no,
balance_func)\ balance_func)\
@ -178,7 +178,7 @@ class AccountCollector:
if self.__period.start is None: if self.__period.start is None:
return None return None
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [VoucherLineItem.currency_code == self.__currency.code,
Voucher.date < self.__period.start] Voucher.date < self.__period.start]
return self.__query_balance(conditions) return self.__query_balance(conditions)
@ -197,7 +197,7 @@ class AccountCollector:
:return: The net income or loss for current period. :return: The net income or loss for current period.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code] = [VoucherLineItem.currency_code == self.__currency.code]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Voucher.date >= self.__period.start) conditions.append(Voucher.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
@ -215,8 +215,8 @@ class AccountCollector:
conditions.extend([sa.not_(Account.base_code.startswith(x)) conditions.extend([sa.not_(Account.base_code.startswith(x))
for x in {"1", "2", "3"}]) for x in {"1", "2", "3"}])
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (VoucherLineItem.is_debit, VoucherLineItem.amount),
else_=-JournalEntry.amount)) else_=-VoucherLineItem.amount))
select_balance: sa.Select = sa.select(balance_func)\ select_balance: sa.Select = sa.select(balance_func)\
.join(Voucher).join(Account).filter(*conditions) .join(Voucher).join(Account).filter(*conditions)
return db.session.scalar(select_balance) return db.session.scalar(select_balance)

View File

@ -26,7 +26,7 @@ from sqlalchemy.orm import selectinload
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Voucher, JournalEntry from accounting.models import Currency, Account, Voucher, VoucherLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -41,18 +41,18 @@ from accounting.utils.cast import be
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
class ReportEntry: class ReportLineItem:
"""An entry in the report.""" """A line item in the report."""
def __init__(self, entry: JournalEntry | None = None): def __init__(self, line_item: VoucherLineItem | None = None):
"""Constructs the entry in the report. """Constructs the line item in the report.
:param entry: The journal entry. :param line_item: The voucher line item.
""" """
self.is_brought_forward: bool = False self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry.""" """Whether this is the brought-forward line item."""
self.is_total: bool = False self.is_total: bool = False
"""Whether this is the total entry.""" """Whether this is the total line item."""
self.date: date | None = None self.date: date | None = None
"""The date.""" """The date."""
self.account: Account | None = None self.account: Account | None = None
@ -68,24 +68,24 @@ class ReportEntry:
self.note: str | None = None self.note: str | None = None
"""The note.""" """The note."""
self.url: str | None = None self.url: str | None = None
"""The URL to the journal entry.""" """The URL to the voucher line item."""
if entry is not None: if line_item is not None:
self.date = entry.voucher.date self.date = line_item.voucher.date
self.account = entry.account self.account = line_item.account
self.summary = entry.summary self.summary = line_item.summary
self.income = None if entry.is_debit else entry.amount self.income = None if line_item.is_debit else line_item.amount
self.expense = entry.amount if entry.is_debit else None self.expense = line_item.amount if line_item.is_debit else None
self.note = entry.voucher.note self.note = line_item.voucher.note
self.url = url_for("accounting.voucher.detail", self.url = url_for("accounting.voucher.detail",
voucher=entry.voucher) voucher=line_item.voucher)
class EntryCollector: class LineItemCollector:
"""The report entry collector.""" """The line item collector."""
def __init__(self, currency: Currency, account: IncomeExpensesAccount, def __init__(self, currency: Currency, account: IncomeExpensesAccount,
period: Period): period: Period):
"""Constructs the report entry collector. """Constructs the line item collector.
:param currency: The currency. :param currency: The currency.
:param account: The account. :param account: The account.
@ -97,74 +97,74 @@ class EntryCollector:
"""The account.""" """The account."""
self.__period: Period = period self.__period: Period = period
"""The period""" """The period"""
self.brought_forward: ReportEntry | None self.brought_forward: ReportLineItem | None
"""The brought-forward entry.""" """The brought-forward line item."""
self.entries: list[ReportEntry] self.line_items: list[ReportLineItem]
"""The log entries.""" """The line items."""
self.total: ReportEntry | None self.total: ReportLineItem | None
"""The total entry.""" """The total line item."""
self.brought_forward = self.__get_brought_forward_entry() self.brought_forward = self.__get_brought_forward()
self.entries = self.__query_entries() self.line_items = self.__query_line_items()
self.total = self.__get_total_entry() self.total = self.__get_total()
self.__populate_balance() self.__populate_balance()
def __get_brought_forward_entry(self) -> ReportEntry | None: def __get_brought_forward(self) -> ReportLineItem | None:
"""Queries, composes and returns the brought-forward entry. """Queries, composes and returns the brought-forward line item.
:return: The brought-forward entry, or None if the period starts from :return: The brought-forward line item, or None if the period starts
the beginning. from the beginning.
""" """
if self.__period.start is None: if self.__period.start is None:
return None return None
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (VoucherLineItem.is_debit, VoucherLineItem.amount),
else_=-JournalEntry.amount)) else_=-VoucherLineItem.amount))
select: sa.Select = sa.Select(balance_func)\ select: sa.Select = sa.Select(balance_func)\
.join(Voucher).join(Account)\ .join(Voucher).join(Account)\
.filter(be(JournalEntry.currency_code == self.__currency.code), .filter(be(VoucherLineItem.currency_code == self.__currency.code),
self.__account_condition, self.__account_condition,
Voucher.date < self.__period.start) Voucher.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: int | None = db.session.scalar(select)
if balance is None: if balance is None:
return None return None
entry: ReportEntry = ReportEntry() line_item: ReportLineItem = ReportLineItem()
entry.is_brought_forward = True line_item.is_brought_forward = True
entry.date = self.__period.start line_item.date = self.__period.start
entry.account = Account.accumulated_change() line_item.account = Account.accumulated_change()
entry.summary = gettext("Brought forward") line_item.summary = gettext("Brought forward")
if balance > 0: if balance > 0:
entry.income = balance line_item.income = balance
elif balance < 0: elif balance < 0:
entry.expense = -balance line_item.expense = -balance
entry.balance = balance line_item.balance = balance
return entry return line_item
def __query_entries(self) -> list[ReportEntry]: def __query_line_items(self) -> list[ReportLineItem]:
"""Queries and returns the log entries. """Queries and returns the line items.
:return: The log entries. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [VoucherLineItem.currency_code == self.__currency.code,
self.__account_condition] self.__account_condition]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Voucher.date >= self.__period.start) conditions.append(Voucher.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Voucher.date <= self.__period.end) conditions.append(Voucher.date <= self.__period.end)
voucher_with_account: sa.Select = sa.Select(Voucher.id).\ voucher_with_account: sa.Select = sa.Select(Voucher.id).\
join(JournalEntry).join(Account).filter(*conditions) join(VoucherLineItem).join(Account).filter(*conditions)
return [ReportEntry(x) return [ReportLineItem(x)
for x in JournalEntry.query.join(Voucher).join(Account) for x in VoucherLineItem.query.join(Voucher).join(Account)
.filter(JournalEntry.voucher_id.in_(voucher_with_account), .filter(VoucherLineItem.voucher_id.in_(voucher_with_account),
JournalEntry.currency_code == self.__currency.code, VoucherLineItem.currency_code == self.__currency.code,
sa.not_(self.__account_condition)) sa.not_(self.__account_condition))
.order_by(Voucher.date, .order_by(Voucher.date,
Voucher.no, Voucher.no,
JournalEntry.is_debit, VoucherLineItem.is_debit,
JournalEntry.no) VoucherLineItem.no)
.options(selectinload(JournalEntry.account), .options(selectinload(VoucherLineItem.account),
selectinload(JournalEntry.voucher))] selectinload(VoucherLineItem.voucher))]
@property @property
def __account_condition(self) -> sa.BinaryExpression: def __account_condition(self) -> sa.BinaryExpression:
@ -175,38 +175,39 @@ class EntryCollector:
Account.base_code.startswith("22")) Account.base_code.startswith("22"))
return Account.id == self.__account.id return Account.id == self.__account.id
def __get_total_entry(self) -> ReportEntry | None: def __get_total(self) -> ReportLineItem | None:
"""Composes the total entry. """Composes the total line item.
:return: The total entry, or None if there is no data. :return: The total line item, or None if there is no data.
""" """
if self.brought_forward is None and len(self.entries) == 0: if self.brought_forward is None and len(self.line_items) == 0:
return None return None
entry: ReportEntry = ReportEntry() line_item: ReportLineItem = ReportLineItem()
entry.is_total = True line_item.is_total = True
entry.summary = gettext("Total") line_item.summary = gettext("Total")
entry.income = sum([x.income for x in self.entries line_item.income = sum([x.income for x in self.line_items
if x.income is not None]) if x.income is not None])
entry.expense = sum([x.expense for x in self.entries line_item.expense = sum([x.expense for x in self.line_items
if x.expense is not None]) if x.expense is not None])
entry.balance = entry.income - entry.expense line_item.balance = line_item.income - line_item.expense
if self.brought_forward is not None: if self.brought_forward is not None:
entry.balance = self.brought_forward.balance + entry.balance line_item.balance \
return entry = self.brought_forward.balance + line_item.balance
return line_item
def __populate_balance(self) -> None: def __populate_balance(self) -> None:
"""Populates the balance of the entries. """Populates the balance of the line items.
:return: None. :return: None.
""" """
balance: Decimal = 0 if self.brought_forward is None \ balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.balance else self.brought_forward.balance
for entry in self.entries: for line_item in self.line_items:
if entry.income is not None: if line_item.income is not None:
balance = balance + entry.income balance = balance + line_item.income
if entry.expense is not None: if line_item.expense is not None:
balance = balance - entry.expense balance = balance - line_item.expense
entry.balance = balance line_item.balance = balance
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
@ -261,19 +262,19 @@ class PageParams(BasePageParams):
account: IncomeExpensesAccount, account: IncomeExpensesAccount,
period: Period, period: Period,
has_data: bool, has_data: bool,
pagination: Pagination[ReportEntry], pagination: Pagination[ReportLineItem],
brought_forward: ReportEntry | None, brought_forward: ReportLineItem | None,
entries: list[ReportEntry], line_items: list[ReportLineItem],
total: ReportEntry | None): total: ReportLineItem | None):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param currency: The currency. :param currency: The currency.
:param account: The account. :param account: The account.
:param period: The period. :param period: The period.
:param has_data: True if there is any data, or False otherwise. :param has_data: True if there is any data, or False otherwise.
:param brought_forward: The brought-forward entry. :param brought_forward: The brought-forward line item.
:param entries: The log entries. :param line_items: The line items.
:param total: The total entry. :param total: The total line item.
""" """
self.currency: Currency = currency self.currency: Currency = currency
"""The currency.""" """The currency."""
@ -283,14 +284,14 @@ class PageParams(BasePageParams):
"""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.pagination: Pagination[ReportEntry] = pagination self.pagination: Pagination[ReportLineItem] = pagination
"""The pagination.""" """The pagination."""
self.brought_forward: ReportEntry | None = brought_forward self.brought_forward: ReportLineItem | None = brought_forward
"""The brought-forward entry.""" """The brought-forward line item."""
self.entries: list[ReportEntry] = entries self.line_items: list[ReportLineItem] = line_items
"""The report entries.""" """The line items."""
self.total: ReportEntry | None = total self.total: ReportLineItem | None = total
"""The total entry.""" """The total line item."""
self.period_chooser: PeriodChooser = PeriodChooser( self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: income_expenses_url(currency, account, x)) lambda x: income_expenses_url(currency, account, x))
"""The period chooser.""" """The period chooser."""
@ -342,14 +343,14 @@ class PageParams(BasePageParams):
income_expenses_url(self.currency, current_al, income_expenses_url(self.currency, current_al,
self.period), self.period),
self.account.id == 0)] self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntry.account_id)\ in_use: sa.Select = sa.Select(VoucherLineItem.account_id)\
.join(Account)\ .join(Account)\
.filter(be(JournalEntry.currency_code == self.currency.code), .filter(be(VoucherLineItem.currency_code == self.currency.code),
sa.or_(Account.base_code.startswith("11"), sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"), Account.base_code.startswith("12"),
Account.base_code.startswith("21"), Account.base_code.startswith("21"),
Account.base_code.startswith("22")))\ Account.base_code.startswith("22")))\
.group_by(JournalEntry.account_id) .group_by(VoucherLineItem.account_id)
options.extend([OptionLink(str(x), options.extend([OptionLink(str(x),
income_expenses_url( income_expenses_url(
self.currency, self.currency,
@ -378,14 +379,15 @@ class IncomeExpenses(BaseReport):
"""The account.""" """The account."""
self.__period: Period = period self.__period: Period = period
"""The period.""" """The period."""
collector: EntryCollector = EntryCollector( collector: LineItemCollector = LineItemCollector(
self.__currency, self.__account, self.__period) self.__currency, self.__account, self.__period)
self.__brought_forward: ReportEntry | None = collector.brought_forward self.__brought_forward: ReportLineItem | None \
"""The brought-forward entry.""" = collector.brought_forward
self.__entries: list[ReportEntry] = collector.entries """The brought-forward line item."""
"""The report entries.""" self.__line_items: list[ReportLineItem] = collector.line_items
self.__total: ReportEntry | None = collector.total """The line items."""
"""The total entry.""" self.__total: ReportLineItem | None = collector.total
"""The total line item."""
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
@ -416,7 +418,7 @@ class IncomeExpenses(BaseReport):
None)) None))
rows.extend([CSVRow(x.date, str(x.account).title(), x.summary, rows.extend([CSVRow(x.date, str(x.account).title(), x.summary,
x.income, x.expense, x.balance, x.note) x.income, x.expense, x.balance, x.note)
for x in self.__entries]) for x in self.__line_items])
if self.__total is not None: if self.__total is not None:
rows.append(CSVRow(gettext("Total"), None, None, rows.append(CSVRow(gettext("Total"), None, None,
self.__total.income, self.__total.expense, self.__total.income, self.__total.expense,
@ -428,31 +430,31 @@ class IncomeExpenses(BaseReport):
:return: The report as HTML. :return: The report as HTML.
""" """
all_entries: list[ReportEntry] = [] all_line_items: list[ReportLineItem] = []
if self.__brought_forward is not None: if self.__brought_forward is not None:
all_entries.append(self.__brought_forward) all_line_items.append(self.__brought_forward)
all_entries.extend(self.__entries) all_line_items.extend(self.__line_items)
if self.__total is not None: if self.__total is not None:
all_entries.append(self.__total) all_line_items.append(self.__total)
pagination: Pagination[ReportEntry] \ pagination: Pagination[ReportLineItem] \
= Pagination[ReportEntry](all_entries, is_reversed=True) = Pagination[ReportLineItem](all_line_items, is_reversed=True)
page_entries: list[ReportEntry] = pagination.list page_line_items: list[ReportLineItem] = pagination.list
has_data: bool = len(page_entries) > 0 has_data: bool = len(page_line_items) > 0
brought_forward: ReportEntry | None = None brought_forward: ReportLineItem | None = None
if len(page_entries) > 0 and page_entries[0].is_brought_forward: if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
brought_forward = page_entries[0] brought_forward = page_line_items[0]
page_entries = page_entries[1:] page_line_items = page_line_items[1:]
total: ReportEntry | None = None total: ReportLineItem | None = None
if len(page_entries) > 0 and page_entries[-1].is_total: if len(page_line_items) > 0 and page_line_items[-1].is_total:
total = page_entries[-1] total = page_line_items[-1]
page_entries = page_entries[:-1] page_line_items = page_line_items[:-1]
params: PageParams = PageParams(currency=self.__currency, params: PageParams = PageParams(currency=self.__currency,
account=self.__account, account=self.__account,
period=self.__period, period=self.__period,
has_data=has_data, has_data=has_data,
pagination=pagination, pagination=pagination,
brought_forward=brought_forward, brought_forward=brought_forward,
entries=page_entries, line_items=page_line_items,
total=total) total=total)
return render_template("accounting/report/income-expenses.html", return render_template("accounting/report/income-expenses.html",
report=params) report=params)

View File

@ -25,7 +25,7 @@ from flask import render_template, Response
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, BaseAccount, Account, Voucher, \ from accounting.models import Currency, BaseAccount, Account, Voucher, \
JournalEntry VoucherLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -256,15 +256,15 @@ class IncomeStatement(BaseReport):
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.startswith(str(x)) for x in range(4, 10)] = [Account.base_code.startswith(str(x)) for x in range(4, 10)]
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [VoucherLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)] sa.or_(*sub_conditions)]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Voucher.date >= self.__period.start) conditions.append(Voucher.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Voucher.date <= self.__period.end) conditions.append(Voucher.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, -JournalEntry.amount), (VoucherLineItem.is_debit, -VoucherLineItem.amount),
else_=JournalEntry.amount)).label("balance") else_=VoucherLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\ select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Voucher).join(Account)\ .join(Voucher).join(Account)\
.filter(*conditions)\ .filter(*conditions)\

View File

@ -25,7 +25,7 @@ from flask import render_template, Response
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Voucher, JournalEntry from accounting.models import Currency, Account, Voucher, VoucherLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -37,29 +37,29 @@ from accounting.report.utils.urls import journal_url
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
class ReportEntry: class ReportLineItem:
"""An entry in the report.""" """A line item in the report."""
def __init__(self, entry: JournalEntry): def __init__(self, line_item: VoucherLineItem):
"""Constructs the entry in the report. """Constructs the line item in the report.
:param entry: The journal entry. :param line_item: The voucher line item.
""" """
self.entry: JournalEntry = entry self.line_item: VoucherLineItem = line_item
"""The journal entry.""" """The voucher line item."""
self.voucher: Voucher = entry.voucher self.voucher: Voucher = line_item.voucher
"""The voucher.""" """The voucher."""
self.currency: Currency = entry.currency self.currency: Currency = line_item.currency
"""The account.""" """The account."""
self.account: Account = entry.account self.account: Account = line_item.account
"""The account.""" """The account."""
self.summary: str | None = entry.summary self.summary: str | None = line_item.summary
"""The summary.""" """The summary."""
self.debit: Decimal | None = entry.debit self.debit: Decimal | None = line_item.debit
"""The debit amount.""" """The debit amount."""
self.credit: Decimal | None = entry.credit self.credit: Decimal | None = line_item.credit
"""The credit amount.""" """The credit amount."""
self.amount: Decimal = entry.amount self.amount: Decimal = line_item.amount
"""The amount.""" """The amount."""
@ -110,19 +110,19 @@ class PageParams(BasePageParams):
"""The HTML page parameters.""" """The HTML page parameters."""
def __init__(self, period: Period, def __init__(self, period: Period,
pagination: Pagination[JournalEntry], pagination: Pagination[VoucherLineItem],
entries: list[JournalEntry]): line_items: list[VoucherLineItem]):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param period: The period. :param period: The period.
:param entries: The journal entries. :param line_items: The line items.
""" """
self.period: Period = period self.period: Period = period
"""The period.""" """The period."""
self.pagination: Pagination[JournalEntry] = pagination self.pagination: Pagination[VoucherLineItem] = pagination
"""The pagination.""" """The pagination."""
self.entries: list[JournalEntry] = entries self.line_items: list[VoucherLineItem] = line_items
"""The entries.""" """The line items."""
self.period_chooser: PeriodChooser = PeriodChooser( self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: journal_url(x)) lambda x: journal_url(x))
"""The period chooser.""" """The period chooser."""
@ -133,7 +133,7 @@ class PageParams(BasePageParams):
:return: True if there is any data, or False otherwise. :return: True if there is any data, or False otherwise.
""" """
return len(self.entries) > 0 return len(self.line_items) > 0
@property @property
def report_chooser(self) -> ReportChooser: def report_chooser(self) -> ReportChooser:
@ -145,10 +145,10 @@ class PageParams(BasePageParams):
period=self.period) period=self.period)
def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]: def get_csv_rows(line_items: list[VoucherLineItem]) -> list[CSVRow]:
"""Composes and returns the CSV rows from the report entries. """Composes and returns the CSV rows from the line items.
:param entries: The report entries. :param line_items: The line items.
:return: The CSV rows. :return: The CSV rows.
""" """
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"), rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
@ -158,7 +158,7 @@ def get_csv_rows(entries: list[JournalEntry]) -> list[CSVRow]:
rows.extend([CSVRow(x.voucher.date, x.currency.code, rows.extend([CSVRow(x.voucher.date, x.currency.code,
str(x.account).title(), x.summary, str(x.account).title(), x.summary,
x.debit, x.credit, x.voucher.note) x.debit, x.credit, x.voucher.note)
for x in entries]) for x in line_items])
return rows return rows
@ -172,28 +172,28 @@ class Journal(BaseReport):
""" """
self.__period: Period = period self.__period: Period = period
"""The period.""" """The period."""
self.__entries: list[JournalEntry] = self.__query_entries() self.__line_items: list[VoucherLineItem] = self.__query_line_items()
"""The journal entries.""" """The line items."""
def __query_entries(self) -> list[JournalEntry]: def __query_line_items(self) -> list[VoucherLineItem]:
"""Queries and returns the journal entries. """Queries and returns the line items.
:return: The journal entries. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.BinaryExpression] = []
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Voucher.date >= self.__period.start) conditions.append(Voucher.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Voucher.date <= self.__period.end) conditions.append(Voucher.date <= self.__period.end)
return JournalEntry.query.join(Voucher)\ return VoucherLineItem.query.join(Voucher)\
.filter(*conditions)\ .filter(*conditions)\
.order_by(Voucher.date, .order_by(Voucher.date,
Voucher.no, Voucher.no,
JournalEntry.is_debit.desc(), VoucherLineItem.is_debit.desc(),
JournalEntry.no)\ VoucherLineItem.no)\
.options(selectinload(JournalEntry.account), .options(selectinload(VoucherLineItem.account),
selectinload(JournalEntry.currency), selectinload(VoucherLineItem.currency),
selectinload(JournalEntry.voucher)).all() selectinload(VoucherLineItem.voucher)).all()
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
@ -201,17 +201,17 @@ class Journal(BaseReport):
:return: The response of the report for download. :return: The response of the report for download.
""" """
filename: str = f"journal-{period_spec(self.__period)}.csv" filename: str = f"journal-{period_spec(self.__period)}.csv"
return csv_download(filename, get_csv_rows(self.__entries)) return csv_download(filename, get_csv_rows(self.__line_items))
def html(self) -> str: def html(self) -> str:
"""Composes and returns the report as HTML. """Composes and returns the report as HTML.
:return: The report as HTML. :return: The report as HTML.
""" """
pagination: Pagination[JournalEntry] \ pagination: Pagination[VoucherLineItem] \
= Pagination[JournalEntry](self.__entries, is_reversed=True) = Pagination[VoucherLineItem](self.__line_items, is_reversed=True)
params: PageParams = PageParams(period=self.__period, params: PageParams = PageParams(period=self.__period,
pagination=pagination, pagination=pagination,
entries=pagination.list) line_items=pagination.list)
return render_template("accounting/report/journal.html", return render_template("accounting/report/journal.html",
report=params) report=params)

View File

@ -26,7 +26,7 @@ from sqlalchemy.orm import selectinload
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Voucher, JournalEntry from accounting.models import Currency, Account, Voucher, VoucherLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -40,18 +40,18 @@ from accounting.utils.cast import be
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
class ReportEntry: class ReportLineItem:
"""An entry in the report.""" """A line item in the report."""
def __init__(self, entry: JournalEntry | None = None): def __init__(self, line_item: VoucherLineItem | None = None):
"""Constructs the entry in the report. """Constructs the line item in the report.
:param entry: The journal entry. :param line_item: The voucher line item.
""" """
self.is_brought_forward: bool = False self.is_brought_forward: bool = False
"""Whether this is the brought-forward entry.""" """Whether this is the brought-forward line item."""
self.is_total: bool = False self.is_total: bool = False
"""Whether this is the total entry.""" """Whether this is the total line item."""
self.date: date | None = None self.date: date | None = None
"""The date.""" """The date."""
self.summary: str | None = None self.summary: str | None = None
@ -65,22 +65,22 @@ class ReportEntry:
self.note: str | None = None self.note: str | None = None
"""The note.""" """The note."""
self.url: str | None = None self.url: str | None = None
"""The URL to the journal entry.""" """The URL to the voucher line item."""
if entry is not None: if line_item is not None:
self.date = entry.voucher.date self.date = line_item.voucher.date
self.summary = entry.summary self.summary = line_item.summary
self.debit = entry.amount if entry.is_debit else None self.debit = line_item.amount if line_item.is_debit else None
self.credit = None if entry.is_debit else entry.amount self.credit = None if line_item.is_debit else line_item.amount
self.note = entry.voucher.note self.note = line_item.voucher.note
self.url = url_for("accounting.voucher.detail", self.url = url_for("accounting.voucher.detail",
voucher=entry.voucher) voucher=line_item.voucher)
class EntryCollector: class LineItemCollector:
"""The report entry collector.""" """The line item collector."""
def __init__(self, currency: Currency, account: Account, period: Period): def __init__(self, currency: Currency, account: Account, period: Period):
"""Constructs the report entry collector. """Constructs the line item collector.
:param currency: The currency. :param currency: The currency.
:param account: The account. :param account: The account.
@ -92,89 +92,90 @@ class EntryCollector:
"""The account.""" """The account."""
self.__period: Period = period self.__period: Period = period
"""The period""" """The period"""
self.brought_forward: ReportEntry | None self.brought_forward: ReportLineItem | None
"""The brought-forward entry.""" """The brought-forward line item."""
self.entries: list[ReportEntry] self.line_items: list[ReportLineItem]
"""The report entries.""" """The line items."""
self.total: ReportEntry | None self.total: ReportLineItem | None
"""The total entry.""" """The total line item."""
self.brought_forward = self.__get_brought_forward_entry() self.brought_forward = self.__get_brought_forward()
self.entries = self.__query_entries() self.line_items = self.__query_line_items()
self.total = self.__get_total_entry() self.total = self.__get_total()
self.__populate_balance() self.__populate_balance()
def __get_brought_forward_entry(self) -> ReportEntry | None: def __get_brought_forward(self) -> ReportLineItem | None:
"""Queries, composes and returns the brought-forward entry. """Queries, composes and returns the brought-forward line item.
:return: The brought-forward entry, or None if the report starts from :return: The brought-forward line item, or None if the report starts
the beginning. from the beginning.
""" """
if self.__period.start is None: if self.__period.start is None:
return None return None
if self.__account.is_nominal: if self.__account.is_nominal:
return None return None
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (VoucherLineItem.is_debit, VoucherLineItem.amount),
else_=-JournalEntry.amount)) else_=-VoucherLineItem.amount))
select: sa.Select = sa.Select(balance_func).join(Voucher)\ select: sa.Select = sa.Select(balance_func).join(Voucher)\
.filter(be(JournalEntry.currency_code == self.__currency.code), .filter(be(VoucherLineItem.currency_code == self.__currency.code),
be(JournalEntry.account_id == self.__account.id), be(VoucherLineItem.account_id == self.__account.id),
Voucher.date < self.__period.start) Voucher.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: int | None = db.session.scalar(select)
if balance is None: if balance is None:
return None return None
entry: ReportEntry = ReportEntry() line_item: ReportLineItem = ReportLineItem()
entry.is_brought_forward = True line_item.is_brought_forward = True
entry.date = self.__period.start line_item.date = self.__period.start
entry.summary = gettext("Brought forward") line_item.summary = gettext("Brought forward")
if balance > 0: if balance > 0:
entry.debit = balance line_item.debit = balance
elif balance < 0: elif balance < 0:
entry.credit = -balance line_item.credit = -balance
entry.balance = balance line_item.balance = balance
return entry return line_item
def __query_entries(self) -> list[ReportEntry]: def __query_line_items(self) -> list[ReportLineItem]:
"""Queries and returns the report entries. """Queries and returns the line items.
:return: The report entries. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code, = [VoucherLineItem.currency_code == self.__currency.code,
JournalEntry.account_id == self.__account.id] VoucherLineItem.account_id == self.__account.id]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Voucher.date >= self.__period.start) conditions.append(Voucher.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Voucher.date <= self.__period.end) conditions.append(Voucher.date <= self.__period.end)
return [ReportEntry(x) for x in JournalEntry.query.join(Voucher) return [ReportLineItem(x) for x in VoucherLineItem.query.join(Voucher)
.filter(*conditions) .filter(*conditions)
.order_by(Voucher.date, .order_by(Voucher.date,
Voucher.no, Voucher.no,
JournalEntry.is_debit.desc(), VoucherLineItem.is_debit.desc(),
JournalEntry.no) VoucherLineItem.no)
.options(selectinload(JournalEntry.voucher)).all()] .options(selectinload(VoucherLineItem.voucher)).all()]
def __get_total_entry(self) -> ReportEntry | None: def __get_total(self) -> ReportLineItem | None:
"""Composes the total entry. """Composes the total line item.
:return: The total entry, or None if there is no data. :return: The total line item, or None if there is no data.
""" """
if self.brought_forward is None and len(self.entries) == 0: if self.brought_forward is None and len(self.line_items) == 0:
return None return None
entry: ReportEntry = ReportEntry() line_item: ReportLineItem = ReportLineItem()
entry.is_total = True line_item.is_total = True
entry.summary = gettext("Total") line_item.summary = gettext("Total")
entry.debit = sum([x.debit for x in self.entries line_item.debit = sum([x.debit for x in self.line_items
if x.debit is not None]) if x.debit is not None])
entry.credit = sum([x.credit for x in self.entries line_item.credit = sum([x.credit for x in self.line_items
if x.credit is not None]) if x.credit is not None])
entry.balance = entry.debit - entry.credit line_item.balance = line_item.debit - line_item.credit
if self.brought_forward is not None: if self.brought_forward is not None:
entry.balance = self.brought_forward.balance + entry.balance line_item.balance \
return entry = self.brought_forward.balance + line_item.balance
return line_item
def __populate_balance(self) -> None: def __populate_balance(self) -> None:
"""Populates the balance of the entries. """Populates the balance of the line items.
:return: None. :return: None.
""" """
@ -182,12 +183,12 @@ class EntryCollector:
return None return None
balance: Decimal = 0 if self.brought_forward is None \ balance: Decimal = 0 if self.brought_forward is None \
else self.brought_forward.balance else self.brought_forward.balance
for entry in self.entries: for line_item in self.line_items:
if entry.debit is not None: if line_item.debit is not None:
balance = balance + entry.debit balance = balance + line_item.debit
if entry.credit is not None: if line_item.credit is not None:
balance = balance - entry.credit balance = balance - line_item.credit
entry.balance = balance line_item.balance = balance
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
@ -238,19 +239,19 @@ class PageParams(BasePageParams):
account: Account, account: Account,
period: Period, period: Period,
has_data: bool, has_data: bool,
pagination: Pagination[ReportEntry], pagination: Pagination[ReportLineItem],
brought_forward: ReportEntry | None, brought_forward: ReportLineItem | None,
entries: list[ReportEntry], line_items: list[ReportLineItem],
total: ReportEntry | None): total: ReportLineItem | None):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param currency: The currency. :param currency: The currency.
:param account: The account. :param account: The account.
:param period: The period. :param period: The period.
:param has_data: True if there is any data, or False otherwise. :param has_data: True if there is any data, or False otherwise.
:param brought_forward: The brought-forward entry. :param brought_forward: The brought-forward line item.
:param entries: The report entries. :param line_items: The line items.
:param total: The total entry. :param total: The total line item.
""" """
self.currency: Currency = currency self.currency: Currency = currency
"""The currency.""" """The currency."""
@ -260,14 +261,14 @@ class PageParams(BasePageParams):
"""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.pagination: Pagination[ReportEntry] = pagination self.pagination: Pagination[ReportLineItem] = pagination
"""The pagination.""" """The pagination."""
self.brought_forward: ReportEntry | None = brought_forward self.brought_forward: ReportLineItem | None = brought_forward
"""The brought-forward entry.""" """The brought-forward line item."""
self.entries: list[ReportEntry] = entries self.line_items: list[ReportLineItem] = line_items
"""The entries.""" """The line items."""
self.total: ReportEntry | None = total self.total: ReportLineItem | None = total
"""The total entry.""" """The total line item."""
self.period_chooser: PeriodChooser = PeriodChooser( self.period_chooser: PeriodChooser = PeriodChooser(
lambda x: ledger_url(currency, account, x)) lambda x: ledger_url(currency, account, x))
"""The period chooser.""" """The period chooser."""
@ -306,9 +307,9 @@ class PageParams(BasePageParams):
:return: The account options. :return: The account options.
""" """
in_use: sa.Select = sa.Select(JournalEntry.account_id)\ in_use: sa.Select = sa.Select(VoucherLineItem.account_id)\
.filter(be(JournalEntry.currency_code == self.currency.code))\ .filter(be(VoucherLineItem.currency_code == self.currency.code))\
.group_by(JournalEntry.account_id) .group_by(VoucherLineItem.account_id)
return [OptionLink(str(x), ledger_url(self.currency, x, self.period), return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
x.id == self.account.id) x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use)) for x in Account.query.filter(Account.id.in_(in_use))
@ -331,14 +332,15 @@ class Ledger(BaseReport):
"""The account.""" """The account."""
self.__period: Period = period self.__period: Period = period
"""The period.""" """The period."""
collector: EntryCollector = EntryCollector( collector: LineItemCollector = LineItemCollector(
self.__currency, self.__account, self.__period) self.__currency, self.__account, self.__period)
self.__brought_forward: ReportEntry | None = collector.brought_forward self.__brought_forward: ReportLineItem | None \
"""The brought-forward entry.""" = collector.brought_forward
self.__entries: list[ReportEntry] = collector.entries """The brought-forward line item."""
"""The report entries.""" self.__line_items: list[ReportLineItem] = collector.line_items
self.__total: ReportEntry | None = collector.total """The line items."""
"""The total entry.""" self.__total: ReportLineItem | None = collector.total
"""The total line item."""
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
@ -367,7 +369,7 @@ class Ledger(BaseReport):
None)) None))
rows.extend([CSVRow(x.date, x.summary, rows.extend([CSVRow(x.date, x.summary,
x.debit, x.credit, x.balance, x.note) x.debit, x.credit, x.balance, x.note)
for x in self.__entries]) for x in self.__line_items])
if self.__total is not None: if self.__total is not None:
rows.append(CSVRow(gettext("Total"), None, rows.append(CSVRow(gettext("Total"), None,
self.__total.debit, self.__total.credit, self.__total.debit, self.__total.credit,
@ -379,31 +381,31 @@ class Ledger(BaseReport):
:return: The report as HTML. :return: The report as HTML.
""" """
all_entries: list[ReportEntry] = [] all_line_items: list[ReportLineItem] = []
if self.__brought_forward is not None: if self.__brought_forward is not None:
all_entries.append(self.__brought_forward) all_line_items.append(self.__brought_forward)
all_entries.extend(self.__entries) all_line_items.extend(self.__line_items)
if self.__total is not None: if self.__total is not None:
all_entries.append(self.__total) all_line_items.append(self.__total)
pagination: Pagination[ReportEntry] \ pagination: Pagination[ReportLineItem] \
= Pagination[ReportEntry](all_entries, is_reversed=True) = Pagination[ReportLineItem](all_line_items, is_reversed=True)
page_entries: list[ReportEntry] = pagination.list page_line_items: list[ReportLineItem] = pagination.list
has_data: bool = len(page_entries) > 0 has_data: bool = len(page_line_items) > 0
brought_forward: ReportEntry | None = None brought_forward: ReportLineItem | None = None
if len(page_entries) > 0 and page_entries[0].is_brought_forward: if len(page_line_items) > 0 and page_line_items[0].is_brought_forward:
brought_forward = page_entries[0] brought_forward = page_line_items[0]
page_entries = page_entries[1:] page_line_items = page_line_items[1:]
total: ReportEntry | None = None total: ReportLineItem | None = None
if len(page_entries) > 0 and page_entries[-1].is_total: if len(page_line_items) > 0 and page_line_items[-1].is_total:
total = page_entries[-1] total = page_line_items[-1]
page_entries = page_entries[:-1] page_line_items = page_line_items[:-1]
params: PageParams = PageParams(currency=self.__currency, params: PageParams = PageParams(currency=self.__currency,
account=self.__account, account=self.__account,
period=self.__period, period=self.__period,
has_data=has_data, has_data=has_data,
pagination=pagination, pagination=pagination,
brought_forward=brought_forward, brought_forward=brought_forward,
entries=page_entries, line_items=page_line_items,
total=total) total=total)
return render_template("accounting/report/ledger.html", return render_template("accounting/report/ledger.html",
report=params) report=params)

View File

@ -26,7 +26,7 @@ from sqlalchemy.orm import selectinload
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \ from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
Voucher, JournalEntry Voucher, VoucherLineItem
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import csv_download from accounting.report.utils.csv_export import csv_download
@ -38,18 +38,18 @@ from accounting.utils.query import parse_query_keywords
from .journal import get_csv_rows from .journal import get_csv_rows
class EntryCollector: class LineItemCollector:
"""The report entry collector.""" """The line item collector."""
def __init__(self): def __init__(self):
"""Constructs the report entry collector.""" """Constructs the line item collector."""
self.entries: list[JournalEntry] = self.__query_entries() self.line_items: list[VoucherLineItem] = self.__query_line_items()
"""The report entries.""" """The line items."""
def __query_entries(self) -> list[JournalEntry]: def __query_line_items(self) -> list[VoucherLineItem]:
"""Queries and returns the journal entries. """Queries and returns the line items.
:return: The journal entries. :return: The line items.
""" """
keywords: list[str] = parse_query_keywords(request.args.get("q")) keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0: if len(keywords) == 0:
@ -57,26 +57,26 @@ class EntryCollector:
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.BinaryExpression] = []
for k in keywords: for k in keywords:
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.BinaryExpression] \
= [JournalEntry.summary.contains(k), = [VoucherLineItem.summary.contains(k),
JournalEntry.account_id.in_( VoucherLineItem.account_id.in_(
self.__get_account_condition(k)), self.__get_account_condition(k)),
JournalEntry.currency_code.in_( VoucherLineItem.currency_code.in_(
self.__get_currency_condition(k)), self.__get_currency_condition(k)),
JournalEntry.voucher_id.in_( VoucherLineItem.voucher_id.in_(
self.__get_voucher_condition(k))] self.__get_voucher_condition(k))]
try: try:
sub_conditions.append(JournalEntry.amount == Decimal(k)) sub_conditions.append(VoucherLineItem.amount == Decimal(k))
except ArithmeticError: except ArithmeticError:
pass pass
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
return JournalEntry.query.join(Voucher).filter(*conditions)\ return VoucherLineItem.query.join(Voucher).filter(*conditions)\
.order_by(Voucher.date, .order_by(Voucher.date,
Voucher.no, Voucher.no,
JournalEntry.is_debit, VoucherLineItem.is_debit,
JournalEntry.no)\ VoucherLineItem.no)\
.options(selectinload(JournalEntry.account), .options(selectinload(VoucherLineItem.account),
selectinload(JournalEntry.currency), selectinload(VoucherLineItem.currency),
selectinload(JournalEntry.voucher)).all() selectinload(VoucherLineItem.voucher)).all()
@staticmethod @staticmethod
def __get_account_condition(k: str) -> sa.Select: def __get_account_condition(k: str) -> sa.Select:
@ -149,16 +149,16 @@ class EntryCollector:
class PageParams(BasePageParams): class PageParams(BasePageParams):
"""The HTML page parameters.""" """The HTML page parameters."""
def __init__(self, pagination: Pagination[JournalEntry], def __init__(self, pagination: Pagination[VoucherLineItem],
entries: list[JournalEntry]): line_items: list[VoucherLineItem]):
"""Constructs the HTML page parameters. """Constructs the HTML page parameters.
:param entries: The search result entries. :param line_items: The search result line items.
""" """
self.pagination: Pagination[JournalEntry] = pagination self.pagination: Pagination[VoucherLineItem] = pagination
"""The pagination.""" """The pagination."""
self.entries: list[JournalEntry] = entries self.line_items: list[VoucherLineItem] = line_items
"""The entries.""" """The line items."""
@property @property
def has_data(self) -> bool: def has_data(self) -> bool:
@ -166,7 +166,7 @@ class PageParams(BasePageParams):
:return: True if there is any data, or False otherwise. :return: True if there is any data, or False otherwise.
""" """
return len(self.entries) > 0 return len(self.line_items) > 0
@property @property
def report_chooser(self) -> ReportChooser: def report_chooser(self) -> ReportChooser:
@ -182,8 +182,9 @@ class Search(BaseReport):
def __init__(self): def __init__(self):
"""Constructs a search.""" """Constructs a search."""
self.__entries: list[JournalEntry] = EntryCollector().entries self.__line_items: list[VoucherLineItem] \
"""The journal entries.""" = LineItemCollector().line_items
"""The line items."""
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
@ -191,16 +192,16 @@ class Search(BaseReport):
:return: The response of the report for download. :return: The response of the report for download.
""" """
filename: str = "search-{q}.csv".format(q=request.args["q"]) filename: str = "search-{q}.csv".format(q=request.args["q"])
return csv_download(filename, get_csv_rows(self.__entries)) return csv_download(filename, get_csv_rows(self.__line_items))
def html(self) -> str: def html(self) -> str:
"""Composes and returns the report as HTML. """Composes and returns the report as HTML.
:return: The report as HTML. :return: The report as HTML.
""" """
pagination: Pagination[JournalEntry] \ pagination: Pagination[VoucherLineItem] \
= Pagination[JournalEntry](self.__entries, is_reversed=True) = Pagination[VoucherLineItem](self.__line_items, is_reversed=True)
params: PageParams = PageParams(pagination=pagination, params: PageParams = PageParams(pagination=pagination,
entries=pagination.list) line_items=pagination.list)
return render_template("accounting/report/search.html", return render_template("accounting/report/search.html",
report=params) report=params)

View File

@ -24,7 +24,7 @@ from flask import Response, render_template
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Currency, Account, Voucher, JournalEntry from accounting.models import Currency, Account, Voucher, VoucherLineItem
from accounting.report.period import Period, PeriodChooser from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport from accounting.report.utils.base_report import BaseReport
@ -178,14 +178,14 @@ class TrialBalance(BaseReport):
:return: None. :return: None.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.currency_code == self.__currency.code] = [VoucherLineItem.currency_code == self.__currency.code]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(Voucher.date >= self.__period.start) conditions.append(Voucher.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(Voucher.date <= self.__period.end) conditions.append(Voucher.date <= self.__period.end)
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntry.is_debit, JournalEntry.amount), (VoucherLineItem.is_debit, VoucherLineItem.amount),
else_=-JournalEntry.amount)).label("balance") else_=-VoucherLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\ select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(Voucher).join(Account)\ .join(Voucher).join(Account)\
.filter(*conditions)\ .filter(*conditions)\

View File

@ -26,7 +26,7 @@ import sqlalchemy as sa
from flask import request from flask import request
from accounting import db from accounting import db
from accounting.models import Currency, JournalEntry from accounting.models import Currency, VoucherLineItem
from accounting.utils.voucher_types import VoucherType from accounting.utils.voucher_types import VoucherType
from .option_link import OptionLink from .option_link import OptionLink
from .report_chooser import ReportChooser from .report_chooser import ReportChooser
@ -81,8 +81,8 @@ class BasePageParams(ABC):
:return: The currency options. :return: The currency options.
""" """
in_use: set[str] = set(db.session.scalars( in_use: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code) sa.select(VoucherLineItem.currency_code)
.group_by(JournalEntry.currency_code)).all()) .group_by(VoucherLineItem.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == active_currency.code) return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use)) for x in Currency.query.filter(Currency.code.in_(in_use))
.order_by(Currency.code).all()] .order_by(Currency.code).all()]

View File

@ -117,29 +117,29 @@
} }
/* Links between objects */ /* Links between objects */
.accounting-original-entry { .accounting-original-line-item {
border-top: thin solid darkslategray; border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
} }
.accounting-original-entry a { .accounting-original-line-item a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
.accounting-original-entry a:hover { .accounting-original-line-item a:hover {
color: inherit; color: inherit;
} }
.accounting-offset-entries { .accounting-offset-line-items {
border-top: thin solid darkslategray; border-top: thin solid darkslategray;
padding: 0.2rem 0.5rem; padding: 0.2rem 0.5rem;
} }
.accounting-offset-entries ul li { .accounting-offset-line-items ul li {
list-style: none; list-style: none;
} }
.accounting-offset-entries ul li a { .accounting-offset-line-items ul li a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
.accounting-offset-entries ul li a:hover { .accounting-offset-line-items ul li a:hover {
color: inherit; color: inherit;
} }
@ -156,31 +156,28 @@
.accounting-currency-content { .accounting-currency-content {
width: calc(100% - 3rem); width: calc(100% - 3rem);
} }
.accounting-entry-content { .accounting-line-item-content {
width: calc(100% - 3rem); width: calc(100% - 3rem);
background-color: transparent; background-color: transparent;
} }
.accounting-entry-control {
border-color: transparent;
}
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) { .accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
background-color: #f2f2f2; background-color: #f2f2f2;
} }
.accounting-list-group-hover .list-group-item:hover { .accounting-list-group-hover .list-group-item:hover {
background-color: #ececec; background-color: #ececec;
} }
.accounting-voucher-entry { .accounting-voucher-line-item {
border: none; border: none;
} }
.accounting-voucher-entry-header { .accounting-voucher-line-item-header {
font-weight: bolder; font-weight: bolder;
border-bottom: thick double slategray; border-bottom: thick double slategray;
} }
.list-group-item.accounting-voucher-entry-total { .list-group-item.accounting-voucher-line-item-total {
font-weight: bolder; font-weight: bolder;
border-top: thick double slategray; border-top: thick double slategray;
} }
.accounting-entry-editor-original-entry-content { .accounting-line-item-editor-original-line-item-content {
width: calc(100% - 3rem); width: calc(100% - 3rem);
} }

View File

@ -29,16 +29,16 @@
class AccountSelector { class AccountSelector {
/** /**
* The journal entry editor * The line item editor
* @type {JournalEntryEditor} * @type {VoucherLineItemEditor}
*/ */
#entryEditor; #lineItemEditor;
/** /**
* The entry type * The side, either "debit" or "credit"
* @type {string} * @type {string}
*/ */
#entryType; #side;
/** /**
* The prefix of the HTML ID and class * The prefix of the HTML ID and class
@ -85,13 +85,13 @@ class AccountSelector {
/** /**
* Constructs an account selector. * Constructs an account selector.
* *
* @param entryEditor {JournalEntryEditor} the journal entry editor * @param lineItemEditor {VoucherLineItemEditor} the line item editor
* @param entryType {string} the entry type, either "debit" or "credit" * @param side {string} the side, either "debit" or "credit"
*/ */
constructor(entryEditor, entryType) { constructor(lineItemEditor, side) {
this.#entryEditor = entryEditor this.#lineItemEditor = lineItemEditor
this.#entryType = entryType; this.#side = side;
this.#prefix = "accounting-account-selector-" + entryType; this.#prefix = "accounting-account-selector-" + side;
this.#query = document.getElementById(this.#prefix + "-query"); this.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result"); this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
this.#optionList = document.getElementById(this.#prefix + "-option-list"); this.#optionList = document.getElementById(this.#prefix + "-option-list");
@ -103,9 +103,9 @@ class AccountSelector {
this.#more.classList.add("d-none"); this.#more.classList.add("d-none");
this.#filterOptions(); this.#filterOptions();
}; };
this.#clearButton.onclick = () => this.#entryEditor.clearAccount(); this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
for (const option of this.#options) { for (const option of this.#options) {
option.onclick = () => this.#entryEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset")); option.onclick = () => this.#lineItemEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
} }
this.#query.addEventListener("input", () => { this.#query.addEventListener("input", () => {
this.#filterOptions(); this.#filterOptions();
@ -143,9 +143,9 @@ class AccountSelector {
* @return {string[]} the account codes that are used in the form * @return {string[]} the account codes that are used in the form
*/ */
#getCodesUsedInForm() { #getCodesUsedInForm() {
const inUse = this.#entryEditor.form.getAccountCodesUsed(this.#entryType); const inUse = this.#lineItemEditor.form.getAccountCodesUsed(this.#side);
if (this.#entryEditor.accountCode !== null) { if (this.#lineItemEditor.accountCode !== null) {
inUse.push(this.#entryEditor.accountCode); inUse.push(this.#lineItemEditor.accountCode);
} }
return inUse return inUse
} }
@ -190,13 +190,13 @@ class AccountSelector {
this.#more.classList.remove("d-none"); this.#more.classList.remove("d-none");
this.#filterOptions(); this.#filterOptions();
for (const option of this.#options) { for (const option of this.#options) {
if (option.dataset.code === this.#entryEditor.accountCode) { if (option.dataset.code === this.#lineItemEditor.accountCode) {
option.classList.add("active"); option.classList.add("active");
} else { } else {
option.classList.remove("active"); option.classList.remove("active");
} }
} }
if (this.#entryEditor.accountCode === null) { if (this.#lineItemEditor.accountCode === null) {
this.#clearButton.classList.add("btn-secondary"); this.#clearButton.classList.add("btn-secondary");
this.#clearButton.classList.remove("btn-danger"); this.#clearButton.classList.remove("btn-danger");
this.#clearButton.disabled = true; this.#clearButton.disabled = true;
@ -210,14 +210,14 @@ class AccountSelector {
/** /**
* Returns the account selector instances. * Returns the account selector instances.
* *
* @param entryEditor {JournalEntryEditor} the journal entry editor * @param lineItemEditor {VoucherLineItemEditor} the line item editor
* @return {{debit: AccountSelector, credit: AccountSelector}} * @return {{debit: AccountSelector, credit: AccountSelector}}
*/ */
static getInstances(entryEditor) { static getInstances(lineItemEditor) {
const selectors = {} const selectors = {}
const modals = Array.from(document.getElementsByClassName("accounting-account-selector")); const modals = Array.from(document.getElementsByClassName("accounting-account-selector"));
for (const modal of modals) { for (const modal of modals) {
selectors[modal.dataset.entryType] = new AccountSelector(entryEditor, modal.dataset.entryType); selectors[modal.dataset.side] = new AccountSelector(lineItemEditor, modal.dataset.side);
} }
return selectors; return selectors;
} }

View File

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Flask Project
* original-entry-selector.js: The JavaScript for the original entry selector * original-line-item-selector.js: The JavaScript for the original line item selector
*/ */
/* Copyright (c) 2023 imacat. /* Copyright (c) 2023 imacat.
@ -23,22 +23,22 @@
"use strict"; "use strict";
/** /**
* The original entry selector. * The original line item selector.
* *
*/ */
class OriginalEntrySelector { class OriginalLineItemSelector {
/** /**
* The journal entry editor * The line item editor
* @type {JournalEntryEditor} * @type {VoucherLineItemEditor}
*/ */
entryEditor; lineItemEditor;
/** /**
* The prefix of the HTML ID and class * The prefix of the HTML ID and class
* @type {string} * @type {string}
*/ */
#prefix = "accounting-original-entry-selector"; #prefix = "accounting-original-line-item-selector";
/** /**
* The query input * The query input
@ -60,13 +60,13 @@ class OriginalEntrySelector {
/** /**
* The options * The options
* @type {OriginalEntry[]} * @type {OriginalLineItem[]}
*/ */
#options; #options;
/** /**
* The options by their ID * The options by their ID
* @type {Object.<string, OriginalEntry>} * @type {Object.<string, OriginalLineItem>}
*/ */
#optionById; #optionById;
@ -77,21 +77,21 @@ class OriginalEntrySelector {
#currencyCode; #currencyCode;
/** /**
* The entry * The side, either "credit" or "debit"
*/ */
#entryType; #side;
/** /**
* Constructs an original entry selector. * Constructs an original line item selector.
* *
* @param entryEditor {JournalEntryEditor} the journal entry editor * @param lineItemEditor {VoucherLineItemEditor} the line item editor
*/ */
constructor(entryEditor) { constructor(lineItemEditor) {
this.entryEditor = entryEditor; this.lineItemEditor = lineItemEditor;
this.#query = document.getElementById(this.#prefix + "-query"); this.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result"); this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
this.#optionList = document.getElementById(this.#prefix + "-option-list"); this.#optionList = document.getElementById(this.#prefix + "-option-list");
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalEntry(this, element)); this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")).map((element) => new OriginalLineItem(this, element));
this.#optionById = {}; this.#optionById = {};
for (const option of this.#options) { for (const option of this.#options) {
this.#optionById[option.id] = option; this.#optionById[option.id] = option;
@ -102,44 +102,44 @@ class OriginalEntrySelector {
} }
/** /**
* Returns the net balance for an original entry. * Returns the net balance for an original line item.
* *
* @param currentEntry {JournalEntrySubForm} the journal entry sub-form that is currently editing * @param currentLineItem {LineItemSubForm} the line item sub-form that is currently editing
* @param form {VoucherForm} the voucher form * @param form {VoucherForm} the voucher form
* @param originalEntryId {string} the ID of the original entry * @param originalLineItemId {string} the ID of the original line item
* @return {Decimal} the net balance of the original entry * @return {Decimal} the net balance of the original line item
*/ */
getNetBalance(currentEntry, form, originalEntryId) { getNetBalance(currentLineItem, form, originalLineItemId) {
const otherEntries = form.getEntries().filter((entry) => entry !== currentEntry); const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem);
let otherOffset = new Decimal(0); let otherOffset = new Decimal(0);
for (const otherEntry of otherEntries) { for (const otherLineItem of otherLineItems) {
if (otherEntry.getOriginalEntryId() === originalEntryId) { if (otherLineItem.getOriginalLineItemId() === originalLineItemId) {
const amount = otherEntry.getAmount(); const amount = otherLineItem.getAmount();
if (amount !== null) { if (amount !== null) {
otherOffset = otherOffset.plus(amount); otherOffset = otherOffset.plus(amount);
} }
} }
} }
return this.#optionById[originalEntryId].bareNetBalance.minus(otherOffset); return this.#optionById[originalLineItemId].bareNetBalance.minus(otherOffset);
} }
/** /**
* Updates the net balances, subtracting the offset amounts on the form but the currently editing journal entry * Updates the net balances, subtracting the offset amounts on the form but the currently editing line item
* *
*/ */
#updateNetBalances() { #updateNetBalances() {
const otherEntries = this.entryEditor.form.getEntries().filter((entry) => entry !== this.entryEditor.entry); const otherLineItems = this.lineItemEditor.form.getLineItems().filter((lineItem) => lineItem !== this.lineItemEditor.lineItem);
const otherOffsets = {} const otherOffsets = {}
for (const otherEntry of otherEntries) { for (const otherLineItem of otherLineItems) {
const otherOriginalEntryId = otherEntry.getOriginalEntryId(); const otherOriginalLineItemId = otherLineItem.getOriginalLineItemId();
const amount = otherEntry.getAmount(); const amount = otherLineItem.getAmount();
if (otherOriginalEntryId === null || amount === null) { if (otherOriginalLineItemId === null || amount === null) {
continue; continue;
} }
if (!(otherOriginalEntryId in otherOffsets)) { if (!(otherOriginalLineItemId in otherOffsets)) {
otherOffsets[otherOriginalEntryId] = new Decimal("0"); otherOffsets[otherOriginalLineItemId] = new Decimal("0");
} }
otherOffsets[otherOriginalEntryId] = otherOffsets[otherOriginalEntryId].plus(amount); otherOffsets[otherOriginalLineItemId] = otherOffsets[otherOriginalLineItemId].plus(amount);
} }
for (const option of this.#options) { for (const option of this.#options) {
if (option.id in otherOffsets) { if (option.id in otherOffsets) {
@ -157,7 +157,7 @@ class OriginalEntrySelector {
#filterOptions() { #filterOptions() {
let hasAnyMatched = false; let hasAnyMatched = false;
for (const option of this.#options) { for (const option of this.#options) {
if (option.isMatched(this.#entryType, this.#currencyCode, this.#query.value)) { if (option.isMatched(this.#side, this.#currencyCode, this.#query.value)) {
option.setShown(true); option.setShown(true);
hasAnyMatched = true; hasAnyMatched = true;
} else { } else {
@ -174,14 +174,14 @@ class OriginalEntrySelector {
} }
/** /**
* The callback when the original entry selector is shown. * The callback when the original line item selector is shown.
* *
*/ */
onOpen() { onOpen() {
this.#currencyCode = this.entryEditor.getCurrencyCode(); this.#currencyCode = this.lineItemEditor.getCurrencyCode();
this.#entryType = this.entryEditor.entryType; this.#side = this.lineItemEditor.side;
for (const option of this.#options) { for (const option of this.#options) {
option.setActive(option.id === this.entryEditor.originalEntryId); option.setActive(option.id === this.lineItemEditor.originalLineItemId);
} }
this.#query.value = ""; this.#query.value = "";
this.#updateNetBalances(); this.#updateNetBalances();
@ -190,14 +190,14 @@ class OriginalEntrySelector {
} }
/** /**
* An original entry. * An original line item.
* *
*/ */
class OriginalEntry { class OriginalLineItem {
/** /**
* The original entry selector * The original line item selector
* @type {OriginalEntrySelector} * @type {OriginalLineItemSelector}
*/ */
#selector; #selector;
@ -220,10 +220,10 @@ class OriginalEntry {
date; date;
/** /**
* The entry type, either "debit" or "credit" * The side, either "debit" or "credit"
* @type {string} * @type {string}
*/ */
#entryType; #side;
/** /**
* The currency code * The currency code
@ -268,7 +268,7 @@ class OriginalEntry {
netBalanceText; netBalanceText;
/** /**
* The text representation of the original entry * The text representation of the original line item
* @type {string} * @type {string}
*/ */
text; text;
@ -280,9 +280,9 @@ class OriginalEntry {
#queryValues; #queryValues;
/** /**
* Constructs an original entry. * Constructs an original line item.
* *
* @param selector {OriginalEntrySelector} the original entry selector * @param selector {OriginalLineItemSelector} the original line item selector
* @param element {HTMLLIElement} the element * @param element {HTMLLIElement} the element
*/ */
constructor(selector, element) { constructor(selector, element) {
@ -290,17 +290,17 @@ class OriginalEntry {
this.#element = element; this.#element = element;
this.id = element.dataset.id; this.id = element.dataset.id;
this.date = element.dataset.date; this.date = element.dataset.date;
this.#entryType = element.dataset.entryType; this.#side = element.dataset.side;
this.#currencyCode = element.dataset.currencyCode; this.#currencyCode = element.dataset.currencyCode;
this.accountCode = element.dataset.accountCode; this.accountCode = element.dataset.accountCode;
this.accountText = element.dataset.accountText; this.accountText = element.dataset.accountText;
this.summary = element.dataset.summary; this.summary = element.dataset.summary;
this.bareNetBalance = new Decimal(element.dataset.netBalance); this.bareNetBalance = new Decimal(element.dataset.netBalance);
this.netBalance = this.bareNetBalance; this.netBalance = this.bareNetBalance;
this.netBalanceText = document.getElementById("accounting-original-entry-selector-option-" + this.id + "-net-balance"); this.netBalanceText = document.getElementById("accounting-original-line-item-selector-option-" + this.id + "-net-balance");
this.text = element.dataset.text; this.text = element.dataset.text;
this.#queryValues = JSON.parse(element.dataset.queryValues); this.#queryValues = JSON.parse(element.dataset.queryValues);
this.#element.onclick = () => this.#selector.entryEditor.saveOriginalEntry(this); this.#element.onclick = () => this.#selector.lineItemEditor.saveOriginalLineItem(this);
} }
/** /**
@ -335,31 +335,31 @@ class OriginalEntry {
/** /**
* Returns whether the original matches. * Returns whether the original matches.
* *
* @param entryType {string} the entry type, either "debit" or "credit" * @param side {string} the side, either "debit" or "credit"
* @param currencyCode {string} the currency code * @param currencyCode {string} the currency code
* @param query {string|null} the query term * @param query {string|null} the query term
*/ */
isMatched(entryType, currencyCode, query = null) { isMatched(side, currencyCode, query = null) {
return this.netBalance.greaterThan(0) return this.netBalance.greaterThan(0)
&& this.date <= this.#selector.entryEditor.form.getDate() && this.date <= this.#selector.lineItemEditor.form.getDate()
&& this.#isEntryTypeMatches(entryType) && this.#isSideMatches(side)
&& this.#currencyCode === currencyCode && this.#currencyCode === currencyCode
&& this.#isQueryMatches(query); && this.#isQueryMatches(query);
} }
/** /**
* Returns whether the original entry matches the entry type. * Returns whether the original line item matches the debit or credit side.
* *
* @param entryType {string} the entry type, either "debit" or credit * @param side {string} the side, either "debit" or credit
* @return {boolean} true if the option matches, or false otherwise * @return {boolean} true if the option matches, or false otherwise
*/ */
#isEntryTypeMatches(entryType) { #isSideMatches(side) {
return (entryType === "debit" && this.#entryType === "credit") return (side === "debit" && this.#side === "credit")
|| (entryType === "credit" && this.#entryType === "debit"); || (side === "credit" && this.#side === "debit");
} }
/** /**
* Returns whether the original entry matches the query. * Returns whether the original line item matches the query.
* *
* @param query {string|null} the query term * @param query {string|null} the query term
* @return {boolean} true if the option matches, or false otherwise * @return {boolean} true if the option matches, or false otherwise

View File

@ -29,10 +29,10 @@
class SummaryEditor { class SummaryEditor {
/** /**
* The journal entry editor * The line item editor
* @type {JournalEntryEditor} * @type {VoucherLineItemEditor}
*/ */
#entryEditor; #lineItemEditor;
/** /**
* The summary editor form * The summary editor form
@ -53,10 +53,10 @@ class SummaryEditor {
#modal; #modal;
/** /**
* The entry type, either "debit" or "credit" * The side, either "debit" or "credit"
* @type {string} * @type {string}
*/ */
entryType; side;
/** /**
* The current tab * The current tab
@ -71,7 +71,7 @@ class SummaryEditor {
summary; summary;
/** /**
* The button to the original entry selector * The button to the original line item selector
* @type {HTMLButtonElement} * @type {HTMLButtonElement}
*/ */
#offsetButton; #offsetButton;
@ -109,13 +109,13 @@ class SummaryEditor {
/** /**
* Constructs a summary editor. * Constructs a summary editor.
* *
* @param entryEditor {JournalEntryEditor} the journal entry editor * @param lineItemEditor {VoucherLineItemEditor} the line item editor
* @param entryType {string} the entry type, either "debit" or "credit" * @param side {string} the side, either "debit" or "credit"
*/ */
constructor(entryEditor, entryType) { constructor(lineItemEditor, side) {
this.#entryEditor = entryEditor; this.#lineItemEditor = lineItemEditor;
this.entryType = entryType; this.side = side;
this.prefix = "accounting-summary-editor-" + entryType; this.prefix = "accounting-summary-editor-" + side;
this.#form = document.getElementById(this.prefix); this.#form = document.getElementById(this.prefix);
this.#modal = document.getElementById(this.prefix + "-modal"); this.#modal = document.getElementById(this.prefix + "-modal");
this.summary = document.getElementById(this.prefix + "-summary"); this.summary = document.getElementById(this.prefix + "-summary");
@ -132,7 +132,7 @@ class SummaryEditor {
this.currentTab = this.tabPlanes.general; this.currentTab = this.tabPlanes.general;
this.#initializeSuggestedAccounts(); this.#initializeSuggestedAccounts();
this.summary.onchange = () => this.#onSummaryChange(); this.summary.onchange = () => this.#onSummaryChange();
this.#offsetButton.onclick = () => this.#entryEditor.originalEntrySelector.onOpen(); this.#offsetButton.onclick = () => this.#lineItemEditor.originalLineItemSelector.onOpen();
this.#form.onsubmit = () => { this.#form.onsubmit = () => {
if (this.currentTab.validate()) { if (this.currentTab.validate()) {
this.#submit(); this.#submit();
@ -215,9 +215,9 @@ class SummaryEditor {
#submit() { #submit() {
bootstrap.Modal.getOrCreateInstance(this.#modal).hide(); bootstrap.Modal.getOrCreateInstance(this.#modal).hide();
if (this.#selectedAccount !== null) { if (this.#selectedAccount !== null) {
this.#entryEditor.saveSummaryWithAccount(this.summary.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset")); this.#lineItemEditor.saveSummaryWithAccount(this.summary.value, this.#selectedAccount.dataset.code, this.#selectedAccount.dataset.text, this.#selectedAccount.classList.contains("accounting-account-is-need-offset"));
} else { } else {
this.#entryEditor.saveSummary(this.summary.value); this.#lineItemEditor.saveSummary(this.summary.value);
} }
} }
@ -227,7 +227,7 @@ class SummaryEditor {
*/ */
onOpen() { onOpen() {
this.#reset(); this.#reset();
this.summary.value = this.#entryEditor.summary === null? "": this.#entryEditor.summary; this.summary.value = this.#lineItemEditor.summary === null? "": this.#lineItemEditor.summary;
this.#onSummaryChange(); this.#onSummaryChange();
} }
@ -246,14 +246,14 @@ class SummaryEditor {
/** /**
* Returns the summary editor instances. * Returns the summary editor instances.
* *
* @param entryEditor {JournalEntryEditor} the journal entry editor * @param lineItemEditor {VoucherLineItemEditor} the line item editor
* @return {{debit: SummaryEditor, credit: SummaryEditor}} * @return {{debit: SummaryEditor, credit: SummaryEditor}}
*/ */
static getInstances(entryEditor) { static getInstances(lineItemEditor) {
const editors = {} const editors = {}
const forms = Array.from(document.getElementsByClassName("accounting-summary-editor")); const forms = Array.from(document.getElementsByClassName("accounting-summary-editor"));
for (const form of forms) { for (const form of forms) {
editors[form.dataset.entryType] = new SummaryEditor(entryEditor, form.dataset.entryType); editors[form.dataset.side] = new SummaryEditor(lineItemEditor, form.dataset.side);
} }
return editors; return editors;
} }

View File

@ -39,10 +39,10 @@ class VoucherForm {
#element; #element;
/** /**
* The template to add a new journal entry * The template to add a new line item
* @type {string} * @type {string}
*/ */
entryTemplate; lineItemTemplate;
/** /**
* The date * The date
@ -99,10 +99,10 @@ class VoucherForm {
#noteError; #noteError;
/** /**
* The journal entry editor * The line item editor
* @type {JournalEntryEditor} * @type {VoucherLineItemEditor}
*/ */
entryEditor; lineItemEditor;
/** /**
* Constructs the voucher form. * Constructs the voucher form.
@ -110,7 +110,7 @@ class VoucherForm {
*/ */
constructor() { constructor() {
this.#element = document.getElementById("accounting-form"); this.#element = document.getElementById("accounting-form");
this.entryTemplate = this.#element.dataset.entryTemplate; this.lineItemTemplate = this.#element.dataset.lineItemTemplate;
this.#date = document.getElementById("accounting-date"); this.#date = document.getElementById("accounting-date");
this.#dateError = document.getElementById("accounting-date-error"); this.#dateError = document.getElementById("accounting-date-error");
this.#currencyControl = document.getElementById("accounting-currencies"); this.#currencyControl = document.getElementById("accounting-currencies");
@ -121,7 +121,7 @@ class VoucherForm {
this.#addCurrencyButton = document.getElementById("accounting-add-currency"); this.#addCurrencyButton = document.getElementById("accounting-add-currency");
this.#note = document.getElementById("accounting-note"); this.#note = document.getElementById("accounting-note");
this.#noteError = document.getElementById("accounting-note-error"); this.#noteError = document.getElementById("accounting-note-error");
this.entryEditor = new JournalEntryEditor(this); this.lineItemEditor = new VoucherLineItemEditor(this);
this.#addCurrencyButton.onclick = () => { this.#addCurrencyButton.onclick = () => {
const newIndex = 1 + (this.#currencies.length === 0? 0: Math.max(...this.#currencies.map((currency) => currency.index))); const newIndex = 1 + (this.#currencies.length === 0? 0: Math.max(...this.#currencies.map((currency) => currency.index)));
@ -162,14 +162,14 @@ class VoucherForm {
this.#currencies[0].deleteButton.classList.add("d-none"); this.#currencies[0].deleteButton.classList.add("d-none");
} else { } else {
for (const currency of this.#currencies) { for (const currency of this.#currencies) {
let isAnyEntryMatched = false; let isAnyLineItemMatched = false;
for (const entry of currency.getEntries()) { for (const lineItem of currency.getLineItems()) {
if (entry.isMatched) { if (lineItem.isMatched) {
isAnyEntryMatched = true; isAnyLineItemMatched = true;
break; break;
} }
} }
if (isAnyEntryMatched) { if (isAnyLineItemMatched) {
currency.deleteButton.classList.add("d-none"); currency.deleteButton.classList.add("d-none");
} else { } else {
currency.deleteButton.classList.remove("d-none"); currency.deleteButton.classList.remove("d-none");
@ -193,27 +193,27 @@ class VoucherForm {
} }
/** /**
* Returns all the journal entries in the form. * Returns all the line items in the form.
* *
* @param entryType {string|null} the entry type, either "debit" or "credit", or null for both * @param side {string|null} the side, either "debit" or "credit", or null for both
* @return {JournalEntrySubForm[]} all the journal entry sub-forms * @return {LineItemSubForm[]} all the line item sub-forms
*/ */
getEntries(entryType = null) { getLineItems(side = null) {
const entries = []; const lineItems = [];
for (const currency of this.#currencies) { for (const currency of this.#currencies) {
entries.push(...currency.getEntries(entryType)); lineItems.push(...currency.getLineItems(side));
} }
return entries; return lineItems;
} }
/** /**
* Returns the account codes used in the form. * Returns the account codes used in the form.
* *
* @param entryType {string} the entry type, either "debit" or "credit" * @param side {string} the side, either "debit" or "credit"
* @return {string[]} the account codes used in the form * @return {string[]} the account codes used in the form
*/ */
getAccountCodesUsed(entryType) { getAccountCodesUsed(side) {
return this.getEntries(entryType).map((entry) => entry.getAccountCode()) return this.getLineItems(side).map((lineItem) => lineItem.getAccountCode())
.filter((code) => code !== null); .filter((code) => code !== null);
} }
@ -231,16 +231,16 @@ class VoucherForm {
* *
*/ */
updateMinDate() { updateMinDate() {
let lastOriginalEntryDate = null; let lastOriginalLineItemDate = null;
for (const entry of this.getEntries()) { for (const lineItem of this.getLineItems()) {
const date = entry.getOriginalEntryDate(); const date = lineItem.getOriginalLineItemDate();
if (date !== null) { if (date !== null) {
if (lastOriginalEntryDate === null || lastOriginalEntryDate < date) { if (lastOriginalLineItemDate === null || lastOriginalLineItemDate < date) {
lastOriginalEntryDate = date; lastOriginalLineItemDate = date;
} }
} }
} }
this.#date.min = lastOriginalEntryDate === null? "": lastOriginalEntryDate; this.#date.min = lastOriginalLineItemDate === null? "": lastOriginalLineItemDate;
this.#validateDate(); this.#validateDate();
} }
@ -272,7 +272,7 @@ class VoucherForm {
} }
if (this.#date.value < this.#date.min) { if (this.#date.value < this.#date.min) {
this.#date.classList.add("is-invalid"); this.#date.classList.add("is-invalid");
this.#dateError.innerText = A_("The date cannot be earlier than the original entries."); this.#dateError.innerText = A_("The date cannot be earlier than the original line items.");
return false; return false;
} }
this.#date.classList.remove("is-invalid"); this.#date.classList.remove("is-invalid");
@ -407,13 +407,13 @@ class CurrencySubForm {
/** /**
* The debit side * The debit side
* @type {DebitCreditSideSubForm|null} * @type {SideSubForm|null}
*/ */
#debit; #debit;
/** /**
* The credit side * The credit side
* @type {DebitCreditSideSubForm|null} * @type {SideSubForm|null}
*/ */
#credit; #credit;
@ -435,9 +435,9 @@ class CurrencySubForm {
this.#codeSelect = document.getElementById(this.#prefix + "-code-select"); this.#codeSelect = document.getElementById(this.#prefix + "-code-select");
this.deleteButton = document.getElementById(this.#prefix + "-delete"); this.deleteButton = document.getElementById(this.#prefix + "-delete");
const debitElement = document.getElementById(this.#prefix + "-debit"); const debitElement = document.getElementById(this.#prefix + "-debit");
this.#debit = debitElement === null? null: new DebitCreditSideSubForm(this, debitElement, "debit"); this.#debit = debitElement === null? null: new SideSubForm(this, debitElement, "debit");
const creditElement = document.getElementById(this.#prefix + "-credit"); const creditElement = document.getElementById(this.#prefix + "-credit");
this.#credit = creditElement == null? null: new DebitCreditSideSubForm(this, creditElement, "credit"); this.#credit = creditElement == null? null: new SideSubForm(this, creditElement, "credit");
this.#codeSelect.onchange = () => this.#code.value = this.#codeSelect.value; this.#codeSelect.onchange = () => this.#code.value = this.#codeSelect.value;
this.deleteButton.onclick = () => { this.deleteButton.onclick = () => {
this.element.parentElement.removeChild(this.element); this.element.parentElement.removeChild(this.element);
@ -455,21 +455,21 @@ class CurrencySubForm {
} }
/** /**
* Returns all the journal entries in the form. * Returns all the line items in the form.
* *
* @param entryType {string|null} the entry type, either "debit" or "credit", or null for both * @param side {string|null} the side, either "debit" or "credit", or null for both
* @return {JournalEntrySubForm[]} all the journal entry sub-forms * @return {LineItemSubForm[]} all the line item sub-forms
*/ */
getEntries(entryType = null) { getLineItems(side = null) {
const entries = [] const lineItems = []
for (const side of [this.#debit, this.#credit]) { for (const sideSubForm of [this.#debit, this.#credit]) {
if (side !== null ) { if (sideSubForm !== null ) {
if (entryType === null || side.entryType === entryType) { if (side === null || sideSubForm.side === side) {
entries.push(...side.entries); lineItems.push(...sideSubForm.lineItems);
} }
} }
} }
return entries; return lineItems;
} }
/** /**
@ -478,8 +478,8 @@ class CurrencySubForm {
*/ */
updateCodeSelectorStatus() { updateCodeSelectorStatus() {
let isEnabled = true; let isEnabled = true;
for (const entry of this.getEntries()) { for (const lineItem of this.getLineItems()) {
if (entry.getOriginalEntryId() !== null) { if (lineItem.getOriginalLineItemId() !== null) {
isEnabled = false; isEnabled = false;
break; break;
} }
@ -527,7 +527,7 @@ class CurrencySubForm {
* The debit or credit side sub-form * The debit or credit side sub-form
* *
*/ */
class DebitCreditSideSubForm { class SideSubForm {
/** /**
* The currency sub-form * The currency sub-form
@ -548,10 +548,10 @@ class DebitCreditSideSubForm {
#currencyIndex; #currencyIndex;
/** /**
* The entry type, either "debit" or "credit" * The side, either "debit" or "credit"
* @type {string} * @type {string}
*/ */
entryType; side;
/** /**
* The prefix of the HTML ID and class * The prefix of the HTML ID and class
@ -566,16 +566,16 @@ class DebitCreditSideSubForm {
#error; #error;
/** /**
* The journal entry list * The line item list
* @type {HTMLUListElement} * @type {HTMLUListElement}
*/ */
#entryList; #lineItemList;
/** /**
* The journal entry sub-forms * The line item sub-forms
* @type {JournalEntrySubForm[]} * @type {LineItemSubForm[]}
*/ */
entries; lineItems;
/** /**
* The total * The total
@ -584,82 +584,82 @@ class DebitCreditSideSubForm {
#total; #total;
/** /**
* The button to add a new entry * The button to add a new line item
* @type {HTMLButtonElement} * @type {HTMLButtonElement}
*/ */
#addEntryButton; #addLineItemButton;
/** /**
* Constructs a debit or credit side sub-form * Constructs a debit or credit side sub-form
* *
* @param currency {CurrencySubForm} the currency sub-form * @param currency {CurrencySubForm} the currency sub-form
* @param element {HTMLDivElement} the element * @param element {HTMLDivElement} the element
* @param entryType {string} the entry type, either "debit" or "credit" * @param side {string} the side, either "debit" or "credit"
*/ */
constructor(currency, element, entryType) { constructor(currency, element, side) {
this.currency = currency; this.currency = currency;
this.#element = element; this.#element = element;
this.#currencyIndex = currency.index; this.#currencyIndex = currency.index;
this.entryType = entryType; this.side = side;
this.#prefix = "accounting-currency-" + String(this.#currencyIndex) + "-" + entryType; this.#prefix = "accounting-currency-" + String(this.#currencyIndex) + "-" + side;
this.#error = document.getElementById(this.#prefix + "-error"); this.#error = document.getElementById(this.#prefix + "-error");
this.#entryList = document.getElementById(this.#prefix + "-list"); this.#lineItemList = document.getElementById(this.#prefix + "-list");
// noinspection JSValidateTypes // noinspection JSValidateTypes
this.entries = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new JournalEntrySubForm(this, element)); this.lineItems = Array.from(document.getElementsByClassName(this.#prefix)).map((element) => new LineItemSubForm(this, element));
this.#total = document.getElementById(this.#prefix + "-total"); this.#total = document.getElementById(this.#prefix + "-total");
this.#addEntryButton = document.getElementById(this.#prefix + "-add-entry"); this.#addLineItemButton = document.getElementById(this.#prefix + "-add-line-item");
this.#addEntryButton.onclick = () => this.currency.form.entryEditor.onAddNew(this); this.#addLineItemButton.onclick = () => this.currency.form.lineItemEditor.onAddNew(this);
this.#resetDeleteJournalEntryButtons(); this.#resetDeleteLineItemButtons();
this.#initializeDragAndDropReordering(); this.#initializeDragAndDropReordering();
} }
/** /**
* Adds a new journal entry sub-form * Adds a new line item sub-form
* *
* @returns {JournalEntrySubForm} the newly-added journal entry sub-form * @returns {LineItemSubForm} the newly-added line item sub-form
*/ */
addJournalEntry() { addLineItem() {
const newIndex = 1 + (this.entries.length === 0? 0: Math.max(...this.entries.map((entry) => entry.entryIndex))); const newIndex = 1 + (this.lineItems.length === 0? 0: Math.max(...this.lineItems.map((lineItem) => lineItem.lineItemIndex)));
const html = this.currency.form.entryTemplate const html = this.currency.form.lineItemTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex))) .replaceAll("CURRENCY_INDEX", escapeHtml(String(this.#currencyIndex)))
.replaceAll("ENTRY_TYPE", escapeHtml(this.entryType)) .replaceAll("SIDE", escapeHtml(this.side))
.replaceAll("ENTRY_INDEX", escapeHtml(String(newIndex))); .replaceAll("LINE_ITEM_INDEX", escapeHtml(String(newIndex)));
this.#entryList.insertAdjacentHTML("beforeend", html); this.#lineItemList.insertAdjacentHTML("beforeend", html);
const entry = new JournalEntrySubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex))); const lineItem = new LineItemSubForm(this, document.getElementById(this.#prefix + "-" + String(newIndex)));
this.entries.push(entry); this.lineItems.push(lineItem);
this.#resetDeleteJournalEntryButtons(); this.#resetDeleteLineItemButtons();
this.#initializeDragAndDropReordering(); this.#initializeDragAndDropReordering();
this.validate(); this.validate();
return entry; return lineItem;
} }
/** /**
* Deletes a journal entry sub-form * Deletes a line item sub-form
* *
* @param entry {JournalEntrySubForm} * @param lineItem {LineItemSubForm}
*/ */
deleteJournalEntry(entry) { deleteLineItem(lineItem) {
const index = this.entries.indexOf(entry); const index = this.lineItems.indexOf(lineItem);
this.entries.splice(index, 1); this.lineItems.splice(index, 1);
this.updateTotal(); this.updateTotal();
this.currency.updateCodeSelectorStatus(); this.currency.updateCodeSelectorStatus();
this.currency.form.updateMinDate(); this.currency.form.updateMinDate();
this.#resetDeleteJournalEntryButtons(); this.#resetDeleteLineItemButtons();
} }
/** /**
* Resets the buttons to delete the journal entry sub-forms * Resets the buttons to delete the line item sub-forms
* *
*/ */
#resetDeleteJournalEntryButtons() { #resetDeleteLineItemButtons() {
if (this.entries.length === 1) { if (this.lineItems.length === 1) {
this.entries[0].deleteButton.classList.add("d-none"); this.lineItems[0].deleteButton.classList.add("d-none");
} else { } else {
for (const entry of this.entries) { for (const lineItem of this.lineItems) {
if (entry.isMatched) { if (lineItem.isMatched) {
entry.deleteButton.classList.add("d-none"); lineItem.deleteButton.classList.add("d-none");
} else { } else {
entry.deleteButton.classList.remove("d-none"); lineItem.deleteButton.classList.remove("d-none");
} }
} }
} }
@ -672,8 +672,8 @@ class DebitCreditSideSubForm {
*/ */
getTotal() { getTotal() {
let total = new Decimal("0"); let total = new Decimal("0");
for (const entry of this.entries) { for (const lineItem of this.lineItems) {
const amount = entry.getAmount(); const amount = lineItem.getAmount();
if (amount !== null) { if (amount !== null) {
total = total.plus(amount); total = total.plus(amount);
} }
@ -695,11 +695,11 @@ class DebitCreditSideSubForm {
* *
*/ */
#initializeDragAndDropReordering() { #initializeDragAndDropReordering() {
initializeDragAndDropReordering(this.#entryList, () => { initializeDragAndDropReordering(this.#lineItemList, () => {
const entryId = Array.from(this.#entryList.children).map((entry) => entry.id); const lineItemId = Array.from(this.#lineItemList.children).map((lineItem) => lineItem.id);
this.entries.sort((a, b) => entryId.indexOf(a.element.id) - entryId.indexOf(b.element.id)); this.lineItems.sort((a, b) => lineItemId.indexOf(a.element.id) - lineItemId.indexOf(b.element.id));
for (let i = 0; i < this.entries.length; i++) { for (let i = 0; i < this.lineItems.length; i++) {
this.entries[i].no.value = String(i + 1); this.lineItems[i].no.value = String(i + 1);
} }
}); });
} }
@ -712,8 +712,8 @@ class DebitCreditSideSubForm {
validate() { validate() {
let isValid = true; let isValid = true;
isValid = this.#validateReal() && isValid; isValid = this.#validateReal() && isValid;
for (const entry of this.entries) { for (const lineItem of this.lineItems) {
isValid = entry.validate() && isValid; isValid = lineItem.validate() && isValid;
} }
return isValid; return isValid;
} }
@ -724,9 +724,9 @@ class DebitCreditSideSubForm {
* @returns {boolean} true if valid, or false otherwise * @returns {boolean} true if valid, or false otherwise
*/ */
#validateReal() { #validateReal() {
if (this.entries.length === 0) { if (this.lineItems.length === 0) {
this.#element.classList.add("is-invalid"); this.#element.classList.add("is-invalid");
this.#error.innerText = A_("Please add some journal entries."); this.#error.innerText = A_("Please add some line items.");
return false; return false;
} }
this.#element.classList.remove("is-invalid"); this.#element.classList.remove("is-invalid");
@ -736,16 +736,16 @@ class DebitCreditSideSubForm {
} }
/** /**
* The journal entry sub-form. * The line item sub-form.
* *
*/ */
class JournalEntrySubForm { class LineItemSubForm {
/** /**
* The debit or credit entry side sub-form * The debit or credit side sub-form
* @type {DebitCreditSideSubForm} * @type {SideSubForm}
*/ */
side; sideSubForm;
/** /**
* The element * The element
@ -754,19 +754,19 @@ class JournalEntrySubForm {
element; element;
/** /**
* The entry type, either "debit" or "credit" * The side, either "debit" or "credit"
* @type {string} * @type {string}
*/ */
entryType; side;
/** /**
* The entry index * The line item index
* @type {number} * @type {number}
*/ */
entryIndex; lineItemIndex;
/** /**
* Whether this is an original entry with offsets * Whether this is an original line item with offsets
* @type {boolean} * @type {boolean}
*/ */
isMatched; isMatched;
@ -820,19 +820,19 @@ class JournalEntrySubForm {
#summaryText; #summaryText;
/** /**
* The ID of the original entry * The ID of the original line item
* @type {HTMLInputElement} * @type {HTMLInputElement}
*/ */
#originalEntryId; #originalLineItemId;
/** /**
* The text of the original entry * The text of the original line item
* @type {HTMLDivElement} * @type {HTMLDivElement}
*/ */
#originalEntryText; #originalLineItemText;
/** /**
* The offset entries * The offset items
* @type {HTMLInputElement} * @type {HTMLInputElement}
*/ */
#offsets; #offsets;
@ -850,24 +850,24 @@ class JournalEntrySubForm {
#amountText; #amountText;
/** /**
* The button to delete journal entry * The button to delete line item
* @type {HTMLButtonElement} * @type {HTMLButtonElement}
*/ */
deleteButton; deleteButton;
/** /**
* Constructs the journal entry sub-form. * Constructs the line item sub-form.
* *
* @param side {DebitCreditSideSubForm} the debit or credit entry side sub-form * @param side {SideSubForm} the debit or credit side sub-form
* @param element {HTMLLIElement} the element * @param element {HTMLLIElement} the element
*/ */
constructor(side, element) { constructor(side, element) {
this.side = side; this.sideSubForm = side;
this.element = element; this.element = element;
this.entryType = element.dataset.entryType; this.side = element.dataset.side;
this.entryIndex = parseInt(element.dataset.entryIndex); this.lineItemIndex = parseInt(element.dataset.lineItemIndex);
this.isMatched = element.classList.contains("accounting-matched-entry"); this.isMatched = element.classList.contains("accounting-matched-line-item");
this.#prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.entryType + "-" + this.entryIndex; this.#prefix = "accounting-currency-" + element.dataset.currencyIndex + "-" + this.side + "-" + this.lineItemIndex;
this.#control = document.getElementById(this.#prefix + "-control"); this.#control = document.getElementById(this.#prefix + "-control");
this.#error = document.getElementById(this.#prefix + "-error"); this.#error = document.getElementById(this.#prefix + "-error");
this.no = document.getElementById(this.#prefix + "-no"); this.no = document.getElementById(this.#prefix + "-no");
@ -875,53 +875,53 @@ class JournalEntrySubForm {
this.#accountText = document.getElementById(this.#prefix + "-account-text"); this.#accountText = document.getElementById(this.#prefix + "-account-text");
this.#summary = document.getElementById(this.#prefix + "-summary"); this.#summary = document.getElementById(this.#prefix + "-summary");
this.#summaryText = document.getElementById(this.#prefix + "-summary-text"); this.#summaryText = document.getElementById(this.#prefix + "-summary-text");
this.#originalEntryId = document.getElementById(this.#prefix + "-original-entry-id"); this.#originalLineItemId = document.getElementById(this.#prefix + "-original-line-item-id");
this.#originalEntryText = document.getElementById(this.#prefix + "-original-entry-text"); this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item-text");
this.#offsets = document.getElementById(this.#prefix + "-offsets"); this.#offsets = document.getElementById(this.#prefix + "-offsets");
this.#amount = document.getElementById(this.#prefix + "-amount"); this.#amount = document.getElementById(this.#prefix + "-amount");
this.#amountText = document.getElementById(this.#prefix + "-amount-text"); this.#amountText = document.getElementById(this.#prefix + "-amount-text");
this.deleteButton = document.getElementById(this.#prefix + "-delete"); this.deleteButton = document.getElementById(this.#prefix + "-delete");
this.#control.onclick = () => this.side.currency.form.entryEditor.onEdit(this); this.#control.onclick = () => this.sideSubForm.currency.form.lineItemEditor.onEdit(this);
this.deleteButton.onclick = () => { this.deleteButton.onclick = () => {
this.element.parentElement.removeChild(this.element); this.element.parentElement.removeChild(this.element);
this.side.deleteJournalEntry(this); this.sideSubForm.deleteLineItem(this);
}; };
} }
/** /**
* Returns whether the entry is an original entry. * Returns whether the line item needs offset.
* *
* @return {boolean} true if the entry is an original entry, or false otherwise * @return {boolean} true if the line item needs offset, or false otherwise
*/ */
isNeedOffset() { isNeedOffset() {
return "isNeedOffset" in this.element.dataset; return "isNeedOffset" in this.element.dataset;
} }
/** /**
* Returns the ID of the original entry. * Returns the ID of the original line item.
* *
* @return {string|null} the ID of the original entry * @return {string|null} the ID of the original line item
*/ */
getOriginalEntryId() { getOriginalLineItemId() {
return this.#originalEntryId.value === ""? null: this.#originalEntryId.value; return this.#originalLineItemId.value === ""? null: this.#originalLineItemId.value;
} }
/** /**
* Returns the date of the original entry. * Returns the date of the original line item.
* *
* @return {string|null} the date of the original entry * @return {string|null} the date of the original line item
*/ */
getOriginalEntryDate() { getOriginalLineItemDate() {
return this.#originalEntryId.dataset.date === ""? null: this.#originalEntryId.dataset.date; return this.#originalLineItemId.dataset.date === ""? null: this.#originalLineItemId.dataset.date;
} }
/** /**
* Returns the text of the original entry. * Returns the text of the original line item.
* *
* @return {string|null} the text of the original entry * @return {string|null} the text of the original line item
*/ */
getOriginalEntryText() { getOriginalLineItemText() {
return this.#originalEntryId.dataset.text === ""? null: this.#originalEntryId.dataset.text; return this.#originalLineItemId.dataset.text === ""? null: this.#originalLineItemId.dataset.text;
} }
/** /**
@ -991,9 +991,9 @@ class JournalEntrySubForm {
} }
/** /**
* Stores the data into the journal entry sub-form. * Stores the data into the line item sub-form.
* *
* @param editor {JournalEntryEditor} the journal entry editor * @param editor {VoucherLineItemEditor} the line item editor
*/ */
save(editor) { save(editor) {
if (editor.isNeedOffset) { if (editor.isNeedOffset) {
@ -1001,15 +1001,15 @@ class JournalEntrySubForm {
} else { } else {
this.#offsets.classList.add("d-none"); this.#offsets.classList.add("d-none");
} }
this.#originalEntryId.value = editor.originalEntryId === null? "": editor.originalEntryId; this.#originalLineItemId.value = editor.originalLineItemId === null? "": editor.originalLineItemId;
this.#originalEntryId.dataset.date = editor.originalEntryDate === null? "": editor.originalEntryDate; this.#originalLineItemId.dataset.date = editor.originalLineItemDate === null? "": editor.originalLineItemDate;
this.#originalEntryId.dataset.text = editor.originalEntryText === null? "": editor.originalEntryText; this.#originalLineItemId.dataset.text = editor.originalLineItemText === null? "": editor.originalLineItemText;
if (editor.originalEntryText === null) { if (editor.originalLineItemText === null) {
this.#originalEntryText.classList.add("d-none"); this.#originalLineItemText.classList.add("d-none");
this.#originalEntryText.innerText = ""; this.#originalLineItemText.innerText = "";
} else { } else {
this.#originalEntryText.classList.remove("d-none"); this.#originalLineItemText.classList.remove("d-none");
this.#originalEntryText.innerText = A_("Offset %(entry)s", {entry: editor.originalEntryText}); this.#originalLineItemText.innerText = A_("Offset %(item)s", {item: editor.originalLineItemText});
} }
this.#accountCode.value = editor.accountCode === null? "": editor.accountCode; this.#accountCode.value = editor.accountCode === null? "": editor.accountCode;
this.#accountCode.dataset.text = editor.accountText === null? "": editor.accountText; this.#accountCode.dataset.text = editor.accountText === null? "": editor.accountText;
@ -1019,9 +1019,9 @@ class JournalEntrySubForm {
this.#amount.value = editor.amount; this.#amount.value = editor.amount;
this.#amountText.innerText = formatDecimal(new Decimal(editor.amount)); this.#amountText.innerText = formatDecimal(new Decimal(editor.amount));
this.validate(); this.validate();
this.side.updateTotal(); this.sideSubForm.updateTotal();
this.side.currency.updateCodeSelectorStatus(); this.sideSubForm.currency.updateCodeSelectorStatus();
this.side.currency.form.updateMinDate(); this.sideSubForm.currency.form.updateMinDate();
} }
} }

View File

@ -1,5 +1,5 @@
/* The Mia! Accounting Flask Project /* The Mia! Accounting Flask Project
* journal-entry-editor.js: The JavaScript for the journal entry editor * voucher-line-item-editor.js: The JavaScript for the voucher line item editor
*/ */
/* Copyright (c) 2023 imacat. /* Copyright (c) 2023 imacat.
@ -23,10 +23,10 @@
"use strict"; "use strict";
/** /**
* The journal entry editor. * The voucher line item editor.
* *
*/ */
class JournalEntryEditor { class VoucherLineItemEditor {
/** /**
* The voucher form * The voucher form
@ -35,7 +35,7 @@ class JournalEntryEditor {
form; form;
/** /**
* The journal entry editor * The voucher line item editor
* @type {HTMLFormElement} * @type {HTMLFormElement}
*/ */
#element; #element;
@ -47,46 +47,46 @@ class JournalEntryEditor {
#modal; #modal;
/** /**
* The entry type, either "debit" or "credit" * The side, either "debit" or "credit"
* @type {string} * @type {string}
*/ */
entryType; side;
/** /**
* The prefix of the HTML ID and class * The prefix of the HTML ID and class
* @type {string} * @type {string}
*/ */
#prefix = "accounting-entry-editor" #prefix = "accounting-line-item-editor"
/** /**
* The container of the original entry * The container of the original line item
* @type {HTMLDivElement} * @type {HTMLDivElement}
*/ */
#originalEntryContainer; #originalLineItemContainer;
/** /**
* The control of the original entry * The control of the original line item
* @type {HTMLDivElement} * @type {HTMLDivElement}
*/ */
#originalEntryControl; #originalLineItemControl;
/** /**
* The original entry * The original line item
* @type {HTMLDivElement} * @type {HTMLDivElement}
*/ */
#originalEntryText; #originalLineItemText;
/** /**
* The error message of the original entry * The error message of the original line item
* @type {HTMLDivElement} * @type {HTMLDivElement}
*/ */
#originalEntryError; #originalLineItemError;
/** /**
* The delete button of the original entry * The delete button of the original line item
* @type {HTMLButtonElement} * @type {HTMLButtonElement}
*/ */
#originalEntryDelete; #originalLineItemDelete;
/** /**
* The control of the summary * The control of the summary
@ -137,40 +137,40 @@ class JournalEntryEditor {
#amountError; #amountError;
/** /**
* The journal entry to edit * The voucher line item to edit
* @type {JournalEntrySubForm|null} * @type {LineItemSubForm|null}
*/ */
entry; lineItem;
/** /**
* The debit or credit entry side sub-form * The debit or credit side sub-form
* @type {DebitCreditSideSubForm} * @type {SideSubForm}
*/ */
#side; #sideSubForm;
/** /**
* Whether the journal entry needs offset * Whether the voucher line item needs offset
* @type {boolean} * @type {boolean}
*/ */
isNeedOffset = false; isNeedOffset = false;
/** /**
* The ID of the original entry * The ID of the original line item
* @type {string|null} * @type {string|null}
*/ */
originalEntryId = null; originalLineItemId = null;
/** /**
* The date of the original entry * The date of the original line item
* @type {string|null} * @type {string|null}
*/ */
originalEntryDate = null; originalLineItemDate = null;
/** /**
* The text of the original entry * The text of the original line item
* @type {string|null} * @type {string|null}
*/ */
originalEntryText = null; originalLineItemText = null;
/** /**
* The account code * The account code
@ -209,13 +209,13 @@ class JournalEntryEditor {
#accountSelectors; #accountSelectors;
/** /**
* The original entry selector * The original line item selector
* @type {OriginalEntrySelector} * @type {OriginalLineItemSelector}
*/ */
originalEntrySelector; originalLineItemSelector;
/** /**
* Constructs a new journal entry editor. * Constructs a new voucher line item editor.
* *
* @param form {VoucherForm} the voucher form * @param form {VoucherForm} the voucher form
*/ */
@ -223,11 +223,11 @@ class JournalEntryEditor {
this.form = form; this.form = form;
this.#element = document.getElementById(this.#prefix); this.#element = document.getElementById(this.#prefix);
this.#modal = document.getElementById(this.#prefix + "-modal"); this.#modal = document.getElementById(this.#prefix + "-modal");
this.#originalEntryContainer = document.getElementById(this.#prefix + "-original-entry-container"); this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container");
this.#originalEntryControl = document.getElementById(this.#prefix + "-original-entry-control"); this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control");
this.#originalEntryText = document.getElementById(this.#prefix + "-original-entry"); this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item");
this.#originalEntryError = document.getElementById(this.#prefix + "-original-entry-error"); this.#originalLineItemError = document.getElementById(this.#prefix + "-original-line-item-error");
this.#originalEntryDelete = document.getElementById(this.#prefix + "-original-entry-delete"); this.#originalLineItemDelete = document.getElementById(this.#prefix + "-original-line-item-delete");
this.#summaryControl = document.getElementById(this.#prefix + "-summary-control"); this.#summaryControl = document.getElementById(this.#prefix + "-summary-control");
this.#summaryText = document.getElementById(this.#prefix + "-summary"); this.#summaryText = document.getElementById(this.#prefix + "-summary");
this.#summaryError = document.getElementById(this.#prefix + "-summary-error"); this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
@ -238,19 +238,19 @@ class JournalEntryEditor {
this.#amountError = document.getElementById(this.#prefix + "-amount-error"); this.#amountError = document.getElementById(this.#prefix + "-amount-error");
this.#summaryEditors = SummaryEditor.getInstances(this); this.#summaryEditors = SummaryEditor.getInstances(this);
this.#accountSelectors = AccountSelector.getInstances(this); this.#accountSelectors = AccountSelector.getInstances(this);
this.originalEntrySelector = new OriginalEntrySelector(this); this.originalLineItemSelector = new OriginalLineItemSelector(this);
this.#originalEntryControl.onclick = () => this.originalEntrySelector.onOpen() this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen()
this.#originalEntryDelete.onclick = () => this.clearOriginalEntry(); this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
this.#summaryControl.onclick = () => this.#summaryEditors[this.entryType].onOpen(); this.#summaryControl.onclick = () => this.#summaryEditors[this.side].onOpen();
this.#accountControl.onclick = () => this.#accountSelectors[this.entryType].onOpen(); this.#accountControl.onclick = () => this.#accountSelectors[this.side].onOpen();
this.#amountInput.onchange = () => this.#validateAmount(); this.#amountInput.onchange = () => this.#validateAmount();
this.#element.onsubmit = () => { this.#element.onsubmit = () => {
if (this.#validate()) { if (this.#validate()) {
if (this.entry === null) { if (this.lineItem === null) {
this.entry = this.#side.addJournalEntry(); this.lineItem = this.#sideSubForm.addLineItem();
} }
this.amount = this.#amountInput.value; this.amount = this.#amountInput.value;
this.entry.save(this); this.lineItem.save(this);
bootstrap.Modal.getInstance(this.#modal).hide(); bootstrap.Modal.getInstance(this.#modal).hide();
} }
return false; return false;
@ -258,48 +258,48 @@ class JournalEntryEditor {
} }
/** /**
* Saves the original entry from the original entry selector. * Saves the original line item from the original line item selector.
* *
* @param originalEntry {OriginalEntry} the original entry * @param originalLineItem {OriginalLineItem} the original line item
*/ */
saveOriginalEntry(originalEntry) { saveOriginalLineItem(originalLineItem) {
this.isNeedOffset = false; this.isNeedOffset = false;
this.#originalEntryContainer.classList.remove("d-none"); this.#originalLineItemContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty"); this.#originalLineItemControl.classList.add("accounting-not-empty");
this.originalEntryId = originalEntry.id; this.originalLineItemId = originalLineItem.id;
this.originalEntryDate = originalEntry.date; this.originalLineItemDate = originalLineItem.date;
this.originalEntryText = originalEntry.text; this.originalLineItemText = originalLineItem.text;
this.#originalEntryText.innerText = originalEntry.text; this.#originalLineItemText.innerText = originalLineItem.text;
this.#setEnableSummaryAccount(false); this.#setEnableSummaryAccount(false);
if (originalEntry.summary === "") { if (originalLineItem.summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty"); this.#summaryControl.classList.remove("accounting-not-empty");
} else { } else {
this.#summaryControl.classList.add("accounting-not-empty"); this.#summaryControl.classList.add("accounting-not-empty");
} }
this.summary = originalEntry.summary === ""? null: originalEntry.summary; this.summary = originalLineItem.summary === ""? null: originalLineItem.summary;
this.#summaryText.innerText = originalEntry.summary; this.#summaryText.innerText = originalLineItem.summary;
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = originalEntry.accountCode; this.accountCode = originalLineItem.accountCode;
this.accountText = originalEntry.accountText; this.accountText = originalLineItem.accountText;
this.#accountText.innerText = originalEntry.accountText; this.#accountText.innerText = originalLineItem.accountText;
this.#amountInput.value = String(originalEntry.netBalance); this.#amountInput.value = String(originalLineItem.netBalance);
this.#amountInput.max = String(originalEntry.netBalance); this.#amountInput.max = String(originalLineItem.netBalance);
this.#amountInput.min = "0"; this.#amountInput.min = "0";
this.#validate(); this.#validate();
} }
/** /**
* Clears the original entry. * Clears the original line item.
* *
*/ */
clearOriginalEntry() { clearOriginalLineItem() {
this.isNeedOffset = false; this.isNeedOffset = false;
this.#originalEntryContainer.classList.add("d-none"); this.#originalLineItemContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty"); this.#originalLineItemControl.classList.remove("accounting-not-empty");
this.originalEntryId = null; this.originalLineItemId = null;
this.originalEntryDate = null; this.originalLineItemDate = null;
this.originalEntryText = null; this.originalLineItemText = null;
this.#originalEntryText.innerText = ""; this.#originalLineItemText.innerText = "";
this.#setEnableSummaryAccount(true); this.#setEnableSummaryAccount(true);
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null; this.accountCode = null;
@ -314,7 +314,7 @@ class JournalEntryEditor {
* @return {string} the currency code * @return {string} the currency code
*/ */
getCurrencyCode() { getCurrencyCode() {
return this.#side.currency.getCurrencyCode(); return this.#sideSubForm.currency.getCurrencyCode();
} }
/** /**
@ -340,7 +340,7 @@ class JournalEntryEditor {
* @param summary {string} the summary * @param summary {string} the summary
* @param accountCode {string} the account code * @param accountCode {string} the account code
* @param accountText {string} the account text * @param accountText {string} the account text
* @param isAccountNeedOffset {boolean} true if the journal entries in the account need offset, or false otherwise * @param isAccountNeedOffset {boolean} true if the line items in the account need offset, or false otherwise
*/ */
saveSummaryWithAccount(summary, accountCode, accountText, isAccountNeedOffset) { saveSummaryWithAccount(summary, accountCode, accountText, isAccountNeedOffset) {
this.isNeedOffset = isAccountNeedOffset; this.isNeedOffset = isAccountNeedOffset;
@ -370,7 +370,7 @@ class JournalEntryEditor {
* *
* @param code {string} the account code * @param code {string} the account code
* @param text {string} the account text * @param text {string} the account text
* @param isNeedOffset {boolean} true if the journal entries in the account need offset or false otherwise * @param isNeedOffset {boolean} true if the line items in the account need offset or false otherwise
*/ */
saveAccount(code, text, isNeedOffset) { saveAccount(code, text, isNeedOffset) {
this.isNeedOffset = isNeedOffset; this.isNeedOffset = isNeedOffset;
@ -388,7 +388,7 @@ class JournalEntryEditor {
*/ */
#validate() { #validate() {
let isValid = true; let isValid = true;
isValid = this.#validateOriginalEntry() && isValid; isValid = this.#validateOriginalLineItem() && isValid;
isValid = this.#validateSummary() && isValid; isValid = this.#validateSummary() && isValid;
isValid = this.#validateAccount() && isValid; isValid = this.#validateAccount() && isValid;
isValid = this.#validateAmount() && isValid isValid = this.#validateAmount() && isValid
@ -396,14 +396,14 @@ class JournalEntryEditor {
} }
/** /**
* Validates the original entry. * Validates the original line item.
* *
* @return {boolean} true if valid, or false otherwise * @return {boolean} true if valid, or false otherwise
* @private * @private
*/ */
#validateOriginalEntry() { #validateOriginalLineItem() {
this.#originalEntryControl.classList.remove("is-invalid"); this.#originalLineItemControl.classList.remove("is-invalid");
this.#originalEntryError.innerText = ""; this.#originalLineItemError.innerText = "";
return true; return true;
} }
@ -458,7 +458,7 @@ class JournalEntryEditor {
if (this.#amountInput.max !== "") { if (this.#amountInput.max !== "") {
if (amount.greaterThan(new Decimal(this.#amountInput.max))) { if (amount.greaterThan(new Decimal(this.#amountInput.max))) {
this.#amountInput.classList.add("is-invalid"); this.#amountInput.classList.add("is-invalid");
this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original entry.", {balance: new Decimal(this.#amountInput.max)}); this.#amountError.innerText = A_("The amount must not exceed the net balance %(balance)s of the original line item.", {balance: new Decimal(this.#amountInput.max)});
return false; return false;
} }
} }
@ -476,22 +476,22 @@ class JournalEntryEditor {
} }
/** /**
* The callback when adding a new journal entry. * The callback when adding a new voucher line item.
* *
* @param side {DebitCreditSideSubForm} the debit or credit side sub-form * @param side {SideSubForm} the debit or credit side sub-form
*/ */
onAddNew(side) { onAddNew(side) {
this.entry = null; this.lineItem = null;
this.#side = side; this.#sideSubForm = side;
this.entryType = this.#side.entryType; this.side = this.#sideSubForm.side;
this.isNeedOffset = false; this.isNeedOffset = false;
this.#originalEntryContainer.classList.add("d-none"); this.#originalLineItemContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty"); this.#originalLineItemControl.classList.remove("accounting-not-empty");
this.#originalEntryControl.classList.remove("is-invalid"); this.#originalLineItemControl.classList.remove("is-invalid");
this.originalEntryId = null; this.originalLineItemId = null;
this.originalEntryDate = null; this.originalLineItemDate = null;
this.originalEntryText = null; this.originalLineItemText = null;
this.#originalEntryText.innerText = ""; this.#originalLineItemText.innerText = "";
this.#setEnableSummaryAccount(true); this.#setEnableSummaryAccount(true);
this.#summaryControl.classList.remove("accounting-not-empty"); this.#summaryControl.classList.remove("accounting-not-empty");
this.#summaryControl.classList.remove("is-invalid"); this.#summaryControl.classList.remove("is-invalid");
@ -512,46 +512,46 @@ class JournalEntryEditor {
} }
/** /**
* The callback when editing a journal entry. * The callback when editing a voucher line item.
* *
* @param entry {JournalEntrySubForm} the journal entry sub-form * @param lineItem {LineItemSubForm} the voucher line item sub-form
*/ */
onEdit(entry) { onEdit(lineItem) {
this.entry = entry; this.lineItem = lineItem;
this.#side = entry.side; this.#sideSubForm = lineItem.sideSubForm;
this.entryType = this.#side.entryType; this.side = this.#sideSubForm.side;
this.isNeedOffset = entry.isNeedOffset(); this.isNeedOffset = lineItem.isNeedOffset();
this.originalEntryId = entry.getOriginalEntryId(); this.originalLineItemId = lineItem.getOriginalLineItemId();
this.originalEntryDate = entry.getOriginalEntryDate(); this.originalLineItemDate = lineItem.getOriginalLineItemDate();
this.originalEntryText = entry.getOriginalEntryText(); this.originalLineItemText = lineItem.getOriginalLineItemText();
this.#originalEntryText.innerText = this.originalEntryText; this.#originalLineItemText.innerText = this.originalLineItemText;
if (this.originalEntryId === null) { if (this.originalLineItemId === null) {
this.#originalEntryContainer.classList.add("d-none"); this.#originalLineItemContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty"); this.#originalLineItemControl.classList.remove("accounting-not-empty");
} else { } else {
this.#originalEntryContainer.classList.remove("d-none"); this.#originalLineItemContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty"); this.#originalLineItemControl.classList.add("accounting-not-empty");
} }
this.#setEnableSummaryAccount(!entry.isMatched && this.originalEntryId === null); this.#setEnableSummaryAccount(!lineItem.isMatched && this.originalLineItemId === null);
this.summary = entry.getSummary(); this.summary = lineItem.getSummary();
if (this.summary === null) { if (this.summary === null) {
this.#summaryControl.classList.remove("accounting-not-empty"); this.#summaryControl.classList.remove("accounting-not-empty");
} else { } else {
this.#summaryControl.classList.add("accounting-not-empty"); this.#summaryControl.classList.add("accounting-not-empty");
} }
this.#summaryText.innerText = this.summary === null? "": this.summary; this.#summaryText.innerText = this.summary === null? "": this.summary;
if (entry.getAccountCode() === null) { if (lineItem.getAccountCode() === null) {
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
} else { } else {
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
} }
this.accountCode = entry.getAccountCode(); this.accountCode = lineItem.getAccountCode();
this.accountText = entry.getAccountText(); this.accountText = lineItem.getAccountText();
this.#accountText.innerText = this.accountText; this.#accountText.innerText = this.accountText;
this.#amountInput.value = entry.getAmount() === null? "": String(entry.getAmount()); this.#amountInput.value = lineItem.getAmount() === null? "": String(lineItem.getAmount());
const maxAmount = this.#getMaxAmount(); const maxAmount = this.#getMaxAmount();
this.#amountInput.max = maxAmount === null? "": maxAmount; this.#amountInput.max = maxAmount === null? "": maxAmount;
this.#amountInput.min = entry.getAmountMin() === null? "": String(entry.getAmountMin()); this.#amountInput.min = lineItem.getAmountMin() === null? "": String(lineItem.getAmountMin());
this.#validate(); this.#validate();
} }
@ -561,10 +561,10 @@ class JournalEntryEditor {
* @return {Decimal|null} the max amount * @return {Decimal|null} the max amount
*/ */
#getMaxAmount() { #getMaxAmount() {
if (this.originalEntryId === null) { if (this.originalLineItemId === null) {
return null; return null;
} }
return this.originalEntrySelector.getNetBalance(this.entry, this.form, this.originalEntryId); return this.originalLineItemSelector.getNetBalance(this.lineItem, this.form, this.originalLineItemId);
} }
/** /**
@ -575,11 +575,11 @@ class JournalEntryEditor {
#setEnableSummaryAccount(isEnabled) { #setEnableSummaryAccount(isEnabled) {
if (isEnabled) { if (isEnabled) {
this.#summaryControl.dataset.bsToggle = "modal"; this.#summaryControl.dataset.bsToggle = "modal";
this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + this.#side.entryType + "-modal"; this.#summaryControl.dataset.bsTarget = "#accounting-summary-editor-" + this.#sideSubForm.side + "-modal";
this.#summaryControl.classList.remove("accounting-disabled"); this.#summaryControl.classList.remove("accounting-disabled");
this.#summaryControl.classList.add("accounting-clickable"); this.#summaryControl.classList.add("accounting-clickable");
this.#accountControl.dataset.bsToggle = "modal"; this.#accountControl.dataset.bsToggle = "modal";
this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#side.entryType + "-modal"; this.#accountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#sideSubForm.side + "-modal";
this.#accountControl.classList.remove("accounting-disabled"); this.#accountControl.classList.remove("accounting-disabled");
this.#accountControl.classList.add("accounting-clickable"); this.#accountControl.classList.add("accounting-clickable");
} else { } else {

View File

@ -65,7 +65,7 @@ First written: 2023/2/1
<div id="accounting-is-need-offset-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2", "3"] %} d-none {% endif %}"> <div id="accounting-is-need-offset-control" class="form-check form-switch mb-3 {% if form.base_code.data[0] not in ["1", "2", "3"] %} d-none {% endif %}">
<input id="accounting-is-need-offset" class="form-check-input" type="checkbox" name="is_need_offset" value="1" {% if form.is_need_offset.data %} checked="checked" {% endif %}> <input id="accounting-is-need-offset" class="form-check-input" type="checkbox" name="is_need_offset" value="1" {% if form.is_need_offset.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="accounting-is-need-offset"> <label class="form-check-label" for="accounting-is-need-offset">
{{ A_("The entries in the account need offset.") }} {{ A_("The line items in the account need offset.") }}
</label> </label>
</div> </div>

View File

@ -19,9 +19,9 @@ income-expenses-row-desktop.html: The row in the income and expenses log for the
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8 First written: 2023/3/8
#} #}
<div>{{ entry.date|accounting_format_date }}</div> <div>{{ line_item.date|accounting_format_date }}</div>
<div>{{ entry.account.title|title }}</div> <div>{{ line_item.account.title|title }}</div>
<div>{{ entry.summary|accounting_default }}</div> <div>{{ line_item.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.expense|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.expense|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div> <div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>

View File

@ -20,31 +20,31 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/5 First written: 2023/3/5
#} #}
<div> <div>
{% if entry.date or entry.account %} {% if line_item.date or line_item.account %}
<div class="text-muted small"> <div class="text-muted small">
{% if entry.date %} {% if line_item.date %}
{{ entry.date|accounting_format_date }} {{ line_item.date|accounting_format_date }}
{% endif %} {% endif %}
{% if entry.account %} {% if line_item.account %}
{{ entry.account.title|title }} {{ line_item.account.title|title }}
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% if entry.summary %} {% if line_item.summary %}
<div>{{ entry.summary }}</div> <div>{{ line_item.summary }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="text-nowrap"> <div class="text-nowrap">
{% if entry.income %} {% if line_item.income %}
<span class="badge rounded-pill bg-success">+{{ entry.income|accounting_format_amount }}</span> <span class="badge rounded-pill bg-success">+{{ line_item.income|accounting_format_amount }}</span>
{% endif %} {% endif %}
{% if entry.expense %} {% if line_item.expense %}
<span class="badge rounded-pill bg-warning">-{{ entry.expense|accounting_format_amount }}</span> <span class="badge rounded-pill bg-warning">-{{ line_item.expense|accounting_format_amount }}</span>
{% endif %} {% endif %}
{% if entry.balance < 0 %} {% if line_item.balance < 0 %}
<span class="badge rounded-pill bg-danger">{{ entry.balance|accounting_format_amount }}</span> <span class="badge rounded-pill bg-danger">{{ line_item.balance|accounting_format_amount }}</span>
{% else %} {% else %}
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span> <span class="badge rounded-pill bg-primary">{{ line_item.balance|accounting_format_amount }}</span>
{% endif %} {% endif %}
</div> </div>

View File

@ -19,10 +19,10 @@ ledger-row-desktop.html: The row in the ledger for the desktop computers
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8 First written: 2023/3/8
#} #}
<div>{{ entry.date|accounting_format_date }}</div> <div>{{ line_item.date|accounting_format_date }}</div>
<div>{{ entry.summary|accounting_default }}</div> <div>{{ line_item.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.credit|accounting_format_amount|accounting_default }}</div>
{% if report.account.is_real %} {% if report.account.is_real %}
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div> <div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
{% endif %} {% endif %}

View File

@ -20,24 +20,24 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/5 First written: 2023/3/5
#} #}
<div> <div>
{% if entry.date %} {% if line_item.date %}
<div class="text-muted small"> <div class="text-muted small">
{{ entry.date|accounting_format_date }} {{ line_item.date|accounting_format_date }}
</div> </div>
{% endif %} {% endif %}
{% if entry.summary %} {% if line_item.summary %}
<div>{{ entry.summary }}</div> <div>{{ line_item.summary }}</div>
{% endif %} {% endif %}
</div> </div>
<div> <div>
{% if entry.debit %} {% if line_item.debit %}
<span class="badge rounded-pill bg-success">+{{ entry.debit|accounting_format_amount }}</span> <span class="badge rounded-pill bg-success">+{{ line_item.debit|accounting_format_amount }}</span>
{% endif %} {% endif %}
{% if entry.credit %} {% if line_item.credit %}
<span class="badge rounded-pill bg-warning">-{{ entry.credit|accounting_format_amount }}</span> <span class="badge rounded-pill bg-warning">-{{ line_item.credit|accounting_format_amount }}</span>
{% endif %} {% endif %}
{% if report.account.is_real %} {% if report.account.is_real %}
<span class="badge rounded-pill bg-primary">{{ entry.balance|accounting_format_amount }}</span> <span class="badge rounded-pill bg-primary">{{ line_item.balance|accounting_format_amount }}</span>
{% endif %} {% endif %}
</div> </div>

View File

@ -62,26 +62,26 @@ 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 entry = report.brought_forward %} {% with line_item = report.brought_forward %}
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
{% include "accounting/report/include/income-expenses-row-desktop.html" %} {% include "accounting/report/include/income-expenses-row-desktop.html" %}
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for entry in report.entries %} {% for line_item in report.line_items %}
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ line_item.url|accounting_append_next }}">
{% include "accounting/report/include/income-expenses-row-desktop.html" %} {% include "accounting/report/include/income-expenses-row-desktop.html" %}
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% if report.total %} {% if report.total %}
{% with entry = report.total %} {% with line_item = 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">{{ entry.income|accounting_format_amount }}</div> <div class="accounting-amount">{{ line_item.income|accounting_format_amount }}</div>
<div class="accounting-amount">{{ entry.expense|accounting_format_amount }}</div> <div class="accounting-amount">{{ line_item.expense|accounting_format_amount }}</div>
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div> <div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
</div> </div>
</div> </div>
{% endwith %} {% endwith %}
@ -90,19 +90,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 entry = report.brought_forward %} {% with line_item = 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-row-mobile.html" %} {% include "accounting/report/include/income-expenses-row-mobile.html" %}
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for entry in report.entries %} {% for line_item in report.line_items %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}"> <a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ line_item.url|accounting_append_next }}">
{% include "accounting/report/include/income-expenses-row-mobile.html" %} {% include "accounting/report/include/income-expenses-row-mobile.html" %}
</a> </a>
{% endfor %} {% endfor %}
{% if report.total %} {% if report.total %}
{% with entry = report.total %} {% with line_item = 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-row-mobile.html" %} {% include "accounting/report/include/income-expenses-row-mobile.html" %}
</div> </div>

View File

@ -59,41 +59,41 @@ First written: 2023/3/4
</div> </div>
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% for entry in report.entries %} {% for line_item in report.line_items %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.voucher.detail", voucher=entry.voucher)|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ url_for("accounting.voucher.detail", voucher=line_item.voucher)|accounting_append_next }}">
<div>{{ entry.voucher.date|accounting_format_date }}</div> <div>{{ line_item.voucher.date|accounting_format_date }}</div>
<div>{{ entry.currency.name }}</div> <div>{{ line_item.currency.name }}</div>
<div> <div>
<span class="d-none d-md-inline">{{ entry.account.code }}</span> <span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ entry.account.title|title }} {{ line_item.account.title|title }}
</div> </div>
<div>{{ entry.summary|accounting_default }}</div> <div>{{ line_item.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.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 entry in report.entries %} {% for line_item in report.line_items %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.voucher.detail", voucher=entry.voucher)|accounting_append_next }}"> <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.voucher.detail", voucher=line_item.voucher)|accounting_append_next }}">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}> <div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small"> <div class="text-muted small">
{{ entry.voucher.date|accounting_format_date }} {{ line_item.voucher.date|accounting_format_date }}
{{ entry.account.title|title }} {{ line_item.account.title|title }}
{% if entry.currency.code != accounting_default_currency_code() %} {% if line_item.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span> <span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
{% endif %} {% endif %}
</div> </div>
{% if entry.summary is not none %} {% if line_item.summary is not none %}
<div>{{ entry.summary }}</div> <div>{{ line_item.summary }}</div>
{% endif %} {% endif %}
</div> </div>
<div> <div>
<span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span> <span class="badge rounded-pill bg-info">{{ line_item.amount|accounting_format_amount }}</span>
</div> </div>
</div> </div>
</a> </a>

View File

@ -63,27 +63,27 @@ 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 entry = report.brought_forward %} {% with line_item = report.brought_forward %}
<div class="accounting-report-table-row"> <div class="accounting-report-table-row">
{% include "accounting/report/include/ledger-row-desktop.html" %} {% include "accounting/report/include/ledger-row-desktop.html" %}
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for entry in report.entries %} {% for line_item in report.line_items %}
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ line_item.url|accounting_append_next }}">
{% include "accounting/report/include/ledger-row-desktop.html" %} {% include "accounting/report/include/ledger-row-desktop.html" %}
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% if report.total %} {% if report.total %}
{% with entry = report.total %} {% with line_item = 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">{{ entry.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.credit|accounting_format_amount|accounting_default }}</div>
{% if report.account.is_real %} {% if report.account.is_real %}
<div class="accounting-amount {% if entry.balance < 0 %} text-danger {% endif %}">{{ entry.balance|accounting_report_format_amount }}</div> <div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_format_amount }}</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@ -93,19 +93,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 entry = report.brought_forward %} {% with line_item = 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-row-mobile.html" %} {% include "accounting/report/include/ledger-row-mobile.html" %}
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% for entry in report.entries %} {% for line_item in report.line_items %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}"> <a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ line_item.url|accounting_append_next }}">
{% include "accounting/report/include/ledger-row-mobile.html" %} {% include "accounting/report/include/ledger-row-mobile.html" %}
</a> </a>
{% endfor %} {% endfor %}
{% if report.total %} {% if report.total %}
{% with entry = report.total %} {% with line_item = 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-row-mobile.html" %} {% include "accounting/report/include/ledger-row-mobile.html" %}
</div> </div>

View File

@ -56,41 +56,41 @@ First written: 2023/3/8
</div> </div>
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% for entry in report.entries %} {% for line_item in report.line_items %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.voucher.detail", voucher=entry.voucher)|accounting_append_next }}"> <a class="accounting-report-table-row" href="{{ url_for("accounting.voucher.detail", voucher=line_item.voucher)|accounting_append_next }}">
<div>{{ entry.voucher.date|accounting_format_date }}</div> <div>{{ line_item.voucher.date|accounting_format_date }}</div>
<div>{{ entry.currency.name }}</div> <div>{{ line_item.currency.name }}</div>
<div> <div>
<span class="d-none d-md-inline">{{ entry.account.code }}</span> <span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ entry.account.title|title }} {{ line_item.account.title|title }}
</div> </div>
<div>{{ entry.summary|accounting_default }}</div> <div>{{ line_item.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.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 entry in report.entries %} {% for line_item in report.line_items %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.voucher.detail", voucher=entry.voucher)|accounting_append_next }}"> <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.voucher.detail", voucher=line_item.voucher)|accounting_append_next }}">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}> <div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small"> <div class="text-muted small">
{{ entry.voucher.date|accounting_format_date }} {{ line_item.voucher.date|accounting_format_date }}
{{ entry.account.title|title }} {{ line_item.account.title|title }}
{% if entry.currency.code != accounting_default_currency_code() %} {% if line_item.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span> <span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
{% endif %} {% endif %}
</div> </div>
{% if entry.summary is not none %} {% if line_item.summary is not none %}
<div>{{ entry.summary }}</div> <div>{{ line_item.summary }}</div>
{% endif %} {% endif %}
</div> </div>
<div> <div>
<span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span> <span class="badge rounded-pill bg-info">{{ line_item.amount|accounting_format_amount }}</span>
</div> </div>
</div> </div>
</a> </a>

View File

@ -34,11 +34,11 @@ First written: 2023/2/26
<div class="mb-2 fw-bolder">{{ currency.name }}</div> <div class="mb-2 fw-bolder">{{ currency.name }}</div>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-voucher-entry accounting-voucher-entry-header">{{ A_("Content") }}</li> <li class="list-group-item accounting-voucher-line-item accounting-voucher-line-item-header">{{ A_("Content") }}</li>
{% with entries = currency.debit %} {% with line_items = currency.debit %}
{% include "accounting/voucher/include/detail-entries.html" %} {% include "accounting/voucher/include/detail-line-items.html" %}
{% endwith %} {% endwith %}
<li class="list-group-item accounting-voucher-entry accounting-voucher-entry-total"> <li class="list-group-item accounting-voucher-line-item accounting-voucher-line-item-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div> <div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -44,31 +44,31 @@ First written: 2023/2/25
<div class="mb-3"> <div class="mb-3">
<div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}"> <div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Content") }}</label> <label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Content") }}</label>
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list"> <ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-line-item-list">
{% for entry_form in debit_forms %} {% for line_item_form in debit_forms %}
{% with currency_index = currency_index, {% with currency_index = currency_index,
entry_type = "debit", side = "debit",
entry_index = loop.index, line_item_index = loop.index,
entry_id = entry_form.eid.data, line_item_id = line_item_form.eid.data,
only_one_entry_form = debit_forms|length == 1, only_one_line_item_form = debit_forms|length == 1,
account_code_data = entry_form.account_code.data|accounting_default, account_code_data = line_item_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors, account_code_error = line_item_form.account_code.errors,
account_text = entry_form.account_text, account_text = line_item_form.account_text,
summary_data = entry_form.summary.data|accounting_default, summary_data = line_item_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors, summary_errors = line_item_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default, original_line_item_id_data = line_item_form.original_line_item_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default, original_line_item_date = line_item_form.original_line_item_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default, original_line_item_text = line_item_form.original_line_item_text|accounting_default,
is_need_offset = entry_form.is_need_offset, is_need_offset = line_item_form.is_need_offset,
offset_entries = entry_form.offsets, offset_items = line_item_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"), offset_total = line_item_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance, net_balance_data = line_item_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount, net_balance_text = line_item_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_voucher_format_amount_input, amount_data = line_item_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = entry_form.amount.errors, amount_errors = line_item_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount, amount_text = line_item_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %} line_item_errors = line_item_form.all_errors %}
{% include "accounting/voucher/include/form-entry-item.html" %} {% include "accounting/voucher/include/form-line-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>
@ -79,7 +79,7 @@ First written: 2023/2/25
</div> </div>
<div> <div>
<button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal"> <button id="accounting-currency-{{ currency_index }}-debit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-side="debit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -50,7 +50,7 @@ First written: 2023/2/25
{% with summary_editor = form.summary_editor.debit %} {% with summary_editor = form.summary_editor.debit %}
{% include "accounting/voucher/include/summary-editor-modal.html" %} {% include "accounting/voucher/include/summary-editor-modal.html" %}
{% endwith %} {% endwith %}
{% with entry_type = "debit", {% with side = "debit",
account_options = form.debit_account_options %} account_options = form.debit_account_options %}
{% include "accounting/voucher/include/account-selector-modal.html" %} {% include "accounting/voucher/include/account-selector-modal.html" %}
{% endwith %} {% endwith %}

View File

@ -19,35 +19,35 @@ account-selector-modal.html: The modal for the account selector
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
<div id="accounting-account-selector-{{ entry_type }}-modal" class="modal fade accounting-account-selector" data-entry-type="{{ entry_type }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ entry_type }}-modal-label" aria-hidden="true"> <div id="accounting-account-selector-{{ side }}-modal" class="modal fade accounting-account-selector" data-side="{{ side }}" tabindex="-1" aria-labelledby="accounting-account-selector-{{ side }}-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ entry_type }}-modal-label">{{ A_("Select Account") }}</h1> <h1 class="modal-title fs-5" id="accounting-account-selector-{{ side }}-modal-label">{{ A_("Select Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button> <button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="input-group mb-2"> <div class="input-group mb-2">
<input id="accounting-account-selector-{{ entry_type }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required"> <input id="accounting-account-selector-{{ side }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-account-selector-{{ entry_type }}-query"> <label class="input-group-text" for="accounting-account-selector-{{ side }}-query">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }} {{ A_("Search") }}
</label> </label>
</div> </div>
<ul id="accounting-account-selector-{{ entry_type }}-option-list" class="list-group accounting-selector-list"> <ul id="accounting-account-selector-{{ side }}-option-list" class="list-group accounting-selector-list">
{% for account in account_options %} {% for account in account_options %}
<li id="accounting-account-selector-{{ entry_type }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ entry_type }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal"> <li id="accounting-account-selector-{{ side }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ side }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
{{ account }} {{ account }}
</li> </li>
{% endfor %} {% endfor %}
<li id="accounting-account-selector-{{ entry_type }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li> <li id="accounting-account-selector-{{ side }}-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul> </ul>
<p id="accounting-account-selector-{{ entry_type }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p> <p id="accounting-account-selector-{{ side }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Cancel") }}</button> <button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button>
<button id="accounting-account-selector-{{ entry_type }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Clear") }}</button> <button id="accounting-account-selector-{{ side }}-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Clear") }}</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
{# {#
The Mia! Accounting Flask Project The Mia! Accounting Flask Project
detail-entries-item: The journal entries in the voucher detail detail-line-items-item: The line items in the voucher detail
Copyright (c) 2023 imacat. Copyright (c) 2023 imacat.
@ -20,28 +20,28 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/14 First written: 2023/3/14
#} #}
{# <ul> For SonarQube not to complain about incorrect HTML #} {# <ul> For SonarQube not to complain about incorrect HTML #}
{% for entry in entries %} {% for line_item in line_items %}
<li class="list-group-item accounting-voucher-entry"> <li class="list-group-item accounting-voucher-line-item">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div> <div>
<div class="small">{{ entry.account }}</div> <div class="small">{{ line_item.account }}</div>
{% if entry.summary is not none %} {% if line_item.summary is not none %}
<div>{{ entry.summary }}</div> <div>{{ line_item.summary }}</div>
{% endif %} {% endif %}
{% if entry.original_entry %} {% if line_item.original_line_item %}
<div class="fst-italic small accounting-original-entry"> <div class="fst-italic small accounting-original-line-item">
<a href="{{ url_for("accounting.voucher.detail", voucher=entry.original_entry.voucher)|accounting_append_next }}"> <a href="{{ url_for("accounting.voucher.detail", voucher=line_item.original_line_item.voucher)|accounting_append_next }}">
{{ A_("Offset %(entry)s", entry=entry.original_entry) }} {{ A_("Offset %(item)s", item=line_item.original_line_item) }}
</a> </a>
</div> </div>
{% endif %} {% endif %}
{% if entry.is_need_offset %} {% if line_item.is_need_offset %}
<div class="fst-italic small accounting-offset-entries"> <div class="fst-italic small accounting-offset-line-items">
{% if entry.offsets %} {% if line_item.offsets %}
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Offsets") }}</div> <div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0"> <ul class="ms-2 ps-0">
{% for offset in entry.offsets %} {% for offset in line_item.offsets %}
<li> <li>
<a href="{{ url_for("accounting.voucher.detail", voucher=offset.voucher)|accounting_append_next }}"> <a href="{{ url_for("accounting.voucher.detail", voucher=offset.voucher)|accounting_append_next }}">
{{ offset.voucher.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }} {{ offset.voucher.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
@ -50,10 +50,10 @@ First written: 2023/3/14
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
{% if entry.balance %} {% if line_item.balance %}
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div> <div>{{ A_("Net balance") }}</div>
<div>{{ entry.balance|accounting_format_amount }}</div> <div>{{ line_item.balance|accounting_format_amount }}</div>
</div> </div>
{% else %} {% else %}
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
@ -68,7 +68,7 @@ First written: 2023/3/14
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div>{{ entry.amount|accounting_format_amount }}</div> <div>{{ line_item.amount|accounting_format_amount }}</div>
</div> </div>
</li> </li>
{% endfor %} {% endfor %}

View File

@ -1,74 +0,0 @@
{#
The Mia! Accounting Flask Project
entry-sub-form.html: The journal entry sub-form in the voucher form
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ entry_type }} {% if offset_entries %} accounting-matched-entry {% endif %}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" {% if is_need_offset %} data-is-need-offset="true" {% endif %}>
{% if entry_id %}
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
{% endif %}
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original-entry-id" class="accounting-original-entry-id" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original_entry_id" value="{{ original_entry_id_data }}" data-date="{{ original_entry_date }}" data-text="{{ original_entry_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}" data-min="{{ offset_total }}">
<div class="accounting-entry-content">
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-text" class="small">{{ account_text }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ summary_data }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-original-entry-text" class="fst-italic small accounting-original-entry {% if not original_entry_text %} d-none {% endif %}">
{% if original_entry_text %}{{ A_("Offset %(entry)s", entry=original_entry_text) }}{% endif %}
</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-offsets" class="fst-italic small accounting-offset-entries {% if not is_need_offset %} d-none {% endif %}">
{% if offset_entries %}
<div class="d-flex justify-content-between {% if not offset_entries %} d-none {% endif %}">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in offset_entries %}
<li>{{ offset.voucher.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li>
{% endfor %}
</ul>
</div>
{% if net_balance_data == 0 %}
<div>{{ A_("Fully offset") }}</div>
{% else %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ net_balance_text }}</div>
</div>
{% endif %}
{% else %}
{{ A_("Unmatched") }}
{% endif %}
</div>
</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-error" class="invalid-feedback">{% if entry_errors %}{{ entry_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_entry_form or offset_entries %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</li>
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -0,0 +1,74 @@
{#
The Mia! Accounting Flask Project
form-line-item.html: The line item sub-form in the voucher form
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-currency-{{ currency_index }}-{{ side }} {% if offset_items %} accounting-matched-line-item {% endif %}" data-currency-index="{{ currency_index }}" data-side="{{ side }}" data-line-item-index="{{ line_item_index }}" {% if is_need_offset %} data-is-need-offset="true" {% endif %}>
{% if line_item_id %}
<input type="hidden" name="currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-eid" value="{{ line_item_id }}">
{% endif %}
<input id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-no" value="{{ line_item_index }}">
<input id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-original-line-item-id" class="accounting-original-line-item-id" type="hidden" name="currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-original_line_item_id" value="{{ original_line_item_id_data }}" data-date="{{ original_line_item_date }}" data-text="{{ original_line_item_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-account-code" type="hidden" name="currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-summary" value="{{ summary_data }}">
<input id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-amount" value="{{ amount_data }}" data-min="{{ offset_total }}">
<div class="accounting-line-item-content">
<div id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-control" class="form-control clickable d-flex justify-content-between {% if line_item_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<div>
<div id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-account-text" class="small">{{ account_text }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-summary-text">{{ summary_data }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-original-line-item-text" class="fst-italic small accounting-original-line-item {% if not original_line_item_text %} d-none {% endif %}">
{% if original_line_item_text %}{{ A_("Offset %(item)s", item=original_line_item_text) }}{% endif %}
</div>
<div id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-offsets" class="fst-italic small accounting-offset-line-items {% if not is_need_offset %} d-none {% endif %}">
{% if offset_items %}
<div class="d-flex justify-content-between {% if not offset_items %} d-none {% endif %}">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in offset_items %}
<li>{{ offset.voucher.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}</li>
{% endfor %}
</ul>
</div>
{% if net_balance_data == 0 %}
<div>{{ A_("Fully offset") }}</div>
{% else %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ net_balance_text }}</div>
</div>
{% endif %}
{% else %}
{{ A_("Unmatched") }}
{% endif %}
</div>
</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_text }}</span></div>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-error" class="invalid-feedback">{% if line_item_errors %}{{ line_item_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}-delete" class="btn btn-danger rounded-circle {% if only_one_line_item_form or offset_items %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ side }}-{{ line_item_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</li>
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -24,9 +24,9 @@ First written: 2023/2/26
{% block accounting_scripts %} {% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/voucher-form.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/voucher-form.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/journal-entry-editor.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/voucher-line-item-editor.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/account-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/original-entry-selector.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/original-line-item-selector.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/summary-editor.js") }}"></script>
{% endblock %} {% endblock %}
@ -39,7 +39,7 @@ First written: 2023/2/26
</a> </a>
</div> </div>
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-entry-template="{{ entry_template }}"> <form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-line-item-template="{{ line_item_template }}">
{{ form.csrf_token }} {{ form.csrf_token }}
{% if request.args.next %} {% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}"> <input type="hidden" name="next" value="{{ request.args.next }}">
@ -88,8 +88,8 @@ First written: 2023/2/26
</div> </div>
</form> </form>
{% include "accounting/voucher/include/journal-entry-editor-modal.html" %} {% include "accounting/voucher/include/voucher-line-item-editor-modal.html" %}
{% block form_modals %}{% endblock %} {% block form_modals %}{% endblock %}
{% include "accounting/voucher/include/original-entry-selector-modal.html" %} {% include "accounting/voucher/include/original-line-item-selector-modal.html" %}
{% endblock %} {% endblock %}

View File

@ -1,76 +0,0 @@
{#
The Mia! Accounting Flask Project
journal-entry-editor-modal.html: The modal of the journal entry editor
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<form id="accounting-entry-editor">
<div id="accounting-entry-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-editor-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-entry-editor-modal-label">{{ A_("Journal Entry Content") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div id="accounting-entry-editor-original-entry-container" class="d-flex justify-content-between mb-3">
<div class="accounting-entry-editor-original-entry-content">
<div id="accounting-entry-editor-original-entry-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-original-entry-selector-modal">
<label class="form-label" for="accounting-entry-editor-original-entry">{{ A_("Original Entry") }}</label>
<div id="accounting-entry-editor-original-entry"></div>
</div>
<div id="accounting-entry-editor-original-entry-error" class="invalid-feedback"></div>
</div>
<div>
<button id="accounting-entry-editor-original-entry-delete" class="btn btn-danger rounded-circle" type="button">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="mb-3">
<div id="accounting-entry-editor-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-entry-editor-summary">{{ A_("Summary") }}</label>
<div id="accounting-entry-editor-summary"></div>
</div>
<div id="accounting-entry-editor-summary-error" class="invalid-feedback"></div>
</div>
<div class="mb-3">
<div id="accounting-entry-editor-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-entry-editor-account">{{ A_("Account") }}</label>
<div id="accounting-entry-editor-account"></div>
</div>
<div id="accounting-entry-editor-account-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-entry-editor-amount" class="form-control" type="number" value="" min="0" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-entry-editor-amount">{{ A_("Amount") }}</label>
<div id="accounting-entry-editor-amount-error" class="invalid-feedback"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -1,56 +0,0 @@
{#
The Mia! Accounting Flask Project
original-entry-selector-modal.html: The modal of the original entry selector
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-original-entry-selector-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-original-entry-selector-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-original-entry-selector-modal-label">{{ A_("Select Original Entry") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-original-entry-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-original-entry-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-original-entry-selector-option-list" class="list-group accounting-selector-list">
{% for entry in form.original_entry_options %}
<li id="accounting-original-entry-selector-option-{{ entry.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-entry-selector-option" data-id="{{ entry.id }}" data-date="{{ entry.voucher.date }}" data-entry-type="{{ "debit" if entry.is_debit else "credit" }}" data-currency-code="{{ entry.currency.code }}" data-account-code="{{ entry.account_code }}" data-account-text="{{ entry.account }}" data-summary="{{ entry.summary|accounting_default }}" data-net-balance="{{ entry.net_balance|accounting_voucher_format_amount_input }}" data-text="{{ entry }}" data-query-values="{{ entry.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">
<div>{{ entry.voucher.date|accounting_format_date }} {{ entry.summary|accounting_default }}</div>
<div>
<span class="badge bg-primary rounded-pill">
<span id="accounting-original-entry-selector-option-{{ entry.id }}-net-balance">{{ entry.net_balance|accounting_format_amount }}</span>
/ {{ entry.amount|accounting_format_amount }}
</span>
</div>
</li>
{% endfor %}
</ul>
<p id="accounting-original-entry-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,56 @@
{#
The Mia! Accounting Flask Project
original-line-item-selector-modal.html: The modal of the original line item selector
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<div id="accounting-original-line-item-selector-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-original-line-item-selector-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-original-line-item-selector-modal-label">{{ A_("Select Original Line Item") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-original-line-item-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-original-line-item-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-original-line-item-selector-option-list" class="list-group accounting-selector-list">
{% for line_item in form.original_line_item_options %}
<li id="accounting-original-line-item-selector-option-{{ line_item.id }}" class="list-group-item d-flex justify-content-between accounting-clickable accounting-original-line-item-selector-option" data-id="{{ line_item.id }}" data-date="{{ line_item.voucher.date }}" data-side="{{ "debit" if line_item.is_debit else "credit" }}" data-currency-code="{{ line_item.currency.code }}" data-account-code="{{ line_item.account_code }}" data-account-text="{{ line_item.account }}" data-summary="{{ line_item.summary|accounting_default }}" data-net-balance="{{ line_item.net_balance|accounting_voucher_format_amount_input }}" data-text="{{ line_item }}" data-query-values="{{ line_item.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<div>{{ line_item.voucher.date|accounting_format_date }} {{ line_item.summary|accounting_default }}</div>
<div>
<span class="badge bg-primary rounded-pill">
<span id="accounting-original-line-item-selector-option-{{ line_item.id }}-net-balance">{{ line_item.net_balance|accounting_format_amount }}</span>
/ {{ line_item.amount|accounting_format_amount }}
</span>
</div>
</li>
{% endfor %}
</ul>
<p id="accounting-original-line-item-selector-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
</div>
</div>
</div>

View File

@ -19,20 +19,20 @@ summary-editor-modal.html: The modal of the summary editor
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/28 First written: 2023/2/28
#} #}
<form id="accounting-summary-editor-{{ summary_editor.type }}" class="accounting-summary-editor" data-entry-type="{{ summary_editor.type }}"> <form id="accounting-summary-editor-{{ summary_editor.side }}" class="accounting-summary-editor" data-side="{{ summary_editor.side }}">
<div id="accounting-summary-editor-{{ summary_editor.type }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-modal-label" aria-hidden="true"> <div id="accounting-summary-editor-{{ summary_editor.side }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-summary-editor-{{ summary_editor.side }}-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-summary-editor-{{ summary_editor.type }}-modal-label"> <h1 class="modal-title fs-5" id="accounting-summary-editor-{{ summary_editor.side }}-modal-label">
<label for="accounting-summary-editor-{{ summary_editor.type }}-summary">{{ A_("Summary") }}</label> <label for="accounting-summary-editor-{{ summary_editor.side }}-summary">{{ A_("Summary") }}</label>
</h1> </h1>
<button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal" aria-label="{{ A_("Close") }}"></button> <button class="btn-close" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="d-flex justify-content-between mb-3"> <div class="d-flex justify-content-between mb-3">
<input id="accounting-summary-editor-{{ summary_editor.type }}-summary" class="form-control" type="text" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-modal-label"> <input id="accounting-summary-editor-{{ summary_editor.side }}-summary" class="form-control" type="text" aria-labelledby="accounting-summary-editor-{{ summary_editor.side }}-modal-label">
<button id="accounting-summary-editor-{{ summary_editor.type }}-offset" class="btn btn-primary text-nowrap ms-2" type="button" data-bs-toggle="modal" data-bs-target="#accounting-original-entry-selector-modal"> <button id="accounting-summary-editor-{{ summary_editor.side }}-offset" class="btn btn-primary text-nowrap ms-2" type="button" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal">
{{ A_("Offset...") }} {{ A_("Offset...") }}
</button> </button>
</div> </div>
@ -40,43 +40,43 @@ First written: 2023/2/28
{# Tab navigation #} {# Tab navigation #}
<ul class="nav nav-tabs mb-2"> <ul class="nav nav-tabs mb-2">
<li class="nav-item"> <li class="nav-item">
<span id="accounting-summary-editor-{{ summary_editor.type }}-general-tab" class="nav-link active accounting-clickable" aria-current="page"> <span id="accounting-summary-editor-{{ summary_editor.side }}-general-tab" class="nav-link active accounting-clickable" aria-current="page">
{{ A_("General") }} {{ A_("General") }}
</span> </span>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<span id="accounting-summary-editor-{{ summary_editor.type }}-travel-tab" class="nav-link accounting-clickable" aria-current="false"> <span id="accounting-summary-editor-{{ summary_editor.side }}-travel-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Travel") }} {{ A_("Travel") }}
</span> </span>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<span id="accounting-summary-editor-{{ summary_editor.type }}-bus-tab" class="nav-link accounting-clickable" aria-current="false"> <span id="accounting-summary-editor-{{ summary_editor.side }}-bus-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Bus") }} {{ A_("Bus") }}
</span> </span>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<span id="accounting-summary-editor-{{ summary_editor.type }}-regular-tab" class="nav-link accounting-clickable" aria-current="false"> <span id="accounting-summary-editor-{{ summary_editor.side }}-regular-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Regular") }} {{ A_("Regular") }}
</span> </span>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<span id="accounting-summary-editor-{{ summary_editor.type }}-annotation-tab" class="nav-link accounting-clickable" aria-current="false"> <span id="accounting-summary-editor-{{ summary_editor.side }}-annotation-tab" class="nav-link accounting-clickable" aria-current="false">
{{ A_("Annotation") }} {{ A_("Annotation") }}
</span> </span>
</li> </li>
</ul> </ul>
{# A general summary with a tag #} {# A general summary with a tag #}
<div id="accounting-summary-editor-{{ summary_editor.type }}-general-page" aria-current="page" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-general-tab"> <div id="accounting-summary-editor-{{ summary_editor.side }}-general-page" aria-current="page" aria-labelledby="accounting-summary-editor-{{ summary_editor.side }}-general-tab">
<div class="form-floating mb-2"> <div class="form-floating mb-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-general-tag" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-summary-editor-{{ summary_editor.side }}-general-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-general-tag">{{ A_("Tag") }}</label> <label class="form-label" for="accounting-summary-editor-{{ summary_editor.side }}-general-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-general-tag-error" class="invalid-feedback"></div> <div id="accounting-summary-editor-{{ summary_editor.side }}-general-tag-error" class="invalid-feedback"></div>
</div> </div>
<div> <div>
{% for tag in summary_editor.general.tags %} {% for tag in summary_editor.general.tags %}
<button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}"> <button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.side }}-general-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }} {{ tag }}
</button> </button>
{% endfor %} {% endfor %}
@ -84,16 +84,16 @@ First written: 2023/2/28
</div> </div>
{# A general trip with the origin and distination #} {# A general trip with the origin and distination #}
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-travel-tab"> <div id="accounting-summary-editor-{{ summary_editor.side }}-travel-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.side }}-travel-tab">
<div class="form-floating mb-2"> <div class="form-floating mb-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-travel-tag" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-summary-editor-{{ summary_editor.side }}-travel-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-travel-tag">{{ A_("Tag") }}</label> <label class="form-label" for="accounting-summary-editor-{{ summary_editor.side }}-travel-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-tag-error" class="invalid-feedback"></div> <div id="accounting-summary-editor-{{ summary_editor.side }}-travel-tag-error" class="invalid-feedback"></div>
</div> </div>
<div> <div>
{% for tag in summary_editor.travel.tags %} {% for tag in summary_editor.travel.tags %}
<button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}"> <button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.side }}-travel-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }} {{ tag }}
</button> </button>
{% endfor %} {% endfor %}
@ -101,40 +101,40 @@ First written: 2023/2/28
<div class="d-flex justify-content-between mt-2"> <div class="d-flex justify-content-between mt-2">
<div class="form-floating"> <div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-travel-from" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-summary-editor-{{ summary_editor.side }}-travel-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-travel-from">{{ A_("From") }}</label> <label class="form-label" for="accounting-summary-editor-{{ summary_editor.side }}-travel-from">{{ A_("From") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-from-error" class="invalid-feedback"></div> <div id="accounting-summary-editor-{{ summary_editor.side }}-travel-from-error" class="invalid-feedback"></div>
</div> </div>
<div class="btn-group-vertical ms-1 me-1"> <div class="btn-group-vertical ms-1 me-1">
<button class="btn btn-primary accounting-summary-editor-{{ summary_editor.type }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="&rarr;">&rarr;</button> <button class="btn btn-primary accounting-summary-editor-{{ summary_editor.side }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="&rarr;">&rarr;</button>
<button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-travel-direction" type="button" tabindex="-1" data-arrow="&harr;">&harr;</button> <button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.side }}-travel-direction" type="button" tabindex="-1" data-arrow="&harr;">&harr;</button>
</div> </div>
<div class="form-floating"> <div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-travel-to" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-summary-editor-{{ summary_editor.side }}-travel-to" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-travel-to">{{ A_("To") }}</label> <label class="form-label" for="accounting-summary-editor-{{ summary_editor.side }}-travel-to">{{ A_("To") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-to-error" class="invalid-feedback"></div> <div id="accounting-summary-editor-{{ summary_editor.side }}-travel-to-error" class="invalid-feedback"></div>
</div> </div>
</div> </div>
</div> </div>
{# A bus trip with the route name or route number, the origin and distination #} {# A bus trip with the route name or route number, the origin and distination #}
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-bus-tab"> <div id="accounting-summary-editor-{{ summary_editor.side }}-bus-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.side }}-bus-tab">
<div class="d-flex justify-content-between mb-2"> <div class="d-flex justify-content-between mb-2">
<div class="form-floating me-2"> <div class="form-floating me-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-bus-tag" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-summary-editor-{{ summary_editor.side }}-bus-tag" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-bus-tag">{{ A_("Tag") }}</label> <label class="form-label" for="accounting-summary-editor-{{ summary_editor.side }}-bus-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-tag-error" class="invalid-feedback"></div> <div id="accounting-summary-editor-{{ summary_editor.side }}-bus-tag-error" class="invalid-feedback"></div>
</div> </div>
<div class="form-floating"> <div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-bus-route" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-summary-editor-{{ summary_editor.side }}-bus-route" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-bus-route">{{ A_("Route") }}</label> <label class="form-label" for="accounting-summary-editor-{{ summary_editor.side }}-bus-route">{{ A_("Route") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-route-error" class="invalid-feedback"></div> <div id="accounting-summary-editor-{{ summary_editor.side }}-bus-route-error" class="invalid-feedback"></div>
</div> </div>
</div> </div>
<div> <div>
{% for tag in summary_editor.bus.tags %} {% for tag in summary_editor.bus.tags %}
<button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}"> <button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.side }}-bus-btn-tag" type="button" tabindex="-1" data-value="{{ tag.name }}" data-accounts="{{ tag.account_codes|tojson|forceescape }}">
{{ tag }} {{ tag }}
</button> </button>
{% endfor %} {% endfor %}
@ -142,50 +142,50 @@ First written: 2023/2/28
<div class="d-flex justify-content-between mt-2"> <div class="d-flex justify-content-between mt-2">
<div class="form-floating me-2"> <div class="form-floating me-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-bus-from" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-summary-editor-{{ summary_editor.side }}-bus-from" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-bus-from">{{ A_("From") }}</label> <label class="form-label" for="accounting-summary-editor-{{ summary_editor.side }}-bus-from">{{ A_("From") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-from-error" class="invalid-feedback"></div> <div id="accounting-summary-editor-{{ summary_editor.side }}-bus-from-error" class="invalid-feedback"></div>
</div> </div>
<div class="form-floating"> <div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-bus-to" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-summary-editor-{{ summary_editor.side }}-bus-to" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-bus-to">{{ A_("To") }}</label> <label class="form-label" for="accounting-summary-editor-{{ summary_editor.side }}-bus-to">{{ A_("To") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-to-error" class="invalid-feedback"></div> <div id="accounting-summary-editor-{{ summary_editor.side }}-bus-to-error" class="invalid-feedback"></div>
</div> </div>
</div> </div>
</div> </div>
{# A regular income or payment #} {# A regular income or payment #}
<div id="accounting-summary-editor-{{ summary_editor.type }}-regular-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-regular-tab"> <div id="accounting-summary-editor-{{ summary_editor.side }}-regular-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.side }}-regular-tab">
{# TODO: To be done #} {# TODO: To be done #}
</div> </div>
{# The annotation #} {# The annotation #}
<div id="accounting-summary-editor-{{ summary_editor.type }}-annotation-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.type }}-annotation-tab"> <div id="accounting-summary-editor-{{ summary_editor.side }}-annotation-page" class="d-none" aria-current="false" aria-labelledby="accounting-summary-editor-{{ summary_editor.side }}-annotation-tab">
<div class="form-floating"> <div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" "> <input id="accounting-summary-editor-{{ summary_editor.side }}-annotation-number" class="form-control" type="number" min="1" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-annotation-number">{{ A_("The number of items") }}</label> <label class="form-label" for="accounting-summary-editor-{{ summary_editor.side }}-annotation-number">{{ A_("The number of items") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-annotation-number-error" class="invalid-feedback"></div> <div id="accounting-summary-editor-{{ summary_editor.side }}-annotation-number-error" class="invalid-feedback"></div>
</div> </div>
<div class="form-floating mt-2"> <div class="form-floating mt-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-annotation-note" class="form-control" type="text" value="" placeholder=" "> <input id="accounting-summary-editor-{{ summary_editor.side }}-annotation-note" class="form-control" type="text" value="" placeholder=" ">
<label class="form-label" for="accounting-summary-editor-{{ summary_editor.type }}-annotation-note">{{ A_("Note") }}</label> <label class="form-label" for="accounting-summary-editor-{{ summary_editor.side }}-annotation-note">{{ A_("Note") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.type }}-annotation-note-error" class="invalid-feedback"></div> <div id="accounting-summary-editor-{{ summary_editor.side }}-annotation-note-error" class="invalid-feedback"></div>
</div> </div>
</div> </div>
{# The suggested accounts #} {# The suggested accounts #}
<div class="mt-3"> <div class="mt-3">
{% for account in summary_editor.accounts %} {% for account in summary_editor.accounts %}
<button class="btn btn-outline-primary d-none accounting-summary-editor-{{ summary_editor.type }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}"> <button class="btn btn-outline-primary d-none accounting-summary-editor-{{ summary_editor.side }}-account {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" type="button" data-code="{{ account.code }}" data-text="{{ account }}">
{{ account }} {{ account }}
</button> </button>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal">{{ A_("Cancel") }}</button> <button class="btn btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">{{ A_("Cancel") }}</button>
<button id="accounting-summary-editor-{{ summary_editor.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button> <button id="accounting-summary-editor-{{ summary_editor.side }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,76 @@
{#
The Mia! Accounting Flask Project
voucher-line-item-editor-modal.html: The modal of the voucher line item editor
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<form id="accounting-line-item-editor">
<div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-line-item-editor-modal-label">{{ A_("Line Item Content") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div id="accounting-line-item-editor-original-line-item-container" class="d-flex justify-content-between mb-3">
<div class="accounting-line-item-editor-original-line-item-content">
<div id="accounting-line-item-editor-original-line-item-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-original-line-item-selector-modal">
<label class="form-label" for="accounting-line-item-editor-original-line-item">{{ A_("Original Line Item") }}</label>
<div id="accounting-line-item-editor-original-line-item"></div>
</div>
<div id="accounting-line-item-editor-original-line-item-error" class="invalid-feedback"></div>
</div>
<div>
<button id="accounting-line-item-editor-original-line-item-delete" class="btn btn-danger rounded-circle" type="button">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="mb-3">
<div id="accounting-line-item-editor-summary-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-line-item-editor-summary">{{ A_("Summary") }}</label>
<div id="accounting-line-item-editor-summary"></div>
</div>
<div id="accounting-line-item-editor-summary-error" class="invalid-feedback"></div>
</div>
<div class="mb-3">
<div id="accounting-line-item-editor-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-line-item-editor-account">{{ A_("Account") }}</label>
<div id="accounting-line-item-editor-account"></div>
</div>
<div id="accounting-line-item-editor-account-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-line-item-editor-amount" class="form-control" type="number" value="" min="0" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-line-item-editor-amount">{{ A_("Amount") }}</label>
<div id="accounting-line-item-editor-amount-error" class="invalid-feedback"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -34,11 +34,11 @@ First written: 2023/2/26
<div class="mb-2 fw-bolder">{{ currency.name }}</div> <div class="mb-2 fw-bolder">{{ currency.name }}</div>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-voucher-entry accounting-voucher-entry-header">{{ A_("Content") }}</li> <li class="list-group-item accounting-voucher-line-item accounting-voucher-line-item-header">{{ A_("Content") }}</li>
{% with entries = currency.credit %} {% with line_items = currency.credit %}
{% include "accounting/voucher/include/detail-entries.html" %} {% include "accounting/voucher/include/detail-line-items.html" %}
{% endwith %} {% endwith %}
<li class="list-group-item accounting-voucher-entry accounting-voucher-entry-total"> <li class="list-group-item accounting-voucher-line-item accounting-voucher-line-item-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div> <div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -44,31 +44,31 @@ First written: 2023/2/25
<div class="mb-3"> <div class="mb-3">
<div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}"> <div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Content") }}</label> <label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Content") }}</label>
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list"> <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-line-item-list">
{% for entry_form in credit_forms %} {% for line_item_form in credit_forms %}
{% with currency_index = currency_index, {% with currency_index = currency_index,
entry_type = "credit", side = "credit",
entry_index = loop.index, line_item_index = loop.index,
only_one_entry_form = debit_forms|length == 1, only_one_line_item_form = debit_forms|length == 1,
entry_id = entry_form.eid.data, line_item_id = line_item_form.eid.data,
account_code_data = entry_form.account_code.data|accounting_default, account_code_data = line_item_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors, account_code_error = line_item_form.account_code.errors,
account_text = entry_form.account_text, account_text = line_item_form.account_text,
summary_data = entry_form.summary.data|accounting_default, summary_data = line_item_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors, summary_errors = line_item_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default, original_line_item_id_data = line_item_form.original_line_item_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default, original_line_item_date = line_item_form.original_line_item_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default, original_line_item_text = line_item_form.original_line_item_text|accounting_default,
is_need_offset = entry_form.is_need_offset, is_need_offset = line_item_form.is_need_offset,
offset_entries = entry_form.offsets, offset_items = line_item_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"), offset_total = line_item_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance, net_balance_data = line_item_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount, net_balance_text = line_item_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_voucher_format_amount_input, amount_data = line_item_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = entry_form.amount.errors, amount_errors = line_item_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount, amount_text = line_item_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %} line_item_errors = line_item_form.all_errors %}
{% include "accounting/voucher/include/form-entry-item.html" %} {% include "accounting/voucher/include/form-line-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>
@ -79,7 +79,7 @@ First written: 2023/2/25
</div> </div>
<div> <div>
<button id="accounting-currency-{{ currency_index }}-credit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal"> <button id="accounting-currency-{{ currency_index }}-credit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-side="credit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -50,7 +50,7 @@ First written: 2023/2/25
{% with summary_editor = form.summary_editor.credit %} {% with summary_editor = form.summary_editor.credit %}
{% include "accounting/voucher/include/summary-editor-modal.html" %} {% include "accounting/voucher/include/summary-editor-modal.html" %}
{% endwith %} {% endwith %}
{% with entry_type = "credit", {% with side = "credit",
account_options = form.credit_account_options %} account_options = form.credit_account_options %}
{% include "accounting/voucher/include/account-selector-modal.html" %} {% include "accounting/voucher/include/account-selector-modal.html" %}
{% endwith %} {% endwith %}

View File

@ -27,14 +27,14 @@ First written: 2023/2/26
<div class="mb-2 fw-bolder">{{ currency.name }}</div> <div class="mb-2 fw-bolder">{{ currency.name }}</div>
<div class="row"> <div class="row">
{# The debit entries #} {# The debit line items #}
<div class="col-sm-6 mb-2"> <div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-voucher-entry accounting-voucher-entry-header">{{ A_("Debit") }}</li> <li class="list-group-item accounting-voucher-line-item accounting-voucher-line-item-header">{{ A_("Debit") }}</li>
{% with entries = currency.debit %} {% with line_items = currency.debit %}
{% include "accounting/voucher/include/detail-entries.html" %} {% include "accounting/voucher/include/detail-line-items.html" %}
{% endwith %} {% endwith %}
<li class="list-group-item accounting-voucher-entry accounting-voucher-entry-total"> <li class="list-group-item accounting-voucher-line-item accounting-voucher-line-item-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div> <div>{{ currency.debit_total|accounting_format_amount }}</div>
@ -43,14 +43,14 @@ First written: 2023/2/26
</ul> </ul>
</div> </div>
{# The credit entries #} {# The credit line items #}
<div class="col-sm-6 mb-2"> <div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover"> <ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-voucher-entry accounting-voucher-entry-header">{{ A_("Credit") }}</li> <li class="list-group-item accounting-voucher-line-item accounting-voucher-line-item-header">{{ A_("Credit") }}</li>
{% with entries = currency.credit %} {% with line_items = currency.credit %}
{% include "accounting/voucher/include/detail-entries.html" %} {% include "accounting/voucher/include/detail-line-items.html" %}
{% endwith %} {% endwith %}
<li class="list-group-item accounting-voucher-entry accounting-voucher-entry-total"> <li class="list-group-item accounting-voucher-line-item accounting-voucher-line-item-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div> <div>{{ currency.debit_total|accounting_format_amount }}</div>

View File

@ -42,35 +42,35 @@ First written: 2023/2/25
</div> </div>
<div class="row"> <div class="row">
{# The debit entries #} {# The debit line items #}
<div class="col-sm-6 mb-3"> <div class="col-sm-6 mb-3">
<div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}"> <div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Debit") }}</label> <label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Debit") }}</label>
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list accounting-currency-{{ currency_index }}-entry-list"> <ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-line-item-list">
{% for entry_form in debit_forms %} {% for line_item_form in debit_forms %}
{% with currency_index = currency_index, {% with currency_index = currency_index,
entry_type = "debit", side = "debit",
entry_index = loop.index, line_item_index = loop.index,
only_one_entry_form = debit_forms|length == 1, only_one_line_item_form = debit_forms|length == 1,
entry_id = entry_form.eid.data, line_item_id = line_item_form.eid.data,
account_code_data = entry_form.account_code.data|accounting_default, account_code_data = line_item_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors, account_code_error = line_item_form.account_code.errors,
account_text = entry_form.account_text, account_text = line_item_form.account_text,
summary_data = entry_form.summary.data|accounting_default, summary_data = line_item_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors, summary_errors = line_item_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default, original_line_item_id_data = line_item_form.original_line_item_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default, original_line_item_date = line_item_form.original_line_item_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default, original_line_item_text = line_item_form.original_line_item_text|accounting_default,
is_need_offset = entry_form.is_need_offset, is_need_offset = line_item_form.is_need_offset,
offset_entries = entry_form.offsets, offset_items = line_item_form.offsets,
offset_total = entry_form.offset_total|accounting_default, offset_total = line_item_form.offset_total|accounting_default,
net_balance_data = entry_form.net_balance, net_balance_data = line_item_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount, net_balance_text = line_item_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_voucher_format_amount_input, amount_data = line_item_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = entry_form.amount.errors, amount_errors = line_item_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount, amount_text = line_item_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %} line_item_errors = line_item_form.all_errors %}
{% include "accounting/voucher/include/form-entry-item.html" %} {% include "accounting/voucher/include/form-line-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>
@ -81,7 +81,7 @@ First written: 2023/2/25
</div> </div>
<div> <div>
<button id="accounting-currency-{{ currency_index }}-debit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal"> <button id="accounting-currency-{{ currency_index }}-debit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-side="debit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>
@ -90,35 +90,35 @@ First written: 2023/2/25
<div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div> <div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
</div> </div>
{# The credit entries #} {# The credit line items #}
<div class="col-sm-6 mb-3"> <div class="col-sm-6 mb-3">
<div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}"> <div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Credit") }}</label> <label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Credit") }}</label>
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list"> <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-line-item-list">
{% for entry_form in credit_forms %} {% for line_item_form in credit_forms %}
{% with currency_index = currency_index, {% with currency_index = currency_index,
entry_type = "credit", side = "credit",
entry_index = loop.index, line_item_index = loop.index,
only_one_entry_form = debit_forms|length == 1, only_one_line_item_form = debit_forms|length == 1,
entry_id = entry_form.eid.data, line_item_id = line_item_form.eid.data,
account_code_data = entry_form.account_code.data|accounting_default, account_code_data = line_item_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors, account_code_error = line_item_form.account_code.errors,
account_text = entry_form.account_text, account_text = line_item_form.account_text,
summary_data = entry_form.summary.data|accounting_default, summary_data = line_item_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors, summary_errors = line_item_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default, original_line_item_id_data = line_item_form.original_line_item_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default, original_line_item_date = line_item_form.original_line_item_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default, original_line_item_text = line_item_form.original_line_item_text|accounting_default,
is_need_offset = entry_form.is_need_offset, is_need_offset = line_item_form.is_need_offset,
offset_entries = entry_form.offsets, offset_items = line_item_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"), offset_total = line_item_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance, net_balance_data = line_item_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount, net_balance_text = line_item_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_voucher_format_amount_input, amount_data = line_item_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = entry_form.amount.errors, amount_errors = line_item_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount, amount_text = line_item_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %} line_item_errors = line_item_form.all_errors %}
{% include "accounting/voucher/include/form-entry-item.html" %} {% include "accounting/voucher/include/form-line-item.html" %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>
@ -129,7 +129,7 @@ First written: 2023/2/25
</div> </div>
<div> <div>
<button id="accounting-currency-{{ currency_index }}-credit-add-entry" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-bs-toggle="modal" data-bs-target="#accounting-entry-editor-modal"> <button id="accounting-currency-{{ currency_index }}-credit-add-line-item" class="btn btn-primary" type="button" data-currency-index="{{ currency_index }}" data-side="credit" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</button> </button>

View File

@ -57,11 +57,11 @@ First written: 2023/2/25
{% with summary_editor = form.summary_editor.credit %} {% with summary_editor = form.summary_editor.credit %}
{% include "accounting/voucher/include/summary-editor-modal.html" %} {% include "accounting/voucher/include/summary-editor-modal.html" %}
{% endwith %} {% endwith %}
{% with entry_type = "debit", {% with side = "debit",
account_options = form.debit_account_options %} account_options = form.debit_account_options %}
{% include "accounting/voucher/include/account-selector-modal.html" %} {% include "accounting/voucher/include/account-selector-modal.html" %}
{% endwith %} {% endwith %}
{% with entry_type = "credit", {% with side = "credit",
account_options = form.credit_account_options %} account_options = form.credit_account_options %}
{% include "accounting/voucher/include/account-selector-modal.html" %} {% include "accounting/voucher/include/account-selector-modal.html" %}
{% endwith %} {% endwith %}

View File

@ -23,7 +23,7 @@ from flask import abort
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting.models import Voucher, JournalEntry from accounting.models import Voucher, VoucherLineItem
from accounting.utils.voucher_types import VoucherType from accounting.utils.voucher_types import VoucherType
@ -37,11 +37,11 @@ class VoucherConverter(BaseConverter):
:param value: The voucher ID. :param value: The voucher ID.
:return: The corresponding voucher. :return: The corresponding voucher.
""" """
voucher: Voucher | None = Voucher.query.join(JournalEntry)\ voucher: Voucher | None = Voucher.query.join(VoucherLineItem)\
.filter(Voucher.id == value)\ .filter(Voucher.id == value)\
.options(selectinload(Voucher.entries) .options(selectinload(Voucher.line_items)
.selectinload(JournalEntry.offsets) .selectinload(VoucherLineItem.offsets)
.selectinload(JournalEntry.voucher))\ .selectinload(VoucherLineItem.voucher))\
.first() .first()
if voucher is None: if voucher is None:
abort(404) abort(404)

View File

@ -28,11 +28,11 @@ from wtforms.validators import DataRequired
from accounting import db from accounting import db
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Currency, JournalEntry from accounting.models import Currency, VoucherLineItem
from accounting.voucher.utils.offset_alias import offset_alias from accounting.voucher.utils.offset_alias import offset_alias
from accounting.utils.cast import be from accounting.utils.cast import be
from accounting.utils.strip_text import strip_text from accounting.utils.strip_text import strip_text
from .journal_entry import JournalEntryForm, CreditEntryForm, DebitEntryForm from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
CURRENCY_REQUIRED: DataRequired = DataRequired( CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency.")) lazy_gettext("Please select the currency."))
@ -50,26 +50,28 @@ class CurrencyExists:
"The currency does not exist.")) "The currency does not exist."))
class SameCurrencyAsOriginalEntries: class SameCurrencyAsOriginalLineItems:
"""The validator to check if the currency is the same as the original """The validator to check if the currency is the same as the
entries.""" original line items."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CurrencyForm) assert isinstance(form, CurrencyForm)
if field.data is None: if field.data is None:
return return
original_entry_id: set[int] = {x.original_entry_id.data original_line_item_id: set[int] \
for x in form.entries = {x.original_line_item_id.data
if x.original_entry_id.data is not None} for x in form.line_items
if len(original_entry_id) == 0: if x.original_line_item_id.data is not None}
if len(original_line_item_id) == 0:
return return
original_entry_currency_codes: set[str] = set(db.session.scalars( original_line_item_currency_codes: set[str] = set(db.session.scalars(
sa.select(JournalEntry.currency_code) sa.select(VoucherLineItem.currency_code)
.filter(JournalEntry.id.in_(original_entry_id))).all()) .filter(VoucherLineItem.id.in_(original_line_item_id))).all())
for currency_code in original_entry_currency_codes: for currency_code in original_line_item_currency_codes:
if field.data != currency_code: if field.data != currency_code:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The currency must be the same as the original entry.")) "The currency must be the same as the"
" original line item."))
class KeepCurrencyWhenHavingOffset: class KeepCurrencyWhenHavingOffset:
@ -81,31 +83,32 @@ class KeepCurrencyWhenHavingOffset:
if field.data is None: if field.data is None:
return return
offset: sa.Alias = offset_alias() offset: sa.Alias = offset_alias()
original_entries: list[JournalEntry] = JournalEntry.query\ original_line_items: list[VoucherLineItem] = VoucherLineItem.query\
.join(offset, be(JournalEntry.id == offset.c.original_entry_id), .join(offset, be(VoucherLineItem.id
== offset.c.original_line_item_id),
isouter=True)\ isouter=True)\
.filter(JournalEntry.id.in_({x.eid.data for x in form.entries .filter(VoucherLineItem.id.in_({x.eid.data for x in form.line_items
if x.eid.data is not None}))\ if x.eid.data is not None}))\
.group_by(JournalEntry.id, JournalEntry.currency_code)\ .group_by(VoucherLineItem.id, VoucherLineItem.currency_code)\
.having(sa.func.count(offset.c.id) > 0).all() .having(sa.func.count(offset.c.id) > 0).all()
for original_entry in original_entries: for original_line_item in original_line_items:
if original_entry.currency_code != field.data: if original_line_item.currency_code != field.data:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The currency must not be changed when there is offset.")) "The currency must not be changed when there is offset."))
class NeedSomeJournalEntries: class NeedSomeLineItems:
"""The validator to check if there is any journal entry sub-form.""" """The validator to check if there is any line item sub-form."""
def __call__(self, form: FlaskForm, field: FieldList) -> None: def __call__(self, form: FlaskForm, field: FieldList) -> None:
if len(field) == 0: if len(field) == 0:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"Please add some journal entries.")) "Please add some line items."))
class IsBalanced: class IsBalanced:
"""The validator to check that the total amount of the debit and credit """The validator to check that the total amount of the debit and credit
entries are equal.""" line items are equal."""
def __call__(self, form: FlaskForm, field: BooleanField) -> None: def __call__(self, form: FlaskForm, field: BooleanField) -> None:
assert isinstance(form, TransferCurrencyForm) assert isinstance(form, TransferCurrencyForm)
@ -126,20 +129,20 @@ class CurrencyForm(FlaskForm):
"""The pseudo field for the whole form validators.""" """The pseudo field for the whole form validators."""
@property @property
def entries(self) -> list[JournalEntryForm]: def line_items(self) -> list[LineItemForm]:
"""Returns the journal entry sub-forms. """Returns the line item sub-forms.
:return: The journal entry sub-forms. :return: The line item sub-forms.
""" """
entry_forms: list[JournalEntryForm] = [] line_item_forms: list[LineItemForm] = []
if isinstance(self, CashReceiptCurrencyForm): if isinstance(self, CashReceiptCurrencyForm):
entry_forms.extend([x.form for x in self.credit]) line_item_forms.extend([x.form for x in self.credit])
elif isinstance(self, CashDisbursementCurrencyForm): elif isinstance(self, CashDisbursementCurrencyForm):
entry_forms.extend([x.form for x in self.debit]) line_item_forms.extend([x.form for x in self.debit])
elif isinstance(self, TransferCurrencyForm): elif isinstance(self, TransferCurrencyForm):
entry_forms.extend([x.form for x in self.debit]) line_item_forms.extend([x.form for x in self.debit])
entry_forms.extend([x.form for x in self.credit]) line_item_forms.extend([x.form for x in self.credit])
return entry_forms return line_item_forms
@property @property
def is_code_locked(self) -> bool: def is_code_locked(self) -> bool:
@ -148,16 +151,16 @@ class CurrencyForm(FlaskForm):
:return: True if the currency code should not be changed, or False :return: True if the currency code should not be changed, or False
otherwise otherwise
""" """
entry_forms: list[JournalEntryForm] = self.entries line_item_forms: list[LineItemForm] = self.line_items
original_entry_id: set[int] \ original_line_item_id: set[int] \
= {x.original_entry_id.data for x in entry_forms = {x.original_line_item_id.data for x in line_item_forms
if x.original_entry_id.data is not None} if x.original_line_item_id.data is not None}
if len(original_entry_id) > 0: if len(original_line_item_id) > 0:
return True return True
entry_id: set[int] = {x.eid.data for x in entry_forms line_item_id: set[int] = {x.eid.data for x in line_item_forms
if x.eid.data is not None} if x.eid.data is not None}
select: sa.Select = sa.select(sa.func.count(JournalEntry.id))\ select: sa.Select = sa.select(sa.func.count(VoucherLineItem.id))\
.filter(JournalEntry.original_entry_id.in_(entry_id)) .filter(VoucherLineItem.original_line_item_id.in_(line_item_id))
return db.session.scalar(select) > 0 return db.session.scalar(select) > 0
@ -169,27 +172,27 @@ class CashReceiptCurrencyForm(CurrencyForm):
filters=[strip_text], filters=[strip_text],
validators=[CURRENCY_REQUIRED, validators=[CURRENCY_REQUIRED,
CurrencyExists(), CurrencyExists(),
SameCurrencyAsOriginalEntries(), SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()]) KeepCurrencyWhenHavingOffset()])
"""The currency code.""" """The currency code."""
credit = FieldList(FormField(CreditEntryForm), credit = FieldList(FormField(CreditLineItemForm),
validators=[NeedSomeJournalEntries()]) validators=[NeedSomeLineItems()])
"""The credit entries.""" """The credit line items."""
whole_form = BooleanField() whole_form = BooleanField()
"""The pseudo field for the whole form validators.""" """The pseudo field for the whole form validators."""
@property @property
def credit_total(self) -> Decimal: def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries. """Returns the total amount of the credit line items.
:return: The total amount of the credit journal entries. :return: The total amount of the credit line items.
""" """
return sum([x.amount.data for x in self.credit return sum([x.amount.data for x in self.credit
if x.amount.data is not None]) if x.amount.data is not None])
@property @property
def credit_errors(self) -> list[str | LazyString]: def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their """Returns the credit line item errors, without the errors in their
sub-forms. sub-forms.
:return: :return:
@ -206,27 +209,27 @@ class CashDisbursementCurrencyForm(CurrencyForm):
filters=[strip_text], filters=[strip_text],
validators=[CURRENCY_REQUIRED, validators=[CURRENCY_REQUIRED,
CurrencyExists(), CurrencyExists(),
SameCurrencyAsOriginalEntries(), SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()]) KeepCurrencyWhenHavingOffset()])
"""The currency code.""" """The currency code."""
debit = FieldList(FormField(DebitEntryForm), debit = FieldList(FormField(DebitLineItemForm),
validators=[NeedSomeJournalEntries()]) validators=[NeedSomeLineItems()])
"""The debit entries.""" """The debit line items."""
whole_form = BooleanField() whole_form = BooleanField()
"""The pseudo field for the whole form validators.""" """The pseudo field for the whole form validators."""
@property @property
def debit_total(self) -> Decimal: def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries. """Returns the total amount of the debit line items.
:return: The total amount of the debit journal entries. :return: The total amount of the debit line items.
""" """
return sum([x.amount.data for x in self.debit return sum([x.amount.data for x in self.debit
if x.amount.data is not None]) if x.amount.data is not None])
@property @property
def debit_errors(self) -> list[str | LazyString]: def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their """Returns the debit line item errors, without the errors in their
sub-forms. sub-forms.
:return: :return:
@ -243,39 +246,39 @@ class TransferCurrencyForm(CurrencyForm):
filters=[strip_text], filters=[strip_text],
validators=[CURRENCY_REQUIRED, validators=[CURRENCY_REQUIRED,
CurrencyExists(), CurrencyExists(),
SameCurrencyAsOriginalEntries(), SameCurrencyAsOriginalLineItems(),
KeepCurrencyWhenHavingOffset()]) KeepCurrencyWhenHavingOffset()])
"""The currency code.""" """The currency code."""
debit = FieldList(FormField(DebitEntryForm), debit = FieldList(FormField(DebitLineItemForm),
validators=[NeedSomeJournalEntries()]) validators=[NeedSomeLineItems()])
"""The debit entries.""" """The debit line items."""
credit = FieldList(FormField(CreditEntryForm), credit = FieldList(FormField(CreditLineItemForm),
validators=[NeedSomeJournalEntries()]) validators=[NeedSomeLineItems()])
"""The credit entries.""" """The credit line items."""
whole_form = BooleanField(validators=[IsBalanced()]) whole_form = BooleanField(validators=[IsBalanced()])
"""The pseudo field for the whole form validators.""" """The pseudo field for the whole form validators."""
@property @property
def debit_total(self) -> Decimal: def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries. """Returns the total amount of the debit line items.
:return: The total amount of the debit journal entries. :return: The total amount of the debit line items.
""" """
return sum([x.amount.data for x in self.debit return sum([x.amount.data for x in self.debit
if x.amount.data is not None]) if x.amount.data is not None])
@property @property
def credit_total(self) -> Decimal: def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries. """Returns the total amount of the credit line items.
:return: The total amount of the credit journal entries. :return: The total amount of the credit line items.
""" """
return sum([x.amount.data for x in self.credit return sum([x.amount.data for x in self.credit
if x.amount.data is not None]) if x.amount.data is not None])
@property @property
def debit_errors(self) -> list[str | LazyString]: def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their """Returns the debit line item errors, without the errors in their
sub-forms. sub-forms.
:return: :return:
@ -285,7 +288,7 @@ class TransferCurrencyForm(CurrencyForm):
@property @property
def credit_errors(self) -> list[str | LazyString]: def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their """Returns the credit line item errors, without the errors in their
sub-forms. sub-forms.
:return: :return:

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The journal entry sub-forms for the voucher management. """The line item sub-forms for the voucher management.
""" """
import re import re
@ -30,7 +30,7 @@ from wtforms.validators import DataRequired, Optional
from accounting import db from accounting import db
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry from accounting.models import Account, VoucherLineItem
from accounting.template_filters import format_amount from accounting.template_filters import format_amount
from accounting.utils.cast import be from accounting.utils.cast import be
from accounting.utils.random_id import new_id from accounting.utils.random_id import new_id
@ -42,64 +42,67 @@ ACCOUNT_REQUIRED: DataRequired = DataRequired(
"""The validator to check if the account code is empty.""" """The validator to check if the account code is empty."""
class OriginalEntryExists: class OriginalLineItemExists:
"""The validator to check if the original entry exists.""" """The validator to check if the original line item exists."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None: def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None: if field.data is None:
return return
if db.session.get(JournalEntry, field.data) is None: if db.session.get(VoucherLineItem, field.data) is None:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The original entry does not exist.")) "The original line item does not exist."))
class OriginalEntryOppositeSide: class OriginalLineItemOppositeSide:
"""The validator to check if the original entry is on the opposite side.""" """The validator to check if the original line item is on the opposite
side."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None: def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None: if field.data is None:
return return
original_entry: JournalEntry | None \ original_line_item: VoucherLineItem | None \
= db.session.get(JournalEntry, field.data) = db.session.get(VoucherLineItem, field.data)
if original_entry is None: if original_line_item is None:
return return
if isinstance(form, CreditEntryForm) and original_entry.is_debit: if isinstance(form, CreditLineItemForm) \
and original_line_item.is_debit:
return return
if isinstance(form, DebitEntryForm) and not original_entry.is_debit: if isinstance(form, DebitLineItemForm) \
and not original_line_item.is_debit:
return return
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The original entry is on the same side.")) "The original line item is on the same side."))
class OriginalEntryNeedOffset: class OriginalLineItemNeedOffset:
"""The validator to check if the original entry needs offset.""" """The validator to check if the original line item needs offset."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None: def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None: if field.data is None:
return return
original_entry: JournalEntry | None \ original_line_item: VoucherLineItem | None \
= db.session.get(JournalEntry, field.data) = db.session.get(VoucherLineItem, field.data)
if original_entry is None: if original_line_item is None:
return return
if not original_entry.account.is_need_offset: if not original_line_item.account.is_need_offset:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The original entry does not need offset.")) "The original line item does not need offset."))
class OriginalEntryNotOffset: class OriginalLineItemNotOffset:
"""The validator to check if the original entry is not itself an offset """The validator to check if the original line item is not itself an
entry.""" offset item."""
def __call__(self, form: FlaskForm, field: IntegerField) -> None: def __call__(self, form: FlaskForm, field: IntegerField) -> None:
if field.data is None: if field.data is None:
return return
original_entry: JournalEntry | None \ original_line_item: VoucherLineItem | None \
= db.session.get(JournalEntry, field.data) = db.session.get(VoucherLineItem, field.data)
if original_entry is None: if original_line_item is None:
return return
if original_entry.original_entry_id is not None: if original_line_item.original_line_item_id is not None:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The original entry cannot be an offset entry.")) "The original line item cannot be an offset item."))
class AccountExists: class AccountExists:
@ -114,7 +117,7 @@ class AccountExists:
class IsDebitAccount: class IsDebitAccount:
"""The validator to check if the account is for debit journal entries.""" """The validator to check if the account is for debit line items."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None: if field.data is None:
@ -124,11 +127,11 @@ class IsDebitAccount:
and not field.data.startswith("3353-"): and not field.data.startswith("3353-"):
return return
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"This account is not for debit entries.")) "This account is not for debit line items."))
class IsCreditAccount: class IsCreditAccount:
"""The validator to check if the account is for credit journal entries.""" """The validator to check if the account is for credit line items."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None: if field.data is None:
@ -138,73 +141,73 @@ class IsCreditAccount:
and not field.data.startswith("3353-"): and not field.data.startswith("3353-"):
return return
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"This account is not for credit entries.")) "This account is not for credit line items."))
class SameAccountAsOriginalEntry: class SameAccountAsOriginalLineItem:
"""The validator to check if the account is the same as the original """The validator to check if the account is the same as the
entry.""" original line item."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, JournalEntryForm) assert isinstance(form, LineItemForm)
if field.data is None or form.original_entry_id.data is None: if field.data is None or form.original_line_item_id.data is None:
return return
original_entry: JournalEntry | None \ original_line_item: VoucherLineItem | None \
= db.session.get(JournalEntry, form.original_entry_id.data) = db.session.get(VoucherLineItem, form.original_line_item_id.data)
if original_entry is None: if original_line_item is None:
return return
if field.data != original_entry.account_code: if field.data != original_line_item.account_code:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The account must be the same as the original entry.")) "The account must be the same as the original line item."))
class KeepAccountWhenHavingOffset: class KeepAccountWhenHavingOffset:
"""The validator to check if the account is the same when having offset.""" """The validator to check if the account is the same when having offset."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, JournalEntryForm) assert isinstance(form, LineItemForm)
if field.data is None or form.eid.data is None: if field.data is None or form.eid.data is None:
return return
entry: JournalEntry | None = db.session.query(JournalEntry)\ line_item: VoucherLineItem | None = db.session.query(VoucherLineItem)\
.filter(JournalEntry.id == form.eid.data)\ .filter(VoucherLineItem.id == form.eid.data)\
.options(selectinload(JournalEntry.offsets)).first() .options(selectinload(VoucherLineItem.offsets)).first()
if entry is None or len(entry.offsets) == 0: if line_item is None or len(line_item.offsets) == 0:
return return
if field.data != entry.account_code: if field.data != line_item.account_code:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The account must not be changed when there is offset.")) "The account must not be changed when there is offset."))
class NotStartPayableFromDebit: class NotStartPayableFromDebit:
"""The validator to check that a payable journal entry does not start from """The validator to check that a payable line item does not start from
the debit side.""" the debit side."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, DebitEntryForm) assert isinstance(form, DebitLineItemForm)
if field.data is None \ if field.data is None \
or field.data[0] != "2" \ or field.data[0] != "2" \
or form.original_entry_id.data is not None: or form.original_line_item_id.data is not None:
return return
account: Account | None = Account.find_by_code(field.data) account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_need_offset: if account is not None and account.is_need_offset:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"A payable entry cannot start from the debit side.")) "A payable line item cannot start from the debit side."))
class NotStartReceivableFromCredit: class NotStartReceivableFromCredit:
"""The validator to check that a receivable journal entry does not start """The validator to check that a receivable line item does not start
from the credit side.""" from the credit side."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
assert isinstance(form, CreditEntryForm) assert isinstance(form, CreditLineItemForm)
if field.data is None \ if field.data is None \
or field.data[0] != "1" \ or field.data[0] != "1" \
or form.original_entry_id.data is not None: or form.original_line_item_id.data is not None:
return return
account: Account | None = Account.find_by_code(field.data) account: Account | None = Account.find_by_code(field.data)
if account is not None and account.is_need_offset: if account is not None and account.is_need_offset:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"A receivable entry cannot start from the credit side.")) "A receivable line item cannot start from the credit side."))
class PositiveAmount: class PositiveAmount:
@ -218,55 +221,57 @@ class PositiveAmount:
"Please fill in a positive amount.")) "Please fill in a positive amount."))
class NotExceedingOriginalEntryNetBalance: class NotExceedingOriginalLineItemNetBalance:
"""The validator to check if the amount exceeds the net balance of the """The validator to check if the amount exceeds the net balance of the
original entry.""" original line item."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None: def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, JournalEntryForm) assert isinstance(form, LineItemForm)
if field.data is None or form.original_entry_id.data is None: if field.data is None or form.original_line_item_id.data is None:
return return
original_entry: JournalEntry | None \ original_line_item: VoucherLineItem | None \
= db.session.get(JournalEntry, form.original_entry_id.data) = db.session.get(VoucherLineItem, form.original_line_item_id.data)
if original_entry is None: if original_line_item is None:
return return
is_debit: bool = isinstance(form, DebitEntryForm) is_debit: bool = isinstance(form, DebitLineItemForm)
existing_entry_id: set[int] = set() existing_line_item_id: set[int] = set()
if form.voucher_form.obj is not None: if form.voucher_form.obj is not None:
existing_entry_id = {x.id for x in form.voucher_form.obj.entries} existing_line_item_id \
= {x.id for x in form.voucher_form.obj.line_items}
offset_total_func: sa.Function = sa.func.sum(sa.case( offset_total_func: sa.Function = sa.func.sum(sa.case(
(be(JournalEntry.is_debit == is_debit), JournalEntry.amount), (be(VoucherLineItem.is_debit == is_debit), VoucherLineItem.amount),
else_=-JournalEntry.amount)) else_=-VoucherLineItem.amount))
offset_total_but_form: Decimal | None = db.session.scalar( offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func) sa.select(offset_total_func)
.filter(be(JournalEntry.original_entry_id == original_entry.id), .filter(be(VoucherLineItem.original_line_item_id
JournalEntry.id.not_in(existing_entry_id))) == original_line_item.id),
VoucherLineItem.id.not_in(existing_line_item_id)))
if offset_total_but_form is None: if offset_total_but_form is None:
offset_total_but_form = Decimal("0") offset_total_but_form = Decimal("0")
offset_total_on_form: Decimal = sum( offset_total_on_form: Decimal = sum(
[x.amount.data for x in form.voucher_form.entries [x.amount.data for x in form.voucher_form.line_items
if x.original_entry_id.data == original_entry.id if x.original_line_item_id.data == original_line_item.id
and x.amount != field and x.amount.data is not None]) and x.amount != field and x.amount.data is not None])
net_balance: Decimal = original_entry.amount - offset_total_but_form \ net_balance: Decimal = original_line_item.amount \
- offset_total_on_form - offset_total_but_form - offset_total_on_form
if field.data > net_balance: if field.data > net_balance:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The amount must not exceed the net balance %(balance)s of the" "The amount must not exceed the net balance %(balance)s of the"
" original entry.", balance=format_amount(net_balance))) " original line item.", balance=format_amount(net_balance)))
class NotLessThanOffsetTotal: class NotLessThanOffsetTotal:
"""The validator to check if the amount is less than the offset total.""" """The validator to check if the amount is less than the offset total."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None: def __call__(self, form: FlaskForm, field: DecimalField) -> None:
assert isinstance(form, JournalEntryForm) assert isinstance(form, LineItemForm)
if field.data is None or form.eid.data is None: if field.data is None or form.eid.data is None:
return return
is_debit: bool = isinstance(form, DebitEntryForm) is_debit: bool = isinstance(form, DebitLineItemForm)
select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case( select_offset_total: sa.Select = sa.select(sa.func.sum(sa.case(
(JournalEntry.is_debit != is_debit, JournalEntry.amount), (VoucherLineItem.is_debit != is_debit, VoucherLineItem.amount),
else_=-JournalEntry.amount)))\ else_=-VoucherLineItem.amount)))\
.filter(be(JournalEntry.original_entry_id == form.eid.data)) .filter(be(VoucherLineItem.original_line_item_id == form.eid.data))
offset_total: Decimal | None = db.session.scalar(select_offset_total) offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total: if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
@ -274,21 +279,21 @@ class NotLessThanOffsetTotal:
total=format_amount(offset_total))) total=format_amount(offset_total)))
class JournalEntryForm(FlaskForm): class LineItemForm(FlaskForm):
"""The base form to create or edit a journal entry.""" """The base form to create or edit a line item."""
eid = IntegerField() eid = IntegerField()
"""The existing journal entry ID.""" """The existing line item ID."""
no = IntegerField() no = IntegerField()
"""The order in the currency.""" """The order in the currency."""
original_entry_id = IntegerField() original_line_item_id = IntegerField()
"""The Id of the original entry.""" """The Id of the original line item."""
account_code = StringField() account_code = StringField()
"""The account code.""" """The account code."""
amount = DecimalField() amount = DecimalField()
"""The amount.""" """The amount."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Constructs a base journal entry form. """Constructs a base line item form.
:param args: The arguments. :param args: The arguments.
:param kwargs: The keyword arguments. :param kwargs: The keyword arguments.
@ -312,51 +317,51 @@ class JournalEntryForm(FlaskForm):
return str(account) return str(account)
@property @property
def __original_entry(self) -> JournalEntry | None: def __original_line_item(self) -> VoucherLineItem | None:
"""Returns the original entry. """Returns the original line item.
:return: The original entry. :return: The original line item.
""" """
if not hasattr(self, "____original_entry"): if not hasattr(self, "____original_line_item"):
def get_entry() -> JournalEntry | None: def get_line_item() -> VoucherLineItem | None:
if self.original_entry_id.data is None: if self.original_line_item_id.data is None:
return None return None
return db.session.get(JournalEntry, return db.session.get(VoucherLineItem,
self.original_entry_id.data) self.original_line_item_id.data)
setattr(self, "____original_entry", get_entry()) setattr(self, "____original_line_item", get_line_item())
return getattr(self, "____original_entry") return getattr(self, "____original_line_item")
@property @property
def original_entry_date(self) -> date | None: def original_line_item_date(self) -> date | None:
"""Returns the text representation of the original entry. """Returns the text representation of the original line item.
:return: The text representation of the original entry. :return: The text representation of the original line item.
""" """
return None if self.__original_entry is None \ return None if self.__original_line_item is None \
else self.__original_entry.voucher.date else self.__original_line_item.voucher.date
@property @property
def original_entry_text(self) -> str | None: def original_line_item_text(self) -> str | None:
"""Returns the text representation of the original entry. """Returns the text representation of the original line item.
:return: The text representation of the original entry. :return: The text representation of the original line item.
""" """
return None if self.__original_entry is None \ return None if self.__original_line_item is None \
else str(self.__original_entry) else str(self.__original_line_item)
@property @property
def is_need_offset(self) -> bool: def is_need_offset(self) -> bool:
"""Returns whether the entry needs offset. """Returns whether the line item needs offset.
:return: True if the entry needs offset, or False otherwise. :return: True if the line item needs offset, or False otherwise.
""" """
if self.account_code.data is None: if self.account_code.data is None:
return False return False
if self.account_code.data[0] == "1": if self.account_code.data[0] == "1":
if isinstance(self, CreditEntryForm): if isinstance(self, CreditLineItemForm):
return False return False
elif self.account_code.data[0] == "2": elif self.account_code.data[0] == "2":
if isinstance(self, DebitEntryForm): if isinstance(self, DebitLineItemForm):
return False return False
else: else:
return False return False
@ -364,21 +369,22 @@ class JournalEntryForm(FlaskForm):
return account is not None and account.is_need_offset return account is not None and account.is_need_offset
@property @property
def offsets(self) -> list[JournalEntry]: def offsets(self) -> list[VoucherLineItem]:
"""Returns the offsets. """Returns the offsets.
:return: The offsets. :return: The offsets.
""" """
if not hasattr(self, "__offsets"): if not hasattr(self, "__offsets"):
def get_offsets() -> list[JournalEntry]: def get_offsets() -> list[VoucherLineItem]:
if not self.is_need_offset or self.eid.data is None: if not self.is_need_offset or self.eid.data is None:
return [] return []
return JournalEntry.query\ return VoucherLineItem.query\
.filter(JournalEntry.original_entry_id == self.eid.data)\ .filter(VoucherLineItem.original_line_item_id
.options(selectinload(JournalEntry.voucher), == self.eid.data)\
selectinload(JournalEntry.account), .options(selectinload(VoucherLineItem.voucher),
selectinload(JournalEntry.offsets) selectinload(VoucherLineItem.account),
.selectinload(JournalEntry.voucher)).all() selectinload(VoucherLineItem.offsets)
.selectinload(VoucherLineItem.voucher)).all()
setattr(self, "__offsets", get_offsets()) setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets") return getattr(self, "__offsets")
@ -392,7 +398,7 @@ class JournalEntryForm(FlaskForm):
def get_offset_total(): def get_offset_total():
if not self.is_need_offset or self.eid.data is None: if not self.is_need_offset or self.eid.data is None:
return None return None
is_debit: bool = isinstance(self, DebitEntryForm) is_debit: bool = isinstance(self, DebitLineItemForm)
return sum([x.amount if x.is_debit != is_debit else -x.amount return sum([x.amount if x.is_debit != is_debit else -x.amount
for x in self.offsets]) for x in self.offsets])
setattr(self, "__offset_total", get_offset_total()) setattr(self, "__offset_total", get_offset_total())
@ -422,48 +428,48 @@ class JournalEntryForm(FlaskForm):
return all_errors return all_errors
class DebitEntryForm(JournalEntryForm): class DebitLineItemForm(LineItemForm):
"""The form to create or edit a debit journal entry.""" """The form to create or edit a debit line item."""
eid = IntegerField() eid = IntegerField()
"""The existing journal entry ID.""" """The existing line item ID."""
no = IntegerField() no = IntegerField()
"""The order in the currency.""" """The order in the currency."""
original_entry_id = IntegerField( original_line_item_id = IntegerField(
validators=[Optional(), validators=[Optional(),
OriginalEntryExists(), OriginalLineItemExists(),
OriginalEntryOppositeSide(), OriginalLineItemOppositeSide(),
OriginalEntryNeedOffset(), OriginalLineItemNeedOffset(),
OriginalEntryNotOffset()]) OriginalLineItemNotOffset()])
"""The Id of the original entry.""" """The ID of the original line item."""
account_code = StringField( account_code = StringField(
filters=[strip_text], filters=[strip_text],
validators=[ACCOUNT_REQUIRED, validators=[ACCOUNT_REQUIRED,
AccountExists(), AccountExists(),
IsDebitAccount(), IsDebitAccount(),
SameAccountAsOriginalEntry(), SameAccountAsOriginalLineItem(),
KeepAccountWhenHavingOffset(), KeepAccountWhenHavingOffset(),
NotStartPayableFromDebit()]) NotStartPayableFromDebit()])
"""The account code.""" """The account code."""
offset_original_entry_id = IntegerField() offset_original_line_item_id = IntegerField()
"""The Id of the original entry.""" """The ID of the original line item."""
summary = StringField(filters=[strip_text]) summary = StringField(filters=[strip_text])
"""The summary.""" """The summary."""
amount = DecimalField( amount = DecimalField(
validators=[PositiveAmount(), validators=[PositiveAmount(),
NotExceedingOriginalEntryNetBalance(), NotExceedingOriginalLineItemNetBalance(),
NotLessThanOffsetTotal()]) NotLessThanOffsetTotal()])
"""The amount.""" """The amount."""
def populate_obj(self, obj: JournalEntry) -> None: def populate_obj(self, obj: VoucherLineItem) -> None:
"""Populates the form data into a journal entry object. """Populates the form data into a line item object.
:param obj: The journal entry object. :param obj: The line item object.
:return: None. :return: None.
""" """
is_new: bool = obj.id is None is_new: bool = obj.id is None
if is_new: if is_new:
obj.id = new_id(JournalEntry) obj.id = new_id(VoucherLineItem)
obj.original_entry_id = self.original_entry_id.data obj.original_line_item_id = self.original_line_item_id.data
obj.account_id = Account.find_by_code(self.account_code.data).id obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data obj.summary = self.summary.data
obj.is_debit = True obj.is_debit = True
@ -474,25 +480,25 @@ class DebitEntryForm(JournalEntryForm):
obj.updated_by_id = current_user_pk obj.updated_by_id = current_user_pk
class CreditEntryForm(JournalEntryForm): class CreditLineItemForm(LineItemForm):
"""The form to create or edit a credit journal entry.""" """The form to create or edit a credit line item."""
eid = IntegerField() eid = IntegerField()
"""The existing journal entry ID.""" """The existing line item ID."""
no = IntegerField() no = IntegerField()
"""The order in the currency.""" """The order in the currency."""
original_entry_id = IntegerField( original_line_item_id = IntegerField(
validators=[Optional(), validators=[Optional(),
OriginalEntryExists(), OriginalLineItemExists(),
OriginalEntryOppositeSide(), OriginalLineItemOppositeSide(),
OriginalEntryNeedOffset(), OriginalLineItemNeedOffset(),
OriginalEntryNotOffset()]) OriginalLineItemNotOffset()])
"""The Id of the original entry.""" """The ID of the original line item."""
account_code = StringField( account_code = StringField(
filters=[strip_text], filters=[strip_text],
validators=[ACCOUNT_REQUIRED, validators=[ACCOUNT_REQUIRED,
AccountExists(), AccountExists(),
IsCreditAccount(), IsCreditAccount(),
SameAccountAsOriginalEntry(), SameAccountAsOriginalLineItem(),
KeepAccountWhenHavingOffset(), KeepAccountWhenHavingOffset(),
NotStartReceivableFromCredit()]) NotStartReceivableFromCredit()])
"""The account code.""" """The account code."""
@ -500,20 +506,20 @@ class CreditEntryForm(JournalEntryForm):
"""The summary.""" """The summary."""
amount = DecimalField( amount = DecimalField(
validators=[PositiveAmount(), validators=[PositiveAmount(),
NotExceedingOriginalEntryNetBalance(), NotExceedingOriginalLineItemNetBalance(),
NotLessThanOffsetTotal()]) NotLessThanOffsetTotal()])
"""The amount.""" """The amount."""
def populate_obj(self, obj: JournalEntry) -> None: def populate_obj(self, obj: VoucherLineItem) -> None:
"""Populates the form data into a journal entry object. """Populates the form data into a line item object.
:param obj: The journal entry object. :param obj: The line item object.
:return: None. :return: None.
""" """
is_new: bool = obj.id is None is_new: bool = obj.id is None
if is_new: if is_new:
obj.id = new_id(JournalEntry) obj.id = new_id(VoucherLineItem)
obj.original_entry_id = self.original_entry_id.data obj.original_line_item_id = self.original_line_item_id.data
obj.account_id = Account.find_by_code(self.account_code.data).id obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data obj.summary = self.summary.data
obj.is_debit = False obj.is_debit = False

View File

@ -30,18 +30,18 @@ from wtforms.validators import DataRequired, ValidationError
from accounting import db from accounting import db
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Voucher, Account, JournalEntry, \ from accounting.models import Voucher, Account, VoucherLineItem, \
VoucherCurrency VoucherCurrency
from accounting.voucher.utils.account_option import AccountOption from accounting.voucher.utils.account_option import AccountOption
from accounting.voucher.utils.original_entries import \ from accounting.voucher.utils.original_line_items import \
get_selectable_original_entries get_selectable_original_line_items
from accounting.voucher.utils.summary_editor import SummaryEditor from accounting.voucher.utils.summary_editor import SummaryEditor
from accounting.utils.random_id import new_id from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .currency import CurrencyForm, CashReceiptCurrencyForm, \ from .currency import CurrencyForm, CashReceiptCurrencyForm, \
CashDisbursementCurrencyForm, TransferCurrencyForm CashDisbursementCurrencyForm, TransferCurrencyForm
from .journal_entry import JournalEntryForm, DebitEntryForm, CreditEntryForm from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
from .reorder import sort_vouchers_in from .reorder import sort_vouchers_in
DATE_REQUIRED: DataRequired = DataRequired( DATE_REQUIRED: DataRequired = DataRequired(
@ -49,9 +49,9 @@ DATE_REQUIRED: DataRequired = DataRequired(
"""The validator to check if the date is empty.""" """The validator to check if the date is empty."""
class NotBeforeOriginalEntries: class NotBeforeOriginalLineItems:
"""The validator to check if the date is not before the original """The validator to check if the date is not before the
entries.""" original line items."""
def __call__(self, form: FlaskForm, field: DateField) -> None: def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, VoucherForm) assert isinstance(form, VoucherForm)
@ -62,11 +62,11 @@ class NotBeforeOriginalEntries:
return return
if field.data < min_date: if field.data < min_date:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The date cannot be earlier than the original entries.")) "The date cannot be earlier than the original line items."))
class NotAfterOffsetEntries: class NotAfterOffsetItems:
"""The validator to check if the date is not after the offset entries.""" """The validator to check if the date is not after the offset items."""
def __call__(self, form: FlaskForm, field: DateField) -> None: def __call__(self, form: FlaskForm, field: DateField) -> None:
assert isinstance(form, VoucherForm) assert isinstance(form, VoucherForm)
@ -77,7 +77,7 @@ class NotAfterOffsetEntries:
return return
if field.data > max_date: if field.data > max_date:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"The date cannot be later than the offset entries.")) "The date cannot be later than the offset items."))
class NeedSomeCurrencies: class NeedSomeCurrencies:
@ -88,21 +88,21 @@ class NeedSomeCurrencies:
raise ValidationError(lazy_gettext("Please add some currencies.")) raise ValidationError(lazy_gettext("Please add some currencies."))
class CannotDeleteOriginalEntriesWithOffset: class CannotDeleteOriginalLineItemsWithOffset:
"""The validator to check the original entries with offset.""" """The validator to check the original line items with offset."""
def __call__(self, form: FlaskForm, field: FieldList) -> None: def __call__(self, form: FlaskForm, field: FieldList) -> None:
assert isinstance(form, VoucherForm) assert isinstance(form, VoucherForm)
if form.obj is None: if form.obj is None:
return return
existing_matched_original_entry_id: set[int] \ existing_matched_original_line_item_id: set[int] \
= {x.id for x in form.obj.entries if len(x.offsets) > 0} = {x.id for x in form.obj.line_items if len(x.offsets) > 0}
entry_id_in_form: set[int] \ line_item_id_in_form: set[int] \
= {x.eid.data for x in form.entries if x.eid.data is not None} = {x.eid.data for x in form.line_items if x.eid.data is not None}
for entry_id in existing_matched_original_entry_id: for line_item_id in existing_matched_original_line_item_id:
if entry_id not in entry_id_in_form: if line_item_id not in line_item_id_in_form:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
"Journal entries with offset cannot be deleted.")) "Line items with offset cannot be deleted."))
class VoucherForm(FlaskForm): class VoucherForm(FlaskForm):
@ -110,7 +110,7 @@ class VoucherForm(FlaskForm):
date = DateField() date = DateField()
"""The date.""" """The date."""
currencies = FieldList(FormField(CurrencyForm)) currencies = FieldList(FormField(CurrencyForm))
"""The journal entries categorized by their currencies.""" """The line items categorized by their currencies."""
note = TextAreaField() note = TextAreaField()
"""The note.""" """The note."""
@ -123,23 +123,23 @@ class VoucherForm(FlaskForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.is_modified: bool = False self.is_modified: bool = False
"""Whether the voucher is modified during populate_obj().""" """Whether the voucher is modified during populate_obj()."""
self.collector: t.Type[JournalEntryCollector] = JournalEntryCollector self.collector: t.Type[LineItemCollector] = LineItemCollector
"""The journal entry collector. The default is the base abstract """The line item collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should collector only to provide the correct type. The subclass forms should
provide their own collectors.""" provide their own collectors."""
self.obj: Voucher | None = kwargs.get("obj") self.obj: Voucher | None = kwargs.get("obj")
"""The voucher, when editing an existing one.""" """The voucher, when editing an existing one."""
self._is_need_payable: bool = False self._is_need_payable: bool = False
"""Whether we need the payable original entries.""" """Whether we need the payable original line items."""
self._is_need_receivable: bool = False self._is_need_receivable: bool = False
"""Whether we need the receivable original entries.""" """Whether we need the receivable original line items."""
self.__original_entry_options: list[JournalEntry] | None = None self.__original_line_item_options: list[VoucherLineItem] | None = None
"""The options of the original entries.""" """The options of the original line items."""
self.__net_balance_exceeded: dict[int, LazyString] | None = None self.__net_balance_exceeded: dict[int, LazyString] | None = None
"""The original entries whose net balances were exceeded by the """The original line items whose net balances were exceeded by the
amounts in the journal entry sub-forms.""" amounts in the line item sub-forms."""
for entry in self.entries: for line_item in self.line_items:
entry.voucher_form = self line_item.voucher_form = self
def populate_obj(self, obj: Voucher) -> None: def populate_obj(self, obj: Voucher) -> None:
"""Populates the form data into a voucher object. """Populates the form data into a voucher object.
@ -154,14 +154,15 @@ class VoucherForm(FlaskForm):
self.__set_date(obj, self.date.data) self.__set_date(obj, self.date.data)
obj.note = self.note.data obj.note = self.note.data
collector_cls: t.Type[JournalEntryCollector] = self.collector collector_cls: t.Type[LineItemCollector] = self.collector
collector: collector_cls = collector_cls(self, obj) collector: collector_cls = collector_cls(self, obj)
collector.collect() collector.collect()
to_delete: set[int] = {x.id for x in obj.entries to_delete: set[int] = {x.id for x in obj.line_items
if x.id not in collector.to_keep} if x.id not in collector.to_keep}
if len(to_delete) > 0: if len(to_delete) > 0:
JournalEntry.query.filter(JournalEntry.id.in_(to_delete)).delete() VoucherLineItem.query\
.filter(VoucherLineItem.id.in_(to_delete)).delete()
self.is_modified = True self.is_modified = True
if is_new or db.session.is_modified(obj): if is_new or db.session.is_modified(obj):
@ -173,15 +174,15 @@ class VoucherForm(FlaskForm):
obj.updated_by_id = current_user_pk obj.updated_by_id = current_user_pk
@property @property
def entries(self) -> list[JournalEntryForm]: def line_items(self) -> list[LineItemForm]:
"""Collects and returns the journal entry sub-forms. """Collects and returns the line item sub-forms.
:return: The journal entry sub-forms. :return: The line item sub-forms.
""" """
entries: list[JournalEntryForm] = [] line_items: list[LineItemForm] = []
for currency in self.currencies: for currency in self.currencies:
entries.extend(currency.entries) line_items.extend(currency.line_items)
return entries return line_items
def __set_date(self, obj: Voucher, new_date: dt.date) -> None: def __set_date(self, obj: Voucher, new_date: dt.date) -> None:
"""Sets the voucher date and number. """Sets the voucher date and number.
@ -221,9 +222,9 @@ class VoucherForm(FlaskForm):
= [AccountOption(x) for x in Account.debit() = [AccountOption(x) for x in Account.debit()
if not (x.code[0] == "2" and x.is_need_offset)] if not (x.code[0] == "2" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars( in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id) sa.select(VoucherLineItem.account_id)
.filter(JournalEntry.is_debit) .filter(VoucherLineItem.is_debit)
.group_by(JournalEntry.account_id)).all()) .group_by(VoucherLineItem.account_id)).all())
for account in accounts: for account in accounts:
account.is_in_use = account.id in in_use account.is_in_use = account.id in in_use
return accounts return accounts
@ -238,9 +239,9 @@ class VoucherForm(FlaskForm):
= [AccountOption(x) for x in Account.credit() = [AccountOption(x) for x in Account.credit()
if not (x.code[0] == "1" and x.is_need_offset)] if not (x.code[0] == "1" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars( in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntry.account_id) sa.select(VoucherLineItem.account_id)
.filter(sa.not_(JournalEntry.is_debit)) .filter(sa.not_(VoucherLineItem.is_debit))
.group_by(JournalEntry.account_id)).all()) .group_by(VoucherLineItem.account_id)).all())
for account in accounts: for account in accounts:
account.is_in_use = account.id in in_use account.is_in_use = account.id in in_use
return accounts return accounts
@ -263,16 +264,18 @@ class VoucherForm(FlaskForm):
return SummaryEditor() return SummaryEditor()
@property @property
def original_entry_options(self) -> list[JournalEntry]: def original_line_item_options(self) -> list[VoucherLineItem]:
"""Returns the selectable original entries. """Returns the selectable original line items.
:return: The selectable original entries. :return: The selectable original line items.
""" """
if self.__original_entry_options is None: if self.__original_line_item_options is None:
self.__original_entry_options = get_selectable_original_entries( self.__original_line_item_options \
{x.eid.data for x in self.entries if x.eid.data is not None}, = get_selectable_original_line_items(
self._is_need_payable, self._is_need_receivable) {x.eid.data for x in self.line_items
return self.__original_entry_options if x.eid.data is not None},
self._is_need_payable, self._is_need_receivable)
return self.__original_line_item_options
@property @property
def min_date(self) -> dt.date | None: def min_date(self) -> dt.date | None:
@ -280,13 +283,14 @@ class VoucherForm(FlaskForm):
:return: The minimal available date. :return: The minimal available date.
""" """
original_entry_id: set[int] \ original_line_item_id: set[int] \
= {x.original_entry_id.data for x in self.entries = {x.original_line_item_id.data for x in self.line_items
if x.original_entry_id.data is not None} if x.original_line_item_id.data is not None}
if len(original_entry_id) == 0: if len(original_line_item_id) == 0:
return None return None
select: sa.Select = sa.select(sa.func.max(Voucher.date))\ select: sa.Select = sa.select(sa.func.max(Voucher.date))\
.join(JournalEntry).filter(JournalEntry.id.in_(original_entry_id)) .join(VoucherLineItem)\
.filter(VoucherLineItem.id.in_(original_line_item_id))
return db.session.scalar(select) return db.session.scalar(select)
@property @property
@ -295,11 +299,11 @@ class VoucherForm(FlaskForm):
:return: The maximum available date. :return: The maximum available date.
""" """
entry_id: set[int] = {x.eid.data for x in self.entries line_item_id: set[int] = {x.eid.data for x in self.line_items
if x.eid.data is not None} if x.eid.data is not None}
select: sa.Select = sa.select(sa.func.min(Voucher.date))\ select: sa.Select = sa.select(sa.func.min(Voucher.date))\
.join(JournalEntry)\ .join(VoucherLineItem)\
.filter(JournalEntry.original_entry_id.in_(entry_id)) .filter(VoucherLineItem.original_line_item_id.in_(line_item_id))
return db.session.scalar(select) return db.session.scalar(select)
@ -307,11 +311,11 @@ T = t.TypeVar("T", bound=VoucherForm)
"""A voucher form variant.""" """A voucher form variant."""
class JournalEntryCollector(t.Generic[T], ABC): class LineItemCollector(t.Generic[T], ABC):
"""The journal entry collector.""" """The line item collector."""
def __init__(self, form: T, obj: Voucher): def __init__(self, form: T, obj: Voucher):
"""Constructs the journal entry collector. """Constructs the line item collector.
:param form: The voucher form. :param form: The voucher form.
:param obj: The voucher. :param obj: The voucher.
@ -320,101 +324,103 @@ class JournalEntryCollector(t.Generic[T], ABC):
"""The voucher form.""" """The voucher form."""
self.__obj: Voucher = obj self.__obj: Voucher = obj
"""The voucher object.""" """The voucher object."""
self.__entries: list[JournalEntry] = list(obj.entries) self.__line_items: list[VoucherLineItem] = list(obj.line_items)
"""The existing journal entries.""" """The existing line items."""
self.__entries_by_id: dict[int, JournalEntry] \ self.__line_items_by_id: dict[int, VoucherLineItem] \
= {x.id: x for x in self.__entries} = {x.id: x for x in self.__line_items}
"""A dictionary from the entry ID to their entries.""" """A dictionary from the line item ID to their line items."""
self.__no_by_id: dict[int, int] = {x.id: x.no for x in self.__entries} self.__no_by_id: dict[int, int] \
"""A dictionary from the entry number to their entries.""" = {x.id: x.no for x in self.__line_items}
"""A dictionary from the line item number to their line items."""
self.__currencies: list[VoucherCurrency] = obj.currencies self.__currencies: list[VoucherCurrency] = obj.currencies
"""The currencies in the voucher.""" """The currencies in the voucher."""
self._debit_no: int = 1 self._debit_no: int = 1
"""The number index for the debit entries.""" """The number index for the debit line items."""
self._credit_no: int = 1 self._credit_no: int = 1
"""The number index for the credit entries.""" """The number index for the credit line items."""
self.to_keep: set[int] = set() self.to_keep: set[int] = set()
"""The ID of the existing journal entries to keep.""" """The ID of the existing line items to keep."""
@abstractmethod @abstractmethod
def collect(self) -> set[int]: def collect(self) -> set[int]:
"""Collects the journal entries. """Collects the line items.
:return: The ID of the journal entries to keep. :return: The ID of the line items to keep.
""" """
def _add_entry(self, form: JournalEntryForm, currency_code: str, no: int) \ def _add_line_item(self, form: LineItemForm, currency_code: str, no: int) \
-> None: -> None:
"""Composes a journal entry from the form. """Composes a line item from the form.
:param form: The journal entry form. :param form: The line item form.
:param currency_code: The code of the currency. :param currency_code: The code of the currency.
:param no: The number of the entry. :param no: The number of the line item.
:return: None. :return: None.
""" """
entry: JournalEntry | None = self.__entries_by_id.get(form.eid.data) line_item: VoucherLineItem | None \
if entry is not None: = self.__line_items_by_id.get(form.eid.data)
entry.currency_code = currency_code if line_item is not None:
form.populate_obj(entry) line_item.currency_code = currency_code
entry.no = no form.populate_obj(line_item)
if db.session.is_modified(entry): line_item.no = no
if db.session.is_modified(line_item):
self.form.is_modified = True self.form.is_modified = True
else: else:
entry = JournalEntry() line_item = VoucherLineItem()
entry.currency_code = currency_code line_item.currency_code = currency_code
form.populate_obj(entry) form.populate_obj(line_item)
entry.no = no line_item.no = no
self.__obj.entries.append(entry) self.__obj.line_items.append(line_item)
self.form.is_modified = True self.form.is_modified = True
self.to_keep.add(entry.id) self.to_keep.add(line_item.id)
def _make_cash_entry(self, forms: list[JournalEntryForm], is_debit: bool, def _make_cash_line_item(self, forms: list[LineItemForm], is_debit: bool,
currency_code: str, no: int) -> None: currency_code: str, no: int) -> None:
"""Composes the cash journal entry at the other side of the cash """Composes the cash line item at the other side of the cash
voucher. voucher.
:param forms: The journal entry forms in the same currency. :param forms: The line item forms in the same currency.
:param is_debit: True for a cash receipt voucher, or False for a :param is_debit: True for a cash receipt voucher, or False for a
cash disbursement voucher. cash disbursement voucher.
:param currency_code: The code of the currency. :param currency_code: The code of the currency.
:param no: The number of the entry. :param no: The number of the line item.
:return: None. :return: None.
""" """
candidates: list[JournalEntry] = [x for x in self.__entries candidates: list[VoucherLineItem] \
if x.is_debit == is_debit = [x for x in self.__line_items
and x.currency_code == currency_code] if x.is_debit == is_debit and x.currency_code == currency_code]
entry: JournalEntry line_item: VoucherLineItem
if len(candidates) > 0: if len(candidates) > 0:
candidates.sort(key=lambda x: x.no) candidates.sort(key=lambda x: x.no)
entry = candidates[0] line_item = candidates[0]
entry.account_id = Account.cash().id line_item.account_id = Account.cash().id
entry.summary = None line_item.summary = None
entry.amount = sum([x.amount.data for x in forms]) line_item.amount = sum([x.amount.data for x in forms])
entry.no = no line_item.no = no
if db.session.is_modified(entry): if db.session.is_modified(line_item):
self.form.is_modified = True self.form.is_modified = True
else: else:
entry = JournalEntry() line_item = VoucherLineItem()
entry.id = new_id(JournalEntry) line_item.id = new_id(VoucherLineItem)
entry.is_debit = is_debit line_item.is_debit = is_debit
entry.currency_code = currency_code line_item.currency_code = currency_code
entry.account_id = Account.cash().id line_item.account_id = Account.cash().id
entry.summary = None line_item.summary = None
entry.amount = sum([x.amount.data for x in forms]) line_item.amount = sum([x.amount.data for x in forms])
entry.no = no line_item.no = no
self.__obj.entries.append(entry) self.__obj.line_items.append(line_item)
self.form.is_modified = True self.form.is_modified = True
self.to_keep.add(entry.id) self.to_keep.add(line_item.id)
def _sort_entry_forms(self, forms: list[JournalEntryForm]) -> None: def _sort_line_item_forms(self, forms: list[LineItemForm]) -> None:
"""Sorts the journal entry forms. """Sorts the line item sub-forms.
:param forms: The journal entry forms. :param forms: The line item sub-forms.
:return: None. :return: None.
""" """
missing_no: int = 100 if len(self.__no_by_id) == 0 \ missing_no: int = 100 if len(self.__no_by_id) == 0 \
else max(self.__no_by_id.values()) + 100 else max(self.__no_by_id.values()) + 100
ord_by_form: dict[JournalEntryForm, int] \ ord_by_form: dict[LineItemForm, int] \
= {forms[i]: i for i in range(len(forms))} = {forms[i]: i for i in range(len(forms))}
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None} recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100 missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
@ -445,42 +451,43 @@ class CashReceiptVoucherForm(VoucherForm):
"""The form to create or edit a cash receipt voucher.""" """The form to create or edit a cash receipt voucher."""
date = DateField( date = DateField(
validators=[DATE_REQUIRED, validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(), NotBeforeOriginalLineItems(),
NotAfterOffsetEntries()]) NotAfterOffsetItems()])
"""The date.""" """The date."""
currencies = FieldList(FormField(CashReceiptCurrencyForm), name="currency", currencies = FieldList(FormField(CashReceiptCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()]) validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies.""" """The line items categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text]) note = TextAreaField(filters=[strip_multiline_text])
"""The note.""" """The note."""
whole_form = BooleanField( whole_form = BooleanField(
validators=[CannotDeleteOriginalEntriesWithOffset()]) validators=[CannotDeleteOriginalLineItemsWithOffset()])
"""The pseudo field for the whole form validators.""" """The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._is_need_receivable = True self._is_need_receivable = True
class Collector(JournalEntryCollector[CashReceiptVoucherForm]): class Collector(LineItemCollector[CashReceiptVoucherForm]):
"""The journal entry collector for the cash receipt vouchers.""" """The line item collector for the cash receipt vouchers."""
def collect(self) -> None: def collect(self) -> None:
currencies: list[CashReceiptCurrencyForm] \ currencies: list[CashReceiptCurrencyForm] \
= [x.form for x in self.form.currencies] = [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies) self._sort_currency_forms(currencies)
for currency in currencies: for currency in currencies:
# The debit cash entry # The debit cash line item
self._make_cash_entry(list(currency.credit), True, self._make_cash_line_item(list(currency.credit), True,
currency.code.data, self._debit_no) currency.code.data,
self._debit_no)
self._debit_no = self._debit_no + 1 self._debit_no = self._debit_no + 1
# The credit forms # The credit forms
credit_forms: list[CreditEntryForm] \ credit_forms: list[CreditLineItemForm] \
= [x.form for x in currency.credit] = [x.form for x in currency.credit]
self._sort_entry_forms(credit_forms) self._sort_line_item_forms(credit_forms)
for credit_form in credit_forms: for credit_form in credit_forms:
self._add_entry(credit_form, currency.code.data, self._add_line_item(credit_form, currency.code.data,
self._credit_no) self._credit_no)
self._credit_no = self._credit_no + 1 self._credit_no = self._credit_no + 1
self.collector = Collector self.collector = Collector
@ -490,26 +497,25 @@ class CashDisbursementVoucherForm(VoucherForm):
"""The form to create or edit a cash disbursement voucher.""" """The form to create or edit a cash disbursement voucher."""
date = DateField( date = DateField(
validators=[DATE_REQUIRED, validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(), NotBeforeOriginalLineItems(),
NotAfterOffsetEntries()]) NotAfterOffsetItems()])
"""The date.""" """The date."""
currencies = FieldList(FormField(CashDisbursementCurrencyForm), currencies = FieldList(FormField(CashDisbursementCurrencyForm),
name="currency", name="currency",
validators=[NeedSomeCurrencies()]) validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies.""" """The line items categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text]) note = TextAreaField(filters=[strip_multiline_text])
"""The note.""" """The note."""
whole_form = BooleanField( whole_form = BooleanField(
validators=[CannotDeleteOriginalEntriesWithOffset()]) validators=[CannotDeleteOriginalLineItemsWithOffset()])
"""The pseudo field for the whole form validators.""" """The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._is_need_payable = True self._is_need_payable = True
class Collector(JournalEntryCollector[CashDisbursementVoucherForm]): class Collector(LineItemCollector[CashDisbursementVoucherForm]):
"""The journal entry collector for the cash disbursement """The line item collector for the cash disbursement vouchers."""
vouchers."""
def collect(self) -> None: def collect(self) -> None:
currencies: list[CashDisbursementCurrencyForm] \ currencies: list[CashDisbursementCurrencyForm] \
@ -517,17 +523,18 @@ class CashDisbursementVoucherForm(VoucherForm):
self._sort_currency_forms(currencies) self._sort_currency_forms(currencies)
for currency in currencies: for currency in currencies:
# The debit forms # The debit forms
debit_forms: list[DebitEntryForm] \ debit_forms: list[DebitLineItemForm] \
= [x.form for x in currency.debit] = [x.form for x in currency.debit]
self._sort_entry_forms(debit_forms) self._sort_line_item_forms(debit_forms)
for debit_form in debit_forms: for debit_form in debit_forms:
self._add_entry(debit_form, currency.code.data, self._add_line_item(debit_form, currency.code.data,
self._debit_no) self._debit_no)
self._debit_no = self._debit_no + 1 self._debit_no = self._debit_no + 1
# The credit forms # The credit forms
self._make_cash_entry(list(currency.debit), False, self._make_cash_line_item(list(currency.debit), False,
currency.code.data, self._credit_no) currency.code.data,
self._credit_no)
self._credit_no = self._credit_no + 1 self._credit_no = self._credit_no + 1
self.collector = Collector self.collector = Collector
@ -537,16 +544,16 @@ class TransferVoucherForm(VoucherForm):
"""The form to create or edit a transfer voucher.""" """The form to create or edit a transfer voucher."""
date = DateField( date = DateField(
validators=[DATE_REQUIRED, validators=[DATE_REQUIRED,
NotBeforeOriginalEntries(), NotBeforeOriginalLineItems(),
NotAfterOffsetEntries()]) NotAfterOffsetItems()])
"""The date.""" """The date."""
currencies = FieldList(FormField(TransferCurrencyForm), name="currency", currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()]) validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies.""" """The line items categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text]) note = TextAreaField(filters=[strip_multiline_text])
"""The note.""" """The note."""
whole_form = BooleanField( whole_form = BooleanField(
validators=[CannotDeleteOriginalEntriesWithOffset()]) validators=[CannotDeleteOriginalLineItemsWithOffset()])
"""The pseudo field for the whole form validators.""" """The pseudo field for the whole form validators."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -554,8 +561,8 @@ class TransferVoucherForm(VoucherForm):
self._is_need_payable = True self._is_need_payable = True
self._is_need_receivable = True self._is_need_receivable = True
class Collector(JournalEntryCollector[TransferVoucherForm]): class Collector(LineItemCollector[TransferVoucherForm]):
"""The journal entry collector for the transfer vouchers.""" """The line item collector for the transfer vouchers."""
def collect(self) -> None: def collect(self) -> None:
currencies: list[TransferCurrencyForm] \ currencies: list[TransferCurrencyForm] \
@ -563,21 +570,21 @@ class TransferVoucherForm(VoucherForm):
self._sort_currency_forms(currencies) self._sort_currency_forms(currencies)
for currency in currencies: for currency in currencies:
# The debit forms # The debit forms
debit_forms: list[DebitEntryForm] \ debit_forms: list[DebitLineItemForm] \
= [x.form for x in currency.debit] = [x.form for x in currency.debit]
self._sort_entry_forms(debit_forms) self._sort_line_item_forms(debit_forms)
for debit_form in debit_forms: for debit_form in debit_forms:
self._add_entry(debit_form, currency.code.data, self._add_line_item(debit_form, currency.code.data,
self._debit_no) self._debit_no)
self._debit_no = self._debit_no + 1 self._debit_no = self._debit_no + 1
# The credit forms # The credit forms
credit_forms: list[CreditEntryForm] \ credit_forms: list[CreditLineItemForm] \
= [x.form for x in currency.credit] = [x.form for x in currency.credit]
self._sort_entry_forms(credit_forms) self._sort_line_item_forms(credit_forms)
for credit_form in credit_forms: for credit_form in credit_forms:
self._add_entry(credit_form, currency.code.data, self._add_line_item(credit_form, currency.code.data,
self._credit_no) self._credit_no)
self._credit_no = self._credit_no + 1 self._credit_no = self._credit_no + 1
self.collector = Collector self.collector = Collector

View File

@ -14,20 +14,20 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The SQLAlchemy alias for the offset entries. """The SQLAlchemy alias for the offset items.
""" """
import typing as t import typing as t
import sqlalchemy as sa import sqlalchemy as sa
from accounting.models import JournalEntry from accounting.models import VoucherLineItem
def offset_alias() -> sa.Alias: def offset_alias() -> sa.Alias:
"""Returns the SQLAlchemy alias for the offset entries. """Returns the SQLAlchemy alias for the offset items.
:return: The SQLAlchemy alias for the offset entries. :return: The SQLAlchemy alias for the offset items.
""" """
def as_from(model_cls: t.Any) -> sa.FromClause: def as_from(model_cls: t.Any) -> sa.FromClause:
@ -36,4 +36,4 @@ def offset_alias() -> sa.Alias:
def as_alias(alias: t.Any) -> sa.Alias: def as_alias(alias: t.Any) -> sa.Alias:
return alias return alias
return as_alias(sa.alias(as_from(JournalEntry), name="offset")) return as_alias(sa.alias(as_from(VoucherLineItem), name="offset"))

View File

@ -78,16 +78,16 @@ class VoucherOperator(ABC):
""" """
@property @property
def _entry_template(self) -> str: def _line_item_template(self) -> str:
"""Renders and returns the template for the journal entry sub-form. """Renders and returns the template for the line item sub-form.
:return: The template for the journal entry sub-form. :return: The template for the line item sub-form.
""" """
return render_template( return render_template(
"accounting/voucher/include/form-entry-item.html", "accounting/voucher/include/form-line-item.html",
currency_index="CURRENCY_INDEX", currency_index="CURRENCY_INDEX",
entry_type="ENTRY_TYPE", side="SIDE",
entry_index="ENTRY_INDEX") line_item_index="LINE_ITEM_INDEX")
class CashReceiptVoucher(VoucherOperator): class CashReceiptVoucher(VoucherOperator):
@ -113,7 +113,7 @@ class CashReceiptVoucher(VoucherOperator):
form=form, form=form,
voucher_type=VoucherType.CASH_RECEIPT, voucher_type=VoucherType.CASH_RECEIPT,
currency_template=self.__currency_template, currency_template=self.__currency_template,
entry_template=self._entry_template) line_item_template=self._line_item_template)
def render_detail_template(self, voucher: Voucher) -> str: def render_detail_template(self, voucher: Voucher) -> str:
"""Renders the template for the detail page. """Renders the template for the detail page.
@ -135,7 +135,7 @@ class CashReceiptVoucher(VoucherOperator):
return render_template("accounting/voucher/receipt/edit.html", return render_template("accounting/voucher/receipt/edit.html",
voucher=voucher, form=form, voucher=voucher, form=form,
currency_template=self.__currency_template, currency_template=self.__currency_template,
entry_template=self._entry_template) line_item_template=self._line_item_template)
def is_my_type(self, voucher: Voucher) -> bool: def is_my_type(self, voucher: Voucher) -> bool:
"""Checks and returns whether the voucher belongs to the type. """Checks and returns whether the voucher belongs to the type.
@ -182,7 +182,7 @@ class CashDisbursementVoucher(VoucherOperator):
form=form, form=form,
voucher_type=VoucherType.CASH_DISBURSEMENT, voucher_type=VoucherType.CASH_DISBURSEMENT,
currency_template=self.__currency_template, currency_template=self.__currency_template,
entry_template=self._entry_template) line_item_template=self._line_item_template)
def render_detail_template(self, voucher: Voucher) -> str: def render_detail_template(self, voucher: Voucher) -> str:
"""Renders the template for the detail page. """Renders the template for the detail page.
@ -204,7 +204,7 @@ class CashDisbursementVoucher(VoucherOperator):
return render_template("accounting/voucher/disbursement/edit.html", return render_template("accounting/voucher/disbursement/edit.html",
voucher=voucher, form=form, voucher=voucher, form=form,
currency_template=self.__currency_template, currency_template=self.__currency_template,
entry_template=self._entry_template) line_item_template=self._line_item_template)
def is_my_type(self, voucher: Voucher) -> bool: def is_my_type(self, voucher: Voucher) -> bool:
"""Checks and returns whether the voucher belongs to the type. """Checks and returns whether the voucher belongs to the type.
@ -251,7 +251,7 @@ class TransferVoucher(VoucherOperator):
form=form, form=form,
voucher_type=VoucherType.TRANSFER, voucher_type=VoucherType.TRANSFER,
currency_template=self.__currency_template, currency_template=self.__currency_template,
entry_template=self._entry_template) line_item_template=self._line_item_template)
def render_detail_template(self, voucher: Voucher) -> str: def render_detail_template(self, voucher: Voucher) -> str:
"""Renders the template for the detail page. """Renders the template for the detail page.
@ -273,7 +273,7 @@ class TransferVoucher(VoucherOperator):
return render_template("accounting/voucher/transfer/edit.html", return render_template("accounting/voucher/transfer/edit.html",
voucher=voucher, form=form, voucher=voucher, form=form,
currency_template=self.__currency_template, currency_template=self.__currency_template,
entry_template=self._entry_template) line_item_template=self._line_item_template)
def is_my_type(self, voucher: Voucher) -> bool: def is_my_type(self, voucher: Voucher) -> bool:
"""Checks and returns whether the voucher belongs to the type. """Checks and returns whether the voucher belongs to the type.

View File

@ -14,7 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The selectable original entries. """The selectable original line items.
""" """
from decimal import Decimal from decimal import Decimal
@ -23,57 +23,59 @@ import sqlalchemy as sa
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting import db from accounting import db
from accounting.models import Account, Voucher, JournalEntry from accounting.models import Account, Voucher, VoucherLineItem
from accounting.utils.cast import be from accounting.utils.cast import be
from .offset_alias import offset_alias from .offset_alias import offset_alias
def get_selectable_original_entries( def get_selectable_original_line_items(
entry_id_on_form: set[int], is_payable: bool, is_receivable: bool) \ line_item_id_on_form: set[int], is_payable: bool,
-> list[JournalEntry]: is_receivable: bool) -> list[VoucherLineItem]:
"""Queries and returns the selectable original entries, with their net """Queries and returns the selectable original line items, with their net
balances. The offset amounts of the form is excluded. balances. The offset amounts of the form is excluded.
:param entry_id_on_form: The ID of the journal entries on the form. :param line_item_id_on_form: The ID of the line items on the form.
:param is_payable: True to check the payable original entries, or False :param is_payable: True to check the payable original line items, or False
otherwise. otherwise.
:param is_receivable: True to check the receivable original entries, or :param is_receivable: True to check the receivable original line items, or
False otherwise. False otherwise.
:return: The selectable original entries, with their net balances. :return: The selectable original line items, with their net balances.
""" """
assert is_payable or is_receivable assert is_payable or is_receivable
offset: sa.Alias = offset_alias() offset: sa.Alias = offset_alias()
net_balance: sa.Label = (JournalEntry.amount + sa.func.sum(sa.case( net_balance: sa.Label = (VoucherLineItem.amount + sa.func.sum(sa.case(
(offset.c.id.in_(entry_id_on_form), 0), (offset.c.id.in_(line_item_id_on_form), 0),
(be(offset.c.is_debit == JournalEntry.is_debit), offset.c.amount), (be(offset.c.is_debit == VoucherLineItem.is_debit), offset.c.amount),
else_=-offset.c.amount))).label("net_balance") else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_need_offset] conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
sub_conditions: list[sa.BinaryExpression] = [] sub_conditions: list[sa.BinaryExpression] = []
if is_payable: if is_payable:
sub_conditions.append(sa.and_(Account.base_code.startswith("2"), sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntry.is_debit))) sa.not_(VoucherLineItem.is_debit)))
if is_receivable: if is_receivable:
sub_conditions.append(sa.and_(Account.base_code.startswith("1"), sub_conditions.append(sa.and_(Account.base_code.startswith("1"),
JournalEntry.is_debit)) VoucherLineItem.is_debit))
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
select_net_balances: sa.Select = sa.select(JournalEntry.id, net_balance)\ select_net_balances: sa.Select \
= sa.select(VoucherLineItem.id, net_balance)\
.join(Account)\ .join(Account)\
.join(offset, be(JournalEntry.id == offset.c.original_entry_id), .join(offset, be(VoucherLineItem.id == offset.c.original_line_item_id),
isouter=True)\ isouter=True)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(JournalEntry.id)\ .group_by(VoucherLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0)) .having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
net_balances: dict[int, Decimal] \ net_balances: dict[int, Decimal] \
= {x.id: x.net_balance = {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()} for x in db.session.execute(select_net_balances).all()}
entries: list[JournalEntry] = JournalEntry.query\ line_items: list[VoucherLineItem] = VoucherLineItem.query\
.filter(JournalEntry.id.in_({x for x in net_balances}))\ .filter(VoucherLineItem.id.in_({x for x in net_balances}))\
.join(Voucher)\ .join(Voucher)\
.order_by(Voucher.date, JournalEntry.is_debit, JournalEntry.no)\ .order_by(Voucher.date, VoucherLineItem.is_debit, VoucherLineItem.no)\
.options(selectinload(JournalEntry.currency), .options(selectinload(VoucherLineItem.currency),
selectinload(JournalEntry.account), selectinload(VoucherLineItem.account),
selectinload(JournalEntry.voucher)).all() selectinload(VoucherLineItem.voucher)).all()
for entry in entries: for line_item in line_items:
entry.net_balance = entry.amount if net_balances[entry.id] is None \ line_item.net_balance = line_item.amount \
else net_balances[entry.id] if net_balances[line_item.id] is None \
return entries else net_balances[line_item.id]
return line_items

View File

@ -22,7 +22,7 @@ import typing as t
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from accounting import db
from accounting.models import Account, JournalEntry from accounting.models import Account, VoucherLineItem
class SummaryAccount: class SummaryAccount:
@ -143,16 +143,16 @@ class SummaryType:
return sorted(self.__tag_dict.values(), key=lambda x: -x.freq) return sorted(self.__tag_dict.values(), key=lambda x: -x.freq)
class SummaryEntryType: class SummarySide:
"""A summary type""" """A summary side"""
def __init__(self, entry_type_id: t.Literal["debit", "credit"]): def __init__(self, side_id: t.Literal["debit", "credit"]):
"""Constructs a summary entry type. """Constructs a summary side.
:param entry_type_id: The entry type ID, either "debit" or "credit". :param side_id: The side ID, either "debit" or "credit".
""" """
self.type: t.Literal["debit", "credit"] = entry_type_id self.side: t.Literal["debit", "credit"] = side_id
"""The entry type.""" """The side."""
self.general: SummaryType = SummaryType("general") self.general: SummaryType = SummaryType("general")
"""The general tags.""" """The general tags."""
self.travel: SummaryType = SummaryType("travel") self.travel: SummaryType = SummaryType("travel")
@ -179,7 +179,7 @@ class SummaryEntryType:
@property @property
def accounts(self) -> list[SummaryAccount]: def accounts(self) -> list[SummaryAccount]:
"""Returns the suggested accounts of all tags in the summary editor in """Returns the suggested accounts of all tags in the summary editor in
the entry type, in their frequency order. the side, in their frequency order.
:return: The suggested accounts of all tags, in their frequency order. :return: The suggested accounts of all tags, in their frequency order.
""" """
@ -202,33 +202,33 @@ class SummaryEditor:
def __init__(self): def __init__(self):
"""Constructs the summary editor.""" """Constructs the summary editor."""
self.debit: SummaryEntryType = SummaryEntryType("debit") self.debit: SummarySide = SummarySide("debit")
"""The debit tags.""" """The debit tags."""
self.credit: SummaryEntryType = SummaryEntryType("credit") self.credit: SummarySide = SummarySide("credit")
"""The credit tags.""" """The credit tags."""
entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"), side: sa.Label = sa.case((VoucherLineItem.is_debit, "debit"),
else_="credit").label("entry_type") else_="credit").label("side")
tag_type: sa.Label = sa.case( tag_type: sa.Label = sa.case(
(JournalEntry.summary.like("_%—_%—_%→_%"), "bus"), (VoucherLineItem.summary.like("_%—_%—_%→_%"), "bus"),
(sa.or_(JournalEntry.summary.like("_%—_%→_%"), (sa.or_(VoucherLineItem.summary.like("_%—_%→_%"),
JournalEntry.summary.like("_%—_%↔_%")), "travel"), VoucherLineItem.summary.like("_%—_%↔_%")), "travel"),
else_="general").label("tag_type") else_="general").label("tag_type")
tag: sa.Label = get_prefix(JournalEntry.summary, "").label("tag") tag: sa.Label = get_prefix(VoucherLineItem.summary, "").label("tag")
select: sa.Select = sa.Select(entry_type, tag_type, tag, select: sa.Select = sa.Select(side, tag_type, tag,
JournalEntry.account_id, VoucherLineItem.account_id,
sa.func.count().label("freq"))\ sa.func.count().label("freq"))\
.filter(JournalEntry.summary.is_not(None), .filter(VoucherLineItem.summary.is_not(None),
JournalEntry.summary.like("_%—_%"), VoucherLineItem.summary.like("_%—_%"),
JournalEntry.original_entry_id.is_(None))\ VoucherLineItem.original_line_item_id.is_(None))\
.group_by(entry_type, tag_type, tag, JournalEntry.account_id) .group_by(side, tag_type, tag, VoucherLineItem.account_id)
result: list[sa.Row] = db.session.execute(select).all() result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \ accounts: dict[int, Account] \
= {x.id: x for x in Account.query = {x.id: x for x in Account.query
.filter(Account.id.in_({x.account_id for x in result})).all()} .filter(Account.id.in_({x.account_id for x in result})).all()}
entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \ side_dict: dict[t.Literal["debit", "credit"], SummarySide] \
= {x.type: x for x in {self.debit, self.credit}} = {x.side: x for x in {self.debit, self.credit}}
for row in result: for row in result:
entry_type_dict[row.entry_type].add_tag( side_dict[row.side].add_tag(
row.tag_type, row.tag, accounts[row.account_id], row.freq) row.tag_type, row.tag, accounts[row.account_id], row.freq)

View File

@ -27,7 +27,7 @@ from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import create_test_app, get_client from testlib import create_test_app, get_client
from testlib_offset import TestData, JournalEntryData, VoucherData, \ from testlib_offset import TestData, VoucherLineItemData, VoucherData, \
CurrencyData CurrencyData
from testlib_voucher import Accounts, match_voucher_detail from testlib_voucher import Accounts, match_voucher_detail
@ -49,7 +49,7 @@ class OffsetTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import BaseAccount, Voucher, \ from accounting.models import BaseAccount, Voucher, \
JournalEntry VoucherLineItem
result: Result result: Result
result = runner.invoke(args="init-db") result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
@ -63,7 +63,7 @@ class OffsetTestCase(unittest.TestCase):
"-u", "editor"]) "-u", "editor"])
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
Voucher.query.delete() Voucher.query.delete()
JournalEntry.query.delete() VoucherLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor") self.client, self.csrf_token = get_client(self.app, "editor")
self.data: TestData = TestData(self.app, self.client, self.csrf_token) self.data: TestData = TestData(self.app, self.client, self.csrf_token)
@ -84,33 +84,34 @@ class OffsetTestCase(unittest.TestCase):
self.data.e_r_or3d.voucher.days, [CurrencyData( self.data.e_r_or3d.voucher.days, [CurrencyData(
"USD", "USD",
[], [],
[JournalEntryData(Accounts.RECEIVABLE, [VoucherLineItemData(Accounts.RECEIVABLE,
self.data.e_r_or1d.summary, "300", self.data.e_r_or1d.summary, "300",
original_entry=self.data.e_r_or1d), original_line_item=self.data.e_r_or1d),
JournalEntryData(Accounts.RECEIVABLE, VoucherLineItemData(Accounts.RECEIVABLE,
self.data.e_r_or1d.summary, "100", self.data.e_r_or1d.summary, "100",
original_entry=self.data.e_r_or1d), original_line_item=self.data.e_r_or1d),
JournalEntryData(Accounts.RECEIVABLE, VoucherLineItemData(Accounts.RECEIVABLE,
self.data.e_r_or3d.summary, "100", self.data.e_r_or3d.summary, "100",
original_entry=self.data.e_r_or3d)])]) original_line_item=self.data.e_r_or3d)])])
# Non-existing original entry ID # Non-existing original line item ID
form = voucher_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = "9999" form["currency-1-credit-1-original_line_item_id"] = "9999"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The same side # The same side
form = voucher_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_or1c.id form["currency-1-credit-1-original_line_item_id"] \
= self.data.e_p_or1c.id
form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account
form["currency-1-credit-1-amount"] = "100" form["currency-1-credit-1-amount"] = "100"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The original entry does not need offset # The original line item does not need offset
with self.app.app_context(): with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = False account.is_need_offset = False
@ -124,9 +125,10 @@ class OffsetTestCase(unittest.TestCase):
account.is_need_offset = True account.is_need_offset = True
db.session.commit() db.session.commit()
# The original entry is also an offset # The original line item is also an offset
form = voucher_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_of1d.id form["currency-1-credit-1-original_line_item_id"] \
= self.data.e_p_of1d.id
form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@ -164,7 +166,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not before the original entries # Not before the original line items
old_days = voucher_data.days old_days = voucher_data.days
voucher_data.days = old_days + 1 voucher_data.days = old_days + 1
form = voucher_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
@ -181,7 +183,7 @@ class OffsetTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
voucher = db.session.get(Voucher, voucher_id) voucher = db.session.get(Voucher, voucher_id)
for offset in voucher.currencies[0].credit: for offset in voucher.currencies[0].credit:
self.assertIsNotNone(offset.original_entry_id) self.assertIsNotNone(offset.original_line_item_id)
def test_edit_receivable_offset(self) -> None: def test_edit_receivable_offset(self) -> None:
"""Tests to edit the receivable offset. """Tests to edit the receivable offset.
@ -201,16 +203,17 @@ class OffsetTestCase(unittest.TestCase):
voucher_data.currencies[0].debit[2].amount = Decimal("600") voucher_data.currencies[0].debit[2].amount = Decimal("600")
voucher_data.currencies[0].credit[2].amount = Decimal("600") voucher_data.currencies[0].credit[2].amount = Decimal("600")
# Non-existing original entry ID # Non-existing original line item ID
form = voucher_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = "9999" form["currency-1-credit-1-original_line_item_id"] = "9999"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The same side # The same side
form = voucher_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_or1c.id form["currency-1-credit-1-original_line_item_id"] \
= self.data.e_p_or1c.id
form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account form["currency-1-credit-1-account_code"] = self.data.e_p_or1c.account
form["currency-1-debit-1-amount"] = "100" form["currency-1-debit-1-amount"] = "100"
form["currency-1-credit-1-amount"] = "100" form["currency-1-credit-1-amount"] = "100"
@ -218,7 +221,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The original entry does not need offset # The original line item does not need offset
with self.app.app_context(): with self.app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = False account.is_need_offset = False
@ -232,9 +235,10 @@ class OffsetTestCase(unittest.TestCase):
account.is_need_offset = True account.is_need_offset = True
db.session.commit() db.session.commit()
# The original entry is also an offset # The original line item is also an offset
form = voucher_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-credit-1-original_entry_id"] = self.data.e_p_of1d.id form["currency-1-credit-1-original_line_item_id"] \
= self.data.e_p_of1d.id
form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account form["currency-1-credit-1-account_code"] = self.data.e_p_of1d.account
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@ -276,7 +280,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not before the original entries # Not before the original line items
old_days: int = voucher_data.days old_days: int = voucher_data.days
voucher_data.days = old_days + 1 voucher_data.days = old_days + 1
form = voucher_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
@ -292,8 +296,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{voucher_data.id}?next=%2F_next") f"{PREFIX}/{voucher_data.id}?next=%2F_next")
def test_edit_receivable_original_entry(self) -> None: def test_edit_receivable_original_line_item(self) -> None:
"""Tests to edit the receivable original entry. """Tests to edit the receivable original line item.
:return: None. :return: None.
""" """
@ -346,7 +350,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not after the offset entries # Not after the offset items
old_days: int = voucher_data.days old_days: int = voucher_data.days
voucher_data.days = old_days - 1 voucher_data.days = old_days - 1
form = voucher_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
@ -355,7 +359,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
voucher_data.days = old_days voucher_data.days = old_days
# Not deleting matched original entries # Not deleting matched original line items
form = voucher_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
del form["currency-1-debit-1-eid"] del form["currency-1-debit-1-eid"]
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
@ -369,8 +373,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{voucher_data.id}?next=%2F_next") f"{PREFIX}/{voucher_data.id}?next=%2F_next")
# The original entry is always before the offset entry, even when they # The original line item is always before the offset item, even when
# happen in the same day. # they happen in the same day.
with self.app.app_context(): with self.app.app_context():
voucher_or: Voucher | None = db.session.get( voucher_or: Voucher | None = db.session.get(
Voucher, voucher_data.id) Voucher, voucher_data.id)
@ -395,34 +399,35 @@ class OffsetTestCase(unittest.TestCase):
voucher_data: VoucherData = VoucherData( voucher_data: VoucherData = VoucherData(
self.data.e_p_or3c.voucher.days, [CurrencyData( self.data.e_p_or3c.voucher.days, [CurrencyData(
"USD", "USD",
[JournalEntryData(Accounts.PAYABLE, [VoucherLineItemData(Accounts.PAYABLE,
self.data.e_p_or1c.summary, "500", self.data.e_p_or1c.summary, "500",
original_entry=self.data.e_p_or1c), original_line_item=self.data.e_p_or1c),
JournalEntryData(Accounts.PAYABLE, VoucherLineItemData(Accounts.PAYABLE,
self.data.e_p_or1c.summary, "300", self.data.e_p_or1c.summary, "300",
original_entry=self.data.e_p_or1c), original_line_item=self.data.e_p_or1c),
JournalEntryData(Accounts.PAYABLE, VoucherLineItemData(Accounts.PAYABLE,
self.data.e_p_or3c.summary, "120", self.data.e_p_or3c.summary, "120",
original_entry=self.data.e_p_or3c)], original_line_item=self.data.e_p_or3c)],
[])]) [])])
# Non-existing original entry ID # Non-existing original line item ID
form = voucher_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = "9999" form["currency-1-debit-1-original_line_item_id"] = "9999"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The same side # The same side
form = voucher_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_or1d.id form["currency-1-debit-1-original_line_item_id"] \
= self.data.e_r_or1d.id
form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account
form["currency-1-debit-1-amount"] = "100" form["currency-1-debit-1-amount"] = "100"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The original entry does not need offset # The original line item does not need offset
with self.app.app_context(): with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = False account.is_need_offset = False
@ -436,9 +441,10 @@ class OffsetTestCase(unittest.TestCase):
account.is_need_offset = True account.is_need_offset = True
db.session.commit() db.session.commit()
# The original entry is also an offset # The original line item is also an offset
form = voucher_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_of1c.id form["currency-1-debit-1-original_line_item_id"] \
= self.data.e_r_of1c.id
form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@ -474,7 +480,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not before the original entries # Not before the original line items
old_days: int = voucher_data.days old_days: int = voucher_data.days
voucher_data.days = old_days + 1 voucher_data.days = old_days + 1
form = voucher_data.new_form(self.csrf_token) form = voucher_data.new_form(self.csrf_token)
@ -491,7 +497,7 @@ class OffsetTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
voucher = db.session.get(Voucher, voucher_id) voucher = db.session.get(Voucher, voucher_id)
for offset in voucher.currencies[0].debit: for offset in voucher.currencies[0].debit:
self.assertIsNotNone(offset.original_entry_id) self.assertIsNotNone(offset.original_line_item_id)
def test_edit_payable_offset(self) -> None: def test_edit_payable_offset(self) -> None:
"""Tests to edit the payable offset. """Tests to edit the payable offset.
@ -511,16 +517,17 @@ class OffsetTestCase(unittest.TestCase):
voucher_data.currencies[0].debit[2].amount = Decimal("900") voucher_data.currencies[0].debit[2].amount = Decimal("900")
voucher_data.currencies[0].credit[2].amount = Decimal("900") voucher_data.currencies[0].credit[2].amount = Decimal("900")
# Non-existing original entry ID # Non-existing original line item ID
form = voucher_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = "9999" form["currency-1-debit-1-original_line_item_id"] = "9999"
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The same side # The same side
form = voucher_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_or1d.id form["currency-1-debit-1-original_line_item_id"] \
= self.data.e_r_or1d.id
form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account form["currency-1-debit-1-account_code"] = self.data.e_r_or1d.account
form["currency-1-debit-1-amount"] = "100" form["currency-1-debit-1-amount"] = "100"
form["currency-1-credit-1-amount"] = "100" form["currency-1-credit-1-amount"] = "100"
@ -528,7 +535,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The original entry does not need offset # The original line item does not need offset
with self.app.app_context(): with self.app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = False account.is_need_offset = False
@ -542,9 +549,10 @@ class OffsetTestCase(unittest.TestCase):
account.is_need_offset = True account.is_need_offset = True
db.session.commit() db.session.commit()
# The original entry is also an offset # The original line item is also an offset
form = voucher_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
form["currency-1-debit-1-original_entry_id"] = self.data.e_r_of1c.id form["currency-1-debit-1-original_line_item_id"] \
= self.data.e_r_of1c.id
form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account form["currency-1-debit-1-account_code"] = self.data.e_r_of1c.account
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@ -586,7 +594,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not before the original entries # Not before the original line items
old_days: int = voucher_data.days old_days: int = voucher_data.days
voucher_data.days = old_days + 1 voucher_data.days = old_days + 1
form = voucher_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
@ -603,10 +611,10 @@ class OffsetTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
voucher = db.session.get(Voucher, voucher_id) voucher = db.session.get(Voucher, voucher_id)
for offset in voucher.currencies[0].debit: for offset in voucher.currencies[0].debit:
self.assertIsNotNone(offset.original_entry_id) self.assertIsNotNone(offset.original_line_item_id)
def test_edit_payable_original_entry(self) -> None: def test_edit_payable_original_line_item(self) -> None:
"""Tests to edit the payable original entry. """Tests to edit the payable original line item.
:return: None. :return: None.
""" """
@ -659,7 +667,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not after the offset entries # Not after the offset items
old_days: int = voucher_data.days old_days: int = voucher_data.days
voucher_data.days = old_days - 1 voucher_data.days = old_days - 1
form = voucher_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
@ -668,7 +676,7 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
voucher_data.days = old_days voucher_data.days = old_days
# Not deleting matched original entries # Not deleting matched original line items
form = voucher_data.update_form(self.csrf_token) form = voucher_data.update_form(self.csrf_token)
del form["currency-1-credit-1-eid"] del form["currency-1-credit-1-eid"]
response = self.client.post(update_uri, data=form) response = self.client.post(update_uri, data=form)
@ -682,8 +690,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{voucher_data.id}?next=%2F_next") f"{PREFIX}/{voucher_data.id}?next=%2F_next")
# The original entry is always before the offset entry, even when they # The original line item is always before the offset item, even when
# happen in the same day # they happen in the same day
with self.app.app_context(): with self.app.app_context():
voucher_or: Voucher | None = db.session.get( voucher_or: Voucher | None = db.session.get(
Voucher, voucher_data.id) Voucher, voucher_data.id)

View File

@ -42,7 +42,7 @@ class SummeryEditorTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import BaseAccount, Voucher, \ from accounting.models import BaseAccount, Voucher, \
JournalEntry VoucherLineItem
result: Result result: Result
result = runner.invoke(args="init-db") result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
@ -56,7 +56,7 @@ class SummeryEditorTestCase(unittest.TestCase):
"-u", "editor"]) "-u", "editor"])
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
Voucher.query.delete() Voucher.query.delete()
JournalEntry.query.delete() VoucherLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor") self.client, self.csrf_token = get_client(self.app, "editor")

View File

@ -53,7 +53,7 @@ class CashReceiptVoucherTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import BaseAccount, Voucher, \ from accounting.models import BaseAccount, Voucher, \
JournalEntry VoucherLineItem
result: Result result: Result
result = runner.invoke(args="init-db") result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
@ -67,7 +67,7 @@ class CashReceiptVoucherTestCase(unittest.TestCase):
"-u", "editor"]) "-u", "editor"])
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
Voucher.query.delete() Voucher.query.delete()
JournalEntry.query.delete() VoucherLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor") self.client, self.csrf_token = get_client(self.app, "editor")
@ -231,7 +231,7 @@ class CashReceiptVoucherTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# A receivable entry cannot start from the credit side # A receivable line item cannot start from the credit side
form = self.__get_add_form() form = self.__get_add_form()
key: str = [x for x in form.keys() key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-credit-" in x][0] if x.endswith("-account_code") and "-credit-" in x][0]
@ -391,7 +391,7 @@ class CashReceiptVoucherTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# A receivable entry cannot start from the credit side # A receivable line item cannot start from the credit side
form = self.__get_add_form() form = self.__get_add_form()
key: str = [x for x in form.keys() key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-credit-" in x][0] if x.endswith("-account_code") and "-credit-" in x][0]
@ -625,7 +625,7 @@ class CashDisbursementVoucherTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import BaseAccount, Voucher, \ from accounting.models import BaseAccount, Voucher, \
JournalEntry VoucherLineItem
result: Result result: Result
result = runner.invoke(args="init-db") result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
@ -639,7 +639,7 @@ class CashDisbursementVoucherTestCase(unittest.TestCase):
"-u", "editor"]) "-u", "editor"])
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
Voucher.query.delete() Voucher.query.delete()
JournalEntry.query.delete() VoucherLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor") self.client, self.csrf_token = get_client(self.app, "editor")
@ -803,7 +803,7 @@ class CashDisbursementVoucherTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# A payable entry cannot start from the debit side # A payable line item cannot start from the debit side
form = self.__get_add_form() form = self.__get_add_form()
key: str = [x for x in form.keys() key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-debit-" in x][0] if x.endswith("-account_code") and "-debit-" in x][0]
@ -966,7 +966,7 @@ class CashDisbursementVoucherTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# A payable entry cannot start from the debit side # A payable line item cannot start from the debit side
form = self.__get_add_form() form = self.__get_add_form()
key: str = [x for x in form.keys() key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-debit-" in x][0] if x.endswith("-account_code") and "-debit-" in x][0]
@ -1204,7 +1204,7 @@ class TransferVoucherTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import BaseAccount, Voucher, \ from accounting.models import BaseAccount, Voucher, \
JournalEntry VoucherLineItem
result: Result result: Result
result = runner.invoke(args="init-db") result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
@ -1218,7 +1218,7 @@ class TransferVoucherTestCase(unittest.TestCase):
"-u", "editor"]) "-u", "editor"])
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
Voucher.query.delete() Voucher.query.delete()
JournalEntry.query.delete() VoucherLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor") self.client, self.csrf_token = get_client(self.app, "editor")
@ -1398,7 +1398,7 @@ class TransferVoucherTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# A receivable entry cannot start from the credit side # A receivable line item cannot start from the credit side
form = self.__get_add_form() form = self.__get_add_form()
key: str = [x for x in form.keys() key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-credit-" in x][0] if x.endswith("-account_code") and "-credit-" in x][0]
@ -1407,7 +1407,7 @@ class TransferVoucherTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# A payable entry cannot start from the debit side # A payable line item cannot start from the debit side
form = self.__get_add_form() form = self.__get_add_form()
key: str = [x for x in form.keys() key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-debit-" in x][0] if x.endswith("-account_code") and "-debit-" in x][0]
@ -1597,7 +1597,7 @@ class TransferVoucherTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# A receivable entry cannot start from the credit side # A receivable line item cannot start from the credit side
form = self.__get_add_form() form = self.__get_add_form()
key: str = [x for x in form.keys() key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-credit-" in x][0] if x.endswith("-account_code") and "-credit-" in x][0]
@ -1606,7 +1606,7 @@ class TransferVoucherTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# A payable entry cannot start from the debit side # A payable line item cannot start from the debit side
form = self.__get_add_form() form = self.__get_add_form()
key: str = [x for x in form.keys() key: str = [x for x in form.keys()
if x.endswith("-account_code") and "-debit-" in x][0] if x.endswith("-account_code") and "-debit-" in x][0]
@ -2056,7 +2056,7 @@ class VoucherReorderTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import BaseAccount, Voucher, \ from accounting.models import BaseAccount, Voucher, \
JournalEntry VoucherLineItem
result: Result result: Result
result = runner.invoke(args="init-db") result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
@ -2070,7 +2070,7 @@ class VoucherReorderTestCase(unittest.TestCase):
"-u", "editor"]) "-u", "editor"])
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
Voucher.query.delete() Voucher.query.delete()
JournalEntry.query.delete() VoucherLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor") self.client, self.csrf_token = get_client(self.app, "editor")

View File

@ -29,63 +29,65 @@ from test_site import db
from testlib_voucher import Accounts, match_voucher_detail, NEXT_URI from testlib_voucher import Accounts, match_voucher_detail, NEXT_URI
class JournalEntryData: class VoucherLineItemData:
"""The journal entry data.""" """The voucher line item data."""
def __init__(self, account: str, summary: str, amount: str, def __init__(self, account: str, summary: str, amount: str,
original_entry: JournalEntryData | None = None): original_line_item: VoucherLineItemData | None = None):
"""Constructs the journal entry data. """Constructs the voucher line item data.
:param account: The account code. :param account: The account code.
:param summary: The summary. :param summary: The summary.
:param amount: The amount. :param amount: The amount.
:param original_entry: The original entry. :param original_line_item: The original voucher line item.
""" """
self.voucher: VoucherData | None = None self.voucher: VoucherData | None = None
self.id: int = -1 self.id: int = -1
self.no: int = -1 self.no: int = -1
self.original_entry: JournalEntryData | None = original_entry self.original_line_item: VoucherLineItemData | None \
= original_line_item
self.account: str = account self.account: str = account
self.summary: str = summary self.summary: str = summary
self.amount: Decimal = Decimal(amount) self.amount: Decimal = Decimal(amount)
def form(self, prefix: str, entry_type: str, index: int, is_update: bool) \ def form(self, prefix: str, side: str, index: int, is_update: bool) \
-> dict[str, str]: -> dict[str, str]:
"""Returns the journal entry as form data. """Returns the line item as form data.
:param prefix: The prefix of the form fields. :param prefix: The prefix of the form fields.
:param entry_type: The entry type, either "debit" or "credit". :param side: The side, either "debit" or "credit".
:param index: The entry index. :param index: The line item index.
:param is_update: True for an update operation, or False otherwise :param is_update: True for an update operation, or False otherwise
:return: The form data. :return: The form data.
""" """
prefix = f"{prefix}-{entry_type}-{index}" prefix = f"{prefix}-{side}-{index}"
form: dict[str, str] = {f"{prefix}-account_code": self.account, form: dict[str, str] = {f"{prefix}-account_code": self.account,
f"{prefix}-summary": self.summary, f"{prefix}-summary": self.summary,
f"{prefix}-amount": str(self.amount)} f"{prefix}-amount": str(self.amount)}
if is_update and self.id != -1: if is_update and self.id != -1:
form[f"{prefix}-eid"] = str(self.id) form[f"{prefix}-eid"] = str(self.id)
form[f"{prefix}-no"] = str(index) if self.no == -1 else str(self.no) form[f"{prefix}-no"] = str(index) if self.no == -1 else str(self.no)
if self.original_entry is not None: if self.original_line_item is not None:
assert self.original_entry.id != -1 assert self.original_line_item.id != -1
form[f"{prefix}-original_entry_id"] = str(self.original_entry.id) form[f"{prefix}-original_line_item_id"] \
= str(self.original_line_item.id)
return form return form
class CurrencyData: class CurrencyData:
"""The voucher currency data.""" """The voucher currency data."""
def __init__(self, currency: str, debit: list[JournalEntryData], def __init__(self, currency: str, debit: list[VoucherLineItemData],
credit: list[JournalEntryData]): credit: list[VoucherLineItemData]):
"""Constructs the voucher currency data. """Constructs the voucher currency data.
:param currency: The currency code. :param currency: The currency code.
:param debit: The debit journal entries. :param debit: The debit line items.
:param credit: The credit journal entries. :param credit: The credit line items.
""" """
self.code: str = currency self.code: str = currency
self.debit: list[JournalEntryData] = debit self.debit: list[VoucherLineItemData] = debit
self.credit: list[JournalEntryData] = credit self.credit: list[VoucherLineItemData] = credit
def form(self, index: int, is_update: bool) -> dict[str, str]: def form(self, index: int, is_update: bool) -> dict[str, str]:
"""Returns the currency as form data. """Returns the currency as form data.
@ -118,10 +120,10 @@ class VoucherData:
self.currencies: list[CurrencyData] = currencies self.currencies: list[CurrencyData] = currencies
self.note: str | None = None self.note: str | None = None
for currency in self.currencies: for currency in self.currencies:
for entry in currency.debit: for line_item in currency.debit:
entry.voucher = self line_item.voucher = self
for entry in currency.credit: for line_item in currency.credit:
entry.voucher = self line_item.voucher = self
def new_form(self, csrf_token: str) -> dict[str, str]: def new_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the voucher as a creation form. """Returns the voucher as a creation form.
@ -173,19 +175,19 @@ class TestData:
self.csrf_token: str = csrf_token self.csrf_token: str = csrf_token
def couple(summary: str, amount: str, debit: str, credit: str) \ def couple(summary: str, amount: str, debit: str, credit: str) \
-> tuple[JournalEntryData, JournalEntryData]: -> tuple[VoucherLineItemData, VoucherLineItemData]:
"""Returns a couple of debit-credit journal entries. """Returns a couple of debit-credit line items.
:param summary: The summary. :param summary: The summary.
:param amount: The amount. :param amount: The amount.
:param debit: The debit account code. :param debit: The debit account code.
:param credit: The credit account code. :param credit: The credit account code.
:return: The debit journal entry and credit journal entry. :return: The debit line item and credit line item.
""" """
return JournalEntryData(debit, summary, amount),\ return VoucherLineItemData(debit, summary, amount),\
JournalEntryData(credit, summary, amount) VoucherLineItemData(credit, summary, amount)
# Receivable original entries # Receivable original line items
self.e_r_or1d, self.e_r_or1c = couple( self.e_r_or1d, self.e_r_or1c = couple(
"Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE) "Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
self.e_r_or2d, self.e_r_or2c = couple( self.e_r_or2d, self.e_r_or2c = couple(
@ -195,7 +197,7 @@ class TestData:
self.e_r_or4d, self.e_r_or4c = couple( self.e_r_or4d, self.e_r_or4c = couple(
"Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST) "Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
# Payable original entries # Payable original line items
self.e_p_or1d, self.e_p_or1c = couple( self.e_p_or1d, self.e_p_or1c = couple(
"Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE) "Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
self.e_p_or2d, self.e_p_or2c = couple( self.e_p_or2d, self.e_p_or2c = couple(
@ -224,39 +226,39 @@ class TestData:
self.__add_voucher(self.v_p_or1) self.__add_voucher(self.v_p_or1)
self.__add_voucher(self.v_p_or2) self.__add_voucher(self.v_p_or2)
# Receivable offset entries # Receivable offset items
self.e_r_of1d, self.e_r_of1c = couple( self.e_r_of1d, self.e_r_of1c = couple(
"Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE) "Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of1c.original_entry = self.e_r_or1d self.e_r_of1c.original_line_item = self.e_r_or1d
self.e_r_of2d, self.e_r_of2c = couple( self.e_r_of2d, self.e_r_of2c = couple(
"Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE) "Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of2c.original_entry = self.e_r_or1d self.e_r_of2c.original_line_item = self.e_r_or1d
self.e_r_of3d, self.e_r_of3c = couple( self.e_r_of3d, self.e_r_of3c = couple(
"Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE) "Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of3c.original_entry = self.e_r_or1d self.e_r_of3c.original_line_item = self.e_r_or1d
self.e_r_of4d, self.e_r_of4c = couple( self.e_r_of4d, self.e_r_of4c = couple(
"Toy", "240", Accounts.CASH, Accounts.RECEIVABLE) "Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of4c.original_entry = self.e_r_or2d self.e_r_of4c.original_line_item = self.e_r_or2d
self.e_r_of5d, self.e_r_of5c = couple( self.e_r_of5d, self.e_r_of5c = couple(
"Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE) "Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
self.e_r_of5c.original_entry = self.e_r_or4d self.e_r_of5c.original_line_item = self.e_r_or4d
# Payable offset entries # Payable offset items
self.e_p_of1d, self.e_p_of1c = couple( self.e_p_of1d, self.e_p_of1c = couple(
"Airplane", "800", Accounts.PAYABLE, Accounts.CASH) "Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of1d.original_entry = self.e_p_or1c self.e_p_of1d.original_line_item = self.e_p_or1c
self.e_p_of2d, self.e_p_of2c = couple( self.e_p_of2d, self.e_p_of2c = couple(
"Airplane", "300", Accounts.PAYABLE, Accounts.CASH) "Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of2d.original_entry = self.e_p_or1c self.e_p_of2d.original_line_item = self.e_p_or1c
self.e_p_of3d, self.e_p_of3c = couple( self.e_p_of3d, self.e_p_of3c = couple(
"Airplane", "100", Accounts.PAYABLE, Accounts.CASH) "Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of3d.original_entry = self.e_p_or1c self.e_p_of3d.original_line_item = self.e_p_or1c
self.e_p_of4d, self.e_p_of4c = couple( self.e_p_of4d, self.e_p_of4c = couple(
"Phone", "400", Accounts.PAYABLE, Accounts.CASH) "Phone", "400", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of4d.original_entry = self.e_p_or2c self.e_p_of4d.original_line_item = self.e_p_or2c
self.e_p_of5d, self.e_p_of5c = couple( self.e_p_of5d, self.e_p_of5c = couple(
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH) "Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
self.e_p_of5d.original_entry = self.e_p_or4c self.e_p_of5d.original_line_item = self.e_p_or4c
# Offset vouchers # Offset vouchers
self.v_r_of1: VoucherData = VoucherData( self.v_r_of1: VoucherData = VoucherData(

View File

@ -154,35 +154,36 @@ def get_unchanged_update_form(voucher_id: int, app: Flask, csrf_token: str) \
currency_prefix: str = f"currency-{currency_index}" currency_prefix: str = f"currency-{currency_index}"
form[f"{currency_prefix}-no"] = str(currency_no) form[f"{currency_prefix}-no"] = str(currency_no)
form[f"{currency_prefix}-code"] = currency.code form[f"{currency_prefix}-code"] = currency.code
entry_indices_used: set[int] line_item_indices_used: set[int]
entry_no: int line_item_no: int
prefix: str prefix: str
entry_indices_used = set() line_item_indices_used = set()
entry_no = 0 line_item_no = 0
for entry in currency.debit: for line_item in currency.debit:
entry_index: int = __get_new_index(entry_indices_used) line_item_index: int = __get_new_index(line_item_indices_used)
entry_no = entry_no + 3 + randbelow(3) line_item_no = line_item_no + 3 + randbelow(3)
prefix = f"{currency_prefix}-debit-{entry_index}" prefix = f"{currency_prefix}-debit-{line_item_index}"
form[f"{prefix}-eid"] = str(entry.id) form[f"{prefix}-eid"] = str(line_item.id)
form[f"{prefix}-no"] = str(entry_no) form[f"{prefix}-no"] = str(line_item_no)
form[f"{prefix}-account_code"] = entry.account.code form[f"{prefix}-account_code"] = line_item.account.code
form[f"{prefix}-summary"] \ form[f"{prefix}-summary"] \
= " " if entry.summary is None else f" {entry.summary} " = " " if line_item.summary is None \
form[f"{prefix}-amount"] = str(entry.amount) else f" {line_item.summary} "
form[f"{prefix}-amount"] = str(line_item.amount)
entry_indices_used = set() line_item_indices_used = set()
entry_no = 0 line_item_no = 0
for entry in currency.credit: for line_item in currency.credit:
entry_index: int = __get_new_index(entry_indices_used) line_item_index: int = __get_new_index(line_item_indices_used)
entry_no = entry_no + 3 + randbelow(3) line_item_no = line_item_no + 3 + randbelow(3)
prefix = f"{currency_prefix}-credit-{entry_index}" prefix = f"{currency_prefix}-credit-{line_item_index}"
form[f"{prefix}-eid"] = str(entry.id) form[f"{prefix}-eid"] = str(line_item.id)
form[f"{prefix}-no"] = str(entry_no) form[f"{prefix}-no"] = str(line_item_no)
form[f"{prefix}-account_code"] = entry.account.code form[f"{prefix}-account_code"] = line_item.account.code
form[f"{prefix}-summary"] \ form[f"{prefix}-summary"] \
= " " if entry.summary is None else f" {entry.summary} " = " " if line_item.summary is None else f" {line_item.summary} "
form[f"{prefix}-amount"] = str(entry.amount) form[f"{prefix}-amount"] = str(line_item.amount)
return form return form
@ -216,7 +217,7 @@ def get_update_form(voucher_id: int, app: Flask,
form: dict[str, str] = get_unchanged_update_form( form: dict[str, str] = get_unchanged_update_form(
voucher_id, app, csrf_token) voucher_id, app, csrf_token)
# Mess up the entries in a currency # Mess up the line items in a currency
currency_prefix: str = __get_currency_prefix(form, "USD") currency_prefix: str = __get_currency_prefix(form, "USD")
if is_debit is None or is_debit: if is_debit is None or is_debit:
form = __mess_up_debit(form, currency_prefix) form = __mess_up_debit(form, currency_prefix)
@ -231,7 +232,7 @@ def get_update_form(voucher_id: int, app: Flask,
def __mess_up_debit(form: dict[str, str], currency_prefix: str) \ def __mess_up_debit(form: dict[str, str], currency_prefix: str) \
-> dict[str, str]: -> dict[str, str]:
"""Mess up the debit journal entries in the form data. """Mess up the debit line items in the form data.
:param form: The form data. :param form: The form data.
:param currency_prefix: The key prefix of the currency sub-form. :param currency_prefix: The key prefix of the currency sub-form.
@ -246,9 +247,9 @@ def __mess_up_debit(form: dict[str, str], currency_prefix: str) \
and form[x] == Accounts.OFFICE][0] and form[x] == Accounts.OFFICE][0]
m = re.match(r"^((.+-)\d+-)account_code$", key) m = re.match(r"^((.+-)\d+-)account_code$", key)
debit_prefix: str = m.group(2) debit_prefix: str = m.group(2)
entry_prefix: str = m.group(1) line_item_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{entry_prefix}amount"]) amount: Decimal = Decimal(form[f"{line_item_prefix}amount"])
form = {x: form[x] for x in form if not x.startswith(entry_prefix)} form = {x: form[x] for x in form if not x.startswith(line_item_prefix)}
# Add a new travel disbursement # Add a new travel disbursement
indices: set[int] = set() indices: set[int] = set()
for key in form: for key in form:
@ -262,15 +263,15 @@ def __mess_up_debit(form: dict[str, str], currency_prefix: str) \
form[f"{debit_prefix}{new_index}-amount"] = str(amount) form[f"{debit_prefix}{new_index}-amount"] = str(amount)
form[f"{debit_prefix}{new_index}-account_code"] = Accounts.TRAVEL form[f"{debit_prefix}{new_index}-account_code"] = Accounts.TRAVEL
# Swap the cash and the bank order # Swap the cash and the bank order
key_cash: str = __get_entry_no_key(form, currency_prefix, Accounts.CASH) key_cash: str = __get_line_item_no_key(form, currency_prefix, Accounts.CASH)
key_bank: str = __get_entry_no_key(form, currency_prefix, Accounts.BANK) key_bank: str = __get_line_item_no_key(form, currency_prefix, Accounts.BANK)
form[key_cash], form[key_bank] = form[key_bank], form[key_cash] form[key_cash], form[key_bank] = form[key_bank], form[key_cash]
return form return form
def __mess_up_credit(form: dict[str, str], currency_prefix: str) \ def __mess_up_credit(form: dict[str, str], currency_prefix: str) \
-> dict[str, str]: -> dict[str, str]:
"""Mess up the credit journal entries in the form data. """Mess up the credit line items in the form data.
:param form: The form data. :param form: The form data.
:param currency_prefix: The key prefix of the currency sub-form. :param currency_prefix: The key prefix of the currency sub-form.
@ -285,9 +286,9 @@ def __mess_up_credit(form: dict[str, str], currency_prefix: str) \
and form[x] == Accounts.SALES][0] and form[x] == Accounts.SALES][0]
m = re.match(r"^((.+-)\d+-)account_code$", key) m = re.match(r"^((.+-)\d+-)account_code$", key)
credit_prefix: str = m.group(2) credit_prefix: str = m.group(2)
entry_prefix: str = m.group(1) line_item_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{entry_prefix}amount"]) amount: Decimal = Decimal(form[f"{line_item_prefix}amount"])
form = {x: form[x] for x in form if not x.startswith(entry_prefix)} form = {x: form[x] for x in form if not x.startswith(line_item_prefix)}
# Add a new agency receipt # Add a new agency receipt
indices: set[int] = set() indices: set[int] = set()
for key in form: for key in form:
@ -301,8 +302,8 @@ def __mess_up_credit(form: dict[str, str], currency_prefix: str) \
form[f"{credit_prefix}{new_index}-amount"] = str(amount) form[f"{credit_prefix}{new_index}-amount"] = str(amount)
form[f"{credit_prefix}{new_index}-account_code"] = Accounts.AGENCY form[f"{credit_prefix}{new_index}-account_code"] = Accounts.AGENCY
# Swap the service and the interest order # Swap the service and the interest order
key_srv: str = __get_entry_no_key(form, currency_prefix, Accounts.SERVICE) key_srv: str = __get_line_item_no_key(form, currency_prefix, Accounts.SERVICE)
key_int: str = __get_entry_no_key(form, currency_prefix, Accounts.INTEREST) key_int: str = __get_line_item_no_key(form, currency_prefix, Accounts.INTEREST)
form[key_srv], form[key_int] = form[key_int], form[key_srv] form[key_srv], form[key_int] = form[key_int], form[key_srv]
return form return form
@ -361,14 +362,14 @@ def __mess_up_currencies(form: dict[str, str]) -> dict[str, str]:
return form return form
def __get_entry_no_key(form: dict[str, str], currency_prefix: str, def __get_line_item_no_key(form: dict[str, str], currency_prefix: str,
code: str) -> str: code: str) -> str:
"""Returns the key of an entry number in the form data. """Returns the key of a line item number in the form data.
:param form: The form data. :param form: The form data.
:param currency_prefix: The prefix of the currency. :param currency_prefix: The prefix of the currency.
:param code: The code of the account. :param code: The code of the account.
:return: The key of the entry number in the form data. :return: The key of the line item number in the form data.
""" """
key: str = [x for x in form key: str = [x for x in form
if x.startswith(currency_prefix) if x.startswith(currency_prefix)
@ -444,7 +445,7 @@ def set_negative_amount(form: dict[str, str]) -> None:
def remove_debit_in_a_currency(form: dict[str, str]) -> None: def remove_debit_in_a_currency(form: dict[str, str]) -> None:
"""Removes credit entries in a currency sub-form. """Removes debit line items in a currency sub-form.
:param form: The form data. :param form: The form data.
:return: None. :return: None.
@ -457,7 +458,7 @@ def remove_debit_in_a_currency(form: dict[str, str]) -> None:
def remove_credit_in_a_currency(form: dict[str, str]) -> None: def remove_credit_in_a_currency(form: dict[str, str]) -> None:
"""Removes credit entries in a currency sub-form. """Removes credit line items in a currency sub-form.
:param form: The form data. :param form: The form data.
:return: None. :return: None.