Added the search to the accounting data.

This commit is contained in:
依瑪貓 2023-03-08 01:50:24 +08:00
parent fe01d5418d
commit 9993f65627
13 changed files with 536 additions and 1 deletions

View File

@ -22,4 +22,5 @@ from .income_expenses import IncomeExpenses
from .income_statement import IncomeStatement
from .journal import Journal
from .ledger import Ledger
from .search import Search
from .trial_balance import TrialBalance

View File

@ -0,0 +1,299 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/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 search.
"""
from datetime import date, datetime
from decimal import Decimal
import sqlalchemy as sa
from flask import Response, render_template, request
from accounting.locale import gettext
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
Transaction, JournalEntry
from accounting.utils.pagination import Pagination
from accounting.utils.query import parse_query_keywords
from .utils.base_report import BaseReport
from .utils.csv_export import BaseCSVRow, csv_download
from .utils.page_params import PageParams
from .utils.report_chooser import ReportChooser
from .utils.report_type import ReportType
class Entry:
"""An entry in the search result."""
def __init__(self, entry: JournalEntry | None = None):
"""Constructs the entry in the search result.
:param entry: The journal entry.
"""
self.entry: JournalEntry | None = None
"""The journal entry."""
self.transaction: Transaction | None = None
"""The transaction."""
self.is_total: bool = False
"""Whether this is the total entry."""
self.currency: Currency | None = None
"""The account."""
self.account: Account | None = None
"""The account."""
self.summary: str | None = None
"""The summary."""
self.debit: Decimal | None = None
"""The debit amount."""
self.credit: Decimal | None = None
"""The credit amount."""
self.amount: Decimal | None = None
"""The amount."""
if entry is not None:
self.entry = entry
self.summary = entry.summary
self.debit = entry.amount if entry.is_debit else None
self.credit = None if entry.is_debit else entry.amount
self.amount = entry.amount
class CSVRow(BaseCSVRow):
"""A row in the CSV search result."""
def __init__(self, txn_date: str | date,
currency: str,
account: str,
summary: str | None,
debit: str | Decimal | None,
credit: str | Decimal | None,
note: str | None):
"""Constructs a row in the CSV search result.
:param txn_date: The transaction date.
:param summary: The summary.
:param debit: The debit amount.
:param credit: The credit amount.
:param note: The note.
"""
self.date: str | date = txn_date
"""The date."""
self.currency: str = currency
"""The currency."""
self.account: str = account
"""The account."""
self.summary: str | None = summary
"""The summary."""
self.debit: str | Decimal | None = debit
"""The debit amount."""
self.credit: str | Decimal | None = credit
"""The credit amount."""
self.note: str | None = note
"""The note."""
@property
def values(self) -> list[str | Decimal | None]:
"""Returns the values of the row.
:return: The values of the row.
"""
return [self.date, self.currency, self.account, self.summary,
self.debit, self.credit, self.note]
class SearchPageParams(PageParams):
"""The HTML parameters of the search result."""
def __init__(self, pagination: Pagination[Entry],
entries: list[Entry]):
"""Constructs the HTML parameters of the search result.
:param entries: The search result entries.
"""
self.pagination: Pagination[Entry] = pagination
"""The pagination."""
self.entries: list[Entry] = entries
"""The entries."""
@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.entries) > 0
@property
def report_chooser(self) -> ReportChooser:
"""Returns the report chooser.
:return: The report chooser.
"""
return ReportChooser(ReportType.SEARCH)
def _populate_entries(entries: list[Entry]) -> None:
"""Populates the search result entries with relative data.
:param entries: The search result entries.
:return: None.
"""
transactions: dict[int, Transaction] \
= {x.id: x for x in Transaction.query.filter(
Transaction.id.in_({x.entry.transaction_id for x in entries}))}
accounts: dict[int, Account] \
= {x.id: x for x in Account.query.filter(
Account.id.in_({x.entry.account_id for x in entries}))}
currencies: dict[int, Currency] \
= {x.code: x for x in Currency.query.filter(
Currency.code.in_({x.entry.currency_code for x in entries}))}
for entry in entries:
entry.transaction = transactions[entry.entry.transaction_id]
entry.account = accounts[entry.entry.account_id]
entry.currency = currencies[entry.entry.currency_code]
class Search(BaseReport):
"""The search."""
def __init__(self):
"""Constructs a search."""
"""The account."""
self.__entries: list[Entry] = self.__query_entries()
"""The journal entries."""
def __query_entries(self) -> list[Entry]:
"""Queries and returns the journal entries.
:return: The journal entries.
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return []
conditions: list[sa.BinaryExpression] = []
for k in keywords:
conditions.append(sa.or_(
JournalEntry.summary.contains(k),
sa.cast(JournalEntry.amount, sa.String).contains(k),
JournalEntry.account_id.in_(self.__get_account_condition(k)),
JournalEntry.currency_code.in_(
self.__get_currency_condition(k)),
JournalEntry.transaction_id.in_(
self.__get_transaction_condition(k))))
return [Entry(x) for x in JournalEntry.query.filter(*conditions)]
@staticmethod
def __get_account_condition(k: str) -> sa.Select:
"""Composes and returns the condition to filter the account.
:param k: The keyword.
:return: The condition to filter the account.
"""
code: sa.BinaryExpression = Account.base_code + "-" \
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1)
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
.filter(AccountL10n.title.contains(k))
conditions: list[sa.BinaryExpression] \
= [Account.base_code.contains(k),
Account.title_l10n.contains(k),
code.contains(k),
Account.id.in_(select_l10n)]
if k in gettext("Pay-off needed"):
conditions.append(Account.is_pay_off_needed)
return sa.select(Account.id).filter(sa.or_(*conditions))
@staticmethod
def __get_currency_condition(k: str) -> sa.Select:
"""Composes and returns the condition to filter the currency.
:param k: The keyword.
:return: The condition to filter the currency.
"""
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
.filter(CurrencyL10n.name.contains(k))
return sa.select(Currency.code).filter(
sa.or_(Currency.code.contains(k),
Currency.name_l10n.contains(k),
Currency.code.in_(select_l10n)))
@staticmethod
def __get_transaction_condition(k: str) -> sa.Select:
"""Composes and returns the condition to filter the transaction.
:param k: The keyword.
:return: The condition to filter the transaction.
"""
conditions: list[sa.BinaryExpression] \
= [Transaction.note.contains(k)]
txn_date: datetime
try:
txn_date = datetime.strptime(k, "%Y")
conditions.append(
sa.extract("year", Transaction.date) == txn_date.year)
except ValueError:
pass
try:
txn_date = datetime.strptime(k, "%Y/%m")
conditions.append(sa.and_(
sa.extract("year", Transaction.date) == txn_date.year,
sa.extract("month", Transaction.date) == txn_date.month))
except ValueError:
pass
try:
txn_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
conditions.append(sa.and_(
sa.extract("month", Transaction.date) == txn_date.month,
sa.extract("day", Transaction.date) == txn_date.day))
except ValueError:
pass
return sa.select(Transaction.id).filter(sa.or_(*conditions))
def csv(self) -> Response:
"""Returns the report as CSV for download.
:return: The response of the report for download.
"""
filename: str = "search-{q}.csv".format(q=request.args["q"])
return csv_download(filename, self.__get_csv_rows())
def __get_csv_rows(self) -> list[CSVRow]:
"""Composes and returns the CSV rows.
:return: The CSV rows.
"""
_populate_entries(self.__entries)
rows: list[CSVRow] = [CSVRow(gettext("Date"), gettext("Currency"),
gettext("Account"), gettext("Summary"),
gettext("Debit"), gettext("Credit"),
gettext("Note"))]
rows.extend([CSVRow(x.transaction.date, x.currency.code,
str(x.account).title(), x.summary,
x.debit, x.credit, x.transaction.note)
for x in self.__entries])
return rows
def html(self) -> str:
"""Composes and returns the report as HTML.
:return: The report as HTML.
"""
pagination: Pagination[Entry] = Pagination[Entry](self.__entries)
page_entries: list[Entry] = pagination.list
_populate_entries(page_entries)
params: SearchPageParams = SearchPageParams(pagination=pagination,
entries=page_entries)
return render_template("accounting/report/search.html",
report=params)

