Changed the unmatched offsets from a module to a report, and to show both the unapplied original line items and the unmatched offsets instead of only the unmatched offsets, and added the accumulated balance, in order for ease of use. Removed the match information from the unapplied original line item report. Added the currency and period filters to both the unapplied original line item report and unmatched offset reports.
This commit is contained in:
parent
f8895e3bff
commit
e2f854b5cc
@ -91,7 +91,4 @@ 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)
|
||||
|
@ -742,7 +742,18 @@ class JournalEntryLineItem(db.Model):
|
||||
|
||||
:return: The debit amount, or None if this is not a debit line item.
|
||||
"""
|
||||
return self.amount if self.is_debit else None
|
||||
if not hasattr(self, "__debit"):
|
||||
setattr(self, "__debit", self.amount if self.is_debit else None)
|
||||
return getattr(self, "__debit")
|
||||
|
||||
@debit.setter
|
||||
def debit(self, debit: Decimal | None) -> None:
|
||||
"""Sets the debit amount.
|
||||
|
||||
:param debit: The debit amount.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__debit", debit)
|
||||
|
||||
@property
|
||||
def credit(self) -> Decimal | None:
|
||||
@ -750,7 +761,18 @@ class JournalEntryLineItem(db.Model):
|
||||
|
||||
:return: The credit amount, or None if this is not a credit line item.
|
||||
"""
|
||||
return None if self.is_debit else self.amount
|
||||
if not hasattr(self, "__credit"):
|
||||
setattr(self, "__credit", None if self.is_debit else self.amount)
|
||||
return getattr(self, "__credit")
|
||||
|
||||
@credit.setter
|
||||
def credit(self, credit: Decimal | None) -> None:
|
||||
"""Sets the credit amount.
|
||||
|
||||
:param credit: The credit amount.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__credit", credit)
|
||||
|
||||
@property
|
||||
def net_balance(self) -> Decimal:
|
||||
@ -773,6 +795,25 @@ class JournalEntryLineItem(db.Model):
|
||||
"""
|
||||
setattr(self, "__net_balance", net_balance)
|
||||
|
||||
@property
|
||||
def balance(self) -> Decimal:
|
||||
"""Returns the net balance.
|
||||
|
||||
:return: The net balance.
|
||||
"""
|
||||
if not hasattr(self, "__balance"):
|
||||
setattr(self, "__balance", Decimal("0"))
|
||||
return getattr(self, "__balance")
|
||||
|
||||
@balance.setter
|
||||
def balance(self, balance: Decimal) -> None:
|
||||
"""Sets the net balance.
|
||||
|
||||
:param balance: The net balance.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__balance", balance)
|
||||
|
||||
@property
|
||||
def offsets(self) -> list[t.Self]:
|
||||
"""Returns the offset items.
|
||||
@ -788,6 +829,26 @@ class JournalEntryLineItem(db.Model):
|
||||
setattr(self, "__offsets", offsets)
|
||||
return getattr(self, "__offsets")
|
||||
|
||||
@property
|
||||
def is_offset(self) -> bool:
|
||||
"""Returns whether the line item is an offset.
|
||||
|
||||
:return: True if the line item is an offset, or False otherwise.
|
||||
"""
|
||||
if not hasattr(self, "__is_offset"):
|
||||
setattr(self, "__is_offset", False)
|
||||
return getattr(self, "__is_offset")
|
||||
|
||||
@is_offset.setter
|
||||
def is_offset(self, is_offset: bool) -> None:
|
||||
"""Sets whether the line item is an offset.
|
||||
|
||||
:param is_offset: True if the line item is an offset, or False
|
||||
otherwise.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__is_offset", is_offset)
|
||||
|
||||
@property
|
||||
def match(self) -> t.Self | None:
|
||||
"""Returns the match of the line item.
|
||||
|
@ -23,18 +23,19 @@ from decimal import Decimal
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.models import Currency, Account, JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
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.csv_export import BaseCSVRow, csv_download, \
|
||||
period_spec
|
||||
from accounting.report.utils.offset_matcher import OffsetMatcher
|
||||
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):
|
||||
@ -75,25 +76,32 @@ class CSVRow(BaseCSVRow):
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, account: Account,
|
||||
is_mark_matches: bool,
|
||||
def __init__(self, currency: Currency,
|
||||
account: Account,
|
||||
period: Period,
|
||||
pagination: Pagination[JournalEntryLineItem],
|
||||
line_items: list[JournalEntryLineItem]):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param is_mark_matches: Whether to mark the matched offsets.
|
||||
:param period: The period.
|
||||
:param pagination: The pagination.
|
||||
:param line_items: The line items.
|
||||
"""
|
||||
self.currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.account: Account = account
|
||||
"""The account."""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
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."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: unapplied_url(currency, account, x))
|
||||
"""The period chooser."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
@ -109,8 +117,18 @@ class PageParams(BasePageParams):
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.UNAPPLIED,
|
||||
account=self.account)
|
||||
return ReportChooser(ReportType.UNAPPLIED, currency=self.currency,
|
||||
account=self.account, period=self.period)
|
||||
|
||||
@property
|
||||
def currency_options(self) -> list[OptionLink]:
|
||||
"""Returns the currency options.
|
||||
|
||||
:return: The currency options.
|
||||
"""
|
||||
return self._get_currency_options(
|
||||
lambda x: unapplied_url(x, self.account, self.period),
|
||||
self.currency)
|
||||
|
||||
@property
|
||||
def account_options(self) -> list[OptionLink]:
|
||||
@ -118,13 +136,15 @@ class PageParams(BasePageParams):
|
||||
|
||||
: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()])
|
||||
options: list[OptionLink] \
|
||||
= [OptionLink(gettext("Accounts"),
|
||||
unapplied_url(self.currency, None, self.period),
|
||||
False)]
|
||||
options.extend(
|
||||
[OptionLink(str(x),
|
||||
unapplied_url(self.currency, x, self.period),
|
||||
x.id == self.account.id)
|
||||
for x in get_accounts_with_unapplied(self.currency, self.period)])
|
||||
return options
|
||||
|
||||
|
||||
@ -146,27 +166,33 @@ def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
|
||||
class UnappliedOriginalLineItems(BaseReport):
|
||||
"""The unapplied original line items."""
|
||||
|
||||
def __init__(self, account: Account):
|
||||
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||
"""Constructs the unapplied original line items.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__account: Account = account
|
||||
"""The account."""
|
||||
offset_matcher: OffsetMatcher = OffsetMatcher(self.__account)
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
offset_matcher: OffsetMatcher \
|
||||
= OffsetMatcher(self.__currency, self.__account, self.__period)
|
||||
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"
|
||||
filename: str = "unapplied-{currency}-{account}-{period}.csv"\
|
||||
.format(currency=self.__currency.code, account=self.__account.code,
|
||||
period=period_spec(self.__period))
|
||||
return csv_download(filename, get_csv_rows(self.__line_items))
|
||||
|
||||
def html(self) -> str:
|
||||
@ -177,8 +203,9 @@ class UnappliedOriginalLineItems(BaseReport):
|
||||
pagination: Pagination[JournalEntryLineItem] \
|
||||
= Pagination[JournalEntryLineItem](self.__line_items,
|
||||
is_reversed=True)
|
||||
params: PageParams = PageParams(account=self.__account,
|
||||
is_mark_matches=self.__is_mark_matches,
|
||||
params: PageParams = PageParams(currency=self.__currency,
|
||||
account=self.__account,
|
||||
period=self.__period,
|
||||
pagination=pagination,
|
||||
line_items=pagination.list)
|
||||
return render_template("accounting/report/unapplied.html",
|
||||
|
@ -23,7 +23,8 @@ from decimal import Decimal
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Account
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
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
|
||||
@ -60,13 +61,24 @@ class CSVRow(BaseCSVRow):
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, accounts: list[Account]):
|
||||
def __init__(self, currency: Currency,
|
||||
period: Period,
|
||||
accounts: list[Account]):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:param accounts: The accounts.
|
||||
"""
|
||||
self.currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
self.accounts: list[Account] = accounts
|
||||
"""The accounts."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: unapplied_url(currency, None, x))
|
||||
"""The period chooser."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
@ -82,7 +94,18 @@ class PageParams(BasePageParams):
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.UNAPPLIED)
|
||||
return ReportChooser(ReportType.UNAPPLIED, currency=self.currency,
|
||||
account=None, period=self.period)
|
||||
|
||||
@property
|
||||
def currency_options(self) -> list[OptionLink]:
|
||||
"""Returns the currency options.
|
||||
|
||||
:return: The currency options.
|
||||
"""
|
||||
return self._get_currency_options(
|
||||
lambda x: unapplied_url(x, None, self.period),
|
||||
self.currency)
|
||||
|
||||
@property
|
||||
def account_options(self) -> list[OptionLink]:
|
||||
@ -90,13 +113,15 @@ class PageParams(BasePageParams):
|
||||
|
||||
: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])
|
||||
options: list[OptionLink] \
|
||||
= [OptionLink(gettext("Accounts"),
|
||||
unapplied_url(self.currency, None, self.period),
|
||||
True)]
|
||||
options.extend(
|
||||
[OptionLink(str(x),
|
||||
unapplied_url(self.currency, x, self.period),
|
||||
False)
|
||||
for x in self.accounts])
|
||||
return options
|
||||
|
||||
|
||||
@ -115,9 +140,18 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
|
||||
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()
|
||||
def __init__(self, currency: Currency, period: Period):
|
||||
"""Constructs the outstanding balances.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
self.__accounts: list[Account] \
|
||||
= get_accounts_with_unapplied(currency, period)
|
||||
"""The accounts."""
|
||||
|
||||
def csv(self) -> Response:
|
||||
@ -134,4 +168,6 @@ class AccountsWithUnappliedOriginalLineItems(BaseReport):
|
||||
:return: The report as HTML.
|
||||
"""
|
||||
return render_template("accounting/report/unapplied-accounts.html",
|
||||
report=PageParams(accounts=self.__accounts))
|
||||
report=PageParams(currency=self.__currency,
|
||||
period=self.__period,
|
||||
accounts=self.__accounts))
|
||||
|
230
src/accounting/report/reports/unmatched.py
Normal file
230
src/accounting/report/reports/unmatched.py
Normal file
@ -0,0 +1,230 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
|
||||
|
||||
# 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 offsets.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from flask import render_template, Response
|
||||
from flask_babel import LazyString
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
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, \
|
||||
period_spec
|
||||
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.unmatched import get_accounts_with_unmatched
|
||||
from accounting.report.utils.urls import unmatched_url
|
||||
from accounting.report.utils.offset_matcher import OffsetMatcher, OffsetPair
|
||||
from accounting.utils.pagination import Pagination
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, journal_entry_date: str | date, currency: str,
|
||||
description: str | None, debit: str | Decimal,
|
||||
credit: str | Decimal, 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 debit: The debit amount.
|
||||
:param credit: The credit amount.
|
||||
:param balance: The balance.
|
||||
"""
|
||||
self.date: str | date = journal_entry_date
|
||||
"""The date."""
|
||||
self.currency: str = currency
|
||||
"""The currency."""
|
||||
self.description: str | None = description
|
||||
"""The description."""
|
||||
self.debit: str | Decimal | None = debit
|
||||
"""The debit amount."""
|
||||
self.credit: str | Decimal | None = credit
|
||||
"""The credit amount."""
|
||||
self.balance: str | Decimal = balance
|
||||
"""The 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.debit,
|
||||
self.credit, self.balance]
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
"""The HTML page parameters."""
|
||||
|
||||
def __init__(self, currency: Currency,
|
||||
account: Account,
|
||||
period: Period,
|
||||
match_status: str | LazyString,
|
||||
matched_pairs: list[OffsetPair],
|
||||
pagination: Pagination[JournalEntryLineItem],
|
||||
line_items: list[JournalEntryLineItem]):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
:param match_status: The match status message.
|
||||
:param matched_pairs: A list of matched pairs.
|
||||
:param pagination: The pagination.
|
||||
:param line_items: The line items.
|
||||
"""
|
||||
self.currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.account: Account = account
|
||||
"""The account."""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
self.match_status: str | LazyString = match_status
|
||||
"""The match status message."""
|
||||
self.matched_pairs: list[OffsetPair] = matched_pairs
|
||||
"""A list of matched pairs."""
|
||||
self.pagination: Pagination[JournalEntryLineItem] = pagination
|
||||
"""The pagination."""
|
||||
self.line_items: list[JournalEntryLineItem] = line_items
|
||||
"""The line items."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: unmatched_url(currency, account, x))
|
||||
"""The period chooser."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return len(self.line_items) > 0
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.UNMATCHED, currency=self.currency,
|
||||
account=self.account)
|
||||
|
||||
@property
|
||||
def currency_options(self) -> list[OptionLink]:
|
||||
"""Returns the currency options.
|
||||
|
||||
:return: The currency options.
|
||||
"""
|
||||
return self._get_currency_options(
|
||||
lambda x: unmatched_url(x, self.account, self.period),
|
||||
self.currency)
|
||||
|
||||
@property
|
||||
def account_options(self) -> list[OptionLink]:
|
||||
"""Returns the account options.
|
||||
|
||||
:return: The account options.
|
||||
"""
|
||||
options: list[OptionLink] \
|
||||
= [OptionLink(gettext("Accounts"),
|
||||
unmatched_url(self.currency, None, self.period),
|
||||
False)]
|
||||
options.extend(
|
||||
[OptionLink(str(x),
|
||||
unmatched_url(self.currency, x, self.period),
|
||||
x.id == self.account.id)
|
||||
for x in get_accounts_with_unmatched(self.currency, self.period)])
|
||||
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("Debit"),
|
||||
gettext("Credit"), gettext("Balance"))]
|
||||
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
|
||||
x.description, x.debit, x.credit, x.balance)
|
||||
for x in line_items])
|
||||
return rows
|
||||
|
||||
|
||||
class UnmatchedOffsets(BaseReport):
|
||||
"""The unmatched offsets."""
|
||||
|
||||
def __init__(self, currency: Currency, account: Account, period: Period):
|
||||
"""Constructs the unmatched offsets.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__account: Account = account
|
||||
"""The account."""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
offset_matcher: OffsetMatcher \
|
||||
= OffsetMatcher(self.__currency, self.__account, self.__period)
|
||||
self.__line_items: list[JournalEntryLineItem] \
|
||||
= offset_matcher.line_items
|
||||
"""The line items."""
|
||||
self.__match_status: str | LazyString = offset_matcher.status
|
||||
"""The match status message."""
|
||||
self.__matched_pairs: list[OffsetPair] = offset_matcher.matched_pairs
|
||||
"""A list of matched pairs."""
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = "unmatched-{currency}-{account}-{period}.csv"\
|
||||
.format(currency=self.__currency.code, account=self.__account.code,
|
||||
period=period_spec(self.__period))
|
||||
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(currency=self.__currency,
|
||||
account=self.__account,
|
||||
period=self.__period,
|
||||
match_status=self.__match_status,
|
||||
matched_pairs=self.__matched_pairs,
|
||||
pagination=pagination,
|
||||
line_items=pagination.list)
|
||||
return render_template("accounting/report/unmatched.html",
|
||||
report=params)
|
173
src/accounting/report/reports/unmatched_accounts.py
Normal file
173
src/accounting/report/reports/unmatched_accounts.py
Normal file
@ -0,0 +1,173 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
|
||||
|
||||
# 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 unmatched offsets.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
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.unmatched import get_accounts_with_unmatched
|
||||
from accounting.report.utils.urls import unmatched_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, currency: Currency,
|
||||
period: Period,
|
||||
accounts: list[Account]):
|
||||
"""Constructs the HTML page parameters.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:param accounts: The accounts.
|
||||
"""
|
||||
self.currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.period: Period = period
|
||||
"""The period."""
|
||||
self.accounts: list[Account] = accounts
|
||||
"""The accounts."""
|
||||
self.period_chooser: PeriodChooser = PeriodChooser(
|
||||
lambda x: unmatched_url(currency, None, x))
|
||||
"""The period chooser."""
|
||||
|
||||
@property
|
||||
def has_data(self) -> bool:
|
||||
"""Returns whether there is any data on the page.
|
||||
|
||||
:return: True if there is any data, or False otherwise.
|
||||
"""
|
||||
return len(self.accounts) > 0
|
||||
|
||||
@property
|
||||
def report_chooser(self) -> ReportChooser:
|
||||
"""Returns the report chooser.
|
||||
|
||||
:return: The report chooser.
|
||||
"""
|
||||
return ReportChooser(ReportType.UNMATCHED, currency=self.currency,
|
||||
account=None, period=self.period)
|
||||
|
||||
@property
|
||||
def currency_options(self) -> list[OptionLink]:
|
||||
"""Returns the currency options.
|
||||
|
||||
:return: The currency options.
|
||||
"""
|
||||
return self._get_currency_options(
|
||||
lambda x: unmatched_url(x, None, self.period),
|
||||
self.currency)
|
||||
|
||||
@property
|
||||
def account_options(self) -> list[OptionLink]:
|
||||
"""Returns the account options.
|
||||
|
||||
:return: The account options.
|
||||
"""
|
||||
options: list[OptionLink] \
|
||||
= [OptionLink(gettext("Accounts"),
|
||||
unmatched_url(self.currency, None, self.period),
|
||||
True)]
|
||||
options.extend(
|
||||
[OptionLink(str(x),
|
||||
unmatched_url(self.currency, x, self.period),
|
||||
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 AccountsWithUnmatchedOffsets(BaseReport):
|
||||
"""The accounts with unmatched offsets."""
|
||||
|
||||
def __init__(self, currency: Currency, period: Period):
|
||||
"""Constructs the outstanding balances.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
"""
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__period: Period = period
|
||||
"""The period."""
|
||||
self.__accounts: list[Account] \
|
||||
= get_accounts_with_unmatched(currency, period)
|
||||
"""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/unmatched-accounts.html",
|
||||
report=PageParams(currency=self.__currency,
|
||||
period=self.__period,
|
||||
accounts=self.__accounts))
|
242
src/accounting/report/utils/offset_matcher.py
Normal file
242
src/accounting/report/utils/offset_matcher.py
Normal file
@ -0,0 +1,242 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask_babel import LazyString
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.offset_alias import offset_alias
|
||||
|
||||
|
||||
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, currency: Currency, account: Account,
|
||||
period: Period | None):
|
||||
"""Constructs the offset matcher.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
:param period: The period, or None for all time.
|
||||
"""
|
||||
self.__currency: Account = currency
|
||||
"""The currency."""
|
||||
self.__account: Account = account
|
||||
"""The account."""
|
||||
self.__period: Period | None = period
|
||||
"""The period."""
|
||||
self.matched_pairs: list[OffsetPair] = []
|
||||
"""A list of matched pairs."""
|
||||
self.__all_line_items: list[JournalEntryLineItem] = []
|
||||
"""The unapplied debits or credits and unmatched offsets."""
|
||||
self.line_items: list[JournalEntryLineItem] = []
|
||||
"""The unapplied debits or credits and unmatched offsets in the
|
||||
period."""
|
||||
self.__all_unapplied: list[JournalEntryLineItem] = []
|
||||
"""The unapplied debits or credits."""
|
||||
self.unapplied: list[JournalEntryLineItem] = []
|
||||
"""The unapplied debits or credits in the period."""
|
||||
self.__all_unmatched: list[JournalEntryLineItem] = []
|
||||
"""The unmatched offsets."""
|
||||
self.unmatched: list[JournalEntryLineItem] = []
|
||||
"""The unmatched offsets in the period."""
|
||||
self.__find_matches()
|
||||
self.__filter_by_period()
|
||||
|
||||
def __find_matches(self) -> None:
|
||||
"""Finds the matched original line items and their offsets.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.__get_line_items()
|
||||
if len(self.__all_unapplied) == 0 or len(self.__all_unmatched) == 0:
|
||||
return
|
||||
remains: list[JournalEntryLineItem] = self.__all_unmatched.copy()
|
||||
for original_item in self.__all_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])
|
||||
|
||||
def __get_line_items(self) -> None:
|
||||
"""Returns the unapplied original line items and unmatched offsets of
|
||||
the account.
|
||||
|
||||
:return: The unapplied original line items and unmatched offsets of the
|
||||
account.
|
||||
"""
|
||||
net_balances: dict[int, Decimal | None] = self.__get_net_balances()
|
||||
unmatched_offset_condition: sa.BinaryExpression \
|
||||
= sa.and_(Account.id == self.__account.id,
|
||||
JournalEntryLineItem.currency_code
|
||||
== self.__currency.code,
|
||||
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))))
|
||||
self.__all_line_items = JournalEntryLineItem.query \
|
||||
.join(Account).join(JournalEntry) \
|
||||
.filter(sa.or_(JournalEntryLineItem.id.in_(net_balances),
|
||||
unmatched_offset_condition)) \
|
||||
.order_by(JournalEntry.date, JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
|
||||
.options(selectinload(JournalEntryLineItem.currency),
|
||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||
for line_item in self.__all_line_items:
|
||||
line_item.is_offset = line_item.id in net_balances
|
||||
self.__all_unapplied = [x for x in self.__all_line_items
|
||||
if x.is_offset]
|
||||
for line_item in self.__all_unapplied:
|
||||
line_item.net_balance = line_item.amount \
|
||||
if net_balances[line_item.id] is None \
|
||||
else net_balances[line_item.id]
|
||||
self.__all_unmatched = [x for x in self.__all_line_items
|
||||
if not x.is_offset]
|
||||
self.__populate_accumulated_balances()
|
||||
|
||||
def __get_net_balances(self) -> dict[int, Decimal | None]:
|
||||
"""Returns the net balances of the unapplied line items of the account.
|
||||
|
||||
:return: The net balances of the unapplied line items of 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 == self.__account.id),
|
||||
be(JournalEntryLineItem.currency_code
|
||||
== self.__currency.code),
|
||||
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))
|
||||
return {x.id: x.net_balance
|
||||
for x in db.session.execute(select_net_balances).all()}
|
||||
|
||||
def __populate_accumulated_balances(self) -> None:
|
||||
"""Populates the accumulated balances of the line items.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
balance: Decimal = Decimal("0")
|
||||
for line_item in self.__all_line_items:
|
||||
amount: Decimal = line_item.amount if line_item.is_offset \
|
||||
else line_item.net_balance
|
||||
if line_item.is_debit:
|
||||
line_item.debit = amount
|
||||
line_item.credit = None
|
||||
balance = balance + amount
|
||||
else:
|
||||
line_item.debit = None
|
||||
line_item.credit = amount
|
||||
balance = balance - amount
|
||||
line_item.balance = balance
|
||||
|
||||
def __filter_by_period(self) -> None:
|
||||
"""Filters the line items by the period.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.line_items = self.__all_line_items.copy()
|
||||
if self.__period is not None:
|
||||
if self.__period.start is not None:
|
||||
self.line_items \
|
||||
= [x for x in self.line_items
|
||||
if x.journal_entry.date >= self.__period.start]
|
||||
if self.__period.end is not None:
|
||||
self.line_items \
|
||||
= [x for x in self.line_items
|
||||
if x.journal_entry.date <= self.__period.end]
|
||||
self.unapplied = [x for x in self.line_items if x.is_offset]
|
||||
self.unmatched = [x for x in self.line_items if not x.is_offset]
|
||||
|
||||
@property
|
||||
def status(self) -> str | LazyString:
|
||||
"""Returns the match status message.
|
||||
|
||||
:return: The match status message.
|
||||
"""
|
||||
if len(self.__all_unmatched) == 0:
|
||||
return lazy_gettext("There is no unmatched offset.")
|
||||
if len(self.matched_pairs) == 0:
|
||||
return lazy_gettext(
|
||||
"%(total)s unmatched offsets without original items.",
|
||||
total=len(self.__all_unmatched))
|
||||
return lazy_gettext(
|
||||
"%(matches)s unmatched offsets out of %(total)s"
|
||||
" can match with their original items.",
|
||||
matches=len(self.matched_pairs),
|
||||
total=len(self.__all_unmatched))
|
||||
|
||||
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
|
@ -31,10 +31,12 @@ from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period, get_period
|
||||
from accounting.template_globals import default_currency_code
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.permission import can_edit
|
||||
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, unapplied_url
|
||||
trial_balance_url, income_statement_url, balance_sheet_url, \
|
||||
unapplied_url, unmatched_url
|
||||
|
||||
|
||||
class ReportChooser:
|
||||
@ -75,6 +77,8 @@ class ReportChooser:
|
||||
self.__reports.append(self.__income_statement)
|
||||
self.__reports.append(self.__balance_sheet)
|
||||
self.__reports.append(self.__unapplied)
|
||||
if can_edit():
|
||||
self.__reports.append(self.__unmatched)
|
||||
for report in self.__reports:
|
||||
if report.is_active:
|
||||
self.current_report = report.title
|
||||
@ -160,15 +164,36 @@ class ReportChooser:
|
||||
"""
|
||||
account: Account = self.__account
|
||||
if not account.is_need_offset:
|
||||
return OptionLink(gettext("Unapplied Original Line Items"),
|
||||
unapplied_url(None),
|
||||
return OptionLink(gettext("Unapplied Items"),
|
||||
unapplied_url(self.__currency, None,
|
||||
self.__period),
|
||||
self.__active_report == ReportType.UNAPPLIED,
|
||||
fa_icon="fa-solid fa-link-slash")
|
||||
return OptionLink(gettext("Unapplied Original Line Items"),
|
||||
unapplied_url(account),
|
||||
return OptionLink(gettext("Unapplied Items"),
|
||||
unapplied_url(self.__currency, self.__account,
|
||||
self.__period),
|
||||
self.__active_report == ReportType.UNAPPLIED,
|
||||
fa_icon="fa-solid fa-link-slash")
|
||||
|
||||
@property
|
||||
def __unmatched(self) -> OptionLink:
|
||||
"""Returns the unmatched offsets.
|
||||
|
||||
:return: The unmatched offsets.
|
||||
"""
|
||||
account: Account = self.__account
|
||||
if not account.is_need_offset:
|
||||
return OptionLink(gettext("Unmatched Offsets"),
|
||||
unmatched_url(self.__currency, None,
|
||||
self.__period),
|
||||
self.__active_report == ReportType.UNMATCHED,
|
||||
fa_icon="fa-solid fa-link-slash")
|
||||
return OptionLink(gettext("Unmatched Offsets"),
|
||||
unmatched_url(self.__currency, self.__account,
|
||||
self.__period),
|
||||
self.__active_report == ReportType.UNMATCHED,
|
||||
fa_icon="fa-solid fa-link-slash")
|
||||
|
||||
def __iter__(self) -> t.Iterator[OptionLink]:
|
||||
"""Returns the iteration of the reports.
|
||||
|
||||
|
@ -36,5 +36,7 @@ class ReportType(Enum):
|
||||
"""The balance sheet."""
|
||||
UNAPPLIED: str = "unapplied"
|
||||
"""The unapplied original line items."""
|
||||
UNMATCHED: str = "unmatched"
|
||||
"""The unmatched offsets."""
|
||||
SEARCH: str = "search"
|
||||
"""The search."""
|
||||
|
@ -20,14 +20,19 @@
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period
|
||||
from accounting.utils.cast import be
|
||||
from accounting.utils.offset_alias import offset_alias
|
||||
|
||||
|
||||
def get_accounts_with_unapplied() -> list[Account]:
|
||||
def get_accounts_with_unapplied(currency: Currency,
|
||||
period: Period) -> list[Account]:
|
||||
"""Returns the accounts with unapplied original line items.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The accounts with unapplied original line items.
|
||||
"""
|
||||
offset: sa.Alias = offset_alias()
|
||||
@ -37,17 +42,24 @@ def get_accounts_with_unapplied() -> list[Account]:
|
||||
(be(offset.c.is_debit == JournalEntryLineItem.is_debit),
|
||||
offset.c.amount),
|
||||
else_=-offset.c.amount))).label("net_balance")
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [Account.is_need_offset,
|
||||
be(JournalEntryLineItem.currency_code == currency.code),
|
||||
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||
sa.not_(JournalEntryLineItem.is_debit)),
|
||||
sa.and_(Account.base_code.startswith("1"),
|
||||
JournalEntryLineItem.is_debit))]
|
||||
if period.start is not None:
|
||||
conditions.append(JournalEntry.date >= period.start)
|
||||
if period.end is not None:
|
||||
conditions.append(JournalEntry.date <= period.end)
|
||||
select_unapplied: sa.Select \
|
||||
= sa.select(JournalEntryLineItem.id)\
|
||||
.join(Account)\
|
||||
.join(JournalEntry).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)))\
|
||||
.filter(*conditions)\
|
||||
.group_by(JournalEntryLineItem.id)\
|
||||
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
|
||||
|
||||
|
@ -14,31 +14,45 @@
|
||||
# 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.
|
||||
"""The unmatched offset utilities.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period
|
||||
from accounting.utils.cast import be
|
||||
|
||||
|
||||
def get_accounts_with_unmatched_offsets() -> list[Account]:
|
||||
def get_accounts_with_unmatched(currency: Currency,
|
||||
period: Period) -> list[Account]:
|
||||
"""Returns the accounts with unmatched offsets.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
: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")
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [Account.is_need_offset,
|
||||
be(JournalEntryLineItem.currency_code == currency.code),
|
||||
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)))]
|
||||
if period.start is not None:
|
||||
conditions.append(JournalEntry.date >= period.start)
|
||||
if period.end is not None:
|
||||
conditions.append(JournalEntry.date <= period.end)
|
||||
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))))\
|
||||
.select_from(Account)\
|
||||
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
|
||||
.filter(*conditions)\
|
||||
.group_by(Account.id)\
|
||||
.having(count_func > 0)
|
||||
counts: dict[int, int] \
|
@ -113,13 +113,39 @@ def balance_sheet_url(currency: Currency, period: Period) -> str:
|
||||
currency=currency, period=period)
|
||||
|
||||
|
||||
def unapplied_url(account: Account | None) -> str:
|
||||
def unapplied_url(currency: Currency, account: Account | None,
|
||||
period: Period) -> str:
|
||||
"""Returns the URL of the unapplied original line items.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account, or None to list the accounts with unapplied
|
||||
original line items.
|
||||
:param period: The period.
|
||||
: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)
|
||||
if currency.code == default_currency_code() and period.is_default:
|
||||
return url_for("accounting-report.unapplied-accounts-default")
|
||||
return url_for("accounting-report.unapplied-accounts",
|
||||
currency=currency, period=period)
|
||||
return url_for("accounting-report.unapplied",
|
||||
currency=currency, account=account, period=period)
|
||||
|
||||
|
||||
def unmatched_url(currency: Currency, account: Account | None,
|
||||
period: Period) -> str:
|
||||
"""Returns the URL of the unmatched offset line items.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The account, or None to list the accounts with unmatched
|
||||
offset line items.
|
||||
:param period: The period.
|
||||
:return: The URL of the unmatched offset line items.
|
||||
"""
|
||||
if account is None:
|
||||
if currency.code == default_currency_code() and period.is_default:
|
||||
return url_for("accounting-report.unmatched-accounts-default")
|
||||
return url_for("accounting-report.unmatched-accounts",
|
||||
currency=currency, period=period)
|
||||
return url_for("accounting-report.unmatched",
|
||||
currency=currency, account=account, period=period)
|
||||
|
@ -17,20 +17,27 @@
|
||||
"""The views for the report management.
|
||||
|
||||
"""
|
||||
from flask import Blueprint, request, Response
|
||||
from flask import Blueprint, request, Response, redirect, flash
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period, get_period
|
||||
from accounting.template_globals import default_currency_code
|
||||
from accounting.utils.cast import s
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.next_uri import or_next
|
||||
from accounting.utils.options import options
|
||||
from accounting.utils.permission import has_permission, can_view
|
||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||
from .period import Period, get_period
|
||||
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
|
||||
IncomeStatement, BalanceSheet, Search
|
||||
from .reports.unapplied import UnappliedOriginalLineItems
|
||||
from .reports.unapplied_accounts import AccountsWithUnappliedOriginalLineItems
|
||||
from .reports.unmatched import UnmatchedOffsets
|
||||
from .reports.unmatched_accounts import AccountsWithUnmatchedOffsets
|
||||
from .template_filters import format_amount
|
||||
from .utils.offset_matcher import OffsetMatcher
|
||||
from .utils.urls import unmatched_url
|
||||
|
||||
bp: Blueprint = Blueprint("accounting-report", __name__)
|
||||
"""The view blueprint for the reports."""
|
||||
@ -278,34 +285,144 @@ def __get_balance_sheet(currency: Currency, period: Period) \
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("unapplied", endpoint="unapplied-default")
|
||||
@bp.get("unapplied", endpoint="unapplied-accounts-default")
|
||||
@has_permission(can_view)
|
||||
def get_default_unapplied() -> str | Response:
|
||||
def get_default_unapplied_accounts() -> str | Response:
|
||||
"""Returns the accounts with unapplied original line items.
|
||||
|
||||
:return: The accounts with unapplied original line items.
|
||||
"""
|
||||
return __get_unapplied_accounts(
|
||||
db.session.get(Currency, default_currency_code()), get_period())
|
||||
|
||||
|
||||
@bp.get("unapplied/<currency:currency>/<period:period>",
|
||||
endpoint="unapplied-accounts")
|
||||
@has_permission(can_view)
|
||||
def get_unapplied_accounts(currency: Currency,
|
||||
period: Period) -> str | Response:
|
||||
"""Returns the accounts with unapplied original line items.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The accounts with unapplied original line items.
|
||||
"""
|
||||
return __get_unapplied_accounts(currency, period)
|
||||
|
||||
|
||||
def __get_unapplied_accounts(currency: Currency,
|
||||
period: Period) -> str | Response:
|
||||
"""Returns the accounts with unapplied original line items.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The accounts with unapplied original line items.
|
||||
"""
|
||||
report: AccountsWithUnappliedOriginalLineItems \
|
||||
= AccountsWithUnappliedOriginalLineItems()
|
||||
= AccountsWithUnappliedOriginalLineItems(currency, period)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("unapplied/<needOffsetAccount:account>", endpoint="unapplied")
|
||||
@bp.get("unapplied/<currency:currency>/<needOffsetAccount:account>/"
|
||||
"<period:period>", endpoint="unapplied")
|
||||
@has_permission(can_view)
|
||||
def get_unapplied(account: Account) -> str | Response:
|
||||
def get_unapplied(currency: Currency, account: Account,
|
||||
period: Period) -> str | Response:
|
||||
"""Returns the unapplied original line items.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The Account.
|
||||
:return: The unapplied original line items.
|
||||
:param period: The period.
|
||||
:return: The unapplied original line items in the period.
|
||||
"""
|
||||
report: UnappliedOriginalLineItems = UnappliedOriginalLineItems(account)
|
||||
report: UnappliedOriginalLineItems \
|
||||
= UnappliedOriginalLineItems(currency, account, period)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("unmatched", endpoint="unmatched-accounts-default")
|
||||
@has_permission(can_edit)
|
||||
def get_default_unmatched_accounts() -> str | Response:
|
||||
"""Returns the accounts with unmatched offsets.
|
||||
|
||||
:return: The accounts with unmatched offsets.
|
||||
"""
|
||||
return __get_unmatched_accounts(
|
||||
db.session.get(Currency, default_currency_code()), get_period())
|
||||
|
||||
|
||||
@bp.get("unmatched/<currency:currency>/<period:period>",
|
||||
endpoint="unmatched-accounts")
|
||||
@has_permission(can_edit)
|
||||
def get_unmatched_accounts(currency: Currency,
|
||||
period: Period) -> str | Response:
|
||||
"""Returns the accounts with unmatched offsets.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The accounts with unmatched offsets.
|
||||
"""
|
||||
return __get_unmatched_accounts(currency, period)
|
||||
|
||||
|
||||
def __get_unmatched_accounts(currency: Currency,
|
||||
period: Period) -> str | Response:
|
||||
"""Returns the accounts with unmatched offsets.
|
||||
|
||||
:param currency: The currency.
|
||||
:param period: The period.
|
||||
:return: The accounts with unmatched offsets.
|
||||
"""
|
||||
report: AccountsWithUnmatchedOffsets \
|
||||
= AccountsWithUnmatchedOffsets(currency, period)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.get("unmatched/<currency:currency>/<needOffsetAccount:account>/"
|
||||
"<period:period>", endpoint="unmatched")
|
||||
@has_permission(can_edit)
|
||||
def get_unmatched(currency: Currency, account: Account,
|
||||
period: Period) -> str | Response:
|
||||
"""Returns the unmatched offsets.
|
||||
|
||||
:param currency: The currency.
|
||||
:param account: The Account.
|
||||
:param period: The period.
|
||||
:return: The unmatched offsets in the period.
|
||||
"""
|
||||
report: UnmatchedOffsets = UnmatchedOffsets(currency, account, period)
|
||||
if "as" in request.args and request.args["as"] == "csv":
|
||||
return report.csv()
|
||||
return report.html()
|
||||
|
||||
|
||||
@bp.post("match-offsets/<currency:currency>/<needOffsetAccount:account>",
|
||||
endpoint="match-offsets")
|
||||
@has_permission(can_edit)
|
||||
def match_offsets(currency: Currency, account: Account) -> redirect:
|
||||
"""Matches the original line items with their offsets.
|
||||
|
||||
:return: Redirection to the view of the unmatched offsets.
|
||||
"""
|
||||
matcher: OffsetMatcher = OffsetMatcher(currency, account, None)
|
||||
if len(matcher.matched_pairs) == 0:
|
||||
flash(s(lazy_gettext("No more offset to match automatically.")),
|
||||
"success")
|
||||
return redirect(or_next(
|
||||
unmatched_url(currency, account, get_period())))
|
||||
matcher.match()
|
||||
db.session.commit()
|
||||
flash(s(lazy_gettext("Matched %(matches)s offsets.",
|
||||
matches=len(matcher.matched_pairs))), "success")
|
||||
return redirect(or_next(unmatched_url(currency, account, get_period())))
|
||||
|
||||
|
||||
@bp.get("search", endpoint="search")
|
||||
@has_permission(can_view)
|
||||
def search() -> str | Response:
|
||||
|
@ -322,7 +322,7 @@ a.accounting-report-table-row {
|
||||
font-style: italic;
|
||||
}
|
||||
.accounting-unapplied-table .accounting-report-table-row {
|
||||
grid-template-columns: 1fr 1fr 5fr 1fr 1fr;
|
||||
grid-template-columns: 1fr 5fr 1fr 1fr;
|
||||
}
|
||||
.accounting-unapplied-account-table .accounting-report-table-row {
|
||||
display: flex;
|
||||
@ -331,6 +331,16 @@ a.accounting-report-table-row {
|
||||
.accounting-unapplied-account-table .accounting-report-table-header .accounting-report-table-row {
|
||||
display: block;
|
||||
}
|
||||
.accounting-unmatched-table .accounting-report-table-row {
|
||||
grid-template-columns: 1fr 5fr 1fr 1fr 1fr;
|
||||
}
|
||||
.accounting-unmatched-account-table .accounting-report-table-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.accounting-unmatched-account-table .accounting-report-table-header .accounting-report-table-row {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The accounting report */
|
||||
.accounting-mobile-journal-credit {
|
||||
|
@ -51,14 +51,6 @@ First written: 2023/1/26
|
||||
{{ A_("Currencies") }}
|
||||
</a>
|
||||
</li>
|
||||
{% if accounting_can_edit() %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% 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") }}">
|
||||
|
@ -21,24 +21,33 @@ First written: 2023/4/8
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Accounts with Unapplied Original Line Items") }}{% endblock %}{% endblock %}
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Accounts with Unapplied Items in %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_account_chooser = true %}
|
||||
{% with use_currency_chooser = true,
|
||||
use_account_chooser = true,
|
||||
use_period_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/period-chooser.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>
|
||||
<h2 class="text-center">{{ A_("Accounts with Unapplied Items in %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="accounting-report-table accounting-unapplied-account-table">
|
||||
@ -49,7 +58,7 @@ First written: 2023/4/8
|
||||
</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) }}">
|
||||
<a class="accounting-report-table-row" href="{{ url_for("accounting-report.unapplied", currency=report.currency, account=account, period=report.period) }}">
|
||||
<div>{{ account }}</div>
|
||||
<div class="accounting-amount">{{ account.count }}</div>
|
||||
</a>
|
||||
|
@ -23,20 +23,25 @@ First written: 2023/4/7
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Unapplied Original Line Items of %(account)s", account=report.account.title|title) }}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{{ A_("Unapplied Items of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_account_chooser = true %}
|
||||
{% with use_currency_chooser = true,
|
||||
use_account_chooser = true,
|
||||
use_period_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/period-chooser.html" %}
|
||||
|
||||
{% include "accounting/report/include/search-modal.html" %}
|
||||
|
||||
{% if report.has_data %}
|
||||
@ -48,7 +53,6 @@ First written: 2023/4/7
|
||||
<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>
|
||||
@ -56,15 +60,9 @@ First written: 2023/4/7
|
||||
</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 }}">
|
||||
<a class="accounting-report-table-row" 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>{{ line_item.description|accounting_default }}</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>
|
||||
@ -78,9 +76,6 @@ First written: 2023/4/7
|
||||
<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>
|
||||
|
@ -0,0 +1,73 @@
|
||||
{#
|
||||
The Mia! Accounting Project
|
||||
unmatched-accounts.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/17
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Accounts with Unmatched Offsets in %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_currency_chooser = true,
|
||||
use_account_chooser = true,
|
||||
use_period_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/period-chooser.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 Unmatched Offsets in %(currency)s %(period)s", currency=report.currency.name|title, period=report.period.desc|title) }}</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.unmatched", currency=report.currency, account=account, period=report.period) }}">
|
||||
<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 %}
|
153
src/accounting/templates/accounting/report/unmatched.html
Normal file
153
src/accounting/templates/accounting/report/unmatched.html
Normal file
@ -0,0 +1,153 @@
|
||||
{#
|
||||
The Mia! Accounting Project
|
||||
unmatched.html: The 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/17
|
||||
#}
|
||||
{% extends "accounting/base.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="mb-3 accounting-toolbar">
|
||||
{% with use_currency_chooser = true,
|
||||
use_account_chooser = true,
|
||||
use_period_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/period-chooser.html" %}
|
||||
|
||||
{% include "accounting/report/include/search-modal.html" %}
|
||||
|
||||
{% if report.matched_pairs %}
|
||||
<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>
|
||||
|
||||
<form action="{{ url_for("accounting-report.match-offsets", currency=report.currency, account=report.account) }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ request.full_path if request.query_string else request.path }}">
|
||||
<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 report.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 %}
|
||||
|
||||
<p>{{ report.match_status }}</p>
|
||||
|
||||
{% 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-unmatched-table">
|
||||
<div class="accounting-report-table-header">
|
||||
<div class="accounting-report-table-row">
|
||||
<div>{{ A_("Date") }}</div>
|
||||
<div>{{ A_("Description") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Debit") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Credit") }}</div>
|
||||
<div class="accounting-amount">{{ A_("Balance") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="accounting-report-table-body">
|
||||
{% for line_item in report.line_items %}
|
||||
<a class="accounting-report-table-row {% if 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.description|accounting_default }}
|
||||
{% if line_item.match %}
|
||||
<div class="small">{{ A_("Can match %(item)s", item=line_item.match) }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ line_item.credit|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount {% if line_item.balance < 0 %} text-danger {% endif %}">{{ line_item.balance|accounting_report_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 }}
|
||||
</div>
|
||||
{% if line_item.description is not none %}
|
||||
<div>{{ line_item.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% if line_item.debit %}
|
||||
<span class="badge rounded-pill bg-success">+{{ line_item.debit|accounting_format_amount }}</span>
|
||||
{% endif %}
|
||||
{% if line_item.credit %}
|
||||
<span class="badge rounded-pill bg-warning">-{{ line_item.credit|accounting_format_amount }}</span>
|
||||
{% endif %}
|
||||
{% if line_item.balance < 0 %}
|
||||
<span class="badge rounded-pill bg-danger">{{ line_item.balance|accounting_format_amount }}</span>
|
||||
{% else %}
|
||||
<span class="badge rounded-pill bg-primary">{{ line_item.balance|accounting_format_amount }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -1,40 +0,0 @@
|
||||
{#
|
||||
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 %}
|
@ -1,107 +0,0 @@
|
||||
{#
|
||||
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 %}
|
@ -1,30 +0,0 @@
|
||||
# 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")
|
@ -1,81 +0,0 @@
|
||||
# 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))
|
@ -1,173 +0,0 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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] = self.__get_unapplied()
|
||||
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_unapplied(self) -> list[JournalEntryLineItem]:
|
||||
"""Returns the unapplied original line items of the account.
|
||||
|
||||
:return: The unapplied original line items of 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 == self.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
|
||||
|
||||
def __get_unmatched_offsets(self) -> list[JournalEntryLineItem]:
|
||||
"""Returns the unmatched offsets of the 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
|
@ -107,10 +107,26 @@ class ReportTestCase(unittest.TestCase):
|
||||
response = client.get(f"{PREFIX}/unapplied?as=csv")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
|
||||
response = client.get(
|
||||
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
|
||||
response = client.get(
|
||||
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time?as=csv")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(f"{PREFIX}/unmatched")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(f"{PREFIX}/unmatched?as=csv")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(
|
||||
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(
|
||||
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time?as=csv")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(f"{PREFIX}/search?q=Salary")
|
||||
@ -190,13 +206,29 @@ class ReportTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
|
||||
|
||||
response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
|
||||
response = client.get(
|
||||
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
|
||||
response = client.get(
|
||||
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time?as=csv")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
|
||||
|
||||
response = client.get(f"{PREFIX}/unmatched")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(f"{PREFIX}/unmatched?as=csv")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(
|
||||
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(
|
||||
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time?as=csv")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(f"{PREFIX}/search?q=Salary")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@ -275,11 +307,28 @@ class ReportTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
|
||||
response = self.client.get(
|
||||
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(
|
||||
f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
|
||||
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time?as=csv")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/unmatched")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/unmatched?as=csv")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
|
||||
|
||||
response = self.client.get(
|
||||
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(
|
||||
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time?as=csv")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
|
||||
|
||||
@ -360,11 +409,28 @@ class ReportTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/unapplied/{Accounts.PAYABLE}")
|
||||
response = self.client.get(
|
||||
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(
|
||||
f"{PREFIX}/unapplied/{Accounts.PAYABLE}?as=csv")
|
||||
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}/all-time?as=csv")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/unmatched")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/unmatched?as=csv")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
|
||||
|
||||
response = self.client.get(
|
||||
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(
|
||||
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}/all-time?as=csv")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
|
||||
|
||||
|
@ -25,9 +25,9 @@ from flask import Flask
|
||||
from test_site import db
|
||||
from test_site.lib import JournalEntryCurrencyData, JournalEntryData, \
|
||||
BaseTestData
|
||||
from testlib import create_test_app, get_client, Accounts
|
||||
from testlib import NEXT_URI, create_test_app, get_client, Accounts
|
||||
|
||||
PREFIX: str = "/accounting/unmatched-offsets"
|
||||
PREFIX: str = "/accounting/match-offsets/USD"
|
||||
"""The URL prefix for the unmatched offset management."""
|
||||
|
||||
|
||||
@ -58,14 +58,9 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
|
||||
DifferentTestData(self.app, "nobody").populate()
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(PREFIX)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(f"{PREFIX}/{Accounts.PAYABLE}")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
|
||||
data={"csrf_token": csrf_token})
|
||||
data={"csrf_token": csrf_token,
|
||||
"next": NEXT_URI})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_viewer(self) -> None:
|
||||
@ -77,14 +72,9 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
|
||||
DifferentTestData(self.app, "viewer").populate()
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(PREFIX)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.get(f"{PREFIX}/{Accounts.PAYABLE}")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
|
||||
data={"csrf_token": csrf_token})
|
||||
data={"csrf_token": csrf_token,
|
||||
"next": NEXT_URI})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_editor(self) -> None:
|
||||
@ -95,17 +85,11 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
|
||||
DifferentTestData(self.app, "editor").populate()
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.get(PREFIX)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/{Accounts.PAYABLE}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}",
|
||||
data={"csrf_token": self.csrf_token})
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
f"{PREFIX}/{Accounts.PAYABLE}")
|
||||
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||
|
||||
def test_empty_db(self) -> None:
|
||||
"""Test the empty database.
|
||||
@ -114,43 +98,42 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
|
||||
"""
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.get(PREFIX)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/{Accounts.PAYABLE}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}",
|
||||
data={"csrf_token": self.csrf_token})
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
f"{PREFIX}/{Accounts.PAYABLE}")
|
||||
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||
|
||||
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
|
||||
from accounting.models import Currency, Account, JournalEntryLineItem
|
||||
from accounting.report.utils.offset_matcher import OffsetMatcher
|
||||
from accounting.template_globals import default_currency_code
|
||||
data: DifferentTestData = DifferentTestData(self.app, "editor")
|
||||
data.populate()
|
||||
account: Account | None
|
||||
line_item: JournalEntryLineItem | None
|
||||
matcher: OffsetMatcher
|
||||
list_uri: str
|
||||
match_uri: str
|
||||
response: httpx.Response
|
||||
|
||||
with self.app.app_context():
|
||||
currency: Currency | None \
|
||||
= db.session.get(Currency, default_currency_code())
|
||||
assert currency is not None
|
||||
|
||||
# The receivables
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.RECEIVABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
matcher = OffsetMatcher(currency, account, None)
|
||||
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},
|
||||
self.assertEqual({x.id for x in matcher.unmatched},
|
||||
{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})
|
||||
@ -164,24 +147,24 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNone(line_item.original_line_item_id)
|
||||
|
||||
list_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
|
||||
match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
|
||||
response = self.client.post(match_uri,
|
||||
data={"csrf_token": self.csrf_token})
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], list_uri)
|
||||
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.RECEIVABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
matcher = OffsetMatcher(currency, account, None)
|
||||
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},
|
||||
self.assertEqual({x.id for x in matcher.unmatched},
|
||||
{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)
|
||||
self.assertEqual(len(matcher.matched_pairs), 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)
|
||||
@ -196,11 +179,11 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.PAYABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
matcher = OffsetMatcher(currency, account, None)
|
||||
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},
|
||||
self.assertEqual({x.id for x in matcher.unmatched},
|
||||
{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})
|
||||
@ -214,24 +197,24 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
|
||||
self.assertIsNotNone(line_item)
|
||||
self.assertIsNone(line_item.original_line_item_id)
|
||||
|
||||
list_uri = f"{PREFIX}/{Accounts.PAYABLE}"
|
||||
match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
|
||||
response = self.client.post(match_uri,
|
||||
data={"csrf_token": self.csrf_token})
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], list_uri)
|
||||
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.PAYABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
matcher = OffsetMatcher(currency, account, None)
|
||||
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},
|
||||
self.assertEqual({x.id for x in matcher.unmatched},
|
||||
{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)
|
||||
self.assertEqual(len(matcher.matched_pairs), 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)
|
||||
@ -247,27 +230,32 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.utils.offset_matcher import OffsetMatcher
|
||||
from accounting.models import Currency, Account, JournalEntryLineItem
|
||||
from accounting.report.utils.offset_matcher import OffsetMatcher
|
||||
from accounting.template_globals import default_currency_code
|
||||
data: SameTestData = SameTestData(self.app, "editor")
|
||||
data.populate()
|
||||
account: Account | None
|
||||
line_item: JournalEntryLineItem | None
|
||||
matcher: OffsetMatcher
|
||||
list_uri: str
|
||||
match_uri: str
|
||||
response: httpx.Response
|
||||
|
||||
with self.app.app_context():
|
||||
currency: Currency | None \
|
||||
= db.session.get(Currency, default_currency_code())
|
||||
assert currency is not None
|
||||
|
||||
# The receivables
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.RECEIVABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
matcher = OffsetMatcher(currency, account, None)
|
||||
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},
|
||||
self.assertEqual({x.id for x in matcher.unmatched},
|
||||
{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})
|
||||
@ -287,22 +275,22 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
|
||||
|
||||
list_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
|
||||
match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
|
||||
response = self.client.post(match_uri,
|
||||
data={"csrf_token": self.csrf_token})
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], list_uri)
|
||||
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.RECEIVABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
matcher = OffsetMatcher(currency, account, None)
|
||||
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},
|
||||
self.assertEqual({x.id for x in matcher.unmatched},
|
||||
{data.l_r_of1c.id, data.l_r_of5c.id})
|
||||
self.assertEqual(matcher.matches, 0)
|
||||
self.assertEqual(len(matcher.matched_pairs), 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)
|
||||
@ -328,12 +316,12 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.PAYABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
matcher = OffsetMatcher(currency, account, None)
|
||||
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},
|
||||
self.assertEqual({x.id for x in matcher.unmatched},
|
||||
{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})
|
||||
@ -353,22 +341,22 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
|
||||
self.assertIsNotNone(line_item.original_line_item_id)
|
||||
self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
|
||||
|
||||
list_uri = f"{PREFIX}/{Accounts.PAYABLE}"
|
||||
match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
|
||||
response = self.client.post(match_uri,
|
||||
data={"csrf_token": self.csrf_token})
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], list_uri)
|
||||
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
account = Account.find_by_code(Accounts.PAYABLE)
|
||||
assert account is not None
|
||||
matcher = OffsetMatcher(account)
|
||||
matcher = OffsetMatcher(currency, account, None)
|
||||
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},
|
||||
self.assertEqual({x.id for x in matcher.unmatched},
|
||||
{data.l_p_of1d.id, data.l_p_of5d.id})
|
||||
self.assertEqual(matcher.matches, 0)
|
||||
self.assertEqual(len(matcher.matched_pairs), 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)
|
||||
|
Loading…
Reference in New Issue
Block a user