Compare commits
25 Commits
3a0e978f76
...
5e3e695e62
Author | SHA1 | Date | |
---|---|---|---|
5e3e695e62 | |||
510d369e9c | |||
b65cae9252 | |||
285c12406b | |||
df240472a4 | |||
1218b224fc | |||
79689ac0e5 | |||
1660e66766 | |||
12d00c9c7d | |||
428018e4a9 | |||
a8f318b0bb | |||
a3507494e5 | |||
3aa6c8d6f6 | |||
052b62cdd4 | |||
3728a4037d | |||
6eee17d44f | |||
e5cc2b5a2f | |||
ac3b5523b1 | |||
5af6fd9619 | |||
71a20cba29 | |||
4a4cf1ea40 | |||
e9824808ec | |||
c984d2d596 | |||
720e77c814 | |||
0f0412827d |
@ -20,14 +20,6 @@ accounting.journal\_entry.utils.description\_editor module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.journal\_entry.utils.offset\_alias module
|
||||
----------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.journal_entry.utils.offset_alias
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.journal\_entry.utils.operators module
|
||||
------------------------------------------------
|
||||
|
||||
|
@ -60,6 +60,22 @@ accounting.report.reports.trial\_balance module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.reports.unapplied module
|
||||
------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.reports.unapplied
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.reports.unapplied\_accounts module
|
||||
----------------------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.reports.unapplied_accounts
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
|
@ -52,6 +52,14 @@ accounting.report.utils.report\_type module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.unapplied module
|
||||
----------------------------------------
|
||||
|
||||
.. automodule:: accounting.report.utils.unapplied
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.report.utils.urls module
|
||||
-----------------------------------
|
||||
|
||||
|
@ -13,6 +13,7 @@ Subpackages
|
||||
accounting.journal_entry
|
||||
accounting.option
|
||||
accounting.report
|
||||
accounting.unmatched_offset
|
||||
accounting.utils
|
||||
|
||||
Submodules
|
||||
|
29
docs/source/accounting.unmatched_offset.rst
Normal file
29
docs/source/accounting.unmatched_offset.rst
Normal file
@ -0,0 +1,29 @@
|
||||
accounting.unmatched\_offset package
|
||||
====================================
|
||||
|
||||
Submodules
|
||||
----------
|
||||
|
||||
accounting.unmatched\_offset.queries module
|
||||
-------------------------------------------
|
||||
|
||||
.. automodule:: accounting.unmatched_offset.queries
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.unmatched\_offset.views module
|
||||
-----------------------------------------
|
||||
|
||||
.. automodule:: accounting.unmatched_offset.views
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
Module contents
|
||||
---------------
|
||||
|
||||
.. automodule:: accounting.unmatched_offset
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
@ -44,6 +44,22 @@ accounting.utils.next\_uri module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.offset\_alias module
|
||||
-------------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.offset_alias
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.offset\_matcher module
|
||||
---------------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.offset_matcher
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.options module
|
||||
-------------------------------
|
||||
|
||||
@ -92,6 +108,14 @@ accounting.utils.strip\_text module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.unapplied module
|
||||
---------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.unapplied
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.user module
|
||||
----------------------------
|
||||
|
||||
|
@ -88,4 +88,7 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
|
||||
from . import option
|
||||
option.init_app(bp)
|
||||
|
||||
from . import unmatched_offset
|
||||
unmatched_offset.init_app(bp)
|
||||
|
||||
app.register_blueprint(bp, url_prefix=url_prefix)
|
||||
|
@ -108,15 +108,15 @@ def __is_need_offset(base_code: str) -> bool:
|
||||
"""
|
||||
# Assets
|
||||
if base_code[0] == "1":
|
||||
if base_code[:3] in {"113", "114", "118", "184"}:
|
||||
if base_code[:3] in {"113", "114", "118", "184", "186"}:
|
||||
return True
|
||||
if base_code in {"1411", "1421", "1431", "1441", "1511", "1521",
|
||||
"1581", "1611", "1851", ""}:
|
||||
if base_code in {"1286", "1411", "1421", "1431", "1441", "1511",
|
||||
"1521", "1581", "1611", "1851"}:
|
||||
return True
|
||||
return False
|
||||
# Liabilities
|
||||
if base_code[0] == "2":
|
||||
if base_code in {"2111", "2114", "2284", "2293"}:
|
||||
if base_code in {"2111", "2114", "2284", "2293", "2861"}:
|
||||
return False
|
||||
return True
|
||||
# Only assets and liabilities need offset
|
||||
|
@ -28,14 +28,13 @@ from wtforms.validators import DataRequired
|
||||
|
||||
from accounting import db
|
||||
from accounting.forms import CurrencyExists
|
||||
from accounting.journal_entry.utils.offset_alias import offset_alias
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import JournalEntryLineItem
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.offset_alias import offset_alias
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
|
||||
|
||||
|
||||
CURRENCY_REQUIRED: DataRequired = DataRequired(
|
||||
lazy_gettext("Please select the currency."))
|
||||
"""The validator to check if the currency code is empty."""
|
||||
|
@ -25,7 +25,7 @@ from sqlalchemy.orm import selectinload
|
||||
from accounting import db
|
||||
from accounting.models import Account, JournalEntry, JournalEntryLineItem
|
||||
from accounting.utils.cast import be
|
||||
from .offset_alias import offset_alias
|
||||
from accounting.utils.offset_alias import offset_alias
|
||||
|
||||
|
||||
def get_selectable_original_line_items(
|
||||
|
@ -214,6 +214,25 @@ class Account(db.Model):
|
||||
"""
|
||||
return not self.is_real
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
"""Returns the number of items in the account.
|
||||
|
||||
:return: The number of items in the account.
|
||||
"""
|
||||
if not hasattr(self, "__count"):
|
||||
setattr(self, "__count", 0)
|
||||
return getattr(self, "__count")
|
||||
|
||||
@count.setter
|
||||
def count(self, count: int) -> None:
|
||||
"""Sets the number of items in the account.
|
||||
|
||||
:param count: The number of items in the account.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__count", count)
|
||||
|
||||
@property
|
||||
def query_values(self) -> list[str]:
|
||||
"""Returns the values to be queried.
|
||||
@ -703,14 +722,6 @@ class JournalEntryLineItem(db.Model):
|
||||
"""
|
||||
return self.account.code
|
||||
|
||||
@property
|
||||
def debit(self) -> Decimal | None:
|
||||
"""Returns the debit amount.
|
||||
|
||||
: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 line item needs offset.
|
||||
@ -725,6 +736,14 @@ class JournalEntryLineItem(db.Model):
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def debit(self) -> Decimal | None:
|
||||
"""Returns the debit amount.
|
||||
|
||||
: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 credit(self) -> Decimal | None:
|
||||
"""Returns the credit amount.
|
||||
@ -769,6 +788,25 @@ class JournalEntryLineItem(db.Model):
|
||||
setattr(self, "__offsets", offsets)
|
||||
return getattr(self, "__offsets")
|
||||
|
||||
@property
|
||||
def match(self) -> t.Self | None:
|
||||
"""Returns the match of the line item.
|
||||
|
||||
:return: The match of the line item.
|
||||
"""
|
||||
if not hasattr(self, "__match"):
|
||||
setattr(self, "__match", None)
|
||||
return getattr(self, "__match")
|
||||
|
||||
@match.setter
|
||||
def match(self, match: t.Self) -> None:
|
||||
"""Sets the match of the line item.
|
||||
|
||||
:param match: The matcho of the line item.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__match", match)
|
||||
|
||||
@property
|
||||
def query_values(self) -> list[str]:
|
||||
"""Returns the values to be queried.
|
||||
|
@ -27,9 +27,11 @@ def init_app(app: Flask, url_prefix: str) -> None:
|
||||
:param url_prefix: The URL prefix of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
from .converters import PeriodConverter, IncomeExpensesAccountConverter
|
||||
from .converters import PeriodConverter, CurrentAccountConverter, \
|
||||
NeedOffsetAccountConverter
|
||||
app.url_map.converters["period"] = PeriodConverter
|
||||
app.url_map.converters["ieAccount"] = IncomeExpensesAccountConverter
|
||||
app.url_map.converters["currentAccount"] = CurrentAccountConverter
|
||||
app.url_map.converters["needOffsetAccount"] = NeedOffsetAccountConverter
|
||||
|
||||
from .views import bp as report_bp
|
||||
app.register_blueprint(report_bp, url_prefix=url_prefix)
|
||||
|
@ -28,8 +28,8 @@ from .period import Period, get_period
|
||||
|
||||
|
||||
class PeriodConverter(BaseConverter):
|
||||
"""The supplier converter to convert the period specification from and to
|
||||
the corresponding period in the routes."""
|
||||
"""The converter to convert the period specification from and to the
|
||||
corresponding period in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> Period:
|
||||
"""Converts a period specification to a period.
|
||||
@ -51,9 +51,9 @@ class PeriodConverter(BaseConverter):
|
||||
return value.spec
|
||||
|
||||
|
||||
class IncomeExpensesAccountConverter(BaseConverter):
|
||||
"""The supplier converter to convert the income and expenses log pseudo
|
||||
account code from and to the corresponding pseudo account in the routes."""
|
||||
class CurrentAccountConverter(BaseConverter):
|
||||
"""The converter to convert the current account code from and to the
|
||||
corresponding account in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> CurrentAccount:
|
||||
"""Converts an account code to an account.
|
||||
@ -77,3 +77,29 @@ class IncomeExpensesAccountConverter(BaseConverter):
|
||||
:return: Its code.
|
||||
"""
|
||||
return value.code
|
||||
|
||||
|
||||
class NeedOffsetAccountConverter(BaseConverter):
|
||||
"""The converter to convert the unapplied original line item account code
|
||||
from and to the corresponding account in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> Account:
|
||||
"""Converts an account code to an account.
|
||||
|
||||
:param value: The account code.
|
||||
:return: The corresponding account.
|
||||
"""
|
||||
account: Account | None = Account.find_by_code(value)
|
||||
if account is None:
|
||||
abort(404)
|
||||
if not account.is_need_offset:
|
||||
abort(404)
|
||||
return account
|
||||
|
||||
def to_url(self, value: Account) -> str:
|
||||
"""Converts an account to account code.
|
||||
|
||||
:param value: The account.
|
||||
:return: Its code.
|
||||
"""
|
||||
return value.code
|
||||
|
185
src/accounting/report/reports/unapplied.py
Normal file
185
src/accounting/report/reports/unapplied.py
Normal file
@ -0,0 +1,185 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The unapplied original line items.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.unapplied import get_accounts_with_unapplied
|
||||
from accounting.report.utils.urls import unapplied_url
|
||||
from accounting.utils.offset_matcher import OffsetMatcher
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import can_edit
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, journal_entry_date: str | date, currency: str,
|
||||
description: str | None, amount: str | Decimal,
|
||||
net_balance: str | Decimal):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param journal_entry_date: The journal entry date.
|
||||
:param currency: The currency.
|
||||
:param description: The description.
|
||||
:param amount: The amount.
|
||||
:param net_balance: The net balance.
|
||||
"""
|
||||
self.date: str | date = journal_entry_date
|
||||
"""The date."""
|
||||
self.currency: str = currency
|
||||
"""The currency."""
|
||||
self.description: str | None = description
|
||||
"""The description."""
|
||||
self.amount: str | Decimal = amount
|
||||
"""The amount."""
|
||||
self.net_balance: str | Decimal = net_balance
|
||||
"""The net balance."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | date | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.date, self.currency, self.description, self.amount,
|
||||
self.net_balance]
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, account: Account,
|
||||
is_mark_matches: bool,
|
||||
pagination: Pagination[JournalEntryLineItem],
|
||||
line_items: list[JournalEntryLineItem]):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param account: The account.
|
||||
:param is_mark_matches: Whether to mark the matched offsets.
|
||||
:param pagination: The pagination.
|
||||
:param line_items: The line items.
|
||||
"""
|
||||
self.account: Account = account
|
||||
"""The account."""
|
||||
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
||||
"""The pagination."""
|
||||
self.line_items: list[JournalEntryLineItem] = line_items
|
||||
"""The line items."""
|
||||
self.is_mark_matches: bool = is_mark_matches
|
||||
"""Whether to mark the matched offsets."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return len(self.line_items) > 0
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.UNAPPLIED,
|
||||
account=self.account)
|
||||
|
||||
@property
|
||||
def account_options(self) -> list[OptionLink]:
|
||||
"""Returns the account options.
|
||||
|
||||
:return: The account options.
|
||||
"""
|
||||
options: list[OptionLink] = [OptionLink(gettext("Accounts"),
|
||||
unapplied_url(None),
|
||||
False)]
|
||||
options.extend([OptionLink(str(x),
|
||||
unapplied_url(x),
|
||||
x.id == self.account.id)
|
||||
for x in get_accounts_with_unapplied()])
|
||||
return options
|
||||
|
||||
|
||||
def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
|
||||
"""Composes and returns the CSV rows from the line items.
|
||||
|
||||
:param line_items: The line items.
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
|
||||
gettext("Description"), gettext("Amount"),
|
||||
gettext("Net Balance"))]
|
||||
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
|
||||
x.description, x.amount, x.net_balance)
|
||||
for x in line_items])
|
||||
return rows
|
||||
|
||||
|
||||
class UnappliedOriginalLineItems(BaseReport):
|
||||
"""The unapplied original line items."""
|
||||
|
||||
def __init__(self, account: Account):
|
||||
"""Constructs the unapplied original line items.
|
||||
|
||||
:param account: The account.
|
||||
"""
|
||||
self.__account: Account = account
|
||||
"""The account."""
|
||||
offset_matcher: OffsetMatcher = OffsetMatcher(self.__account)
|
||||
self.__line_items: list[JournalEntryLineItem] \
|
||||
= offset_matcher.unapplied
|
||||
"""The line items."""
|
||||
self.__is_mark_matches: bool \
|
||||
= can_edit() and len(offset_matcher.unmatched_offsets) > 0
|
||||
"""Whether to mark the matched offsets."""
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = f"unapplied-{self.__account.code}.csv"
|
||||
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[JournalEntryLineItem] \
|
||||
= Pagination[JournalEntryLineItem](self.__line_items,
|
||||
is_reversed=True)
|
||||
params: PageParams = PageParams(account=self.__account,
|
||||
is_mark_matches=self.__is_mark_matches,
|
||||
pagination=pagination,
|
||||
line_items=pagination.list)
|
||||
return render_template("accounting/report/unapplied.html",
|
||||
report=params)
|
137
src/accounting/report/reports/unapplied_accounts.py
Normal file
137
src/accounting/report/reports/unapplied_accounts.py
Normal file
@ -0,0 +1,137 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The accounts with unapplied original line items.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Account
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.unapplied import get_accounts_with_unapplied
|
||||
from accounting.report.utils.urls import unapplied_url
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, account: str, count: int | str):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param account: The account.
|
||||
:param count: The number of unapplied original line items.
|
||||
"""
|
||||
self.account: str = account
|
||||
"""The currency."""
|
||||
self.count: int | str = count
|
||||
"""The number of unapplied original line items."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | date | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
"""
|
||||
return [self.account, self.count]
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, accounts: list[Account]):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param accounts: The accounts.
|
||||
"""
|
||||
self.accounts: list[Account] = accounts
|
||||
"""The accounts."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return len(self.accounts) > 0
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.UNAPPLIED)
|
||||
|
||||
@property
|
||||
def account_options(self) -> list[OptionLink]:
|
||||
"""Returns the account options.
|
||||
|
||||
:return: The account options.
|
||||
"""
|
||||
options: list[OptionLink] = [OptionLink(gettext("Accounts"),
|
||||
unapplied_url(None),
|
||||
True)]
|
||||
options.extend([OptionLink(str(x),
|
||||
unapplied_url(x),
|
||||
False)
|
||||
for x in self.accounts])
|
||||
return options
|
||||
|
||||
|
||||
def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
|
||||
"""Composes and returns the CSV rows from the line items.
|
||||
|
||||
:param accounts: The accounts.
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
|
||||
rows.extend([CSVRow(str(x).title(), x.count)
|
||||
for x in accounts])
|
||||
return rows
|
||||
|
||||
|
||||
class AccountsWithUnappliedOriginalLineItems(BaseReport):
|
||||
"""The accounts with unapplied original line items."""
|
||||
|
||||
def __init__(self):
|
||||
"""Constructs the outstanding balances."""
|
||||
self.__accounts: list[Account] = get_accounts_with_unapplied()
|
||||
"""The accounts."""
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = f"unapplied-accounts.csv"
|
||||
return csv_download(filename, get_csv_rows(self.__accounts))
|
||||
|
||||
def html(self) -> str:
|
||||
"""Composes and returns the report as HTML.
|
||||
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
return render_template("accounting/report/unapplied-accounts.html",
|
||||
report=PageParams(accounts=self.__accounts))
|
@ -34,7 +34,7 @@ from accounting.utils.current_account import CurrentAccount
|
||||
from .option_link import OptionLink
|
||||
from .report_type import ReportType
|
||||
from .urls import journal_url, ledger_url, income_expenses_url, \
|
||||
trial_balance_url, income_statement_url, balance_sheet_url
|
||||
trial_balance_url, income_statement_url, balance_sheet_url, unapplied_url
|
||||
|
||||
|
||||
class ReportChooser:
|
||||
@ -74,6 +74,7 @@ class ReportChooser:
|
||||
self.__reports.append(self.__trial_balance)
|
||||
self.__reports.append(self.__income_statement)
|
||||
self.__reports.append(self.__balance_sheet)
|
||||
self.__reports.append(self.__unapplied)
|
||||
for report in self.__reports:
|
||||
if report.is_active:
|
||||
self.current_report = report.title
|
||||
@ -151,6 +152,23 @@ class ReportChooser:
|
||||
self.__active_report == ReportType.BALANCE_SHEET,
|
||||
fa_icon="fa-solid fa-scale-balanced")
|
||||
|
||||
@property
|
||||
def __unapplied(self) -> OptionLink:
|
||||
"""Returns the unapplied original line items.
|
||||
|
||||
:return: The unapplied original line items.
|
||||
"""
|
||||
account: Account = self.__account
|
||||
if not account.is_need_offset:
|
||||
return OptionLink(gettext("Unapplied Original Line Items"),
|
||||
unapplied_url(None),
|
||||
self.__active_report == ReportType.UNAPPLIED,
|
||||
fa_icon="fa-solid fa-link-slash")
|
||||
return OptionLink(gettext("Unapplied Original Line Items"),
|
||||
unapplied_url(account),
|
||||
self.__active_report == ReportType.UNAPPLIED,
|
||||
fa_icon="fa-solid fa-link-slash")
|
||||
|
||||
def __iter__(self) -> t.Iterator[OptionLink]:
|
||||
"""Returns the iteration of the reports.
|
||||
|
||||
|
@ -34,5 +34,7 @@ class ReportType(Enum):
|
||||
"""The income statement."""
|
||||
BALANCE_SHEET: str = "balance-sheet"
|
||||
"""The balance sheet."""
|
||||
UNAPPLIED: str = "unapplied"
|
||||
"""The unapplied original line items."""
|
||||
SEARCH: str = "search"
|
||||
"""The search."""
|
||||
|
67
src/accounting/report/utils/unapplied.py
Normal file
67
src/accounting/report/utils/unapplied.py
Normal file
@ -0,0 +1,67 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The unapplied original line item utilities.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.offset_alias import offset_alias
|
||||
|
||||
|
||||
def get_accounts_with_unapplied() -> list[Account]:
|
||||
"""Returns the accounts with unapplied original line items.
|
||||
|
||||
:return: The accounts with unapplied original line items.
|
||||
"""
|
||||
offset: sa.Alias = offset_alias()
|
||||
net_balance: sa.Label \
|
||||
= (JournalEntryLineItem.amount
|
||||
+ sa.func.sum(sa.case(
|
||||
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
|
||||
offset.c.amount),
|
||||
else_=-offset.c.amount))).label("net_balance")
|
||||
select_unapplied: sa.Select \
|
||||
= sa.select(JournalEntryLineItem.id)\
|
||||
.join(Account)\
|
||||
.join(offset, be(JournalEntryLineItem.id
|
||||
== offset.c.original_line_item_id),
|
||||
isouter=True)\
|
||||
.filter(Account.is_need_offset,
|
||||
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||
sa.not_(JournalEntryLineItem.is_debit)),
|
||||
sa.and_(Account.base_code.startswith("1"),
|
||||
JournalEntryLineItem.is_debit)))\
|
||||
.group_by(JournalEntryLineItem.id)\
|
||||
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
|
||||
|
||||
count_func: sa.Label \
|
||||
= sa.func.count(JournalEntryLineItem.id).label("count")
|
||||
select: sa.Select = sa.select(Account.id, count_func)\
|
||||
.join(JournalEntryLineItem, isouter=True)\
|
||||
.filter(JournalEntryLineItem.id.in_(select_unapplied))\
|
||||
.group_by(Account.id)\
|
||||
.having(count_func > 0)
|
||||
counts: dict[int, int] \
|
||||
= {x.id: x.count for x in db.session.execute(select)}
|
||||
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
|
||||
.order_by(Account.base_code, Account.no).all()
|
||||
for account in accounts:
|
||||
account.count = counts[account.id]
|
||||
return accounts
|
@ -116,3 +116,15 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
|
||||
currency=currency)
|
||||
return url_for("accounting-report.balance-sheet",
|
||||
currency=currency, period=period)
|
||||
|
||||
|
||||
def unapplied_url(account: Account | None) -> str:
|
||||
"""Returns the URL of the unapplied original line items.
|
||||
|
||||
:param account: The account, or None to list the accounts with unapplied
|
||||
original line items.
|
||||
:return: The URL of the unapplied original line items.
|
||||
"""
|
||||
if account is None:
|
||||
return url_for("accounting-report.unapplied-default")
|
||||
return url_for("accounting-report.unapplied", account=account)
|
||||
|
@ -28,6 +28,8 @@ from accounting.utils.options import options
|
||||
from accounting.utils.permission import has_permission, can_view
|
||||
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
|
||||
IncomeStatement, BalanceSheet, Search
|
||||
from .reports.unapplied import UnappliedOriginalLineItems
|
||||
from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems
|
||||
from .template_filters import format_amount
|
||||
|
||||
bp: Blueprint = Blueprint("accounting-report", __name__)
|
||||
@ -124,7 +126,7 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
|
||||
@bp.get("income-expenses/<currency:currency>/<currentAccount:account>",
|
||||
endpoint="income-expenses-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_income_expenses(currency: Currency, account: CurrentAccount) \
|
||||
@ -138,9 +140,8 @@ def get_default_income_expenses(currency: Currency, account: CurrentAccount) \
|
||||
return __get_income_expenses(currency, account, get_period())
|
||||
|
||||
|
||||
@bp.get(
|
||||
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
|
||||
endpoint="income-expenses")
|
||||
@bp.get("income-expenses/<currency:currency>/<currentAccount:account>/"
|
||||
"<period:period>", endpoint="income-expenses")
|
||||
@has_permission(can_view)
|
||||
def get_income_expenses(currency: Currency, account: CurrentAccount,
|
||||
period: Period) -> str | Response:
|
||||
@ -286,6 +287,34 @@ def __get_balance_sheet(currency: Currency, period: Period) \
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("unapplied", endpoint="unapplied-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_unapplied() -> str | Response:
|
||||
"""Returns the accounts with unapplied original line items.
|
||||
|
||||
:return: The accounts with unapplied original line items.
|
||||
"""
|
||||
report: AccountsWithUnappliedOriginalLineItems \
|
||||
= AccountsWithUnappliedOriginalLineItems()
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("unapplied/<needOffsetAccount:account>", endpoint="unapplied")
|
||||
@has_permission(can_view)
|
||||
def get_unapplied(account: Account) -> str | Response:
|
||||
"""Returns the unapplied original line items.
|
||||
|
||||
:param account: The Account.
|
||||
:return: The unapplied original line items.
|
||||
"""
|
||||
report: UnappliedOriginalLineItems = UnappliedOriginalLineItems(account)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("search", endpoint="search")
|
||||
@has_permission(can_view)
|
||||
def search() -> str | Response:
|
||||
|
@ -209,11 +209,23 @@ a.accounting-report-table-row {
|
||||
.accounting-report-table-body .accounting-amount {
|
||||
font-style: italic;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row:nth-child(2n+1) {
|
||||
background-color: #f2f2f2;
|
||||
background-color: #ecedee;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.075);
|
||||
background-color: #e5e6e7;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row-danger {
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row-danger:nth-child(2n+1) {
|
||||
background-color: #eccccf;
|
||||
}
|
||||
.accounting-report-table-body .accounting-report-table-row-danger:hover {
|
||||
background-color: #e5c7ca;
|
||||
}
|
||||
.accounting-journal-table .accounting-report-table-row {
|
||||
grid-template-columns: 1fr 1fr 2fr 4fr 1fr 1fr;
|
||||
@ -309,6 +321,16 @@ a.accounting-report-table-row {
|
||||
.accounting-balance-sheet-total .accounting-amount, .accounting-balance-sheet-subtotal, .accounting-amount {
|
||||
font-style: italic;
|
||||
}
|
||||
.accounting-unapplied-table .accounting-report-table-row {
|
||||
grid-template-columns: 1fr 1fr 5fr 1fr 1fr;
|
||||
}
|
||||
.accounting-unapplied-account-table .accounting-report-table-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.accounting-unapplied-account-table .accounting-report-table-header .accounting-report-table-row {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The accounting report */
|
||||
.accounting-mobile-journal-credit {
|
||||
@ -343,6 +365,12 @@ a.accounting-report-table-row {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* The unmatched offsets */
|
||||
.accounting-unmatched-offset-pair-list {
|
||||
height: 20rem;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
/* The Material Design text field (floating form control in Bootstrap) */
|
||||
.accounting-material-text-field {
|
||||
position: relative;
|
||||
|
@ -51,6 +51,12 @@ First written: 2023/1/26
|
||||
{{ A_("Currencies") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.unmatched-offset.") %} active {% endif %}" href="{{ url_for("accounting.unmatched-offset.dashboard") }}">
|
||||
<i class="fa-solid fa-link-slash"></i>
|
||||
{{ A_("Unmatched Offsets") }}
|
||||
</a>
|
||||
</li>
|
||||
{% if accounting_can_admin() %}
|
||||
<li>
|
||||
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.option.") %} active {% endif %}" href="{{ url_for("accounting.option.detail") }}">
|
||||
|
@ -42,11 +42,11 @@ First written: 2023/3/22
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">{{ A_("Default Currency") }}</th>
|
||||
<td>{{ obj.default_currency_text }}</td>
|
||||
<td>{{ obj.default_currency }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{{ A_("Default Account for the Income and Expenses Log") }}</th>
|
||||
<td>{{ obj.default_ie_account_code_text }}</td>
|
||||
<td>{{ obj.default_ie_account }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -0,0 +1,64 @@
|
||||
{#
|
||||
The Mia! Accounting Project
|
||||
unapplied-accounts.html: The account list with unapplied original line items
|
||||
|
||||
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/4/8
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Accounts with Unapplied Original Line Items") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_account_chooser = true %}
|
||||
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
|
||||
|
||||
{% include "accounting/report/include/search-modal.html" %}
|
||||
|
||||
{% if report.has_data %}
|
||||
<div class="accounting-sheet">
|
||||
<div class="d-none d-sm-flex justify-content-center mb-3">
|
||||
<h2 class="text-center">{{ A_("Accounts with Unapplied Original Line Items") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="accounting-report-table accounting-unapplied-account-table">
|
||||
<div class="accounting-report-table-header">
|
||||
<div class="accounting-report-table-row">
|
||||
<div class="accounting-amount">{{ A_("Count") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounting-report-table-body">
|
||||
{% for account in report.accounts %}
|
||||
<a class="accounting-report-table-row" href="{{ url_for("accounting-report.unapplied", account=account) }}">
|
||||
<div>{{ account }}</div>
|
||||
<div class="accounting-amount">{{ account.count }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
101
src/accounting/templates/accounting/report/unapplied.html
Normal file
101
src/accounting/templates/accounting/report/unapplied.html
Normal file
@ -0,0 +1,101 @@
|
||||
{#
|
||||
The Mia! Accounting Project
|
||||
unapplied.html: The unapplied original line items
|
||||
|
||||
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/4/7
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Unapplied Original Line Items of %(account)s", account=report.account.title|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_account_chooser = true %}
|
||||
{% include "accounting/report/include/toolbar-buttons.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% include "accounting/report/include/add-journal-entry-material-fab.html" %}
|
||||
|
||||
{% include "accounting/report/include/search-modal.html" %}
|
||||
|
||||
{% if report.has_data %}
|
||||
{% with pagination = report.pagination %}
|
||||
{% include "accounting/include/pagination.html" %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="d-none d-md-block accounting-report-table accounting-unapplied-table">
|
||||
<div class="accounting-report-table-header">
|
||||
<div class="accounting-report-table-row">
|
||||
<div>{{ A_("Date") }}</div>
|
||||
<div>{{ A_("Currency") }}</div>
|
||||
<div>{{ A_("Description") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Amount") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Net Balance") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounting-report-table-body">
|
||||
{% for line_item in report.line_items %}
|
||||
<a class="accounting-report-table-row {% if report.is_mark_matches and not line_item.match %} accounting-report-table-row-danger {% endif %}" href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.journal_entry)|accounting_append_next }}">
|
||||
<div>{{ line_item.journal_entry.date|accounting_format_date }}</div>
|
||||
<div>{{ line_item.currency.name }}</div>
|
||||
<div>
|
||||
{{ line_item.description|accounting_default }}
|
||||
{% if report.is_mark_matches and line_item.match %}
|
||||
<div>{{ A_("Can match %(offset)s", offset=line_item.match) }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="accounting-amount">{{ line_item.amount|accounting_format_amount }}</div>
|
||||
<div class="accounting-amount">{{ line_item.net_balance|accounting_format_amount }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group d-md-none">
|
||||
{% for line_item in report.line_items %}
|
||||
<a class="list-group-item list-group-item-action d-flex justify-content-between" href="{{ url_for("accounting.journal-entry.detail", journal_entry=line_item.journal_entry)|accounting_append_next }}">
|
||||
<div>
|
||||
<div class="text-muted small">
|
||||
{{ line_item.journal_entry.date|accounting_format_date }}
|
||||
{% if line_item.currency.code != accounting_default_currency_code() %}
|
||||
<span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if line_item.description is not none %}
|
||||
<div>{{ line_item.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="badge rounded-pill bg-info">{{ line_item.amount|accounting_format_amount }}</span>
|
||||
<span class="badge rounded-pill bg-info">{{ line_item.net_balance|accounting_format_amount }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,40 @@
|
||||
{#
|
||||
The Mia! Accounting Project
|
||||
dashboard.html: The account list with unmatched offsets
|
||||
|
||||
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/4/8
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Unmatched Offsets") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if list %}
|
||||
<div>
|
||||
{% for account in list %}
|
||||
<a class="btn btn-primary mb-1" role="button" href="{{ url_for("accounting.unmatched-offset.list", account=account) }}">
|
||||
{{ account }} ({{ account.count }})
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
107
src/accounting/templates/accounting/unmatched-offset/list.html
Normal file
107
src/accounting/templates/accounting/unmatched-offset/list.html
Normal file
@ -0,0 +1,107 @@
|
||||
{#
|
||||
The Mia! Accounting Project
|
||||
list.html: The unmatched offset list
|
||||
|
||||
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/4/8
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Unmatched Offsets in %(account)s", account=matcher.account.title|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-3" role="group" aria-label="{{ A_("Toolbar") }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.unmatched-offset.dashboard") }}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
</a>
|
||||
{% if matcher.is_having_matches %}
|
||||
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-match-modal">
|
||||
<i class="fa-solid fa-link"></i>
|
||||
{{ A_("Match") }}
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn btn-secondary" type="button" disabled="disabled">
|
||||
<i class="fa-solid fa-link"></i>
|
||||
{{ A_("Match") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if matcher.is_having_matches %}
|
||||
<form action="{{ url_for("accounting.unmatched-offset.match", account=matcher.account) }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal fade" id="accounting-match-modal" tabindex="-1" aria-labelledby="accounting-match-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-match-modal-label">{{ A_("Confirm Match Offsets") }}</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{{ A_("Do you really want to match the following original line items with their offsets? This cannot be undone. Please backup your database first, and review before you confirm.") }}</p>
|
||||
|
||||
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover accounting-unmatched-offset-pair-list">
|
||||
{% for pair in matcher.matched_pairs %}
|
||||
<li class="list-group-item">
|
||||
{{ pair.offset.description|accounting_default }}
|
||||
<span class="badge bg-info">{{ pair.offset.amount|accounting_format_amount }}</span>
|
||||
{{ pair.original_line_item.journal_entry.date|accounting_format_date }} → {{ pair.offset.journal_entry.date|accounting_format_date }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</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-danger">{{ A_("Confirm") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if matcher.total %}
|
||||
{% if matcher.is_having_matches %}
|
||||
<p>{{ A_("%(matches)s unapplied original line items out of %(total)s can match with their offsets.", matches=matcher.matches, total=matcher.total) }}</p>
|
||||
{% else %}
|
||||
<p>{{ A_("%(total)s unapplied original line items without matching offsets.", total=matcher.total) }}</p>
|
||||
{% endif %}
|
||||
<p><a href="{{ url_for("accounting-report.unapplied", account=matcher.account) }}">{{ A_("Go to unapplied original line items.") }}</a></p>
|
||||
{% else %}
|
||||
<p>{{ A_("All original line items are fully offset.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if list %}
|
||||
{% include "accounting/include/pagination.html" %}
|
||||
|
||||
<div class="list-group">
|
||||
{% for item in list %}
|
||||
<a class="list-group-item list-group-item-action {% if not item.match %} list-group-item-danger {% endif %}" href="{{ url_for("accounting.journal-entry.detail", journal_entry=item.journal_entry)|accounting_append_next }}">
|
||||
{{ item }}
|
||||
{% if item.match %}
|
||||
<div class="small">{{ A_("Can match %(item)s", item=item.match) }}</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -6,10 +6,10 @@
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: mia-accounting 1.0.0\n"
|
||||
"Project-Id-Version: mia-accounting 1.1.0\n"
|
||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||
"POT-Creation-Date: 2023-04-06 02:34+0800\n"
|
||||
"PO-Revision-Date: 2023-04-06 02:34+0800\n"
|
||||
"POT-Creation-Date: 2023-04-09 00:22+0800\n"
|
||||
"PO-Revision-Date: 2023-04-09 00:37+0800\n"
|
||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||
"Language: zh_Hant\n"
|
||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||
@ -35,22 +35,22 @@ msgstr "沒有這個貨幣。"
|
||||
msgid "The account does not exist."
|
||||
msgstr "沒有這個科目。"
|
||||
|
||||
#: src/accounting/models.py:562
|
||||
#: src/accounting/models.py:581
|
||||
#, python-format
|
||||
msgid "Cash Disbursement Journal Entry#%(id)s"
|
||||
msgstr "現金支出傳票#%(id)s"
|
||||
|
||||
#: src/accounting/models.py:565
|
||||
#: src/accounting/models.py:584
|
||||
#, python-format
|
||||
msgid "Cash Receipt Journal Entry#%(id)s"
|
||||
msgstr "現金收入傳票#%(id)s"
|
||||
|
||||
#: src/accounting/models.py:566
|
||||
#: src/accounting/models.py:585
|
||||
#, python-format
|
||||
msgid "Transfer Journal Entry#%(id)s"
|
||||
msgstr "轉帳傳票#%(id)s"
|
||||
|
||||
#: src/accounting/models.py:699
|
||||
#: src/accounting/models.py:714
|
||||
#, python-format
|
||||
msgid "%(date)s %(description)s %(amount)s"
|
||||
msgstr "%(date)s %(description)s %(amount)s"
|
||||
@ -205,24 +205,24 @@ msgstr "傳票不可刪除。"
|
||||
msgid "The journal entry is deleted successfully."
|
||||
msgstr "傳票刪掉了"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:40
|
||||
#: src/accounting/journal_entry/forms/currency.py:39
|
||||
msgid "Please select the currency."
|
||||
msgstr "請選擇貨幣。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:63
|
||||
#: src/accounting/journal_entry/forms/currency.py:62
|
||||
msgid "The currency must be the same as the original line item."
|
||||
msgstr "貨幣需和原始分錄相同。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:90
|
||||
#: src/accounting/journal_entry/forms/currency.py:89
|
||||
msgid "The currency must not be changed when there is offset."
|
||||
msgstr "抵銷過不可變更貨幣。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:99
|
||||
#: src/accounting/journal_entry/forms/currency.py:98
|
||||
#: src/accounting/static/js/journal-entry-form.js:773
|
||||
msgid "Please add some line items."
|
||||
msgstr "請加上分錄。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:112
|
||||
#: src/accounting/journal_entry/forms/currency.py:111
|
||||
#: src/accounting/static/js/journal-entry-form.js:522
|
||||
msgid "The totals of the debit and credit amounts do not match."
|
||||
msgstr "借方貸方合計不符。 "
|
||||
@ -271,24 +271,24 @@ msgstr "原始分錄不可以是抵銷分錄。"
|
||||
msgid "The account must be the same as the original line item."
|
||||
msgstr "科目需和原始分錄相同。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:137
|
||||
#: src/accounting/journal_entry/forms/line_item.py:135
|
||||
msgid "The account must not be changed when there is offset."
|
||||
msgstr "抵銷過不可變更科目。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:153
|
||||
#: src/accounting/journal_entry/forms/line_item.py:151
|
||||
msgid "A payable line item cannot start from debit."
|
||||
msgstr "不可由借方新建應付款。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:169
|
||||
#: src/accounting/journal_entry/forms/line_item.py:167
|
||||
msgid "A receivable line item cannot start from credit."
|
||||
msgstr "不可由貸方新建應收款。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:180
|
||||
#: src/accounting/journal_entry/forms/line_item.py:178
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:436
|
||||
msgid "Please fill in a positive amount."
|
||||
msgstr "金額請填正數。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:222
|
||||
#: src/accounting/journal_entry/forms/line_item.py:220
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:442
|
||||
#, python-format
|
||||
msgid ""
|
||||
@ -296,17 +296,17 @@ msgid ""
|
||||
"line item."
|
||||
msgstr "金額不可超過原始分錄凈額 %(balance)s 。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:243
|
||||
#: src/accounting/journal_entry/forms/line_item.py:241
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:450
|
||||
#, python-format
|
||||
msgid "The amount must not be less than the offset total %(total)s."
|
||||
msgstr "金額不可低於抵銷總額 %(total)s 。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:416
|
||||
#: src/accounting/journal_entry/forms/line_item.py:413
|
||||
msgid "This account is not for debit line items."
|
||||
msgstr "科目不是借方科目。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:468
|
||||
#: src/accounting/journal_entry/forms/line_item.py:465
|
||||
msgid "This account is not for credit line items."
|
||||
msgstr "科目不是貸方科目。"
|
||||
|
||||
@ -442,20 +442,23 @@ msgid "Brought forward"
|
||||
msgstr "前期轉入"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:407
|
||||
#: src/accounting/report/reports/journal.py:155
|
||||
#: src/accounting/report/reports/journal.py:158
|
||||
#: src/accounting/report/reports/ledger.py:366
|
||||
#: src/accounting/report/reports/unapplied.py:137
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:50
|
||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:111
|
||||
#: src/accounting/templates/accounting/report/income-expenses.html:55
|
||||
#: src/accounting/templates/accounting/report/journal.html:53
|
||||
#: src/accounting/templates/accounting/report/ledger.html:55
|
||||
#: src/accounting/templates/accounting/report/search.html:50
|
||||
#: src/accounting/templates/accounting/report/unapplied.html:50
|
||||
msgid "Date"
|
||||
msgstr "日期"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:407
|
||||
#: src/accounting/report/reports/journal.py:156
|
||||
#: src/accounting/report/reports/journal.py:159
|
||||
#: src/accounting/report/reports/trial_balance.py:225
|
||||
#: src/accounting/report/reports/unapplied_accounts.py:109
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:57
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:39
|
||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:90
|
||||
@ -467,14 +470,16 @@ msgid "Account"
|
||||
msgstr "科目"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:408
|
||||
#: src/accounting/report/reports/journal.py:156
|
||||
#: src/accounting/report/reports/journal.py:159
|
||||
#: src/accounting/report/reports/ledger.py:366
|
||||
#: src/accounting/report/reports/unapplied.py:138
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:28
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:49
|
||||
#: src/accounting/templates/accounting/report/income-expenses.html:57
|
||||
#: src/accounting/templates/accounting/report/journal.html:56
|
||||
#: src/accounting/templates/accounting/report/ledger.html:56
|
||||
#: src/accounting/templates/accounting/report/search.html:53
|
||||
#: src/accounting/templates/accounting/report/unapplied.html:52
|
||||
msgid "Description"
|
||||
msgstr "摘要"
|
||||
|
||||
@ -496,7 +501,7 @@ msgid "Balance"
|
||||
msgstr "餘額"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:410
|
||||
#: src/accounting/report/reports/journal.py:158
|
||||
#: src/accounting/report/reports/journal.py:161
|
||||
#: src/accounting/report/reports/ledger.py:368
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:178
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:73
|
||||
@ -528,20 +533,24 @@ msgid "net income or loss for current period"
|
||||
msgstr "本期損益"
|
||||
|
||||
#: src/accounting/report/reports/income_statement.py:301
|
||||
#: src/accounting/report/reports/unapplied.py:138
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:65
|
||||
#: src/accounting/templates/accounting/report/income-statement.html:55
|
||||
#: src/accounting/templates/accounting/report/unapplied.html:53
|
||||
msgid "Amount"
|
||||
msgstr "金額"
|
||||
|
||||
#: src/accounting/report/reports/journal.py:155
|
||||
#: src/accounting/report/reports/journal.py:158
|
||||
#: src/accounting/report/reports/unapplied.py:137
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form-currency.html:33
|
||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:73
|
||||
#: src/accounting/templates/accounting/report/journal.html:54
|
||||
#: src/accounting/templates/accounting/report/search.html:51
|
||||
#: src/accounting/templates/accounting/report/unapplied.html:51
|
||||
msgid "Currency"
|
||||
msgstr "貨幣"
|
||||
|
||||
#: src/accounting/report/reports/journal.py:157
|
||||
#: src/accounting/report/reports/journal.py:160
|
||||
#: src/accounting/report/reports/ledger.py:367
|
||||
#: src/accounting/report/reports/trial_balance.py:225
|
||||
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33
|
||||
@ -553,7 +562,7 @@ msgstr "貨幣"
|
||||
msgid "Debit"
|
||||
msgstr "借方"
|
||||
|
||||
#: src/accounting/report/reports/journal.py:157
|
||||
#: src/accounting/report/reports/journal.py:160
|
||||
#: src/accounting/report/reports/ledger.py:367
|
||||
#: src/accounting/report/reports/trial_balance.py:226
|
||||
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49
|
||||
@ -565,7 +574,23 @@ msgstr "借方"
|
||||
msgid "Credit"
|
||||
msgstr "貸方"
|
||||
|
||||
#: src/accounting/report/utils/report_chooser.py:81
|
||||
#: src/accounting/report/reports/unapplied.py:121
|
||||
#: src/accounting/report/reports/unapplied_accounts.py:93
|
||||
#: src/accounting/templates/accounting/include/nav.html:39
|
||||
msgid "Accounts"
|
||||
msgstr "科目"
|
||||
|
||||
#: src/accounting/report/reports/unapplied.py:139
|
||||
#: src/accounting/templates/accounting/report/unapplied.html:54
|
||||
msgid "Net Balance"
|
||||
msgstr "淨額"
|
||||
|
||||
#: src/accounting/report/reports/unapplied_accounts.py:109
|
||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:47
|
||||
msgid "Count"
|
||||
msgstr "數量"
|
||||
|
||||
#: src/accounting/report/utils/report_chooser.py:82
|
||||
#: src/accounting/templates/accounting/account/include/form.html:98
|
||||
#: src/accounting/templates/accounting/account/list.html:40
|
||||
#: src/accounting/templates/accounting/base-account/list.html:34
|
||||
@ -581,30 +606,35 @@ msgstr "貸方"
|
||||
msgid "Search"
|
||||
msgstr "搜尋"
|
||||
|
||||
#: src/accounting/report/utils/report_chooser.py:92
|
||||
#: src/accounting/report/utils/report_chooser.py:93
|
||||
msgid "Income and Expenses Log"
|
||||
msgstr "收支帳"
|
||||
|
||||
#: src/accounting/report/utils/report_chooser.py:105
|
||||
#: src/accounting/report/utils/report_chooser.py:106
|
||||
msgid "Ledger"
|
||||
msgstr "分類帳"
|
||||
|
||||
#: src/accounting/report/utils/report_chooser.py:117
|
||||
#: src/accounting/report/utils/report_chooser.py:118
|
||||
msgid "Journal"
|
||||
msgstr "日記簿"
|
||||
|
||||
#: src/accounting/report/utils/report_chooser.py:127
|
||||
#: src/accounting/report/utils/report_chooser.py:128
|
||||
msgid "Trial Balance"
|
||||
msgstr "試算表"
|
||||
|
||||
#: src/accounting/report/utils/report_chooser.py:138
|
||||
#: src/accounting/report/utils/report_chooser.py:139
|
||||
msgid "Income Statement"
|
||||
msgstr "損益表"
|
||||
|
||||
#: src/accounting/report/utils/report_chooser.py:149
|
||||
#: src/accounting/report/utils/report_chooser.py:150
|
||||
msgid "Balance Sheet"
|
||||
msgstr "資產負債表"
|
||||
|
||||
#: src/accounting/report/utils/report_chooser.py:163
|
||||
#: src/accounting/report/utils/report_chooser.py:167
|
||||
msgid "Unapplied Original Line Items"
|
||||
msgstr "未抵銷原始分錄"
|
||||
|
||||
#: src/accounting/static/js/account-form.js:206
|
||||
msgid "Please fill in the title."
|
||||
msgstr "請填上標題。"
|
||||
@ -726,6 +756,7 @@ msgstr "新增科目"
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:38
|
||||
#: src/accounting/templates/accounting/journal-entry/order.html:36
|
||||
#: src/accounting/templates/accounting/option/form.html:36
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:31
|
||||
msgid "Back"
|
||||
msgstr "回上頁"
|
||||
|
||||
@ -766,6 +797,7 @@ msgstr "確認刪除科目"
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:28
|
||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:27
|
||||
#: src/accounting/templates/accounting/report/include/search-modal.html:28
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:54
|
||||
msgid "Close"
|
||||
msgstr "關閉"
|
||||
|
||||
@ -783,6 +815,7 @@ msgstr "你確定要刪掉這個科目嗎?"
|
||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:48
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:65
|
||||
#: src/accounting/templates/accounting/report/include/search-modal.html:37
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:70
|
||||
msgid "Cancel"
|
||||
msgstr "取消"
|
||||
|
||||
@ -790,6 +823,7 @@ msgstr "取消"
|
||||
#: src/accounting/templates/accounting/currency/detail.html:80
|
||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:85
|
||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:141
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:71
|
||||
msgid "Confirm"
|
||||
msgstr "確定"
|
||||
|
||||
@ -849,6 +883,10 @@ msgstr "新增"
|
||||
#: src/accounting/templates/accounting/report/ledger.html:116
|
||||
#: src/accounting/templates/accounting/report/search.html:100
|
||||
#: src/accounting/templates/accounting/report/trial-balance.html:82
|
||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:61
|
||||
#: src/accounting/templates/accounting/report/unapplied.html:98
|
||||
#: src/accounting/templates/accounting/unmatched-offset/dashboard.html:37
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:104
|
||||
msgid "There is no data."
|
||||
msgstr "沒有資料。"
|
||||
|
||||
@ -938,10 +976,6 @@ msgstr "記帳"
|
||||
msgid "Reports"
|
||||
msgstr "報表"
|
||||
|
||||
#: src/accounting/templates/accounting/include/nav.html:39
|
||||
msgid "Accounts"
|
||||
msgstr "科目"
|
||||
|
||||
#: src/accounting/templates/accounting/include/nav.html:45
|
||||
msgid "Base Accounts"
|
||||
msgstr "基本科目"
|
||||
@ -950,7 +984,12 @@ msgstr "基本科目"
|
||||
msgid "Currencies"
|
||||
msgstr "貨幣"
|
||||
|
||||
#: src/accounting/templates/accounting/include/nav.html:58
|
||||
#: src/accounting/templates/accounting/include/nav.html:57
|
||||
#: src/accounting/templates/accounting/unmatched-offset/dashboard.html:24
|
||||
msgid "Unmatched Offsets"
|
||||
msgstr "遺漏的抵銷分錄"
|
||||
|
||||
#: src/accounting/templates/accounting/include/nav.html:64
|
||||
#: src/accounting/templates/accounting/option/detail.html:24
|
||||
#: src/accounting/templates/accounting/option/detail.html:41
|
||||
#: src/accounting/templates/accounting/option/form.html:29
|
||||
@ -1210,6 +1249,21 @@ msgstr "%(period)s%(currency)s%(account)s分類帳"
|
||||
msgid "Trial Balance of %(currency)s %(period)s"
|
||||
msgstr "%(period)s%(currency)s試算表"
|
||||
|
||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:24
|
||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:41x
|
||||
msgid "Accounts with Unapplied Original Line Items"
|
||||
msgstr "有未抵銷原始分錄的科目"
|
||||
|
||||
#: src/accounting/templates/accounting/report/unapplied.html:28
|
||||
#, python-format
|
||||
msgid "Unapplied Original Line Items of %(account)s"
|
||||
msgstr "%(account)s未抵銷原始分錄"
|
||||
|
||||
#: src/accounting/templates/accounting/report/unapplied.html:65
|
||||
#, python-format
|
||||
msgid "Can match %(offset)s"
|
||||
msgstr "可抵銷 %(offset)s"
|
||||
|
||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:26
|
||||
msgid "Period Chooser"
|
||||
msgstr "選擇日期範圍"
|
||||
@ -1243,6 +1297,66 @@ msgstr "期間"
|
||||
msgid "Download"
|
||||
msgstr "下載"
|
||||
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:24
|
||||
#, python-format
|
||||
msgid "Unmatched Offsets in %(account)s"
|
||||
msgstr "%(account)s遺漏的抵銷分錄"
|
||||
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:28
|
||||
msgid "Toolbar"
|
||||
msgstr "工具列"
|
||||
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:36
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:41
|
||||
msgid "Match"
|
||||
msgstr "抵銷"
|
||||
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:53
|
||||
msgid "Confirm Match Offsets"
|
||||
msgstr "確認抵銷"
|
||||
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:57
|
||||
msgid ""
|
||||
"Do you really want to match the following original line items with their "
|
||||
"offsets? This cannot be undone. Please backup your database first, and "
|
||||
"review before you confirm."
|
||||
msgstr "你確定要抵銷下列原始分錄與抵銷分錄嗎?結果無法復原。請先備份資料庫,並仔細核對抵銷分錄是否正確。"
|
||||
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:81
|
||||
#, python-format
|
||||
msgid ""
|
||||
"%(matches)s unapplied original line items out of %(total)s can match with"
|
||||
" their offsets."
|
||||
msgstr ""
|
||||
"%(total)s 筆未抵銷原始分錄中,可配對抵銷掉 %(matches)s 筆。"
|
||||
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:83
|
||||
#, python-format
|
||||
msgid "%(total)s unapplied original line items without matching offsets."
|
||||
msgstr "%(total)s 筆未抵銷原始分錄,無法自動配對抵銷。"
|
||||
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:85
|
||||
msgid "Go to unapplied original line items."
|
||||
msgstr "查閱未抵銷原始分錄。"
|
||||
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:87
|
||||
msgid "All original line items are fully offset."
|
||||
msgstr "原始分錄已全部抵銷。"
|
||||
|
||||
#: src/accounting/templates/accounting/unmatched-offset/list.html:98
|
||||
#, python-format
|
||||
msgid "Can match %(item)s"
|
||||
msgstr "可抵銷 %(item)s"
|
||||
|
||||
#: src/accounting/unmatched_offset/views.py:71
|
||||
msgid "No more offset to match automatically."
|
||||
msgstr "無法自動配對抵銷。"
|
||||
|
||||
#: src/accounting/unmatched_offset/views.py:77
|
||||
#, python-format
|
||||
msgid "Matches %(matches)s from %(total)s unapplied line items."
|
||||
msgstr "%(total)s 筆未抵銷原始分錄中,配對抵銷掉 %(matches) 筆。"
|
||||
|
||||
#: src/accounting/utils/current_account.py:65
|
||||
msgid "current assets and liabilities"
|
||||
msgstr "流動資產與負債"
|
||||
|
30
src/accounting/unmatched_offset/__init__.py
Normal file
30
src/accounting/unmatched_offset/__init__.py
Normal file
@ -0,0 +1,30 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The unmatched offset management.
|
||||
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
def init_app(bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
from .views import bp as unmatched_offset_bp
|
||||
bp.register_blueprint(unmatched_offset_bp, url_prefix="/unmatched-offsets")
|
50
src/accounting/unmatched_offset/queries.py
Normal file
50
src/accounting/unmatched_offset/queries.py
Normal file
@ -0,0 +1,50 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The queries for the unmatched offset management.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
|
||||
|
||||
def get_accounts_with_unmatched_offsets() -> list[Account]:
|
||||
"""Returns the accounts with unmatched offsets.
|
||||
|
||||
:return: The accounts with unmatched offsets, with the "count" property set
|
||||
to the number of unmatched offsets.
|
||||
"""
|
||||
count_func: sa.Label \
|
||||
= sa.func.count(JournalEntryLineItem.id).label("count")
|
||||
select: sa.Select = sa.select(Account.id, count_func)\
|
||||
.select_from(Account).join(JournalEntryLineItem, isouter=True)\
|
||||
.filter(Account.is_need_offset,
|
||||
JournalEntryLineItem.original_line_item_id.is_(None),
|
||||
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||
JournalEntryLineItem.is_debit),
|
||||
sa.and_(Account.base_code.startswith("1"),
|
||||
sa.not_(JournalEntryLineItem.is_debit))))\
|
||||
.group_by(Account.id)\
|
||||
.having(count_func > 0)
|
||||
counts: dict[int, int] \
|
||||
= {x.id: x.count for x in db.session.execute(select)}
|
||||
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
|
||||
.order_by(Account.base_code, Account.no).all()
|
||||
for account in accounts:
|
||||
account.count = counts[account.id]
|
||||
return accounts
|
81
src/accounting/unmatched_offset/views.py
Normal file
81
src/accounting/unmatched_offset/views.py
Normal file
@ -0,0 +1,81 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The views for the unmatched offset management.
|
||||
|
||||
"""
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import JournalEntryLineItem, Account
|
||||
from accounting.utils.cast import s
|
||||
from accounting.utils.offset_matcher import OffsetMatcher
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import has_permission, can_edit
|
||||
from .queries import get_accounts_with_unmatched_offsets
|
||||
|
||||
bp: Blueprint = Blueprint("unmatched-offset", __name__)
|
||||
"""The view blueprint for the unmatched offset management."""
|
||||
|
||||
|
||||
@bp.get("", endpoint="dashboard")
|
||||
@has_permission(can_edit)
|
||||
def show_offset_dashboard() -> str:
|
||||
"""Shows the dashboard about offsets.
|
||||
|
||||
:return: The dashboard about offsets.
|
||||
"""
|
||||
return render_template("accounting/unmatched-offset/dashboard.html",
|
||||
list=get_accounts_with_unmatched_offsets())
|
||||
|
||||
|
||||
@bp.get("<needOffsetAccount:account>", endpoint="list")
|
||||
@has_permission(can_edit)
|
||||
def show_unmatched_offsets(account: Account) -> str:
|
||||
"""Shows the unmatched offsets in an account.
|
||||
|
||||
:return: The unmatched offsets in an account.
|
||||
"""
|
||||
matcher: OffsetMatcher = OffsetMatcher(account)
|
||||
pagination: Pagination \
|
||||
= Pagination[JournalEntryLineItem](matcher.unmatched_offsets,
|
||||
is_reversed=True)
|
||||
return render_template("accounting/unmatched-offset/list.html",
|
||||
matcher=matcher,
|
||||
list=pagination.list, pagination=pagination)
|
||||
|
||||
|
||||
@bp.post("<needOffsetAccount:account>", endpoint="match")
|
||||
@has_permission(can_edit)
|
||||
def match_offsets(account: Account) -> redirect:
|
||||
"""Matches the original line items with their offsets.
|
||||
|
||||
:return: Redirection to the view of the unmatched offsets.
|
||||
"""
|
||||
matcher: OffsetMatcher = OffsetMatcher(account)
|
||||
if not matcher.is_having_matches:
|
||||
flash(s(lazy_gettext("No more offset to match automatically.")),
|
||||
"success")
|
||||
return redirect(url_for("accounting.unmatched-offset.list",
|
||||
account=account))
|
||||
matcher.match()
|
||||
db.session.commit()
|
||||
flash(s(lazy_gettext(
|
||||
"Matches %(matches)s from %(total)s unapplied line items.",
|
||||
matches=matcher.matches, total=matcher.total)), "success")
|
||||
return redirect(url_for("accounting.unmatched-offset.list",
|
||||
account=account))
|
129
src/accounting/utils/offset_matcher.py
Normal file
129
src/accounting/utils/offset_matcher.py
Normal file
@ -0,0 +1,129 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The forms for the unmatched offset management.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting.models import Account, JournalEntry, JournalEntryLineItem
|
||||
from accounting.utils.unapplied import get_unapplied_original_line_items
|
||||
|
||||
|
||||
class OffsetPair:
|
||||
"""A pair of an original line item and its offset."""
|
||||
|
||||
def __init__(self, original_line_item: JournalEntryLineItem,
|
||||
offset: JournalEntryLineItem):
|
||||
"""Constructs a pair of an original line item and its offset.
|
||||
|
||||
:param original_line_item: The original line item.
|
||||
:param offset: The offset.
|
||||
"""
|
||||
self.original_line_item: JournalEntryLineItem = original_line_item
|
||||
"""The original line item."""
|
||||
self.offset: JournalEntryLineItem = offset
|
||||
"""The offset."""
|
||||
|
||||
|
||||
class OffsetMatcher:
|
||||
"""The offset matcher."""
|
||||
|
||||
def __init__(self, account: Account):
|
||||
"""Constructs the offset matcher.
|
||||
|
||||
:param account: The account.
|
||||
"""
|
||||
self.account: Account = account
|
||||
"""The account."""
|
||||
self.matched_pairs: list[OffsetPair] = []
|
||||
"""A list of matched pairs."""
|
||||
self.is_having_matches: bool = False
|
||||
"""Whether there is any matches."""
|
||||
self.total: int = 0
|
||||
"""The total number of unapplied debits or credits."""
|
||||
self.unapplied: list[JournalEntryLineItem] = []
|
||||
"""The unapplied debits or credits."""
|
||||
self.unmatched_offsets: list[JournalEntryLineItem] = []
|
||||
"""The unmatched offsets."""
|
||||
self.__find_matches()
|
||||
|
||||
def __find_matches(self) -> None:
|
||||
"""Finds the matched original line items and their offsets.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.unapplied: list[JournalEntryLineItem] \
|
||||
= get_unapplied_original_line_items(self.account)
|
||||
self.total = len(self.unapplied)
|
||||
if self.total == 0:
|
||||
self.is_having_matches = False
|
||||
return
|
||||
self.unmatched_offsets = self.__get_unmatched_offsets()
|
||||
remains: list[JournalEntryLineItem] = self.unmatched_offsets.copy()
|
||||
for original_item in self.unapplied:
|
||||
offset_candidates: list[JournalEntryLineItem] \
|
||||
= [x for x in remains
|
||||
if (x.journal_entry.date > original_item.journal_entry.date
|
||||
or (x.journal_entry.date
|
||||
== original_item.journal_entry.date
|
||||
and x.journal_entry.no
|
||||
> original_item.journal_entry.no))
|
||||
and x.currency_code == original_item.currency_code
|
||||
and x.description == original_item.description
|
||||
and x.amount == original_item.net_balance]
|
||||
if len(offset_candidates) == 0:
|
||||
continue
|
||||
self.matched_pairs.append(
|
||||
OffsetPair(original_item, offset_candidates[0]))
|
||||
original_item.match = offset_candidates[0]
|
||||
offset_candidates[0].match = original_item
|
||||
remains.remove(offset_candidates[0])
|
||||
self.is_having_matches = len(self.matched_pairs) > 0
|
||||
|
||||
def __get_unmatched_offsets(self) -> list[JournalEntryLineItem]:
|
||||
"""Returns the unmatched offsets of an account.
|
||||
|
||||
:return: The unmatched offsets of the account.
|
||||
"""
|
||||
return JournalEntryLineItem.query.join(Account).join(JournalEntry)\
|
||||
.filter(Account.id == self.account.id,
|
||||
JournalEntryLineItem.original_line_item_id.is_(None),
|
||||
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||
JournalEntryLineItem.is_debit),
|
||||
sa.and_(Account.base_code.startswith("1"),
|
||||
sa.not_(JournalEntryLineItem.is_debit))))\
|
||||
.order_by(JournalEntry.date, JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
|
||||
.options(selectinload(JournalEntryLineItem.currency),
|
||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||
|
||||
@property
|
||||
def matches(self) -> int:
|
||||
"""Returns the number of matches.
|
||||
|
||||
:return: The number of matches.
|
||||
"""
|
||||
return len(self.matched_pairs)
|
||||
|
||||
def match(self) -> None:
|
||||
"""Matches the original line items with offsets.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
for pair in self.matched_pairs:
|
||||
pair.offset.original_line_item_id = pair.original_line_item.id
|
@ -99,12 +99,12 @@ class Options:
|
||||
self.__set_option("default_currency_code", value)
|
||||
|
||||
@property
|
||||
def default_currency_text(self) -> str:
|
||||
"""Returns the text of the default currency code.
|
||||
def default_currency(self) -> Currency:
|
||||
"""Returns the default currency.
|
||||
|
||||
:return: The text of the default currency code.
|
||||
:return: The default currency.
|
||||
"""
|
||||
return str(db.session.get(Currency, self.default_currency_code))
|
||||
return db.session.get(Currency, self.default_currency_code)
|
||||
|
||||
@property
|
||||
def default_ie_account_code(self) -> str:
|
||||
@ -123,22 +123,11 @@ class Options:
|
||||
"""
|
||||
self.__set_option("default_ie_account", value)
|
||||
|
||||
@property
|
||||
def default_ie_account_code_text(self) -> str:
|
||||
"""Returns the text of the default currency code.
|
||||
|
||||
:return: The text of the default currency code.
|
||||
"""
|
||||
code: str = self.default_ie_account_code
|
||||
if code == CurrentAccount.CURRENT_AL_CODE:
|
||||
return str(CurrentAccount.current_assets_and_liabilities())
|
||||
return str(CurrentAccount(Account.find_by_code(code)))
|
||||
|
||||
@property
|
||||
def default_ie_account(self) -> CurrentAccount:
|
||||
"""Returns the default account code for the income and expenses log.
|
||||
"""Returns the default account for the income and expenses log.
|
||||
|
||||
:return: The default account code for the income and expenses log.
|
||||
:return: The default account for the income and expenses log.
|
||||
"""
|
||||
if self.default_ie_account_code \
|
||||
== CurrentAccount.CURRENT_AL_CODE:
|
||||
|
72
src/accounting/utils/unapplied.py
Normal file
72
src/accounting/utils/unapplied.py
Normal file
@ -0,0 +1,72 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The unapplied original line item utilities.
|
||||
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Account, JournalEntry, JournalEntryLineItem
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.offset_alias import offset_alias
|
||||
|
||||
|
||||
def get_unapplied_original_line_items(account: Account) \
|
||||
-> list[JournalEntryLineItem]:
|
||||
"""Queries and returns the unapplied original line items in an account.
|
||||
|
||||
:param account: The account.
|
||||
:return: The unapplied original line items in the account.
|
||||
"""
|
||||
offset: sa.Alias = offset_alias()
|
||||
net_balance: sa.Label \
|
||||
= (JournalEntryLineItem.amount
|
||||
+ sa.func.sum(sa.case(
|
||||
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
|
||||
offset.c.amount),
|
||||
else_=-offset.c.amount))).label("net_balance")
|
||||
select_net_balances: sa.Select \
|
||||
= sa.select(JournalEntryLineItem.id, net_balance) \
|
||||
.join(Account) \
|
||||
.join(offset, be(JournalEntryLineItem.id
|
||||
== offset.c.original_line_item_id),
|
||||
isouter=True) \
|
||||
.filter(be(Account.id == account.id),
|
||||
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||
sa.not_(JournalEntryLineItem.is_debit)),
|
||||
sa.and_(Account.base_code.startswith("1"),
|
||||
JournalEntryLineItem.is_debit))) \
|
||||
.group_by(JournalEntryLineItem.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()}
|
||||
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query \
|
||||
.filter(JournalEntryLineItem.id.in_({x for x in net_balances})) \
|
||||
.join(JournalEntry) \
|
||||
.order_by(JournalEntry.date, JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
|
||||
.options(selectinload(JournalEntryLineItem.currency),
|
||||
selectinload(JournalEntryLineItem.journal_entry)).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
|
@ -81,21 +81,21 @@ class OffsetTestCase(unittest.TestCase):
|
||||
response: httpx.Response
|
||||
|
||||
journal_entry_data: JournalEntryData = JournalEntryData(
|
||||
self.data.e_r_or3d.journal_entry.days, [CurrencyData(
|
||||
self.data.l_r_or3d.journal_entry.days, [CurrencyData(
|
||||
"USD",
|
||||
[],
|
||||
[JournalEntryLineItemData(
|
||||
Accounts.RECEIVABLE,
|
||||
self.data.e_r_or1d.description, "300",
|
||||
original_line_item=self.data.e_r_or1d),
|
||||
self.data.l_r_or1d.description, "300",
|
||||
original_line_item=self.data.l_r_or1d),
|
||||
JournalEntryLineItemData(
|
||||
Accounts.RECEIVABLE,
|
||||
self.data.e_r_or1d.description, "100",
|
||||
original_line_item=self.data.e_r_or1d),
|
||||
self.data.l_r_or1d.description, "100",
|
||||
original_line_item=self.data.l_r_or1d),
|
||||
JournalEntryLineItemData(
|
||||
Accounts.RECEIVABLE,
|
||||
self.data.e_r_or3d.description, "100",
|
||||
original_line_item=self.data.e_r_or3d)])])
|
||||
self.data.l_r_or3d.description, "100",
|
||||
original_line_item=self.data.l_r_or3d)])])
|
||||
|
||||
# Non-existing original line item ID
|
||||
form = journal_entry_data.new_form(self.csrf_token)
|
||||
@ -107,8 +107,8 @@ class OffsetTestCase(unittest.TestCase):
|
||||
# The same debit or credit
|
||||
form = journal_entry_data.new_form(self.csrf_token)
|
||||
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
|
||||
= self.data.l_p_or1c.id
|
||||
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
|
||||
form["currency-1-credit-1-amount"] = "100"
|
||||
response = self.client.post(store_uri, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
@ -131,8 +131,8 @@ class OffsetTestCase(unittest.TestCase):
|
||||
# The original line item is also an offset
|
||||
form = journal_entry_data.new_form(self.csrf_token)
|
||||
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
|
||||
= self.data.l_p_of1d.id
|
||||
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
|
||||
response = self.client.post(store_uri, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
@ -195,13 +195,13 @@ class OffsetTestCase(unittest.TestCase):
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account
|
||||
journal_entry_data: JournalEntryData = self.data.v_r_of2
|
||||
journal_entry_data: JournalEntryData = self.data.j_r_of2
|
||||
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
|
||||
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
|
||||
form: dict[str, str]
|
||||
response: httpx.Response
|
||||
|
||||
journal_entry_data.days = self.data.v_r_or2.days
|
||||
journal_entry_data.days = self.data.j_r_or2.days
|
||||
journal_entry_data.currencies[0].debit[0].amount = Decimal("600")
|
||||
journal_entry_data.currencies[0].credit[0].amount = Decimal("600")
|
||||
journal_entry_data.currencies[0].debit[2].amount = Decimal("600")
|
||||
@ -217,8 +217,8 @@ class OffsetTestCase(unittest.TestCase):
|
||||
# The same debit or credit
|
||||
form = journal_entry_data.update_form(self.csrf_token)
|
||||
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
|
||||
= self.data.l_p_or1c.id
|
||||
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
|
||||
form["currency-1-debit-1-amount"] = "100"
|
||||
form["currency-1-credit-1-amount"] = "100"
|
||||
response = self.client.post(update_uri, data=form)
|
||||
@ -242,8 +242,8 @@ class OffsetTestCase(unittest.TestCase):
|
||||
# The original line item is also an offset
|
||||
form = journal_entry_data.update_form(self.csrf_token)
|
||||
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
|
||||
= self.data.l_p_of1d.id
|
||||
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
|
||||
response = self.client.post(update_uri, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], edit_uri)
|
||||
@ -308,13 +308,13 @@ class OffsetTestCase(unittest.TestCase):
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import JournalEntry
|
||||
journal_entry_data: JournalEntryData = self.data.v_r_or1
|
||||
journal_entry_data: JournalEntryData = self.data.j_r_or1
|
||||
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
|
||||
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
|
||||
form: dict[str, str]
|
||||
response: httpx.Response
|
||||
|
||||
journal_entry_data.days = self.data.v_r_of1.days
|
||||
journal_entry_data.days = self.data.j_r_of1.days
|
||||
journal_entry_data.currencies[0].debit[0].amount = Decimal("800")
|
||||
journal_entry_data.currencies[0].credit[0].amount = Decimal("800")
|
||||
journal_entry_data.currencies[0].debit[1].amount = Decimal("3.4")
|
||||
@ -388,7 +388,7 @@ class OffsetTestCase(unittest.TestCase):
|
||||
JournalEntry, journal_entry_data.id)
|
||||
self.assertIsNotNone(journal_entry_or)
|
||||
journal_entry_of: JournalEntry | None = db.session.get(
|
||||
JournalEntry, self.data.v_r_of1.id)
|
||||
JournalEntry, self.data.j_r_of1.id)
|
||||
self.assertIsNotNone(journal_entry_of)
|
||||
self.assertEqual(journal_entry_or.date, journal_entry_of.date)
|
||||
self.assertLess(journal_entry_or.no, journal_entry_of.no)
|
||||
@ -405,20 +405,20 @@ class OffsetTestCase(unittest.TestCase):
|
||||
response: httpx.Response
|
||||
|
||||
journal_entry_data: JournalEntryData = JournalEntryData(
|
||||
self.data.e_p_or3c.journal_entry.days, [CurrencyData(
|
||||
self.data.l_p_or3c.journal_entry.days, [CurrencyData(
|
||||
"USD",
|
||||
[JournalEntryLineItemData(
|
||||
Accounts.PAYABLE,
|
||||
self.data.e_p_or1c.description, "500",
|
||||
original_line_item=self.data.e_p_or1c),
|
||||
self.data.l_p_or1c.description, "500",
|
||||
original_line_item=self.data.l_p_or1c),
|
||||
JournalEntryLineItemData(
|
||||
Accounts.PAYABLE,
|
||||
self.data.e_p_or1c.description, "300",
|
||||
original_line_item=self.data.e_p_or1c),
|
||||
self.data.l_p_or1c.description, "300",
|
||||
original_line_item=self.data.l_p_or1c),
|
||||
JournalEntryLineItemData(
|
||||
Accounts.PAYABLE,
|
||||
self.data.e_p_or3c.description, "120",
|
||||
original_line_item=self.data.e_p_or3c)],
|
||||
self.data.l_p_or3c.description, "120",
|
||||
original_line_item=self.data.l_p_or3c)],
|
||||
[])])
|
||||
|
||||
# Non-existing original line item ID
|
||||
@ -431,8 +431,8 @@ class OffsetTestCase(unittest.TestCase):
|
||||
# The same debit or credit
|
||||
form = journal_entry_data.new_form(self.csrf_token)
|
||||
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
|
||||
= self.data.l_r_or1d.id
|
||||
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
|
||||
form["currency-1-debit-1-amount"] = "100"
|
||||
response = self.client.post(store_uri, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
@ -455,8 +455,8 @@ class OffsetTestCase(unittest.TestCase):
|
||||
# The original line item is also an offset
|
||||
form = journal_entry_data.new_form(self.csrf_token)
|
||||
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
|
||||
= self.data.l_r_of1c.id
|
||||
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
|
||||
response = self.client.post(store_uri, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
@ -519,13 +519,13 @@ class OffsetTestCase(unittest.TestCase):
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account, JournalEntry
|
||||
journal_entry_data: JournalEntryData = self.data.v_p_of2
|
||||
journal_entry_data: JournalEntryData = self.data.j_p_of2
|
||||
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
|
||||
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
|
||||
form: dict[str, str]
|
||||
response: httpx.Response
|
||||
|
||||
journal_entry_data.days = self.data.v_p_or2.days
|
||||
journal_entry_data.days = self.data.j_p_or2.days
|
||||
journal_entry_data.currencies[0].debit[0].amount = Decimal("1100")
|
||||
journal_entry_data.currencies[0].credit[0].amount = Decimal("1100")
|
||||
journal_entry_data.currencies[0].debit[2].amount = Decimal("900")
|
||||
@ -541,8 +541,8 @@ class OffsetTestCase(unittest.TestCase):
|
||||
# The same debit or credit
|
||||
form = journal_entry_data.update_form(self.csrf_token)
|
||||
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
|
||||
= self.data.l_r_or1d.id
|
||||
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
|
||||
form["currency-1-debit-1-amount"] = "100"
|
||||
form["currency-1-credit-1-amount"] = "100"
|
||||
response = self.client.post(update_uri, data=form)
|
||||
@ -566,8 +566,8 @@ class OffsetTestCase(unittest.TestCase):
|
||||
# The original line item is also an offset
|
||||
form = journal_entry_data.update_form(self.csrf_token)
|
||||
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
|
||||
= self.data.l_r_of1c.id
|
||||
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
|
||||
response = self.client.post(update_uri, data=form)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], edit_uri)
|
||||
@ -636,13 +636,13 @@ class OffsetTestCase(unittest.TestCase):
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import JournalEntry
|
||||
journal_entry_data: JournalEntryData = self.data.v_p_or1
|
||||
journal_entry_data: JournalEntryData = self.data.j_p_or1
|
||||
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
|
||||
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
|
||||
form: dict[str, str]
|
||||
response: httpx.Response
|
||||
|
||||
journal_entry_data.days = self.data.v_p_of1.days
|
||||
journal_entry_data.days = self.data.j_p_of1.days
|
||||
journal_entry_data.currencies[0].debit[0].amount = Decimal("1200")
|
||||
journal_entry_data.currencies[0].credit[0].amount = Decimal("1200")
|
||||
journal_entry_data.currencies[0].debit[1].amount = Decimal("0.9")
|
||||
@ -716,7 +716,7 @@ class OffsetTestCase(unittest.TestCase):
|
||||
JournalEntry, journal_entry_data.id)
|
||||
self.assertIsNotNone(journal_entry_or)
|
||||
journal_entry_of: JournalEntry | None = db.session.get(
|
||||
JournalEntry, self.data.v_p_of1.id)
|
||||
JournalEntry, self.data.j_p_of1.id)
|
||||
self.assertIsNotNone(journal_entry_of)
|
||||
self.assertEqual(journal_entry_or.date, journal_entry_of.date)
|
||||
self.assertLess(journal_entry_or.no, journal_entry_of.no)
|
||||
|
692
tests/test_offset_matcher.py
Normal file
692
tests/test_offset_matcher.py
Normal file
@ -0,0 +1,692 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/11
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The test for the offset matcher.
|
||||
|
||||
"""
|
||||
import unittest
|
||||
|
||||
import httpx
|
||||
from click.testing import Result
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from test_site import db
|
||||
from testlib import create_test_app, get_client, Accounts
|
||||
from testlib_journal_entry import match_journal_entry_detail
|
||||
from testlib_offset import JournalEntryData, CurrencyData, \
|
||||
JournalEntryLineItemData
|
||||
|
||||
|
||||
class OffsetMatcherTestCase(unittest.TestCase):
|
||||
"""The offset matcher test case."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Sets up the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.app: Flask = create_test_app()
|
||||
|
||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||
with self.app.app_context():
|
||||
from accounting.models import BaseAccount, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
result: Result
|
||||
result = runner.invoke(args="init-db")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
if BaseAccount.query.first() is None:
|
||||
result = runner.invoke(args="accounting-init-base")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
result = runner.invoke(args=["accounting-init-currencies",
|
||||
"-u", "editor"])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
result = runner.invoke(args=["accounting-init-accounts",
|
||||
"-u", "editor"])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
JournalEntry.query.delete()
|
||||
JournalEntryLineItem.query.delete()
|
||||
|
||||
self.client, self.csrf_token = get_client(self.app, "editor")
|
||||
|
||||
def test_different(self) -> None:
|
||||
"""Tests to match against different descriptions and amounts.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.utils.offset_matcher import OffsetMatcher
|
||||
data: DifferentTestData \
|
||||
= DifferentTestData(self.app, self.client, self.csrf_token)
|
||||
account: Account | None
|
||||
line_item: JournalEntryLineItem | None
|
||||
matcher: OffsetMatcher
|
||||
list_uri: str
|
||||
response: httpx.Response
|
||||
|
||||
# The receivables
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.RECEIVABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
self.assertEqual({x.id for x in matcher.unapplied},
|
||||
{data.l_r_or1d.id, data.l_r_or2d.id,
|
||||
data.l_r_or3d.id, data.l_r_or4d.id})
|
||||
self.assertEqual({x.id for x in matcher.unmatched_offsets},
|
||||
{data.l_r_of1c.id, data.l_r_of2c.id,
|
||||
data.l_r_of3c.id, data.l_r_of4c.id,
|
||||
data.l_r_of5c.id})
|
||||
self.assertEqual({(x.original_line_item.id, x.offset.id)
|
||||
for x in matcher.matched_pairs},
|
||||
{(data.l_r_or4d.id, data.l_r_of5c.id)})
|
||||
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
|
||||
data.l_r_of3c.id, data.l_r_of4c.id,
|
||||
data.l_r_of5c.id}:
|
||||
line_item = db.session.get(JournalEntryLineItem, line_item_id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNone(line_item.original_line_item_id)
|
||||
|
||||
list_uri = f"/accounting/unmatched-offsets/{Accounts.RECEIVABLE}"
|
||||
response = self.client.post(list_uri,
|
||||
data={"csrf_token": self.csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], list_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.RECEIVABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
self.assertEqual({x.id for x in matcher.unapplied},
|
||||
{data.l_r_or1d.id, data.l_r_or2d.id,
|
||||
data.l_r_or3d.id})
|
||||
self.assertEqual({x.id for x in matcher.unmatched_offsets},
|
||||
{data.l_r_of1c.id, data.l_r_of2c.id,
|
||||
data.l_r_of3c.id, data.l_r_of4c.id})
|
||||
self.assertEqual(matcher.matches, 0)
|
||||
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
|
||||
data.l_r_of3c.id, data.l_r_of4c.id}:
|
||||
line_item = db.session.get(JournalEntryLineItem, line_item_id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNone(line_item.original_line_item_id)
|
||||
line_item = db.session.get(JournalEntryLineItem, data.l_r_of5c.id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
|
||||
|
||||
# The payables
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.PAYABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
self.assertEqual({x.id for x in matcher.unapplied},
|
||||
{data.l_p_or1c.id, data.l_p_or2c.id,
|
||||
data.l_p_or3c.id, data.l_p_or4c.id})
|
||||
self.assertEqual({x.id for x in matcher.unmatched_offsets},
|
||||
{data.l_p_of1d.id, data.l_p_of2d.id,
|
||||
data.l_p_of3d.id, data.l_p_of4d.id,
|
||||
data.l_p_of5d.id})
|
||||
self.assertEqual({(x.original_line_item.id, x.offset.id)
|
||||
for x in matcher.matched_pairs},
|
||||
{(data.l_p_or4c.id, data.l_p_of5d.id)})
|
||||
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
|
||||
data.l_p_of3d.id, data.l_p_of4d.id,
|
||||
data.l_p_of5d.id}:
|
||||
line_item = db.session.get(JournalEntryLineItem, line_item_id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNone(line_item.original_line_item_id)
|
||||
|
||||
list_uri = f"/accounting/unmatched-offsets/{Accounts.PAYABLE}"
|
||||
response = self.client.post(list_uri,
|
||||
data={"csrf_token": self.csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], list_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.PAYABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
self.assertEqual({x.id for x in matcher.unapplied},
|
||||
{data.l_p_or1c.id, data.l_p_or2c.id,
|
||||
data.l_p_or3c.id})
|
||||
self.assertEqual({x.id for x in matcher.unmatched_offsets},
|
||||
{data.l_p_of1d.id, data.l_p_of2d.id,
|
||||
data.l_p_of3d.id, data.l_p_of4d.id})
|
||||
self.assertEqual(matcher.matches, 0)
|
||||
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
|
||||
data.l_p_of3d.id, data.l_p_of4d.id}:
|
||||
line_item = db.session.get(JournalEntryLineItem, line_item_id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNone(line_item.original_line_item_id)
|
||||
line_item = db.session.get(JournalEntryLineItem, data.l_p_of5d.id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_p_or4c.id)
|
||||
|
||||
def test_same(self) -> None:
|
||||
"""Tests to match against same descriptions and amounts.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.utils.offset_matcher import OffsetMatcher
|
||||
data: SameTestData \
|
||||
= SameTestData(self.app, self.client, self.csrf_token)
|
||||
account: Account | None
|
||||
line_item: JournalEntryLineItem | None
|
||||
matcher: OffsetMatcher
|
||||
list_uri: str
|
||||
response: httpx.Response
|
||||
|
||||
# The receivables
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.RECEIVABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
self.assertEqual({x.id for x in matcher.unapplied},
|
||||
{data.l_r_or1d.id, data.l_r_or3d.id,
|
||||
data.l_r_or4d.id, data.l_r_or5d.id,
|
||||
data.l_r_or6d.id})
|
||||
self.assertEqual({x.id for x in matcher.unmatched_offsets},
|
||||
{data.l_r_of1c.id, data.l_r_of2c.id,
|
||||
data.l_r_of4c.id, data.l_r_of5c.id,
|
||||
data.l_r_of6c.id})
|
||||
self.assertEqual({(x.original_line_item.id, x.offset.id)
|
||||
for x in matcher.matched_pairs},
|
||||
{(data.l_r_or1d.id, data.l_r_of2c.id),
|
||||
(data.l_r_or3d.id, data.l_r_of4c.id),
|
||||
(data.l_r_or4d.id, data.l_r_of6c.id)})
|
||||
for line_item_id in {data.l_r_of1c.id, data.l_r_of2c.id,
|
||||
data.l_r_of4c.id, data.l_r_of5c.id,
|
||||
data.l_r_of6c.id}:
|
||||
line_item = db.session.get(JournalEntryLineItem, line_item_id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNone(line_item.original_line_item_id)
|
||||
line_item = db.session.get(JournalEntryLineItem, data.l_r_of3c.id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
|
||||
|
||||
list_uri = f"/accounting/unmatched-offsets/{Accounts.RECEIVABLE}"
|
||||
response = self.client.post(list_uri,
|
||||
data={"csrf_token": self.csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], list_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.RECEIVABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
self.assertEqual({x.id for x in matcher.unapplied},
|
||||
{data.l_r_or5d.id, data.l_r_or6d.id})
|
||||
self.assertEqual({x.id for x in matcher.unmatched_offsets},
|
||||
{data.l_r_of1c.id, data.l_r_of5c.id})
|
||||
self.assertEqual(matcher.matches, 0)
|
||||
for line_item_id in {data.l_r_of1c.id, data.l_r_of5c.id}:
|
||||
line_item = db.session.get(JournalEntryLineItem, line_item_id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNone(line_item.original_line_item_id)
|
||||
line_item = db.session.get(JournalEntryLineItem, data.l_r_of2c.id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_r_or1d.id)
|
||||
line_item = db.session.get(JournalEntryLineItem, data.l_r_of3c.id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
|
||||
line_item = db.session.get(JournalEntryLineItem, data.l_r_of4c.id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_r_or3d.id)
|
||||
line_item = db.session.get(JournalEntryLineItem, data.l_r_of6c.id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
|
||||
|
||||
# The payables
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.PAYABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
self.assertEqual({x.id for x in matcher.unapplied},
|
||||
{data.l_p_or1c.id, data.l_p_or3c.id,
|
||||
data.l_p_or4c.id, data.l_p_or5c.id,
|
||||
data.l_p_or6c.id})
|
||||
self.assertEqual({x.id for x in matcher.unmatched_offsets},
|
||||
{data.l_p_of1d.id, data.l_p_of2d.id,
|
||||
data.l_p_of4d.id, data.l_p_of5d.id,
|
||||
data.l_p_of6d.id})
|
||||
self.assertEqual({(x.original_line_item.id, x.offset.id)
|
||||
for x in matcher.matched_pairs},
|
||||
{(data.l_p_or1c.id, data.l_p_of2d.id),
|
||||
(data.l_p_or3c.id, data.l_p_of4d.id),
|
||||
(data.l_p_or4c.id, data.l_p_of6d.id)})
|
||||
for line_item_id in {data.l_p_of1d.id, data.l_p_of2d.id,
|
||||
data.l_p_of4d.id, data.l_p_of5d.id,
|
||||
data.l_p_of6d.id}:
|
||||
line_item = db.session.get(JournalEntryLineItem, line_item_id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNone(line_item.original_line_item_id)
|
||||
line_item = db.session.get(JournalEntryLineItem, data.l_p_of3d.id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
|
||||
|
||||
list_uri = f"/accounting/unmatched-offsets/{Accounts.PAYABLE}"
|
||||
response = self.client.post(list_uri,
|
||||
data={"csrf_token": self.csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], list_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.PAYABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
self.assertEqual({x.id for x in matcher.unapplied},
|
||||
{data.l_p_or5c.id, data.l_p_or6c.id})
|
||||
self.assertEqual({x.id for x in matcher.unmatched_offsets},
|
||||
{data.l_p_of1d.id, data.l_p_of5d.id})
|
||||
self.assertEqual(matcher.matches, 0)
|
||||
for line_item_id in {data.l_p_of1d.id, data.l_p_of5d.id}:
|
||||
line_item = db.session.get(JournalEntryLineItem, line_item_id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNone(line_item.original_line_item_id)
|
||||
line_item = db.session.get(JournalEntryLineItem, data.l_p_of2d.id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_p_or1c.id)
|
||||
line_item = db.session.get(JournalEntryLineItem, data.l_p_of3d.id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
|
||||
line_item = db.session.get(JournalEntryLineItem, data.l_p_of4d.id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_p_or3c.id)
|
||||
line_item = db.session.get(JournalEntryLineItem, data.l_p_of6d.id)
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_p_or4c.id)
|
||||
|
||||
|
||||
class DifferentTestData:
|
||||
"""The test data for different descriptions and amounts."""
|
||||
|
||||
def __init__(self, app: Flask, client: httpx.Client, csrf_token: str):
|
||||
"""Constructs the test data.
|
||||
|
||||
:param app: The Flask application.
|
||||
:param client: The client.
|
||||
:param csrf_token: The CSRF token.
|
||||
"""
|
||||
self.app: Flask = app
|
||||
self.client: httpx.Client = client
|
||||
self.csrf_token: str = csrf_token
|
||||
|
||||
def couple(description: str, amount: str, debit: str, credit: str) \
|
||||
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
|
||||
"""Returns a couple of debit-credit line items.
|
||||
|
||||
:param description: The description.
|
||||
:param amount: The amount.
|
||||
:param debit: The debit account code.
|
||||
:param credit: The credit account code.
|
||||
:return: The debit line item and credit line item.
|
||||
"""
|
||||
return JournalEntryLineItemData(debit, description, amount),\
|
||||
JournalEntryLineItemData(credit, description, amount)
|
||||
|
||||
# Receivable original line items
|
||||
self.l_r_or1d, self.l_r_or1c = couple(
|
||||
"Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
|
||||
self.l_r_or2d, self.l_r_or2c = couple(
|
||||
"Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
|
||||
self.l_r_or3d, self.l_r_or3c = couple(
|
||||
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
|
||||
self.l_r_or4d, self.l_r_or4c = couple(
|
||||
"Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
|
||||
|
||||
# Payable original line items
|
||||
self.l_p_or1d, self.l_p_or1c = couple(
|
||||
"Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
|
||||
self.l_p_or2d, self.l_p_or2c = couple(
|
||||
"Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
|
||||
self.l_p_or3d, self.l_p_or3c = couple(
|
||||
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
|
||||
self.l_p_or4d, self.l_p_or4c = couple(
|
||||
"Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
|
||||
|
||||
# Original journal entries
|
||||
self.j_r_or1: JournalEntryData = JournalEntryData(
|
||||
50, [CurrencyData("USD", [self.l_r_or1d, self.l_r_or4d],
|
||||
[self.l_r_or1c, self.l_r_or4c])])
|
||||
self.j_r_or2: JournalEntryData = JournalEntryData(
|
||||
30, [CurrencyData("USD", [self.l_r_or2d, self.l_r_or3d],
|
||||
[self.l_r_or2c, self.l_r_or3c])])
|
||||
self.j_p_or1: JournalEntryData = JournalEntryData(
|
||||
40, [CurrencyData("USD", [self.l_p_or1d, self.l_p_or4d],
|
||||
[self.l_p_or1c, self.l_p_or4c])])
|
||||
self.j_p_or2: JournalEntryData = JournalEntryData(
|
||||
20, [CurrencyData("USD", [self.l_p_or2d, self.l_p_or3d],
|
||||
[self.l_p_or2c, self.l_p_or3c])])
|
||||
|
||||
self.__add_journal_entry(self.j_r_or1)
|
||||
self.__add_journal_entry(self.j_r_or2)
|
||||
self.__add_journal_entry(self.j_p_or1)
|
||||
self.__add_journal_entry(self.j_p_or2)
|
||||
|
||||
# Receivable offset items
|
||||
self.l_r_of1d, self.l_r_of1c = couple(
|
||||
"Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.l_r_of2d, self.l_r_of2c = couple(
|
||||
"Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.l_r_of3d, self.l_r_of3c = couple(
|
||||
"Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.l_r_of4d, self.l_r_of4c = couple(
|
||||
"Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.l_r_of5d, self.l_r_of5c = couple(
|
||||
"Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
|
||||
# Payable offset items
|
||||
self.l_p_of1d, self.l_p_of1c = couple(
|
||||
"Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.l_p_of2d, self.l_p_of2c = couple(
|
||||
"Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.l_p_of3d, self.l_p_of3c = couple(
|
||||
"Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.l_p_of4d, self.l_p_of4c = couple(
|
||||
"Phone", "400", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.l_p_of5d, self.l_p_of5c = couple(
|
||||
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
|
||||
|
||||
# Offset journal entries
|
||||
self.j_r_of1: JournalEntryData = JournalEntryData(
|
||||
25, [CurrencyData("USD", [self.l_r_of1d], [self.l_r_of1c])])
|
||||
self.j_r_of2: JournalEntryData = JournalEntryData(
|
||||
20, [CurrencyData("USD",
|
||||
[self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
|
||||
[self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
|
||||
self.j_r_of3: JournalEntryData = JournalEntryData(
|
||||
15, [CurrencyData("USD", [self.l_r_of5d], [self.l_r_of5c])])
|
||||
self.j_p_of1: JournalEntryData = JournalEntryData(
|
||||
15, [CurrencyData("USD", [self.l_p_of1d], [self.l_p_of1c])])
|
||||
self.j_p_of2: JournalEntryData = JournalEntryData(
|
||||
10, [CurrencyData("USD",
|
||||
[self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
|
||||
[self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
|
||||
self.j_p_of3: JournalEntryData = JournalEntryData(
|
||||
5, [CurrencyData("USD", [self.l_p_of5d], [self.l_p_of5c])])
|
||||
|
||||
self.__set_is_need_offset(False)
|
||||
self.__add_journal_entry(self.j_r_of1)
|
||||
self.__add_journal_entry(self.j_r_of2)
|
||||
self.__add_journal_entry(self.j_r_of3)
|
||||
self.__add_journal_entry(self.j_p_of1)
|
||||
self.__add_journal_entry(self.j_p_of2)
|
||||
self.__add_journal_entry(self.j_p_of3)
|
||||
self.__set_is_need_offset(True)
|
||||
|
||||
def __set_is_need_offset(self, is_need_offset: bool) -> None:
|
||||
"""Sets whether the payables and receivables need offset.
|
||||
|
||||
:param is_need_offset: True if payables and receivables need offset, or
|
||||
False otherwise.
|
||||
:return:
|
||||
"""
|
||||
from accounting.models import Account
|
||||
with self.app.app_context():
|
||||
for code in {Accounts.RECEIVABLE, Accounts.PAYABLE}:
|
||||
account: Account | None = Account.find_by_code(code)
|
||||
assert account is not None
|
||||
account.is_need_offset = is_need_offset
|
||||
db.session.commit()
|
||||
|
||||
def __add_journal_entry(self, journal_entry_data: JournalEntryData) \
|
||||
-> None:
|
||||
"""Adds a journal entry.
|
||||
|
||||
:param journal_entry_data: The journal entry data.
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import JournalEntry
|
||||
store_uri: str = "/accounting/journal-entries/store/transfer"
|
||||
|
||||
response: httpx.Response = self.client.post(
|
||||
store_uri, data=journal_entry_data.new_form(self.csrf_token))
|
||||
assert response.status_code == 302
|
||||
journal_entry_id: int \
|
||||
= match_journal_entry_detail(response.headers["Location"])
|
||||
journal_entry_data.id = journal_entry_id
|
||||
with self.app.app_context():
|
||||
journal_entry: JournalEntry | None \
|
||||
= db.session.get(JournalEntry, journal_entry_id)
|
||||
assert journal_entry is not None
|
||||
for i in range(len(journal_entry.currencies)):
|
||||
for j in range(len(journal_entry.currencies[i].debit)):
|
||||
journal_entry_data.currencies[i].debit[j].id \
|
||||
= journal_entry.currencies[i].debit[j].id
|
||||
for j in range(len(journal_entry.currencies[i].credit)):
|
||||
journal_entry_data.currencies[i].credit[j].id \
|
||||
= journal_entry.currencies[i].credit[j].id
|
||||
|
||||
|
||||
class SameTestData:
|
||||
"""The test data with same descriptions and amounts."""
|
||||
|
||||
def __init__(self, app: Flask, client: httpx.Client, csrf_token: str):
|
||||
"""Constructs the test data.
|
||||
|
||||
:param app: The Flask application.
|
||||
:param client: The client.
|
||||
:param csrf_token: The CSRF token.
|
||||
"""
|
||||
self.app: Flask = app
|
||||
self.client: httpx.Client = client
|
||||
self.csrf_token: str = csrf_token
|
||||
|
||||
def couple(description: str, amount: str, debit: str, credit: str) \
|
||||
-> tuple[JournalEntryLineItemData, JournalEntryLineItemData]:
|
||||
"""Returns a couple of debit-credit line items.
|
||||
|
||||
:param description: The description.
|
||||
:param amount: The amount.
|
||||
:param debit: The debit account code.
|
||||
:param credit: The credit account code.
|
||||
:return: The debit line item and credit line item.
|
||||
"""
|
||||
return JournalEntryLineItemData(debit, description, amount),\
|
||||
JournalEntryLineItemData(credit, description, amount)
|
||||
|
||||
# Receivable original line items
|
||||
self.l_r_or1d, self.l_r_or1c = couple(
|
||||
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
|
||||
self.l_r_or2d, self.l_r_or2c = couple(
|
||||
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
|
||||
self.l_r_or3d, self.l_r_or3c = couple(
|
||||
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
|
||||
self.l_r_or4d, self.l_r_or4c = couple(
|
||||
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
|
||||
self.l_r_or5d, self.l_r_or5c = couple(
|
||||
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
|
||||
self.l_r_or6d, self.l_r_or6c = couple(
|
||||
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
|
||||
|
||||
# Payable original line items
|
||||
self.l_p_or1d, self.l_p_or1c = couple(
|
||||
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
|
||||
self.l_p_or2d, self.l_p_or2c = couple(
|
||||
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
|
||||
self.l_p_or3d, self.l_p_or3c = couple(
|
||||
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
|
||||
self.l_p_or4d, self.l_p_or4c = couple(
|
||||
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
|
||||
self.l_p_or5d, self.l_p_or5c = couple(
|
||||
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
|
||||
self.l_p_or6d, self.l_p_or6c = couple(
|
||||
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
|
||||
|
||||
# Original journal entries
|
||||
self.j_r_or1: JournalEntryData = JournalEntryData(
|
||||
60, [CurrencyData("USD", [self.l_r_or1d], [self.l_r_or1c])])
|
||||
self.j_r_or2: JournalEntryData = JournalEntryData(
|
||||
50, [CurrencyData("USD", [self.l_r_or2d], [self.l_r_or2c])])
|
||||
self.j_r_or3: JournalEntryData = JournalEntryData(
|
||||
40, [CurrencyData("USD", [self.l_r_or3d], [self.l_r_or3c])])
|
||||
self.j_r_or4: JournalEntryData = JournalEntryData(
|
||||
30, [CurrencyData("USD", [self.l_r_or4d], [self.l_r_or4c])])
|
||||
self.j_r_or5: JournalEntryData = JournalEntryData(
|
||||
20, [CurrencyData("USD", [self.l_r_or5d], [self.l_r_or5c])])
|
||||
self.j_r_or6: JournalEntryData = JournalEntryData(
|
||||
10, [CurrencyData("USD", [self.l_r_or6d], [self.l_r_or6c])])
|
||||
self.j_p_or1: JournalEntryData = JournalEntryData(
|
||||
60, [CurrencyData("USD", [self.l_p_or1d], [self.l_p_or1c])])
|
||||
self.j_p_or2: JournalEntryData = JournalEntryData(
|
||||
50, [CurrencyData("USD", [self.l_p_or2d], [self.l_p_or2c])])
|
||||
self.j_p_or3: JournalEntryData = JournalEntryData(
|
||||
40, [CurrencyData("USD", [self.l_p_or3d], [self.l_p_or3c])])
|
||||
self.j_p_or4: JournalEntryData = JournalEntryData(
|
||||
30, [CurrencyData("USD", [self.l_p_or4d], [self.l_p_or4c])])
|
||||
self.j_p_or5: JournalEntryData = JournalEntryData(
|
||||
20, [CurrencyData("USD", [self.l_p_or5d], [self.l_p_or5c])])
|
||||
self.j_p_or6: JournalEntryData = JournalEntryData(
|
||||
10, [CurrencyData("USD", [self.l_p_or6d], [self.l_p_or6c])])
|
||||
|
||||
self.__add_journal_entry(self.j_r_or1)
|
||||
self.__add_journal_entry(self.j_r_or2)
|
||||
self.__add_journal_entry(self.j_r_or3)
|
||||
self.__add_journal_entry(self.j_r_or4)
|
||||
self.__add_journal_entry(self.j_r_or5)
|
||||
self.__add_journal_entry(self.j_r_or6)
|
||||
self.__add_journal_entry(self.j_p_or1)
|
||||
self.__add_journal_entry(self.j_p_or2)
|
||||
self.__add_journal_entry(self.j_p_or3)
|
||||
self.__add_journal_entry(self.j_p_or4)
|
||||
self.__add_journal_entry(self.j_p_or5)
|
||||
self.__add_journal_entry(self.j_p_or6)
|
||||
|
||||
# Receivable offset items
|
||||
self.l_r_of1d, self.l_r_of1c = couple(
|
||||
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.l_r_of2d, self.l_r_of2c = couple(
|
||||
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.l_r_of3d, self.l_r_of3c = couple(
|
||||
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.l_r_of3c.original_line_item = self.l_r_or2d
|
||||
self.l_r_of4d, self.l_r_of4c = couple(
|
||||
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.l_r_of5d, self.l_r_of5c = couple(
|
||||
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.l_r_of6d, self.l_r_of6c = couple(
|
||||
"Noodles", "100", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
|
||||
# Payable offset items
|
||||
self.l_p_of1d, self.l_p_of1c = couple(
|
||||
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.l_p_of2d, self.l_p_of2c = couple(
|
||||
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.l_p_of3d, self.l_p_of3c = couple(
|
||||
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.l_p_of3d.original_line_item = self.l_p_or2c
|
||||
self.l_p_of4d, self.l_p_of4c = couple(
|
||||
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.l_p_of5d, self.l_p_of5c = couple(
|
||||
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.l_p_of6d, self.l_p_of6c = couple(
|
||||
"Steak", "120", Accounts.PAYABLE, Accounts.CASH)
|
||||
|
||||
# Offset journal entries
|
||||
self.j_r_of1: JournalEntryData = JournalEntryData(
|
||||
65, [CurrencyData("USD", [self.l_r_of1d], [self.l_r_of1c])])
|
||||
self.j_r_of2: JournalEntryData = JournalEntryData(
|
||||
35, [CurrencyData("USD", [self.l_r_of2d], [self.l_r_of2c])])
|
||||
self.j_r_of3: JournalEntryData = JournalEntryData(
|
||||
35, [CurrencyData("USD", [self.l_r_of3d], [self.l_r_of3c])])
|
||||
self.j_r_of4: JournalEntryData = JournalEntryData(
|
||||
35, [CurrencyData("USD", [self.l_r_of4d], [self.l_r_of4c])])
|
||||
self.j_r_of5: JournalEntryData = JournalEntryData(
|
||||
35, [CurrencyData("USD", [self.l_r_of5d], [self.l_r_of5c])])
|
||||
self.j_r_of6: JournalEntryData = JournalEntryData(
|
||||
15, [CurrencyData("USD", [self.l_r_of6d], [self.l_r_of6c])])
|
||||
self.j_p_of1: JournalEntryData = JournalEntryData(
|
||||
65, [CurrencyData("USD", [self.l_p_of1d], [self.l_p_of1c])])
|
||||
self.j_p_of2: JournalEntryData = JournalEntryData(
|
||||
35, [CurrencyData("USD", [self.l_p_of2d], [self.l_p_of2c])])
|
||||
self.j_p_of3: JournalEntryData = JournalEntryData(
|
||||
35, [CurrencyData("USD", [self.l_p_of3d], [self.l_p_of3c])])
|
||||
self.j_p_of4: JournalEntryData = JournalEntryData(
|
||||
35, [CurrencyData("USD", [self.l_p_of4d], [self.l_p_of4c])])
|
||||
self.j_p_of5: JournalEntryData = JournalEntryData(
|
||||
35, [CurrencyData("USD", [self.l_p_of5d], [self.l_p_of5c])])
|
||||
self.j_p_of6: JournalEntryData = JournalEntryData(
|
||||
15, [CurrencyData("USD", [self.l_p_of6d], [self.l_p_of6c])])
|
||||
|
||||
self.__set_is_need_offset(False)
|
||||
self.__add_journal_entry(self.j_r_of1)
|
||||
self.__add_journal_entry(self.j_r_of2)
|
||||
self.__add_journal_entry(self.j_r_of4)
|
||||
self.__add_journal_entry(self.j_r_of5)
|
||||
self.__add_journal_entry(self.j_r_of6)
|
||||
self.__add_journal_entry(self.j_p_of1)
|
||||
self.__add_journal_entry(self.j_p_of2)
|
||||
self.__add_journal_entry(self.j_p_of4)
|
||||
self.__add_journal_entry(self.j_p_of5)
|
||||
self.__add_journal_entry(self.j_p_of6)
|
||||
self.__set_is_need_offset(True)
|
||||
self.__add_journal_entry(self.j_r_of3)
|
||||
self.__add_journal_entry(self.j_p_of3)
|
||||
|
||||
def __set_is_need_offset(self, is_need_offset: bool) -> None:
|
||||
"""Sets whether the payables and receivables need offset.
|
||||
|
||||
:param is_need_offset: True if payables and receivables need offset, or
|
||||
False otherwise.
|
||||
:return:
|
||||
"""
|
||||
from accounting.models import Account
|
||||
with self.app.app_context():
|
||||
for code in {Accounts.RECEIVABLE, Accounts.PAYABLE}:
|
||||
account: Account | None = Account.find_by_code(code)
|
||||
assert account is not None
|
||||
account.is_need_offset = is_need_offset
|
||||
db.session.commit()
|
||||
|
||||
def __add_journal_entry(self, journal_entry_data: JournalEntryData) \
|
||||
-> None:
|
||||
"""Adds a journal entry.
|
||||
|
||||
:param journal_entry_data: The journal entry data.
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import JournalEntry
|
||||
store_uri: str = "/accounting/journal-entries/store/transfer"
|
||||
|
||||
response: httpx.Response = self.client.post(
|
||||
store_uri, data=journal_entry_data.new_form(self.csrf_token))
|
||||
assert response.status_code == 302
|
||||
journal_entry_id: int \
|
||||
= match_journal_entry_detail(response.headers["Location"])
|
||||
journal_entry_data.id = journal_entry_id
|
||||
with self.app.app_context():
|
||||
journal_entry: JournalEntry | None \
|
||||
= db.session.get(JournalEntry, journal_entry_id)
|
||||
assert journal_entry is not None
|
||||
for i in range(len(journal_entry.currencies)):
|
||||
for j in range(len(journal_entry.currencies[i].debit)):
|
||||
journal_entry_data.currencies[i].debit[j].id \
|
||||
= journal_entry.currencies[i].debit[j].id
|
||||
for j in range(len(journal_entry.currencies[i].credit)):
|
||||
journal_entry_data.currencies[i].credit[j].id \
|
||||
= journal_entry.currencies[i].credit[j].id
|
@ -189,102 +189,102 @@ class TestData:
|
||||
JournalEntryLineItemData(credit, description, amount)
|
||||
|
||||
# Receivable original line items
|
||||
self.e_r_or1d, self.e_r_or1c = couple(
|
||||
self.l_r_or1d, self.l_r_or1c = couple(
|
||||
"Accountant", "1200", Accounts.RECEIVABLE, Accounts.SERVICE)
|
||||
self.e_r_or2d, self.e_r_or2c = couple(
|
||||
self.l_r_or2d, self.l_r_or2c = couple(
|
||||
"Toy", "600", Accounts.RECEIVABLE, Accounts.SALES)
|
||||
self.e_r_or3d, self.e_r_or3c = couple(
|
||||
self.l_r_or3d, self.l_r_or3c = couple(
|
||||
"Noodles", "100", Accounts.RECEIVABLE, Accounts.SALES)
|
||||
self.e_r_or4d, self.e_r_or4c = couple(
|
||||
self.l_r_or4d, self.l_r_or4c = couple(
|
||||
"Interest", "3.4", Accounts.RECEIVABLE, Accounts.INTEREST)
|
||||
|
||||
# Payable original line items
|
||||
self.e_p_or1d, self.e_p_or1c = couple(
|
||||
self.l_p_or1d, self.l_p_or1c = couple(
|
||||
"Airplane", "2000", Accounts.TRAVEL, Accounts.PAYABLE)
|
||||
self.e_p_or2d, self.e_p_or2c = couple(
|
||||
self.l_p_or2d, self.l_p_or2c = couple(
|
||||
"Phone", "900", Accounts.OFFICE, Accounts.PAYABLE)
|
||||
self.e_p_or3d, self.e_p_or3c = couple(
|
||||
self.l_p_or3d, self.l_p_or3c = couple(
|
||||
"Steak", "120", Accounts.MEAL, Accounts.PAYABLE)
|
||||
self.e_p_or4d, self.e_p_or4c = couple(
|
||||
self.l_p_or4d, self.l_p_or4c = couple(
|
||||
"Envelop", "0.9", Accounts.OFFICE, Accounts.PAYABLE)
|
||||
|
||||
# Original journal entries
|
||||
self.v_r_or1: JournalEntryData = JournalEntryData(
|
||||
50, [CurrencyData("USD", [self.e_r_or1d, self.e_r_or4d],
|
||||
[self.e_r_or1c, self.e_r_or4c])])
|
||||
self.v_r_or2: JournalEntryData = JournalEntryData(
|
||||
30, [CurrencyData("USD", [self.e_r_or2d, self.e_r_or3d],
|
||||
[self.e_r_or2c, self.e_r_or3c])])
|
||||
self.v_p_or1: JournalEntryData = JournalEntryData(
|
||||
40, [CurrencyData("USD", [self.e_p_or1d, self.e_p_or4d],
|
||||
[self.e_p_or1c, self.e_p_or4c])])
|
||||
self.v_p_or2: JournalEntryData = JournalEntryData(
|
||||
20, [CurrencyData("USD", [self.e_p_or2d, self.e_p_or3d],
|
||||
[self.e_p_or2c, self.e_p_or3c])])
|
||||
self.j_r_or1: JournalEntryData = JournalEntryData(
|
||||
50, [CurrencyData("USD", [self.l_r_or1d, self.l_r_or4d],
|
||||
[self.l_r_or1c, self.l_r_or4c])])
|
||||
self.j_r_or2: JournalEntryData = JournalEntryData(
|
||||
30, [CurrencyData("USD", [self.l_r_or2d, self.l_r_or3d],
|
||||
[self.l_r_or2c, self.l_r_or3c])])
|
||||
self.j_p_or1: JournalEntryData = JournalEntryData(
|
||||
40, [CurrencyData("USD", [self.l_p_or1d, self.l_p_or4d],
|
||||
[self.l_p_or1c, self.l_p_or4c])])
|
||||
self.j_p_or2: JournalEntryData = JournalEntryData(
|
||||
20, [CurrencyData("USD", [self.l_p_or2d, self.l_p_or3d],
|
||||
[self.l_p_or2c, self.l_p_or3c])])
|
||||
|
||||
self.__add_journal_entry(self.v_r_or1)
|
||||
self.__add_journal_entry(self.v_r_or2)
|
||||
self.__add_journal_entry(self.v_p_or1)
|
||||
self.__add_journal_entry(self.v_p_or2)
|
||||
self.__add_journal_entry(self.j_r_or1)
|
||||
self.__add_journal_entry(self.j_r_or2)
|
||||
self.__add_journal_entry(self.j_p_or1)
|
||||
self.__add_journal_entry(self.j_p_or2)
|
||||
|
||||
# Receivable offset items
|
||||
self.e_r_of1d, self.e_r_of1c = couple(
|
||||
self.l_r_of1d, self.l_r_of1c = couple(
|
||||
"Accountant", "500", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.e_r_of1c.original_line_item = self.e_r_or1d
|
||||
self.e_r_of2d, self.e_r_of2c = couple(
|
||||
self.l_r_of1c.original_line_item = self.l_r_or1d
|
||||
self.l_r_of2d, self.l_r_of2c = couple(
|
||||
"Accountant", "200", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.e_r_of2c.original_line_item = self.e_r_or1d
|
||||
self.e_r_of3d, self.e_r_of3c = couple(
|
||||
self.l_r_of2c.original_line_item = self.l_r_or1d
|
||||
self.l_r_of3d, self.l_r_of3c = couple(
|
||||
"Accountant", "100", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.e_r_of3c.original_line_item = self.e_r_or1d
|
||||
self.e_r_of4d, self.e_r_of4c = couple(
|
||||
self.l_r_of3c.original_line_item = self.l_r_or1d
|
||||
self.l_r_of4d, self.l_r_of4c = couple(
|
||||
"Toy", "240", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.e_r_of4c.original_line_item = self.e_r_or2d
|
||||
self.e_r_of5d, self.e_r_of5c = couple(
|
||||
self.l_r_of4c.original_line_item = self.l_r_or2d
|
||||
self.l_r_of5d, self.l_r_of5c = couple(
|
||||
"Interest", "3.4", Accounts.CASH, Accounts.RECEIVABLE)
|
||||
self.e_r_of5c.original_line_item = self.e_r_or4d
|
||||
self.l_r_of5c.original_line_item = self.l_r_or4d
|
||||
|
||||
# Payable offset items
|
||||
self.e_p_of1d, self.e_p_of1c = couple(
|
||||
self.l_p_of1d, self.l_p_of1c = couple(
|
||||
"Airplane", "800", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.e_p_of1d.original_line_item = self.e_p_or1c
|
||||
self.e_p_of2d, self.e_p_of2c = couple(
|
||||
self.l_p_of1d.original_line_item = self.l_p_or1c
|
||||
self.l_p_of2d, self.l_p_of2c = couple(
|
||||
"Airplane", "300", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.e_p_of2d.original_line_item = self.e_p_or1c
|
||||
self.e_p_of3d, self.e_p_of3c = couple(
|
||||
self.l_p_of2d.original_line_item = self.l_p_or1c
|
||||
self.l_p_of3d, self.l_p_of3c = couple(
|
||||
"Airplane", "100", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.e_p_of3d.original_line_item = self.e_p_or1c
|
||||
self.e_p_of4d, self.e_p_of4c = couple(
|
||||
self.l_p_of3d.original_line_item = self.l_p_or1c
|
||||
self.l_p_of4d, self.l_p_of4c = couple(
|
||||
"Phone", "400", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.e_p_of4d.original_line_item = self.e_p_or2c
|
||||
self.e_p_of5d, self.e_p_of5c = couple(
|
||||
self.l_p_of4d.original_line_item = self.l_p_or2c
|
||||
self.l_p_of5d, self.l_p_of5c = couple(
|
||||
"Envelop", "0.9", Accounts.PAYABLE, Accounts.CASH)
|
||||
self.e_p_of5d.original_line_item = self.e_p_or4c
|
||||
self.l_p_of5d.original_line_item = self.l_p_or4c
|
||||
|
||||
# Offset journal entries
|
||||
self.v_r_of1: JournalEntryData = JournalEntryData(
|
||||
25, [CurrencyData("USD", [self.e_r_of1d], [self.e_r_of1c])])
|
||||
self.v_r_of2: JournalEntryData = JournalEntryData(
|
||||
self.j_r_of1: JournalEntryData = JournalEntryData(
|
||||
25, [CurrencyData("USD", [self.l_r_of1d], [self.l_r_of1c])])
|
||||
self.j_r_of2: JournalEntryData = JournalEntryData(
|
||||
20, [CurrencyData("USD",
|
||||
[self.e_r_of2d, self.e_r_of3d, self.e_r_of4d],
|
||||
[self.e_r_of2c, self.e_r_of3c, self.e_r_of4c])])
|
||||
self.v_r_of3: JournalEntryData = JournalEntryData(
|
||||
15, [CurrencyData("USD", [self.e_r_of5d], [self.e_r_of5c])])
|
||||
self.v_p_of1: JournalEntryData = JournalEntryData(
|
||||
15, [CurrencyData("USD", [self.e_p_of1d], [self.e_p_of1c])])
|
||||
self.v_p_of2: JournalEntryData = JournalEntryData(
|
||||
[self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
|
||||
[self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
|
||||
self.j_r_of3: JournalEntryData = JournalEntryData(
|
||||
15, [CurrencyData("USD", [self.l_r_of5d], [self.l_r_of5c])])
|
||||
self.j_p_of1: JournalEntryData = JournalEntryData(
|
||||
15, [CurrencyData("USD", [self.l_p_of1d], [self.l_p_of1c])])
|
||||
self.j_p_of2: JournalEntryData = JournalEntryData(
|
||||
10, [CurrencyData("USD",
|
||||
[self.e_p_of2d, self.e_p_of3d, self.e_p_of4d],
|
||||
[self.e_p_of2c, self.e_p_of3c, self.e_p_of4c])])
|
||||
self.v_p_of3: JournalEntryData = JournalEntryData(
|
||||
5, [CurrencyData("USD", [self.e_p_of5d], [self.e_p_of5c])])
|
||||
[self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
|
||||
[self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
|
||||
self.j_p_of3: JournalEntryData = JournalEntryData(
|
||||
5, [CurrencyData("USD", [self.l_p_of5d], [self.l_p_of5c])])
|
||||
|
||||
self.__add_journal_entry(self.v_r_of1)
|
||||
self.__add_journal_entry(self.v_r_of2)
|
||||
self.__add_journal_entry(self.v_r_of3)
|
||||
self.__add_journal_entry(self.v_p_of1)
|
||||
self.__add_journal_entry(self.v_p_of2)
|
||||
self.__add_journal_entry(self.v_p_of3)
|
||||
self.__add_journal_entry(self.j_r_of1)
|
||||
self.__add_journal_entry(self.j_r_of2)
|
||||
self.__add_journal_entry(self.j_r_of3)
|
||||
self.__add_journal_entry(self.j_p_of1)
|
||||
self.__add_journal_entry(self.j_p_of2)
|
||||
self.__add_journal_entry(self.j_p_of3)
|
||||
|
||||
def __add_journal_entry(self, journal_entry_data: JournalEntryData) \
|
||||
-> None:
|
||||
|
Loading…
Reference in New Issue
Block a user