View File

@ -34,3 +34,5 @@ class ReportType(Enum):
"""The income statement."""
BALANCE_SHEET: str = "balance-sheet"
"""The balance sheet."""
SEARCH: str = "search"
"""The balance sheet."""

View File

@ -24,7 +24,7 @@ from accounting.utils.permission import has_permission, can_view
from .income_expense_account import IncomeExpensesAccount
from .period import Period
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement, BalanceSheet
IncomeStatement, BalanceSheet, Search
from .template_filters import format_amount
bp: Blueprint = Blueprint("report", __name__)
@ -275,3 +275,17 @@ def __get_balance_sheet_list(currency: Currency, period: Period) \
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()
@bp.get("search", endpoint="search")
@has_permission(can_view)
def search() -> str | Response:
"""Returns the search result.
:return: The search result.
"""
report: Search = Search()
if "as" in request.args and request.args["as"] == "csv":
return report.csv()
return report.html()

View File

@ -116,6 +116,8 @@ First written: 2023/3/7
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% 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">

View File

@ -29,5 +29,10 @@ First written: 2023/3/4
{% for report in report_chooser %}
<li><a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">{{ report.title }}</a></li>
{% endfor %}
<li>
<span class="dropdown-item accounting-clickable" data-bs-toggle="modal" data-bs-target="#accounting-search-modal">
{{ A_("Search") }}
</span>
</li>
</ul>
</div>

