Renamed "journal entry" to "voucher line item", and "entry type" to "side".
This commit is contained in:
		| @@ -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": | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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, | ||||
|     """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 entry.""" | ||||
|     original_entry = db.relationship("JournalEntry", back_populates="offsets", | ||||
|     """The ID of the original line item.""" | ||||
|     original_line_item = db.relationship("VoucherLineItem", | ||||
|                                          back_populates="offsets", | ||||
|                                          remote_side=id, passive_deletes=True) | ||||
|     """The original entry.""" | ||||
|     offsets = db.relationship("JournalEntry", back_populates="original_entry") | ||||
|     """The offset entries.""" | ||||
|     """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 | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
|         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]) | ||||
|         entry.expense = sum([x.expense for x in self.entries | ||||
|         line_item.expense = sum([x.expense for x in self.line_items | ||||
|                                  if x.expense is not None]) | ||||
|         entry.balance = entry.income - entry.expense | ||||
|         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) | ||||
|   | ||||
| @@ -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)\ | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
|         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]) | ||||
|         entry.credit = sum([x.credit for x in self.entries | ||||
|         line_item.credit = sum([x.credit for x in self.line_items | ||||
|                                 if x.credit is not None]) | ||||
|         entry.balance = entry.debit - entry.credit | ||||
|         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) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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)\ | ||||
|   | ||||
| @@ -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()] | ||||
|   | ||||
| @@ -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); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
|   | ||||
| @@ -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 | ||||
| @@ -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; | ||||
|     } | ||||
|   | ||||
| @@ -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(); | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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 { | ||||
| @@ -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> | ||||
|  | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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 %} | ||||
| @@ -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 #} | ||||
| @@ -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 #} | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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> | ||||
|  | ||||
| @@ -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> | ||||
|  | ||||
| @@ -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="→">→</button> | ||||
|                 <button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.type }}-travel-direction" type="button" tabindex="-1" data-arrow="↔">↔</button> | ||||
|                 <button class="btn btn-primary accounting-summary-editor-{{ summary_editor.side }}-travel-direction accounting-default" type="button" tabindex="-1" data-arrow="→">→</button> | ||||
|                 <button class="btn btn-outline-primary accounting-summary-editor-{{ summary_editor.side }}-travel-direction" type="button" tabindex="-1" data-arrow="↔">↔</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> | ||||
|   | ||||
| @@ -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> | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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 %} | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
|             .filter(VoucherLineItem.id.in_({x.eid.data for x in form.line_items | ||||
|                                             if x.eid.data is not None}))\ | ||||
|             .group_by(JournalEntry.id, JournalEntry.currency_code)\ | ||||
|             .group_by(VoucherLineItem.id, VoucherLineItem.currency_code)\ | ||||
|             .having(sa.func.count(offset.c.id) > 0).all() | ||||
|         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 | ||||
|         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(JournalEntry.id))\ | ||||
|             .filter(JournalEntry.original_entry_id.in_(entry_id)) | ||||
|         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: | ||||
|   | ||||
| @@ -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 | ||||
| @@ -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}, | ||||
|         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_entry_options | ||||
|         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 | ||||
|         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, | ||||
|     def _make_cash_line_item(self, forms: list[LineItemForm], is_debit: bool, | ||||
|                              currency_code: str, no: int) -> None: | ||||
|         """Composes the cash journal entry at the other side of the cash | ||||
|         """Composes the cash line item at the other side of the cash | ||||
|         voucher. | ||||
|  | ||||
|         :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,41 +451,42 @@ 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._add_line_item(credit_form, currency.code.data, | ||||
|                                             self._credit_no) | ||||
|                         self._credit_no = self._credit_no + 1 | ||||
|  | ||||
| @@ -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._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,20 +570,20 @@ 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._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._add_line_item(credit_form, currency.code.data, | ||||
|                                             self._credit_no) | ||||
|                         self._credit_no = self._credit_no + 1 | ||||
|  | ||||
|   | ||||
| @@ -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")) | ||||
|   | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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 | ||||
| @@ -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) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|                 [VoucherLineItemData(Accounts.RECEIVABLE, | ||||
|                                      self.data.e_r_or1d.summary, "300", | ||||
|                                   original_entry=self.data.e_r_or1d), | ||||
|                  JournalEntryData(Accounts.RECEIVABLE, | ||||
|                                      original_line_item=self.data.e_r_or1d), | ||||
|                  VoucherLineItemData(Accounts.RECEIVABLE, | ||||
|                                      self.data.e_r_or1d.summary, "100", | ||||
|                                   original_entry=self.data.e_r_or1d), | ||||
|                  JournalEntryData(Accounts.RECEIVABLE, | ||||
|                                      original_line_item=self.data.e_r_or1d), | ||||
|                  VoucherLineItemData(Accounts.RECEIVABLE, | ||||
|                                      self.data.e_r_or3d.summary, "100", | ||||
|                                   original_entry=self.data.e_r_or3d)])]) | ||||
|                                      original_line_item=self.data.e_r_or3d)])]) | ||||
|  | ||||
|         # Non-existing original entry ID | ||||
|         # Non-existing original line item ID | ||||
|         form = voucher_data.new_form(self.csrf_token) | ||||
|         form["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, | ||||
|                 [VoucherLineItemData(Accounts.PAYABLE, | ||||
|                                      self.data.e_p_or1c.summary, "500", | ||||
|                                   original_entry=self.data.e_p_or1c), | ||||
|                  JournalEntryData(Accounts.PAYABLE, | ||||
|                                      original_line_item=self.data.e_p_or1c), | ||||
|                  VoucherLineItemData(Accounts.PAYABLE, | ||||
|                                      self.data.e_p_or1c.summary, "300", | ||||
|                                   original_entry=self.data.e_p_or1c), | ||||
|                  JournalEntryData(Accounts.PAYABLE, | ||||
|                                      original_line_item=self.data.e_p_or1c), | ||||
|                  VoucherLineItemData(Accounts.PAYABLE, | ||||
|                                      self.data.e_p_or3c.summary, "120", | ||||
|                                   original_entry=self.data.e_p_or3c)], | ||||
|                                      original_line_item=self.data.e_p_or3c)], | ||||
|                 [])]) | ||||
|  | ||||
|         # Non-existing original entry ID | ||||
|         # Non-existing original line item ID | ||||
|         form = voucher_data.new_form(self.csrf_token) | ||||
|         form["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) | ||||
|   | ||||
| @@ -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") | ||||
|  | ||||
|   | ||||
| @@ -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") | ||||
|  | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
| @@ -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, | ||||
| def __get_line_item_no_key(form: dict[str, str], currency_prefix: str, | ||||
|                            code: str) -> str: | ||||
|     """Returns the key of an entry number in the form data. | ||||
|     """Returns the key of a line item number in the form data. | ||||
|  | ||||
|     :param form: The form data. | ||||
|     :param 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. | ||||
|   | ||||
		Reference in New Issue
	
	Block a user