Added the unmatched offset list and the offset matcher.

This commit is contained in:
依瑪貓 2023-04-08 14:09:21 +08:00
parent 428018e4a9
commit 12d00c9c7d
9 changed files with 451 additions and 0 deletions

View File

@ -88,4 +88,7 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
from . import option from . import option
option.init_app(bp) option.init_app(bp)
from . import unmatched_offset
unmatched_offset.init_app(bp)
app.register_blueprint(bp, url_prefix=url_prefix) app.register_blueprint(bp, url_prefix=url_prefix)

View File

@ -353,6 +353,12 @@ a.accounting-report-table-row {
margin: 0; margin: 0;
} }
/* The unmatched offsets */
.accounting-unmatched-offset-pair-list {
height: 20rem;
overflow-y: scroll;
}
/* The Material Design text field (floating form control in Bootstrap) */ /* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field { .accounting-material-text-field {
position: relative; position: relative;

View File

@ -58,6 +58,12 @@ First written: 2023/1/26
{{ A_("Settings") }} {{ A_("Settings") }}
</a> </a>
</li> </li>
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.unmatched-offset.") %} active {% endif %}" href="{{ url_for("accounting.unmatched-offset.dashboard") }}">
<i class="fa-solid fa-link-slash"></i>
{{ A_("Unmatched Offsets") }}
</a>
</li>
{% endif %} {% endif %}
</ul> </ul>
</li> </li>

View File

@ -0,0 +1,40 @@
{#
The Mia! Accounting Project
dashboard.html: The account list with unmatched offsets
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/4/8
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Unmatched Offsets") }}{% endblock %}{% endblock %}
{% block content %}
{% if list %}
<div>
{% for account in list %}
<a class="btn btn-primary mb-1" role="button" href="{{ url_for("accounting.unmatched-offset.list", account=account) }}">
{{ account }} ({{ account.count }})
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,107 @@
{#
The Mia! Accounting Project
list.html: The unmatched offset list
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/4/8
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Unmatched Offsets in %(account)s", account=matcher.account.title|title) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3" role="group" aria-label="{{ A_("Toolbar") }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.unmatched-offset.dashboard") }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if matcher.is_having_matches %}
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-match-modal">
<i class="fa-solid fa-link"></i>
{{ A_("Match") }}
</button>
{% else %}
<button class="btn btn-secondary" type="button" disabled="disabled">
<i class="fa-solid fa-link"></i>
{{ A_("Match") }}
</button>
{% endif %}
</div>
{% if matcher.is_having_matches %}
<form action="{{ url_for("accounting.unmatched-offset.match", account=matcher.account) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal fade" id="accounting-match-modal" tabindex="-1" aria-labelledby="accounting-match-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-match-modal-label">{{ A_("Confirm Match Offsets") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<p>{{ A_("Do you really want to match the following original line items with their offsets? This cannot be undone. Please backup your database first, and review before you confirm.") }}</p>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover accounting-unmatched-offset-pair-list">
{% for pair in matcher.matched_pairs %}
<li class="list-group-item">
{{ pair.offset.description|accounting_default }}
<span class="badge bg-info">{{ pair.offset.amount|accounting_format_amount }}</span>
{{ pair.original_line_item.journal_entry.date|accounting_format_date }} &rarr; {{ 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 %}

View File

@ -0,0 +1,30 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The unmatched offset management.
"""
from flask import Blueprint
def init_app(bp: Blueprint) -> None:
"""Initialize the application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .views import bp as unmatched_offset_bp
bp.register_blueprint(unmatched_offset_bp, url_prefix="/unmatched-offsets")

View File

@ -0,0 +1,128 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The forms for the unmatched offset management.
"""
import sqlalchemy as sa
from sqlalchemy.orm import selectinload
from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.unapplied import get_unapplied_original_line_items
class OffsetPair:
"""A pair of an original line item and its offset."""
def __init__(self, original_line_item: JournalEntryLineItem,
offset: JournalEntryLineItem):
"""Constructs a pair of an original line item and its offset.
:param original_line_item: The original line item.
:param offset: The offset.
"""
self.original_line_item: JournalEntryLineItem = original_line_item
"""The original line item."""
self.offset: JournalEntryLineItem = offset
"""The offset."""
class OffsetMatcher:
"""The offset matcher."""
def __init__(self, account: Account):
"""Constructs the offset matcher.
:param account: The account.
"""
self.account: Account = account
"""The account."""
self.matched_pairs: list[OffsetPair] = []
"""A list of matched pairs."""
self.is_having_matches: bool = False
"""Whether there is any matches."""
self.total: int = 0
"""The total number of unapplied debits or credits."""
self.unapplied: list[JournalEntryLineItem] = []
"""The unapplied debits or credits."""
self.unmatched_offsets: list[JournalEntryLineItem] = []
"""The unmatched offsets."""
self.__find_matches()
def __find_matches(self) -> None:
"""Finds the matched original line items and their offsets.
:return: None.
"""
self.unapplied: list[JournalEntryLineItem] \
= get_unapplied_original_line_items(self.account)
self.total = len(self.unapplied)
if self.total == 0:
self.is_having_matches = False
return
self.unmatched_offsets = self.__get_unmatched_offsets()
remains: list[JournalEntryLineItem] = self.unmatched_offsets.copy()
for original_item in self.unapplied:
offset_candidates: list[JournalEntryLineItem] \
= [x for x in remains
if (x.journal_entry.date > original_item.journal_entry.date
or (x.journal_entry.date
== original_item.journal_entry.date
and x.journal_entry.no
> original_item.journal_entry.no))
and x.currency_code == original_item.currency_code
and x.description == original_item.description
and x.amount == original_item.net_balance]
if len(offset_candidates) == 0:
continue
self.matched_pairs.append(
OffsetPair(original_item, offset_candidates[0]))
offset_candidates[0].match = original_item
remains.remove(offset_candidates[0])
self.is_having_matches = len(self.matched_pairs) > 0
def __get_unmatched_offsets(self) -> list[JournalEntryLineItem]:
"""Returns the unmatched offsets of an account.
:return: The unmatched offsets of the account.
"""
return JournalEntryLineItem.query.join(Account).join(JournalEntry)\
.filter(Account.id == self.account.id,
JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"),
JournalEntryLineItem.is_debit),
sa.and_(Account.base_code.startswith("1"),
sa.not_(JournalEntryLineItem.is_debit))))\
.order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
@property
def matches(self) -> int:
"""Returns the number of matches.
:return: The number of matches.
"""
return len(self.matched_pairs)
def match(self) -> None:
"""Matches the original line items with offsets.
:return: None.
"""
for pair in self.matched_pairs:
pair.offset.original_line_item_id = pair.original_line_item.id

View File

@ -0,0 +1,50 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The queries for the unmatched offset management.
"""
import sqlalchemy as sa
from accounting import db
from accounting.models import Account, JournalEntryLineItem
def get_accounts_with_unmatched_offsets() -> list[Account]:
"""Returns the accounts with unmatched offsets.
:return: The accounts with unmatched offsets, with the "count" property set
to the number of unmatched offsets.
"""
count_func: sa.Label \
= sa.func.count(JournalEntryLineItem.id).label("count")
select: sa.Select = sa.select(Account.id, count_func)\
.select_from(Account).join(JournalEntryLineItem, isouter=True)\
.filter(Account.is_need_offset,
JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"),
JournalEntryLineItem.is_debit),
sa.and_(Account.base_code.startswith("1"),
sa.not_(JournalEntryLineItem.is_debit))))\
.group_by(Account.id)\
.having(count_func > 0)
counts: dict[int, int] \
= {x.id: x.count for x in db.session.execute(select)}
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
.order_by(Account.base_code, Account.no).all()
for account in accounts:
account.count = counts[account.id]
return accounts

View File

@ -0,0 +1,81 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The views for the unmatched offset management.
"""
from flask import Blueprint, render_template, redirect, url_for, flash
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem, Account
from accounting.utils.cast import s
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_admin
from .forms import OffsetMatcher
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_admin)
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_admin)
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_admin)
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))