View File

@ -0,0 +1,43 @@
{#
The Mia! Accounting Flask Project
search-modal.html: The search modal
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/3/8
#}
<form action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-label="{{ A_("Search the Accounting Data") }}">
<div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-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-search-modal-label">{{ A_("Search") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="form-floating">
<input id="accounting-search-modal-search" class="form-control" type="text" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label for="accounting-search-modal-search">{{ A_("Search") }}</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-primary">{{ A_("Search") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -146,6 +146,8 @@ First written: 2023/3/5
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
{% with pagination = report.pagination %}
{% include "accounting/include/pagination.html" %}

View File

@ -116,6 +116,8 @@ First written: 2023/3/7
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% 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">

View File

@ -88,6 +88,8 @@ First written: 2023/3/4
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
{% with pagination = report.pagination %}
{% include "accounting/include/pagination.html" %}

View File

@ -146,6 +146,8 @@ First written: 2023/3/5
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
{% with pagination = report.pagination %}
{% include "accounting/include/pagination.html" %}

View File

@ -0,0 +1,159 @@
{#
The Mia! Accounting Flask Project
search.html: The search result
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/3/8
#}
{% 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_("Search Result for \"%(query)s\"", query=request.args.q) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_EXPENSE)|accounting_append_next }}">
{{ A_("Cash Expense") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.CASH_INCOME)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=report.txn_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<form class="btn btn-primary d-flex input-group accounting-search-desktop-form" action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label for="accounting-search-desktop" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
<a class="btn btn-primary" role="button" href="{{ report.csv_uri }}">
<i class="fa-solid fa-download"></i>
{{ A_("Download") }}
</a>
</div>
{% with txn_types = report.txn_types %}
{% include "accounting/include/add-txn-material-fab.html" %}
{% endwith %}
<div class="btn-group btn-actions mb-3 d-md-none">
{% with report_chooser = report.report_chooser %}
{% include "accounting/report/include/report-chooser.html" %}
{% endwith %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.report.search") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label for="accounting-search-mobile" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
{% include "accounting/report/include/search-modal.html" %}
{% if report.has_data %}
{% with pagination = report.pagination %}
{% include "accounting/include/pagination.html" %}
{% endwith %}
<div class="d-none d-md-block accounting-report-table accounting-journal-table">
<div class="accounting-report-table-header">
<div class="accounting-report-table-row">
<div>{{ A_("Date") }}</div>
<div>{{ A_("Currency") }}</div>
<div>{{ A_("Account") }}</div>
<div>{{ A_("Summary") }}</div>
<div class="accounting-amount">{{ A_("Debit") }}</div>
<div class="accounting-amount">{{ A_("Credit") }}</div>
</div>
</div>
<div class="accounting-report-table-body">
{% for entry in report.entries %}
<a class="accounting-report-table-row" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div>{{ entry.transaction.date|accounting_format_date }}</div>
<div>{{ entry.currency.name }}</div>
<div>
<span class="d-none d-md-inline">{{ entry.account.code }}</span>
{{ entry.account.title|title }}
</div>
<div>{{ entry.summary|accounting_default }}</div>
<div class="accounting-amount">{{ entry.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ entry.credit|accounting_format_amount|accounting_default }}</div>
</a>
{% endfor %}
</div>
</div>
<div class="list-group d-md-none">
{% for entry in report.entries %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=entry.transaction)|accounting_append_next }}">
<div class="d-flex justify-content-between">
<div {% if not entry.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small">
{{ entry.transaction.date|accounting_format_date }}
{{ entry.account.title|title }}
{% if entry.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ entry.currency.code }}</span>
{% endif %}
</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>
<span class="badge rounded-pill bg-info">{{ entry.amount|accounting_format_amount }}</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -116,6 +116,8 @@ First written: 2023/3/5
{% include "accounting/report/include/period-chooser.html" %}
{% endwith %}
{% 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">