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:
"""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.
: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
if base_code[0] == "1":

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
/* 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.
@ -23,22 +23,22 @@
"use strict";
/**
* The original entry selector.
* The original line item selector.
*
*/
class OriginalEntrySelector {
class OriginalLineItemSelector {
/**
* The journal entry editor
* @type {JournalEntryEditor}
* The line item editor
* @type {VoucherLineItemEditor}
*/
entryEditor;
lineItemEditor;
/**
* The prefix of the HTML ID and class
* @type {string}
*/
#prefix = "accounting-original-entry-selector";
#prefix = "accounting-original-line-item-selector";
/**
* The query input
@ -60,13 +60,13 @@ class OriginalEntrySelector {
/**
* The options
* @type {OriginalEntry[]}
* @type {OriginalLineItem[]}
*/
#options;
/**
* The options by their ID
* @type {Object.<string, OriginalEntry>}
* @type {Object.<string, OriginalLineItem>}
*/
#optionById;
@ -77,21 +77,21 @@ class OriginalEntrySelector {
#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) {
this.entryEditor = entryEditor;
constructor(lineItemEditor) {
this.lineItemEditor = lineItemEditor;
this.#query = document.getElementById(this.#prefix + "-query");
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
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 = {};
for (const option of this.#options) {
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 originalEntryId {string} the ID of the original entry
* @return {Decimal} the net balance of the original entry
* @param originalLineItemId {string} the ID of the original line item
* @return {Decimal} the net balance of the original line item
*/
getNetBalance(currentEntry, form, originalEntryId) {
const otherEntries = form.getEntries().filter((entry) => entry !== currentEntry);
getNetBalance(currentLineItem, form, originalLineItemId) {
const otherLineItems = form.getLineItems().filter((lineItem) => lineItem !== currentLineItem);
let otherOffset = new Decimal(0);
for (const otherEntry of otherEntries) {
if (otherEntry.getOriginalEntryId() === originalEntryId) {
const amount = otherEntry.getAmount();
for (const otherLineItem of otherLineItems) {
if (otherLineItem.getOriginalLineItemId() === originalLineItemId) {
const amount = otherLineItem.getAmount();
if (amount !== null) {
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() {
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 = {}
for (const otherEntry of otherEntries) {
const otherOriginalEntryId = otherEntry.getOriginalEntryId();
const amount = otherEntry.getAmount();
if (otherOriginalEntryId === null || amount === null) {
for (const otherLineItem of otherLineItems) {
const otherOriginalLineItemId = otherLineItem.getOriginalLineItemId();
const amount = otherLineItem.getAmount();
if (otherOriginalLineItemId === null || amount === null) {
continue;
}
if (!(otherOriginalEntryId in otherOffsets)) {
otherOffsets[otherOriginalEntryId] = new Decimal("0");
if (!(otherOriginalLineItemId in otherOffsets)) {
otherOffsets[otherOriginalLineItemId] = new Decimal("0");
}
otherOffsets[otherOriginalEntryId] = otherOffsets[otherOriginalEntryId].plus(amount);
otherOffsets[otherOriginalLineItemId] = otherOffsets[otherOriginalLineItemId].plus(amount);
}
for (const option of this.#options) {
if (option.id in otherOffsets) {
@ -157,7 +157,7 @@ class OriginalEntrySelector {
#filterOptions() {
let hasAnyMatched = false;
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);
hasAnyMatched = true;
} 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() {
this.#currencyCode = this.entryEditor.getCurrencyCode();
this.#entryType = this.entryEditor.entryType;
this.#currencyCode = this.lineItemEditor.getCurrencyCode();
this.#side = this.lineItemEditor.side;
for (const option of this.#options) {
option.setActive(option.id === this.entryEditor.originalEntryId);
option.setActive(option.id === this.lineItemEditor.originalLineItemId);
}
this.#query.value = "";
this.#updateNetBalances();
@ -190,14 +190,14 @@ class OriginalEntrySelector {
}
/**
* An original entry.
* An original line item.
*
*/
class OriginalEntry {
class OriginalLineItem {
/**
* The original entry selector
* @type {OriginalEntrySelector}
* The original line item selector
* @type {OriginalLineItemSelector}
*/
#selector;
@ -220,10 +220,10 @@ class OriginalEntry {
date;
/**
* The entry type, either "debit" or "credit"
* The side, either "debit" or "credit"
* @type {string}
*/
#entryType;
#side;
/**
* The currency code
@ -268,7 +268,7 @@ class OriginalEntry {
netBalanceText;
/**
* The text representation of the original entry
* The text representation of the original line item
* @type {string}
*/
text;
@ -280,9 +280,9 @@ class OriginalEntry {
#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
*/
constructor(selector, element) {
@ -290,17 +290,17 @@ class OriginalEntry {
this.#element = element;
this.id = element.dataset.id;
this.date = element.dataset.date;
this.#entryType = element.dataset.entryType;
this.#side = element.dataset.side;
this.#currencyCode = element.dataset.currencyCode;
this.accountCode = element.dataset.accountCode;
this.accountText = element.dataset.accountText;
this.summary = element.dataset.summary;
this.bareNetBalance = new Decimal(element.dataset.netBalance);
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.#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.
*
* @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 query {string|null} the query term
*/
isMatched(entryType, currencyCode, query = null) {
isMatched(side, currencyCode, query = null) {
return this.netBalance.greaterThan(0)
&& this.date <= this.#selector.entryEditor.form.getDate()
&& this.#isEntryTypeMatches(entryType)
&& this.date <= this.#selector.lineItemEditor.form.getDate()
&& this.#isSideMatches(side)
&& this.#currencyCode === currencyCode
&& 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
*/
#isEntryTypeMatches(entryType) {
return (entryType === "debit" && this.#entryType === "credit")
|| (entryType === "credit" && this.#entryType === "debit");
#isSideMatches(side) {
return (side === "debit" && this.#side === "credit")
|| (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
* @return {boolean} true if the option matches, or false otherwise

View File

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

View File

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

View File

@ -1,5 +1,5 @@
/* 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.
@ -23,10 +23,10 @@
"use strict";
/**
* The journal entry editor.
* The voucher line item editor.
*
*/
class JournalEntryEditor {
class VoucherLineItemEditor {
/**
* The voucher form
@ -35,7 +35,7 @@ class JournalEntryEditor {
form;
/**
* The journal entry editor
* The voucher line item editor
* @type {HTMLFormElement}
*/
#element;
@ -47,46 +47,46 @@ class JournalEntryEditor {
#modal;
/**
* The entry type, either "debit" or "credit"
* The side, either "debit" or "credit"
* @type {string}
*/
entryType;
side;
/**
* The prefix of the HTML ID and class
* @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}
*/
#originalEntryContainer;
#originalLineItemContainer;
/**
* The control of the original entry
* The control of the original line item
* @type {HTMLDivElement}
*/
#originalEntryControl;
#originalLineItemControl;
/**
* The original entry
* The original line item
* @type {HTMLDivElement}
*/
#originalEntryText;
#originalLineItemText;
/**
* The error message of the original entry
* The error message of the original line item
* @type {HTMLDivElement}
*/
#originalEntryError;
#originalLineItemError;
/**
* The delete button of the original entry
* The delete button of the original line item
* @type {HTMLButtonElement}
*/
#originalEntryDelete;
#originalLineItemDelete;
/**
* The control of the summary
@ -137,40 +137,40 @@ class JournalEntryEditor {
#amountError;
/**
* The journal entry to edit
* @type {JournalEntrySubForm|null}
* The voucher line item to edit
* @type {LineItemSubForm|null}
*/
entry;
lineItem;
/**
* The debit or credit entry side sub-form
* @type {DebitCreditSideSubForm}
* The debit or credit side sub-form
* @type {SideSubForm}
*/
#side;
#sideSubForm;
/**
* Whether the journal entry needs offset
* Whether the voucher line item needs offset
* @type {boolean}
*/
isNeedOffset = false;
/**
* The ID of the original entry
* The ID of the original line item
* @type {string|null}
*/
originalEntryId = null;
originalLineItemId = null;
/**
* The date of the original entry
* The date of the original line item
* @type {string|null}
*/
originalEntryDate = null;
originalLineItemDate = null;
/**
* The text of the original entry
* The text of the original line item
* @type {string|null}
*/
originalEntryText = null;
originalLineItemText = null;
/**
* The account code
@ -209,13 +209,13 @@ class JournalEntryEditor {
#accountSelectors;
/**
* The original entry selector
* @type {OriginalEntrySelector}
* The original line item selector
* @type {OriginalLineItemSelector}
*/
originalEntrySelector;
originalLineItemSelector;
/**
* Constructs a new journal entry editor.
* Constructs a new voucher line item editor.
*
* @param form {VoucherForm} the voucher form
*/
@ -223,11 +223,11 @@ class JournalEntryEditor {
this.form = form;
this.#element = document.getElementById(this.#prefix);
this.#modal = document.getElementById(this.#prefix + "-modal");
this.#originalEntryContainer = document.getElementById(this.#prefix + "-original-entry-container");
this.#originalEntryControl = document.getElementById(this.#prefix + "-original-entry-control");
this.#originalEntryText = document.getElementById(this.#prefix + "-original-entry");
this.#originalEntryError = document.getElementById(this.#prefix + "-original-entry-error");
this.#originalEntryDelete = document.getElementById(this.#prefix + "-original-entry-delete");
this.#originalLineItemContainer = document.getElementById(this.#prefix + "-original-line-item-container");
this.#originalLineItemControl = document.getElementById(this.#prefix + "-original-line-item-control");
this.#originalLineItemText = document.getElementById(this.#prefix + "-original-line-item");
this.#originalLineItemError = document.getElementById(this.#prefix + "-original-line-item-error");
this.#originalLineItemDelete = document.getElementById(this.#prefix + "-original-line-item-delete");
this.#summaryControl = document.getElementById(this.#prefix + "-summary-control");
this.#summaryText = document.getElementById(this.#prefix + "-summary");
this.#summaryError = document.getElementById(this.#prefix + "-summary-error");
@ -238,19 +238,19 @@ class JournalEntryEditor {
this.#amountError = document.getElementById(this.#prefix + "-amount-error");
this.#summaryEditors = SummaryEditor.getInstances(this);
this.#accountSelectors = AccountSelector.getInstances(this);
this.originalEntrySelector = new OriginalEntrySelector(this);
this.#originalEntryControl.onclick = () => this.originalEntrySelector.onOpen()
this.#originalEntryDelete.onclick = () => this.clearOriginalEntry();
this.#summaryControl.onclick = () => this.#summaryEditors[this.entryType].onOpen();
this.#accountControl.onclick = () => this.#accountSelectors[this.entryType].onOpen();
this.originalLineItemSelector = new OriginalLineItemSelector(this);
this.#originalLineItemControl.onclick = () => this.originalLineItemSelector.onOpen()
this.#originalLineItemDelete.onclick = () => this.clearOriginalLineItem();
this.#summaryControl.onclick = () => this.#summaryEditors[this.side].onOpen();
this.#accountControl.onclick = () => this.#accountSelectors[this.side].onOpen();
this.#amountInput.onchange = () => this.#validateAmount();
this.#element.onsubmit = () => {
if (this.#validate()) {
if (this.entry === null) {
this.entry = this.#side.addJournalEntry();
if (this.lineItem === null) {
this.lineItem = this.#sideSubForm.addLineItem();
}
this.amount = this.#amountInput.value;
this.entry.save(this);
this.lineItem.save(this);
bootstrap.Modal.getInstance(this.#modal).hide();
}
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.#originalEntryContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty");
this.originalEntryId = originalEntry.id;
this.originalEntryDate = originalEntry.date;
this.originalEntryText = originalEntry.text;
this.#originalEntryText.innerText = originalEntry.text;
this.#originalLineItemContainer.classList.remove("d-none");
this.#originalLineItemControl.classList.add("accounting-not-empty");
this.originalLineItemId = originalLineItem.id;
this.originalLineItemDate = originalLineItem.date;
this.originalLineItemText = originalLineItem.text;
this.#originalLineItemText.innerText = originalLineItem.text;
this.#setEnableSummaryAccount(false);
if (originalEntry.summary === "") {
if (originalLineItem.summary === "") {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.summary = originalEntry.summary === ""? null: originalEntry.summary;
this.#summaryText.innerText = originalEntry.summary;
this.summary = originalLineItem.summary === ""? null: originalLineItem.summary;
this.#summaryText.innerText = originalLineItem.summary;
this.#accountControl.classList.add("accounting-not-empty");
this.accountCode = originalEntry.accountCode;
this.accountText = originalEntry.accountText;
this.#accountText.innerText = originalEntry.accountText;
this.#amountInput.value = String(originalEntry.netBalance);
this.#amountInput.max = String(originalEntry.netBalance);
this.accountCode = originalLineItem.accountCode;
this.accountText = originalLineItem.accountText;
this.#accountText.innerText = originalLineItem.accountText;
this.#amountInput.value = String(originalLineItem.netBalance);
this.#amountInput.max = String(originalLineItem.netBalance);
this.#amountInput.min = "0";
this.#validate();
}
/**
* Clears the original entry.
* Clears the original line item.
*
*/
clearOriginalEntry() {
clearOriginalLineItem() {
this.isNeedOffset = false;
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
this.originalEntryId = null;
this.originalEntryDate = null;
this.originalEntryText = null;
this.#originalEntryText.innerText = "";
this.#originalLineItemContainer.classList.add("d-none");
this.#originalLineItemControl.classList.remove("accounting-not-empty");
this.originalLineItemId = null;
this.originalLineItemDate = null;
this.originalLineItemText = null;
this.#originalLineItemText.innerText = "";
this.#setEnableSummaryAccount(true);
this.#accountControl.classList.remove("accounting-not-empty");
this.accountCode = null;
@ -314,7 +314,7 @@ class JournalEntryEditor {
* @return {string} the currency code
*/
getCurrencyCode() {
return this.#side.currency.getCurrencyCode();
return this.#sideSubForm.currency.getCurrencyCode();
}
/**
@ -340,7 +340,7 @@ class JournalEntryEditor {
* @param summary {string} the summary
* @param accountCode {string} the account code
* @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) {
this.isNeedOffset = isAccountNeedOffset;
@ -370,7 +370,7 @@ class JournalEntryEditor {
*
* @param code {string} the account code
* @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) {
this.isNeedOffset = isNeedOffset;
@ -388,7 +388,7 @@ class JournalEntryEditor {
*/
#validate() {
let isValid = true;
isValid = this.#validateOriginalEntry() && isValid;
isValid = this.#validateOriginalLineItem() && isValid;
isValid = this.#validateSummary() && isValid;
isValid = this.#validateAccount() && 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
* @private
*/
#validateOriginalEntry() {
this.#originalEntryControl.classList.remove("is-invalid");
this.#originalEntryError.innerText = "";
#validateOriginalLineItem() {
this.#originalLineItemControl.classList.remove("is-invalid");
this.#originalLineItemError.innerText = "";
return true;
}
@ -458,7 +458,7 @@ class JournalEntryEditor {
if (this.#amountInput.max !== "") {
if (amount.greaterThan(new Decimal(this.#amountInput.max))) {
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;
}
}
@ -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) {
this.entry = null;
this.#side = side;
this.entryType = this.#side.entryType;
this.lineItem = null;
this.#sideSubForm = side;
this.side = this.#sideSubForm.side;
this.isNeedOffset = false;
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
this.#originalEntryControl.classList.remove("is-invalid");
this.originalEntryId = null;
this.originalEntryDate = null;
this.originalEntryText = null;
this.#originalEntryText.innerText = "";
this.#originalLineItemContainer.classList.add("d-none");
this.#originalLineItemControl.classList.remove("accounting-not-empty");
this.#originalLineItemControl.classList.remove("is-invalid");
this.originalLineItemId = null;
this.originalLineItemDate = null;
this.originalLineItemText = null;
this.#originalLineItemText.innerText = "";
this.#setEnableSummaryAccount(true);
this.#summaryControl.classList.remove("accounting-not-empty");
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) {
this.entry = entry;
this.#side = entry.side;
this.entryType = this.#side.entryType;
this.isNeedOffset = entry.isNeedOffset();
this.originalEntryId = entry.getOriginalEntryId();
this.originalEntryDate = entry.getOriginalEntryDate();
this.originalEntryText = entry.getOriginalEntryText();
this.#originalEntryText.innerText = this.originalEntryText;
if (this.originalEntryId === null) {
this.#originalEntryContainer.classList.add("d-none");
this.#originalEntryControl.classList.remove("accounting-not-empty");
onEdit(lineItem) {
this.lineItem = lineItem;
this.#sideSubForm = lineItem.sideSubForm;
this.side = this.#sideSubForm.side;
this.isNeedOffset = lineItem.isNeedOffset();
this.originalLineItemId = lineItem.getOriginalLineItemId();
this.originalLineItemDate = lineItem.getOriginalLineItemDate();
this.originalLineItemText = lineItem.getOriginalLineItemText();
this.#originalLineItemText.innerText = this.originalLineItemText;
if (this.originalLineItemId === null) {
this.#originalLineItemContainer.classList.add("d-none");
this.#originalLineItemControl.classList.remove("accounting-not-empty");
} else {
this.#originalEntryContainer.classList.remove("d-none");
this.#originalEntryControl.classList.add("accounting-not-empty");
this.#originalLineItemContainer.classList.remove("d-none");
this.#originalLineItemControl.classList.add("accounting-not-empty");
}
this.#setEnableSummaryAccount(!entry.isMatched && this.originalEntryId === null);
this.summary = entry.getSummary();
this.#setEnableSummaryAccount(!lineItem.isMatched && this.originalLineItemId === null);
this.summary = lineItem.getSummary();
if (this.summary === null) {
this.#summaryControl.classList.remove("accounting-not-empty");
} else {
this.#summaryControl.classList.add("accounting-not-empty");
}
this.#summaryText.innerText = this.summary === null? "": this.summary;
if (entry.getAccountCode() === null) {
if (lineItem.getAccountCode() === null) {
this.#accountControl.classList.remove("accounting-not-empty");
} else {
this.#accountControl.classList.add("accounting-not-empty");
}
this.accountCode = entry.getAccountCode();
this.accountText = entry.getAccountText();
this.accountCode = lineItem.getAccountCode();
this.accountText = lineItem.getAccountText();
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();
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();
}
@ -561,10 +561,10 @@ class JournalEntryEditor {
* @return {Decimal|null} the max amount
*/
#getMaxAmount() {
if (this.originalEntryId === null) {
if (this.originalLineItemId === 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) {
if (isEnabled) {
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.add("accounting-clickable");
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.add("accounting-clickable");
} 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 %}">
<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">
{{ A_("The entries in the account need offset.") }}
{{ A_("The line items in the account need offset.") }}
</label>
</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)
First written: 2023/3/8
#}
<div>{{ entry.date|accounting_format_date }}</div>
<div>{{ entry.account.title|title }}</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.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>{{ line_item.date|accounting_format_date }}</div>
<div>{{ line_item.account.title|title }}</div>
<div>{{ line_item.summary|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.expense|accounting_format_amount|accounting_default }}</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
#}
<div>
{% if entry.date or entry.account %}
{% if line_item.date or line_item.account %}
<div class="text-muted small">
{% if entry.date %}
{{ entry.date|accounting_format_date }}
{% if line_item.date %}
{{ line_item.date|accounting_format_date }}
{% endif %}
{% if entry.account %}
{{ entry.account.title|title }}
{% if line_item.account %}
{{ line_item.account.title|title }}
{% endif %}
</div>
{% endif %}
{% if entry.summary %}
<div>{{ entry.summary }}</div>
{% if line_item.summary %}
<div>{{ line_item.summary }}</div>
{% endif %}
</div>
<div class="text-nowrap">
{% if entry.income %}
<span class="badge rounded-pill bg-success">+{{ entry.income|accounting_format_amount }}</span>
{% if line_item.income %}
<span class="badge rounded-pill bg-success">+{{ line_item.income|accounting_format_amount }}</span>
{% endif %}
{% if entry.expense %}
<span class="badge rounded-pill bg-warning">-{{ entry.expense|accounting_format_amount }}</span>
{% if line_item.expense %}
<span class="badge rounded-pill bg-warning">-{{ line_item.expense|accounting_format_amount }}</span>
{% endif %}
{% if entry.balance < 0 %}
<span class="badge rounded-pill bg-danger">{{ entry.balance|accounting_format_amount }}</span>
{% if line_item.balance < 0 %}
<span class="badge rounded-pill bg-danger">{{ line_item.balance|accounting_format_amount }}</span>
{% 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 %}
</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)
First written: 2023/3/8
#}
<div>{{ entry.date|accounting_format_date }}</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
<div>{{ line_item.date|accounting_format_date }}</div>
<div>{{ line_item.summary|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.credit|accounting_format_amount|accounting_default }}</div>
{% 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 %}

View File

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

View File

@ -62,26 +62,26 @@ First written: 2023/3/5
</div>
<div class="accounting-report-table-body">
{% if report.brought_forward %}
{% with entry = report.brought_forward %}
{% with line_item = report.brought_forward %}
<div class="accounting-report-table-row">
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
</div>
{% endwith %}
{% endif %}
{% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
{% for line_item in report.line_items %}
<a class="accounting-report-table-row" href="{{ line_item.url|accounting_append_next }}">
{% include "accounting/report/include/income-expenses-row-desktop.html" %}
</a>
{% endfor %}
</div>
{% if report.total %}
{% with entry = report.total %}
{% with line_item = report.total %}
<div class="accounting-report-table-footer">
<div class="accounting-report-table-row">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ entry.income|accounting_format_amount }}</div>
<div class="accounting-amount">{{ entry.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">{{ line_item.income|accounting_format_amount }}</div>
<div class="accounting-amount">{{ line_item.expense|accounting_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>
{% endwith %}
@ -90,19 +90,19 @@ First written: 2023/3/5
<div class="list-group d-md-none">
{% 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">
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
</div>
{% endwith %}
{% endif %}
{% for entry in report.entries %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}">
{% for line_item in report.line_items %}
<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" %}
</a>
{% endfor %}
{% 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">
{% include "accounting/report/include/income-expenses-row-mobile.html" %}
</div>

View File

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

View File

@ -63,27 +63,27 @@ First written: 2023/3/5
</div>
<div class="accounting-report-table-body">
{% if report.brought_forward %}
{% with entry = report.brought_forward %}
{% with line_item = report.brought_forward %}
<div class="accounting-report-table-row">
{% include "accounting/report/include/ledger-row-desktop.html" %}
</div>
{% endwith %}
{% endif %}
{% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ entry.url|accounting_append_next }}">
{% for line_item in report.line_items %}
<a class="accounting-report-table-row" href="{{ line_item.url|accounting_append_next }}">
{% include "accounting/report/include/ledger-row-desktop.html" %}
</a>
{% endfor %}
</div>
{% if report.total %}
{% with entry = report.total %}
{% with line_item = report.total %}
<div class="accounting-report-table-footer">
<div class="accounting-report-table-row">
<div>{{ A_("Total") }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.credit|accounting_format_amount|accounting_default }}</div>
{% 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 %}
</div>
</div>
@ -93,19 +93,19 @@ First written: 2023/3/5
<div class="list-group d-md-none">
{% 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">
{% include "accounting/report/include/ledger-row-mobile.html" %}
</div>
{% endwith %}
{% endif %}
{% for entry in report.entries %}
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ entry.url|accounting_append_next }}">
{% for line_item in report.line_items %}
<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" %}
</a>
{% endfor %}
{% 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">
{% include "accounting/report/include/ledger-row-mobile.html" %}
</div>

View File

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

View File

@ -34,11 +34,11 @@ First written: 2023/2/26
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<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>
{% with entries = currency.debit %}
{% include "accounting/voucher/include/detail-entries.html" %}
<li class="list-group-item accounting-voucher-line-item accounting-voucher-line-item-header">{{ A_("Content") }}</li>
{% with line_items = currency.debit %}
{% include "accounting/voucher/include/detail-line-items.html" %}
{% 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>{{ A_("Total") }}</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 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>
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list">
{% for entry_form in debit_forms %}
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-line-item-list">
{% for line_item_form in debit_forms %}
{% with currency_index = currency_index,
entry_type = "debit",
entry_index = loop.index,
entry_id = entry_form.eid.data,
only_one_entry_form = debit_forms|length == 1,
account_code_data = entry_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_need_offset = entry_form.is_need_offset,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/voucher/include/form-entry-item.html" %}
side = "debit",
line_item_index = loop.index,
line_item_id = line_item_form.eid.data,
only_one_line_item_form = debit_forms|length == 1,
account_code_data = line_item_form.account_code.data|accounting_default,
account_code_error = line_item_form.account_code.errors,
account_text = line_item_form.account_text,
summary_data = line_item_form.summary.data|accounting_default,
summary_errors = line_item_form.summary.errors,
original_line_item_id_data = line_item_form.original_line_item_id.data|accounting_default,
original_line_item_date = line_item_form.original_line_item_date|accounting_default,
original_line_item_text = line_item_form.original_line_item_text|accounting_default,
is_need_offset = line_item_form.is_need_offset,
offset_items = line_item_form.offsets,
offset_total = line_item_form.offset_total|accounting_default("0"),
net_balance_data = line_item_form.net_balance,
net_balance_text = line_item_form.net_balance|accounting_format_amount,
amount_data = line_item_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = line_item_form.amount.errors,
amount_text = line_item_form.amount.data|accounting_format_amount,
line_item_errors = line_item_form.all_errors %}
{% include "accounting/voucher/include/form-line-item.html" %}
{% endwith %}
{% endfor %}
</ul>
@ -79,7 +79,7 @@ First written: 2023/2/25
</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>
{{ A_("New") }}
</button>

View File

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

View File

@ -19,35 +19,35 @@ account-selector-modal.html: The modal for the account selector
Author: imacat@mail.imacat.idv.tw (imacat)
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-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-account-selector-{{ entry_type }}-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>
<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-line-item-editor-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<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">
<label class="input-group-text" for="accounting-account-selector-{{ entry_type }}-query">
<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-{{ side }}-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</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 %}
<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 }}
</li>
{% 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>
<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 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 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 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-{{ 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>

View File

@ -1,6 +1,6 @@
{#
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.
@ -20,28 +20,28 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/14
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
{% for entry in entries %}
<li class="list-group-item accounting-voucher-entry">
{% for line_item in line_items %}
<li class="list-group-item accounting-voucher-line-item">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
<div class="small">{{ line_item.account }}</div>
{% if line_item.summary is not none %}
<div>{{ line_item.summary }}</div>
{% endif %}
{% if entry.original_entry %}
<div class="fst-italic small accounting-original-entry">
<a href="{{ url_for("accounting.voucher.detail", voucher=entry.original_entry.voucher)|accounting_append_next }}">
{{ A_("Offset %(entry)s", entry=entry.original_entry) }}
{% if line_item.original_line_item %}
<div class="fst-italic small accounting-original-line-item">
<a href="{{ url_for("accounting.voucher.detail", voucher=line_item.original_line_item.voucher)|accounting_append_next }}">
{{ A_("Offset %(item)s", item=line_item.original_line_item) }}
</a>
</div>
{% endif %}
{% if entry.is_need_offset %}
<div class="fst-italic small accounting-offset-entries">
{% if entry.offsets %}
{% if line_item.is_need_offset %}
<div class="fst-italic small accounting-offset-line-items">
{% if line_item.offsets %}
<div class="d-flex justify-content-between">
<div>{{ A_("Offsets") }}</div>
<ul class="ms-2 ps-0">
{% for offset in entry.offsets %}
{% for offset in line_item.offsets %}
<li>
<a href="{{ url_for("accounting.voucher.detail", voucher=offset.voucher)|accounting_append_next }}">
{{ offset.voucher.date|accounting_format_date }} {{ offset.amount|accounting_format_amount }}
@ -50,10 +50,10 @@ First written: 2023/3/14
{% endfor %}
</ul>
</div>
{% if entry.balance %}
{% if line_item.balance %}
<div class="d-flex justify-content-between">
<div>{{ A_("Net balance") }}</div>
<div>{{ entry.balance|accounting_format_amount }}</div>
<div>{{ line_item.balance|accounting_format_amount }}</div>
</div>
{% else %}
<div class="d-flex justify-content-between">
@ -68,7 +68,7 @@ First written: 2023/3/14
</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_format_amount }}</div>
<div>{{ line_item.amount|accounting_format_amount }}</div>
</div>
</li>
{% 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 %}
<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/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/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>
{% endblock %}
@ -39,7 +39,7 @@ First written: 2023/2/26
</a>
</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 }}
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
@ -88,8 +88,8 @@ First written: 2023/2/26
</div>
</form>
{% include "accounting/voucher/include/journal-entry-editor-modal.html" %}
{% include "accounting/voucher/include/voucher-line-item-editor-modal.html" %}
{% block form_modals %}{% endblock %}
{% include "accounting/voucher/include/original-entry-selector-modal.html" %}
{% include "accounting/voucher/include/original-line-item-selector-modal.html" %}
{% 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)
First written: 2023/2/28
#}
<form id="accounting-summary-editor-{{ summary_editor.type }}" class="accounting-summary-editor" data-entry-type="{{ summary_editor.type }}">
<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">
<form id="accounting-summary-editor-{{ summary_editor.side }}" class="accounting-summary-editor" data-side="{{ summary_editor.side }}">
<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-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-summary-editor-{{ summary_editor.type }}-modal-label">
<label for="accounting-summary-editor-{{ summary_editor.type }}-summary">{{ A_("Summary") }}</label>
<h1 class="modal-title fs-5" id="accounting-summary-editor-{{ summary_editor.side }}-modal-label">
<label for="accounting-summary-editor-{{ summary_editor.side }}-summary">{{ A_("Summary") }}</label>
</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 class="modal-body">
<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">
<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">
<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.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...") }}
</button>
</div>
@ -40,43 +40,43 @@ First written: 2023/2/28
{# Tab navigation #}
<ul class="nav nav-tabs mb-2">
<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") }}
</span>
</li>
<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") }}
</span>
</li>
<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") }}
</span>
</li>
<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") }}
</span>
</li>
<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") }}
</span>
</li>
</ul>
{# 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">
<input id="accounting-summary-editor-{{ summary_editor.type }}-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>
<div id="accounting-summary-editor-{{ summary_editor.type }}-general-tag-error" class="invalid-feedback"></div>
<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.side }}-general-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.side }}-general-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% 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 }}
</button>
{% endfor %}
@ -84,16 +84,16 @@ First written: 2023/2/28
</div>
{# 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">
<input id="accounting-summary-editor-{{ summary_editor.type }}-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>
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-tag-error" class="invalid-feedback"></div>
<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.side }}-travel-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.side }}-travel-tag-error" class="invalid-feedback"></div>
</div>
<div>
{% 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 }}
</button>
{% endfor %}
@ -101,40 +101,40 @@ First written: 2023/2/28
<div class="d-flex justify-content-between mt-2">
<div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-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>
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-from-error" class="invalid-feedback"></div>
<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.side }}-travel-from">{{ A_("From") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.side }}-travel-from-error" class="invalid-feedback"></div>
</div>
<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-outline-primary accounting-summary-editor-{{ summary_editor.type }}-travel-direction" type="button" tabindex="-1" data-arrow="&harr;">&harr;</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.side }}-travel-direction" type="button" tabindex="-1" data-arrow="&harr;">&harr;</button>
</div>
<div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-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>
<div id="accounting-summary-editor-{{ summary_editor.type }}-travel-to-error" class="invalid-feedback"></div>
<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.side }}-travel-to">{{ A_("To") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.side }}-travel-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# 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="form-floating me-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-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>
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-tag-error" class="invalid-feedback"></div>
<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.side }}-bus-tag">{{ A_("Tag") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.side }}-bus-tag-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-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>
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-route-error" class="invalid-feedback"></div>
<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.side }}-bus-route">{{ A_("Route") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.side }}-bus-route-error" class="invalid-feedback"></div>
</div>
</div>
<div>
{% 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 }}
</button>
{% endfor %}
@ -142,50 +142,50 @@ First written: 2023/2/28
<div class="d-flex justify-content-between mt-2">
<div class="form-floating me-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-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>
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-from-error" class="invalid-feedback"></div>
<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.side }}-bus-from">{{ A_("From") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.side }}-bus-from-error" class="invalid-feedback"></div>
</div>
<div class="form-floating">
<input id="accounting-summary-editor-{{ summary_editor.type }}-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>
<div id="accounting-summary-editor-{{ summary_editor.type }}-bus-to-error" class="invalid-feedback"></div>
<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.side }}-bus-to">{{ A_("To") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.side }}-bus-to-error" class="invalid-feedback"></div>
</div>
</div>
</div>
{# 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 #}
</div>
{# 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">
<input id="accounting-summary-editor-{{ summary_editor.type }}-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>
<div id="accounting-summary-editor-{{ summary_editor.type }}-annotation-number-error" class="invalid-feedback"></div>
<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.side }}-annotation-number">{{ A_("The number of items") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.side }}-annotation-number-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mt-2">
<input id="accounting-summary-editor-{{ summary_editor.type }}-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>
<div id="accounting-summary-editor-{{ summary_editor.type }}-annotation-note-error" class="invalid-feedback"></div>
<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.side }}-annotation-note">{{ A_("Note") }}</label>
<div id="accounting-summary-editor-{{ summary_editor.side }}-annotation-note-error" class="invalid-feedback"></div>
</div>
</div>
{# The suggested accounts #}
<div class="mt-3">
{% 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 }}
</button>
{% endfor %}
</div>
</div>
<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 id="accounting-summary-editor-{{ summary_editor.type }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</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.side }}-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</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>
<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>
{% with entries = currency.credit %}
{% include "accounting/voucher/include/detail-entries.html" %}
<li class="list-group-item accounting-voucher-line-item accounting-voucher-line-item-header">{{ A_("Content") }}</li>
{% with line_items = currency.credit %}
{% include "accounting/voucher/include/detail-line-items.html" %}
{% 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>{{ A_("Total") }}</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 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>
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
{% for entry_form in credit_forms %}
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-line-item-list">
{% for line_item_form in credit_forms %}
{% with currency_index = currency_index,
entry_type = "credit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = entry_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_need_offset = entry_form.is_need_offset,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/voucher/include/form-entry-item.html" %}
side = "credit",
line_item_index = loop.index,
only_one_line_item_form = debit_forms|length == 1,
line_item_id = line_item_form.eid.data,
account_code_data = line_item_form.account_code.data|accounting_default,
account_code_error = line_item_form.account_code.errors,
account_text = line_item_form.account_text,
summary_data = line_item_form.summary.data|accounting_default,
summary_errors = line_item_form.summary.errors,
original_line_item_id_data = line_item_form.original_line_item_id.data|accounting_default,
original_line_item_date = line_item_form.original_line_item_date|accounting_default,
original_line_item_text = line_item_form.original_line_item_text|accounting_default,
is_need_offset = line_item_form.is_need_offset,
offset_items = line_item_form.offsets,
offset_total = line_item_form.offset_total|accounting_default("0"),
net_balance_data = line_item_form.net_balance,
net_balance_text = line_item_form.net_balance|accounting_format_amount,
amount_data = line_item_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = line_item_form.amount.errors,
amount_text = line_item_form.amount.data|accounting_format_amount,
line_item_errors = line_item_form.all_errors %}
{% include "accounting/voucher/include/form-line-item.html" %}
{% endwith %}
{% endfor %}
</ul>
@ -79,7 +79,7 @@ First written: 2023/2/25
</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>
{{ A_("New") }}
</button>

View File

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

View File

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

View File

@ -42,35 +42,35 @@ First written: 2023/2/25
</div>
<div class="row">
{# The debit entries #}
{# The debit line items #}
<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 %}">
<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">
{% for entry_form in debit_forms %}
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-line-item-list">
{% for line_item_form in debit_forms %}
{% with currency_index = currency_index,
entry_type = "debit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = entry_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_need_offset = entry_form.is_need_offset,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default,
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/voucher/include/form-entry-item.html" %}
side = "debit",
line_item_index = loop.index,
only_one_line_item_form = debit_forms|length == 1,
line_item_id = line_item_form.eid.data,
account_code_data = line_item_form.account_code.data|accounting_default,
account_code_error = line_item_form.account_code.errors,
account_text = line_item_form.account_text,
summary_data = line_item_form.summary.data|accounting_default,
summary_errors = line_item_form.summary.errors,
original_line_item_id_data = line_item_form.original_line_item_id.data|accounting_default,
original_line_item_date = line_item_form.original_line_item_date|accounting_default,
original_line_item_text = line_item_form.original_line_item_text|accounting_default,
is_need_offset = line_item_form.is_need_offset,
offset_items = line_item_form.offsets,
offset_total = line_item_form.offset_total|accounting_default,
net_balance_data = line_item_form.net_balance,
net_balance_text = line_item_form.net_balance|accounting_format_amount,
amount_data = line_item_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = line_item_form.amount.errors,
amount_text = line_item_form.amount.data|accounting_format_amount,
line_item_errors = line_item_form.all_errors %}
{% include "accounting/voucher/include/form-line-item.html" %}
{% endwith %}
{% endfor %}
</ul>
@ -81,7 +81,7 @@ First written: 2023/2/25
</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>
{{ A_("New") }}
</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>
{# The credit entries #}
{# The credit line items #}
<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 %}">
<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">
{% for entry_form in credit_forms %}
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-line-item-list">
{% for line_item_form in credit_forms %}
{% with currency_index = currency_index,
entry_type = "credit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = entry_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = entry_form.summary.data|accounting_default,
summary_errors = entry_form.summary.errors,
original_entry_id_data = entry_form.original_entry_id.data|accounting_default,
original_entry_date = entry_form.original_entry_date|accounting_default,
original_entry_text = entry_form.original_entry_text|accounting_default,
is_need_offset = entry_form.is_need_offset,
offset_entries = entry_form.offsets,
offset_total = entry_form.offset_total|accounting_default("0"),
net_balance_data = entry_form.net_balance,
net_balance_text = entry_form.net_balance|accounting_format_amount,
amount_data = entry_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/voucher/include/form-entry-item.html" %}
side = "credit",
line_item_index = loop.index,
only_one_line_item_form = debit_forms|length == 1,
line_item_id = line_item_form.eid.data,
account_code_data = line_item_form.account_code.data|accounting_default,
account_code_error = line_item_form.account_code.errors,
account_text = line_item_form.account_text,
summary_data = line_item_form.summary.data|accounting_default,
summary_errors = line_item_form.summary.errors,
original_line_item_id_data = line_item_form.original_line_item_id.data|accounting_default,
original_line_item_date = line_item_form.original_line_item_date|accounting_default,
original_line_item_text = line_item_form.original_line_item_text|accounting_default,
is_need_offset = line_item_form.is_need_offset,
offset_items = line_item_form.offsets,
offset_total = line_item_form.offset_total|accounting_default("0"),
net_balance_data = line_item_form.net_balance,
net_balance_text = line_item_form.net_balance|accounting_format_amount,
amount_data = line_item_form.amount.data|accounting_voucher_format_amount_input,
amount_errors = line_item_form.amount.errors,
amount_text = line_item_form.amount.data|accounting_format_amount,
line_item_errors = line_item_form.all_errors %}
{% include "accounting/voucher/include/form-line-item.html" %}
{% endwith %}
{% endfor %}
</ul>
@ -129,7 +129,7 @@ First written: 2023/2/25
</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>
{{ A_("New") }}
</button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,20 +14,20 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The SQLAlchemy alias for the offset entries.
"""The SQLAlchemy alias for the offset items.
"""
import typing as t
import sqlalchemy as sa
from accounting.models import JournalEntry
from accounting.models import VoucherLineItem
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:
@ -36,4 +36,4 @@ def offset_alias() -> sa.Alias:
def as_alias(alias: t.Any) -> sa.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
def _entry_template(self) -> str:
"""Renders and returns the template for the journal entry sub-form.
def _line_item_template(self) -> str:
"""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(
"accounting/voucher/include/form-entry-item.html",
"accounting/voucher/include/form-line-item.html",
currency_index="CURRENCY_INDEX",
entry_type="ENTRY_TYPE",
entry_index="ENTRY_INDEX")
side="SIDE",
line_item_index="LINE_ITEM_INDEX")
class CashReceiptVoucher(VoucherOperator):
@ -113,7 +113,7 @@ class CashReceiptVoucher(VoucherOperator):
form=form,
voucher_type=VoucherType.CASH_RECEIPT,
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:
"""Renders the template for the detail page.
@ -135,7 +135,7 @@ class CashReceiptVoucher(VoucherOperator):
return render_template("accounting/voucher/receipt/edit.html",
voucher=voucher, form=form,
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:
"""Checks and returns whether the voucher belongs to the type.
@ -182,7 +182,7 @@ class CashDisbursementVoucher(VoucherOperator):
form=form,
voucher_type=VoucherType.CASH_DISBURSEMENT,
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:
"""Renders the template for the detail page.
@ -204,7 +204,7 @@ class CashDisbursementVoucher(VoucherOperator):
return render_template("accounting/voucher/disbursement/edit.html",
voucher=voucher, form=form,
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:
"""Checks and returns whether the voucher belongs to the type.
@ -251,7 +251,7 @@ class TransferVoucher(VoucherOperator):
form=form,
voucher_type=VoucherType.TRANSFER,
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:
"""Renders the template for the detail page.
@ -273,7 +273,7 @@ class TransferVoucher(VoucherOperator):
return render_template("accounting/voucher/transfer/edit.html",
voucher=voucher, form=form,
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:
"""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.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The selectable original entries.
"""The selectable original line items.
"""
from decimal import Decimal
@ -23,57 +23,59 @@ import sqlalchemy as sa
from sqlalchemy.orm import selectinload
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 .offset_alias import offset_alias
def get_selectable_original_entries(
entry_id_on_form: set[int], is_payable: bool, is_receivable: bool) \
-> list[JournalEntry]:
"""Queries and returns the selectable original entries, with their net
def get_selectable_original_line_items(
line_item_id_on_form: set[int], is_payable: bool,
is_receivable: bool) -> list[VoucherLineItem]:
"""Queries and returns the selectable original line items, with their net
balances. The offset amounts of the form is excluded.
:param entry_id_on_form: The ID of the journal entries on the form.
:param is_payable: True to check the payable original entries, or False
:param line_item_id_on_form: The ID of the line items on the form.
:param is_payable: True to check the payable original line items, or False
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.
: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
offset: sa.Alias = offset_alias()
net_balance: sa.Label = (JournalEntry.amount + sa.func.sum(sa.case(
(offset.c.id.in_(entry_id_on_form), 0),
(be(offset.c.is_debit == JournalEntry.is_debit), offset.c.amount),
net_balance: sa.Label = (VoucherLineItem.amount + sa.func.sum(sa.case(
(offset.c.id.in_(line_item_id_on_form), 0),
(be(offset.c.is_debit == VoucherLineItem.is_debit), offset.c.amount),
else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
sub_conditions: list[sa.BinaryExpression] = []
if is_payable:
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntry.is_debit)))
sa.not_(VoucherLineItem.is_debit)))
if is_receivable:
sub_conditions.append(sa.and_(Account.base_code.startswith("1"),
JournalEntry.is_debit))
VoucherLineItem.is_debit))
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(offset, be(JournalEntry.id == offset.c.original_entry_id),
.join(offset, be(VoucherLineItem.id == offset.c.original_line_item_id),
isouter=True)\
.filter(*conditions)\
.group_by(JournalEntry.id)\
.group_by(VoucherLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
net_balances: dict[int, Decimal] \
= {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()}
entries: list[JournalEntry] = JournalEntry.query\
.filter(JournalEntry.id.in_({x for x in net_balances}))\
line_items: list[VoucherLineItem] = VoucherLineItem.query\
.filter(VoucherLineItem.id.in_({x for x in net_balances}))\
.join(Voucher)\
.order_by(Voucher.date, JournalEntry.is_debit, JournalEntry.no)\
.options(selectinload(JournalEntry.currency),
selectinload(JournalEntry.account),
selectinload(JournalEntry.voucher)).all()
for entry in entries:
entry.net_balance = entry.amount if net_balances[entry.id] is None \
else net_balances[entry.id]
return entries
.order_by(Voucher.date, VoucherLineItem.is_debit, VoucherLineItem.no)\
.options(selectinload(VoucherLineItem.currency),
selectinload(VoucherLineItem.account),
selectinload(VoucherLineItem.voucher)).all()
for line_item in line_items:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
else net_balances[line_item.id]
return line_items

View File

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

View File

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

View File

@ -42,7 +42,7 @@ class SummeryEditorTestCase(unittest.TestCase):
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Voucher, \
JournalEntry
VoucherLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
@ -56,7 +56,7 @@ class SummeryEditorTestCase(unittest.TestCase):
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Voucher.query.delete()
JournalEntry.query.delete()
VoucherLineItem.query.delete()
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()
with self.app.app_context():
from accounting.models import BaseAccount, Voucher, \
JournalEntry
VoucherLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
@ -67,7 +67,7 @@ class CashReceiptVoucherTestCase(unittest.TestCase):
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Voucher.query.delete()
JournalEntry.query.delete()
VoucherLineItem.query.delete()
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.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()
key: str = [x for x in form.keys()
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.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()
key: str = [x for x in form.keys()
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()
with self.app.app_context():
from accounting.models import BaseAccount, Voucher, \
JournalEntry
VoucherLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
@ -639,7 +639,7 @@ class CashDisbursementVoucherTestCase(unittest.TestCase):
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Voucher.query.delete()
JournalEntry.query.delete()
VoucherLineItem.query.delete()
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.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()
key: str = [x for x in form.keys()
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.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()
key: str = [x for x in form.keys()
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()
with self.app.app_context():
from accounting.models import BaseAccount, Voucher, \
JournalEntry
VoucherLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
@ -1218,7 +1218,7 @@ class TransferVoucherTestCase(unittest.TestCase):
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Voucher.query.delete()
JournalEntry.query.delete()
VoucherLineItem.query.delete()
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.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()
key: str = [x for x in form.keys()
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.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()
key: str = [x for x in form.keys()
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.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()
key: str = [x for x in form.keys()
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.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()
key: str = [x for x in form.keys()
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()
with self.app.app_context():
from accounting.models import BaseAccount, Voucher, \
JournalEntry
VoucherLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
@ -2070,7 +2070,7 @@ class VoucherReorderTestCase(unittest.TestCase):
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Voucher.query.delete()
JournalEntry.query.delete()
VoucherLineItem.query.delete()
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
class JournalEntryData:
"""The journal entry data."""
class VoucherLineItemData:
"""The voucher line item data."""
def __init__(self, account: str, summary: str, amount: str,
original_entry: JournalEntryData | None = None):
"""Constructs the journal entry data.
original_line_item: VoucherLineItemData | None = None):
"""Constructs the voucher line item data.
:param account: The account code.
:param summary: The summary.
: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.id: 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.summary: str = summary
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]:
"""Returns the journal entry as form data.
"""Returns the line item as form data.
:param prefix: The prefix of the form fields.
:param entry_type: The entry type, either "debit" or "credit".
:param index: The entry index.
:param side: The side, either "debit" or "credit".
:param index: The line item index.
:param is_update: True for an update operation, or False otherwise
: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,
f"{prefix}-summary": self.summary,
f"{prefix}-amount": str(self.amount)}
if is_update and self.id != -1:
form[f"{prefix}-eid"] = str(self.id)
form[f"{prefix}-no"] = str(index) if self.no == -1 else str(self.no)
if self.original_entry is not None:
assert self.original_entry.id != -1
form[f"{prefix}-original_entry_id"] = str(self.original_entry.id)
if self.original_line_item is not None:
assert self.original_line_item.id != -1
form[f"{prefix}-original_line_item_id"] \
= str(self.original_line_item.id)
return form
class CurrencyData:
"""The voucher currency data."""
def __init__(self, currency: str, debit: list[JournalEntryData],
credit: list[JournalEntryData]):
def __init__(self, currency: str, debit: list[VoucherLineItemData],
credit: list[VoucherLineItemData]):
"""Constructs the voucher currency data.
:param currency: The currency code.
:param debit: The debit journal entries.
:param credit: The credit journal entries.
:param debit: The debit line items.
:param credit: The credit line items.
"""
self.code: str = currency
self.debit: list[JournalEntryData] = debit
self.credit: list[JournalEntryData] = credit
self.debit: list[VoucherLineItemData] = debit
self.credit: list[VoucherLineItemData] = credit
def form(self, index: int, is_update: bool) -> dict[str, str]:
"""Returns the currency as form data.
@ -118,10 +120,10 @@ class VoucherData:
self.currencies: list[CurrencyData] = currencies
self.note: str | None = None
for currency in self.currencies:
for entry in currency.debit:
entry.voucher = self
for entry in currency.credit:
entry.voucher = self
for line_item in currency.debit:
line_item.voucher = self
for line_item in currency.credit:
line_item.voucher = self
def new_form(self, csrf_token: str) -> dict[str, str]:
"""Returns the voucher as a creation form.
@ -173,19 +175,19 @@ class TestData:
self.csrf_token: str = csrf_token
def couple(summary: str, amount: str, debit: str, credit: str) \
-> tuple[JournalEntryData, JournalEntryData]:
"""Returns a couple of debit-credit journal entries.
-> tuple[VoucherLineItemData, VoucherLineItemData]:
"""Returns a couple of debit-credit line items.
:param summary: The summary.
:param amount: The amount.
:param debit: The debit 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),\
JournalEntryData(credit, summary, amount)
return VoucherLineItemData(debit, summary, amount),\
VoucherLineItemData(credit, summary, amount)
# Receivable original entries
# Receivable original line items
self.e_r_or1d, self.e_r_or1c = couple(
"Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
self.e_r_or2d, self.e_r_or2c = couple(
@ -195,7 +197,7 @@ class TestData:
self.e_r_or4d, self.e_r_or4c = couple(
"Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
# Payable original entries
# Payable original line items
self.e_p_or1d, self.e_p_or1c = couple(
"Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
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_or2)
# Receivable offset entries
# Receivable offset items
self.e_r_of1d, self.e_r_of1c = couple(
"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(
"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(
"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(
"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(
"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(
"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(
"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(
"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(
"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(
"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
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}"
form[f"{currency_prefix}-no"] = str(currency_no)
form[f"{currency_prefix}-code"] = currency.code
entry_indices_used: set[int]
entry_no: int
line_item_indices_used: set[int]
line_item_no: int
prefix: str
entry_indices_used = set()
entry_no = 0
for entry in currency.debit:
entry_index: int = __get_new_index(entry_indices_used)
entry_no = entry_no + 3 + randbelow(3)
prefix = f"{currency_prefix}-debit-{entry_index}"
form[f"{prefix}-eid"] = str(entry.id)
form[f"{prefix}-no"] = str(entry_no)
form[f"{prefix}-account_code"] = entry.account.code
line_item_indices_used = set()
line_item_no = 0
for line_item in currency.debit:
line_item_index: int = __get_new_index(line_item_indices_used)
line_item_no = line_item_no + 3 + randbelow(3)
prefix = f"{currency_prefix}-debit-{line_item_index}"
form[f"{prefix}-eid"] = str(line_item.id)
form[f"{prefix}-no"] = str(line_item_no)
form[f"{prefix}-account_code"] = line_item.account.code
form[f"{prefix}-summary"] \
= " " if entry.summary is None else f" {entry.summary} "
form[f"{prefix}-amount"] = str(entry.amount)
= " " if line_item.summary is None \
else f" {line_item.summary} "
form[f"{prefix}-amount"] = str(line_item.amount)
entry_indices_used = set()
entry_no = 0
for entry in currency.credit:
entry_index: int = __get_new_index(entry_indices_used)
entry_no = entry_no + 3 + randbelow(3)
prefix = f"{currency_prefix}-credit-{entry_index}"
form[f"{prefix}-eid"] = str(entry.id)
form[f"{prefix}-no"] = str(entry_no)
form[f"{prefix}-account_code"] = entry.account.code
line_item_indices_used = set()
line_item_no = 0
for line_item in currency.credit:
line_item_index: int = __get_new_index(line_item_indices_used)
line_item_no = line_item_no + 3 + randbelow(3)
prefix = f"{currency_prefix}-credit-{line_item_index}"
form[f"{prefix}-eid"] = str(line_item.id)
form[f"{prefix}-no"] = str(line_item_no)
form[f"{prefix}-account_code"] = line_item.account.code
form[f"{prefix}-summary"] \
= " " if entry.summary is None else f" {entry.summary} "
form[f"{prefix}-amount"] = str(entry.amount)
= " " if line_item.summary is None else f" {line_item.summary} "
form[f"{prefix}-amount"] = str(line_item.amount)
return form
@ -216,7 +217,7 @@ def get_update_form(voucher_id: int, app: Flask,
form: dict[str, str] = get_unchanged_update_form(
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")
if is_debit is None or is_debit:
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) \
-> 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 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]
m = re.match(r"^((.+-)\d+-)account_code$", key)
debit_prefix: str = m.group(2)
entry_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{entry_prefix}amount"])
form = {x: form[x] for x in form if not x.startswith(entry_prefix)}
line_item_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{line_item_prefix}amount"])
form = {x: form[x] for x in form if not x.startswith(line_item_prefix)}
# Add a new travel disbursement
indices: set[int] = set()
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}-account_code"] = Accounts.TRAVEL
# Swap the cash and the bank order
key_cash: str = __get_entry_no_key(form, currency_prefix, Accounts.CASH)
key_bank: str = __get_entry_no_key(form, currency_prefix, Accounts.BANK)
key_cash: str = __get_line_item_no_key(form, currency_prefix, Accounts.CASH)
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]
return form
def __mess_up_credit(form: dict[str, str], currency_prefix: 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 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]
m = re.match(r"^((.+-)\d+-)account_code$", key)
credit_prefix: str = m.group(2)
entry_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{entry_prefix}amount"])
form = {x: form[x] for x in form if not x.startswith(entry_prefix)}
line_item_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{line_item_prefix}amount"])
form = {x: form[x] for x in form if not x.startswith(line_item_prefix)}
# Add a new agency receipt
indices: set[int] = set()
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}-account_code"] = Accounts.AGENCY
# Swap the service and the interest order
key_srv: str = __get_entry_no_key(form, currency_prefix, Accounts.SERVICE)
key_int: str = __get_entry_no_key(form, currency_prefix, Accounts.INTEREST)
key_srv: str = __get_line_item_no_key(form, currency_prefix, Accounts.SERVICE)
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]
return form
@ -361,14 +362,14 @@ def __mess_up_currencies(form: dict[str, str]) -> dict[str, str]:
return form
def __get_entry_no_key(form: dict[str, str], currency_prefix: str,
code: str) -> str:
"""Returns the key of an entry number in the form data.
def __get_line_item_no_key(form: dict[str, str], currency_prefix: str,
code: str) -> str:
"""Returns the key of a line item number in the form data.
:param form: The form data.
:param currency_prefix: The prefix of the currency.
: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
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:
"""Removes credit entries in a currency sub-form.
"""Removes debit line items in a currency sub-form.
:param form: The form data.
: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:
"""Removes credit entries in a currency sub-form.
"""Removes credit line items in a currency sub-form.
:param form: The form data.
:return: None.