Added the journal report as the first accounting report.
This commit is contained in:
parent
55c2ce6695
commit
9bfcd3c50c
@ -89,4 +89,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
|||||||
from . import transaction
|
from . import transaction
|
||||||
transaction.init_app(app, bp)
|
transaction.init_app(app, bp)
|
||||||
|
|
||||||
|
from . import report
|
||||||
|
report.init_app(app, bp)
|
||||||
|
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
|
34
src/accounting/report/__init__.py
Normal file
34
src/accounting/report/__init__.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||||
|
|
||||||
|
# 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 report management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import Flask, Blueprint
|
||||||
|
|
||||||
|
|
||||||
|
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||||
|
"""Initialize the application.
|
||||||
|
|
||||||
|
:param app: The Flask application.
|
||||||
|
:param bp: The blueprint of the accounting application.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from .converters import PeriodConverter
|
||||||
|
app.url_map.converters["period"] = PeriodConverter
|
||||||
|
|
||||||
|
from .views import bp as report_bp
|
||||||
|
bp.register_blueprint(report_bp, url_prefix="/reports")
|
47
src/accounting/report/converters.py
Normal file
47
src/accounting/report/converters.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||||
|
|
||||||
|
# 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 path converters for the report management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import abort
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
|
from .period import Period
|
||||||
|
|
||||||
|
|
||||||
|
class PeriodConverter(BaseConverter):
|
||||||
|
"""The supplier converter to convert the period specification from and to
|
||||||
|
the corresponding period in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> Period:
|
||||||
|
"""Converts a period specification to a period.
|
||||||
|
|
||||||
|
:param value: The period specification.
|
||||||
|
:return: The corresponding period.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return Period.get_instance(value)
|
||||||
|
except ValueError:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
def to_url(self, value: Period) -> str:
|
||||||
|
"""Converts a period to its specification.
|
||||||
|
|
||||||
|
:param value: The period.
|
||||||
|
:return: Its specification.
|
||||||
|
"""
|
||||||
|
return value.spec
|
555
src/accounting/report/period.py
Normal file
555
src/accounting/report/period.py
Normal file
@ -0,0 +1,555 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# 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 date period.
|
||||||
|
|
||||||
|
This file is largely taken from the NanoParma ERP project, first written in
|
||||||
|
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||||
|
|
||||||
|
"""
|
||||||
|
import calendar
|
||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
import typing as t
|
||||||
|
|
||||||
|
from accounting.locale import gettext
|
||||||
|
|
||||||
|
|
||||||
|
class Period:
|
||||||
|
"""A date period."""
|
||||||
|
|
||||||
|
def __init__(self, start: datetime.date | None, end: datetime.date | None):
|
||||||
|
"""Constructs a new date period.
|
||||||
|
|
||||||
|
:param start: The start date, or None from the very beginning.
|
||||||
|
:param end: The end date, or None till no end.
|
||||||
|
"""
|
||||||
|
self.start: datetime.date | None = start
|
||||||
|
"""The start of the period."""
|
||||||
|
self.end: datetime.date | None = end
|
||||||
|
"""The end of the period."""
|
||||||
|
self.is_default: bool = False
|
||||||
|
"""Whether the is the default period."""
|
||||||
|
self.is_this_month: bool = False
|
||||||
|
"""Whether the period is this month."""
|
||||||
|
self.is_last_month: bool = False
|
||||||
|
"""Whether the period is last month."""
|
||||||
|
self.is_since_last_month: bool = False
|
||||||
|
"""Whether the period is since last month."""
|
||||||
|
self.is_this_year: bool = False
|
||||||
|
"""Whether the period is this year."""
|
||||||
|
self.is_last_year: bool = False
|
||||||
|
"""Whether the period is last year."""
|
||||||
|
self.is_today: bool = False
|
||||||
|
"""Whether the period is today."""
|
||||||
|
self.is_yesterday: bool = False
|
||||||
|
"""Whether the period is yesterday."""
|
||||||
|
self.is_all: bool = start is None and end is None
|
||||||
|
"""Whether the period is all time."""
|
||||||
|
self.spec: str = ""
|
||||||
|
"""The period specification."""
|
||||||
|
self.desc: str = ""
|
||||||
|
"""The text description."""
|
||||||
|
self.is_type_month: bool = False
|
||||||
|
"""Whether the period is for the month chooser."""
|
||||||
|
self.is_a_year: bool = False
|
||||||
|
"""Whether the period is a whole year."""
|
||||||
|
self.is_a_day: bool = False
|
||||||
|
"""Whether the period is a single day."""
|
||||||
|
self._set_properties()
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
"""Sets the following properties.
|
||||||
|
|
||||||
|
* self.spec
|
||||||
|
* self.desc
|
||||||
|
* self.is_a_month
|
||||||
|
* self.is_type_month
|
||||||
|
* self.is_a_year
|
||||||
|
* self.is_a_day
|
||||||
|
|
||||||
|
Override this method to set the properties in the subclasses.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.spec = self.__get_spec()
|
||||||
|
self.desc = self.__get_desc()
|
||||||
|
if self.start is None or self.end is None:
|
||||||
|
return
|
||||||
|
self.is_type_month \
|
||||||
|
= self.start.day == 1 and self.end == _month_end(self.start)
|
||||||
|
self.is_a_year = self.start == datetime.date(self.start.year, 1, 1) \
|
||||||
|
and self.end == datetime.date(self.start.year, 12, 31)
|
||||||
|
self.is_a_day = self.start == self.end
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls, spec: str | None = None) -> t.Self:
|
||||||
|
"""Returns a period instance.
|
||||||
|
|
||||||
|
:param spec: The period specification, or omit for the default.
|
||||||
|
:return: The period instance.
|
||||||
|
:raise ValueError: When the period is invalid.
|
||||||
|
"""
|
||||||
|
if spec is None:
|
||||||
|
return ThisMonth()
|
||||||
|
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = {
|
||||||
|
"this-month": lambda: ThisMonth(),
|
||||||
|
"last-month": lambda: LastMonth(),
|
||||||
|
"since-last-month": lambda: SinceLastMonth(),
|
||||||
|
"this-year": lambda: ThisYear(),
|
||||||
|
"last-year": lambda: LastYear(),
|
||||||
|
"today": lambda: Today(),
|
||||||
|
"yesterday": lambda: Yesterday(),
|
||||||
|
}
|
||||||
|
if spec in named_periods:
|
||||||
|
return named_periods[spec]()
|
||||||
|
start: datetime.date
|
||||||
|
end: datetime.date
|
||||||
|
start, end = _parse_period_spec(spec)
|
||||||
|
if start is not None and end is not None and start > end:
|
||||||
|
raise ValueError
|
||||||
|
return cls(start, end)
|
||||||
|
|
||||||
|
def __get_spec(self) -> str:
|
||||||
|
"""Returns the period specification.
|
||||||
|
|
||||||
|
:return: The period specification.
|
||||||
|
"""
|
||||||
|
if self.start is None:
|
||||||
|
if self.end is None:
|
||||||
|
return "-"
|
||||||
|
else:
|
||||||
|
if self.end.day != _month_end(self.end).day:
|
||||||
|
return "-%04d-%02d-%02d" % (
|
||||||
|
self.end.year, self.end.month, self.end.day)
|
||||||
|
if self.end.month != 12:
|
||||||
|
return "-%04d-%02d" % (self.end.year, self.end.month)
|
||||||
|
return "-%04d" % self.end.year
|
||||||
|
else:
|
||||||
|
if self.end is None:
|
||||||
|
if self.start.day != 1:
|
||||||
|
return "%04d-%02d-%02d-" % (
|
||||||
|
self.start.year, self.start.month, self.start.day)
|
||||||
|
if self.start.month != 1:
|
||||||
|
return "%04d-%02d-" % (self.start.year, self.start.month)
|
||||||
|
return "%04d-" % self.start.year
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return self.__get_year_spec()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return self.__get_month_spec()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return self.__get_day_spec()
|
||||||
|
|
||||||
|
def __get_year_spec(self) -> str:
|
||||||
|
"""Returns the period specification as a year range.
|
||||||
|
|
||||||
|
:return: The period specification as a year range.
|
||||||
|
:raise ValueError: The period is not a year range.
|
||||||
|
"""
|
||||||
|
if self.start.month != 1 or self.start.day != 1 \
|
||||||
|
or self.end.month != 12 or self.end.day != 31:
|
||||||
|
raise ValueError
|
||||||
|
if self.start.year == self.end.year:
|
||||||
|
return "%04d" % self.start.year
|
||||||
|
return "%04d-%04d" % (self.start.year, self.end.year)
|
||||||
|
|
||||||
|
def __get_month_spec(self) -> str:
|
||||||
|
"""Returns the period specification as a month range.
|
||||||
|
|
||||||
|
:return: The period specification as a month range.
|
||||||
|
:raise ValueError: The period is not a month range.
|
||||||
|
"""
|
||||||
|
if self.start.day != 1 or self.end != month_end(self.end):
|
||||||
|
raise ValueError
|
||||||
|
if self.start.year == self.end.year \
|
||||||
|
and self.start.month == self.end.month:
|
||||||
|
return "%04d-%02d" % (self.start.year, self.start.month)
|
||||||
|
return "%04d-%02d-%04d-%02d" % (
|
||||||
|
self.start.year, self.start.month,
|
||||||
|
self.end.year, self.end.month)
|
||||||
|
|
||||||
|
def __get_day_spec(self) -> str:
|
||||||
|
"""Returns the period specification as a day range.
|
||||||
|
|
||||||
|
:return: The period specification as a day range.
|
||||||
|
:raise ValueError: The period is a month or year range.
|
||||||
|
"""
|
||||||
|
if self.start == self.end:
|
||||||
|
return "%04d-%02d-%02d" % (
|
||||||
|
self.start.year, self.start.month, self.start.day)
|
||||||
|
return "%04d-%02d-%02d-%04d-%02d-%02d" % (
|
||||||
|
self.start.year, self.start.month, self.start.day,
|
||||||
|
self.end.year, self.end.month, self.end.day)
|
||||||
|
|
||||||
|
def __get_desc(self) -> str:
|
||||||
|
"""Returns the period description.
|
||||||
|
|
||||||
|
:return: The period description.
|
||||||
|
"""
|
||||||
|
cls: t.Type[t.Self] = self.__class__
|
||||||
|
if self.start is None:
|
||||||
|
if self.end is None:
|
||||||
|
return gettext("for all time")
|
||||||
|
else:
|
||||||
|
if self.end != _month_end(self.end):
|
||||||
|
return gettext("until %(end)s",
|
||||||
|
end=cls.__format_date(self.end))
|
||||||
|
if self.end.month != 12:
|
||||||
|
return gettext("until %(end)s",
|
||||||
|
end=cls.__format_month(self.end))
|
||||||
|
return gettext("until %(end)s", end=str(self.end.year))
|
||||||
|
else:
|
||||||
|
if self.end is None:
|
||||||
|
if self.start.day != 1:
|
||||||
|
return gettext("since %(start)s",
|
||||||
|
start=cls.__format_date(self.start))
|
||||||
|
if self.start.month != 1:
|
||||||
|
return gettext("since %(start)s",
|
||||||
|
start=cls.__format_month(self.start))
|
||||||
|
return gettext("since %(start)s", start=str(self.start.year))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return self.__get_year_desc()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return self.__get_month_desc()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return self.__get_day_desc()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __format_date(date: datetime.date) -> str:
|
||||||
|
"""Formats a date.
|
||||||
|
|
||||||
|
:param date: The date.
|
||||||
|
:return: The formatted date.
|
||||||
|
"""
|
||||||
|
return F"{date.year}/{date.month}/{date.day}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __format_month(month: datetime.date) -> str:
|
||||||
|
"""Formats a month.
|
||||||
|
|
||||||
|
:param month: The month.
|
||||||
|
:return: The formatted month.
|
||||||
|
"""
|
||||||
|
return F"{month.year}/{month.month}"
|
||||||
|
|
||||||
|
def __get_year_desc(self) -> str:
|
||||||
|
"""Returns the description as a year range.
|
||||||
|
|
||||||
|
:return: The description as a year range.
|
||||||
|
:raise ValueError: The period is not a year range.
|
||||||
|
"""
|
||||||
|
if self.start.month != 1 or self.start.day != 1 \
|
||||||
|
or self.end.month != 12 or self.end.day != 31:
|
||||||
|
raise ValueError
|
||||||
|
start: str = str(self.start.year)
|
||||||
|
if self.start.year == self.end.year:
|
||||||
|
return gettext("in %(period)s", period=start)
|
||||||
|
end: str = str(self.end.year)
|
||||||
|
return gettext("in %(start)s-%(end)s", start=start, end=end)
|
||||||
|
|
||||||
|
def __get_month_desc(self) -> str:
|
||||||
|
"""Returns the description as a month range.
|
||||||
|
|
||||||
|
:return: The description as a month range.
|
||||||
|
:raise ValueError: The period is not a month range.
|
||||||
|
"""
|
||||||
|
if self.start.day != 1 or self.end != _month_end(self.end):
|
||||||
|
raise ValueError
|
||||||
|
start: str = F"{self.start.year}/{self.start.month}"
|
||||||
|
if self.start.year == self.end.year \
|
||||||
|
and self.start.month == self.end.month:
|
||||||
|
return gettext("in %(period)s", period=start)
|
||||||
|
if self.start.year == self.end.year:
|
||||||
|
end_month: str = str(self.end.month)
|
||||||
|
return gettext("in %(start)s-%(end)s", start=start, end=end_month)
|
||||||
|
end: str = F"{self.end.year}/{self.end.month}"
|
||||||
|
return gettext("in %(start)s-%(end)s", start=start, end=end)
|
||||||
|
|
||||||
|
def __get_day_desc(self) -> str:
|
||||||
|
"""Returns the description as a day range.
|
||||||
|
|
||||||
|
:return: The description as a day range.
|
||||||
|
:raise ValueError: The period is a month or year range.
|
||||||
|
"""
|
||||||
|
start: str = F"{self.start.year}/{self.start.month}/{self.start.day}"
|
||||||
|
if self.start == self.end:
|
||||||
|
return gettext("in %(period)s", period=start)
|
||||||
|
if self.start.year == self.end.year \
|
||||||
|
and self.start.month == self.end.month:
|
||||||
|
end_day: str = str(self.end.day)
|
||||||
|
return gettext("in %(start)s-%(end)s", start=start, end=end_day)
|
||||||
|
if self.start.year == self.end.year:
|
||||||
|
end_month_day: str = F"{self.end.month}/{self.end.day}"
|
||||||
|
return gettext("in %(start)s-%(end)s",
|
||||||
|
start=start, end=end_month_day)
|
||||||
|
end: str = F"{self.end.year}/{self.end.month}/{self.end.day}"
|
||||||
|
return gettext("in %(start)s-%(end)s", start=start, end=end)
|
||||||
|
|
||||||
|
def is_year(self, year: int) -> bool:
|
||||||
|
"""Returns whether the period is the specific year period.
|
||||||
|
|
||||||
|
:param year: The year.
|
||||||
|
:return: True if the period is the year period, or False otherwise.
|
||||||
|
"""
|
||||||
|
if not self.is_a_year:
|
||||||
|
return False
|
||||||
|
return self.start.year == year
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_type_arbitrary(self) -> bool:
|
||||||
|
"""Returns whether this period is an arbitrary period.
|
||||||
|
|
||||||
|
:return: True if this is an arbitrary period, or False otherwise.
|
||||||
|
"""
|
||||||
|
return not self.is_type_month and not self.is_a_year \
|
||||||
|
and not self.is_a_day
|
||||||
|
|
||||||
|
@property
|
||||||
|
def before(self) -> t.Self | None:
|
||||||
|
"""Returns the period before this period.
|
||||||
|
|
||||||
|
:return: The period before this period.
|
||||||
|
"""
|
||||||
|
if self.start is None:
|
||||||
|
return None
|
||||||
|
return self.__class__(None, self.start - datetime.timedelta(days=1))
|
||||||
|
|
||||||
|
|
||||||
|
class ThisMonth(Period):
|
||||||
|
"""The period of this month."""
|
||||||
|
def __init__(self):
|
||||||
|
today: datetime.date = datetime.date.today()
|
||||||
|
this_month_start: datetime.date \
|
||||||
|
= datetime.date(today.year, today.month, 1)
|
||||||
|
super(ThisMonth, self).__init__(this_month_start, _month_end(today))
|
||||||
|
self.is_default = True
|
||||||
|
self.is_this_month = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "this-month"
|
||||||
|
self.desc = gettext("This month")
|
||||||
|
self.is_a_month = True
|
||||||
|
self.is_type_month = True
|
||||||
|
|
||||||
|
|
||||||
|
class LastMonth(Period):
|
||||||
|
"""The period of this month."""
|
||||||
|
def __init__(self):
|
||||||
|
today: datetime.date = datetime.date.today()
|
||||||
|
year: int = today.year
|
||||||
|
month: int = today.month - 1
|
||||||
|
if month < 1:
|
||||||
|
year = year - 1
|
||||||
|
month = 12
|
||||||
|
start: datetime.date = datetime.date(year, month, 1)
|
||||||
|
super(LastMonth, self).__init__(start, _month_end(start))
|
||||||
|
self.is_last_month = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "last-month"
|
||||||
|
self.desc = gettext("Last month")
|
||||||
|
self.is_a_month = True
|
||||||
|
self.is_type_month = True
|
||||||
|
|
||||||
|
|
||||||
|
class SinceLastMonth(Period):
|
||||||
|
"""The period of this month."""
|
||||||
|
def __init__(self):
|
||||||
|
today: datetime.date = datetime.date.today()
|
||||||
|
year: int = today.year
|
||||||
|
month: int = today.month - 1
|
||||||
|
if month < 1:
|
||||||
|
year = year - 1
|
||||||
|
month = 12
|
||||||
|
start: datetime.date = datetime.date(year, month, 1)
|
||||||
|
super(SinceLastMonth, self).__init__(start, None)
|
||||||
|
self.is_since_last_month = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "since-last-month"
|
||||||
|
self.desc = gettext("Since last month")
|
||||||
|
self.is_type_month = True
|
||||||
|
|
||||||
|
|
||||||
|
class ThisYear(Period):
|
||||||
|
"""The period of this year."""
|
||||||
|
def __init__(self):
|
||||||
|
year: int = datetime.date.today().year
|
||||||
|
start: datetime.date = datetime.date(year, 1, 1)
|
||||||
|
end: datetime.date = datetime.date(year, 12, 31)
|
||||||
|
super(ThisYear, self).__init__(start, end)
|
||||||
|
self.is_this_year = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "this-year"
|
||||||
|
self.desc = gettext("This year")
|
||||||
|
self.is_a_year = True
|
||||||
|
|
||||||
|
|
||||||
|
class LastYear(Period):
|
||||||
|
"""The period of last year."""
|
||||||
|
def __init__(self):
|
||||||
|
year: int = datetime.date.today().year
|
||||||
|
start: datetime.date = datetime.date(year - 1, 1, 1)
|
||||||
|
end: datetime.date = datetime.date(year - 1, 12, 31)
|
||||||
|
super(LastYear, self).__init__(start, end)
|
||||||
|
self.is_last_year = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "last-year"
|
||||||
|
self.desc = gettext("Last year")
|
||||||
|
self.is_a_year = True
|
||||||
|
|
||||||
|
|
||||||
|
class Today(Period):
|
||||||
|
"""The period of today."""
|
||||||
|
def __init__(self):
|
||||||
|
today: datetime.date = datetime.date.today()
|
||||||
|
super(Today, self).__init__(today, today)
|
||||||
|
self.is_this_year = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "today"
|
||||||
|
self.desc = gettext("Today")
|
||||||
|
self.is_a_day = True
|
||||||
|
self.is_today = True
|
||||||
|
|
||||||
|
|
||||||
|
class Yesterday(Period):
|
||||||
|
"""The period of yesterday."""
|
||||||
|
def __init__(self):
|
||||||
|
yesterday: datetime.date \
|
||||||
|
= datetime.date.today() - datetime.timedelta(days=1)
|
||||||
|
super(Yesterday, self).__init__(yesterday, yesterday)
|
||||||
|
self.is_this_year = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "yesterday"
|
||||||
|
self.desc = gettext("Yesterday")
|
||||||
|
self.is_a_day = True
|
||||||
|
self.is_yesterday = True
|
||||||
|
|
||||||
|
|
||||||
|
class TemplatePeriod(Period):
|
||||||
|
"""The period template."""
|
||||||
|
def __init__(self):
|
||||||
|
super(TemplatePeriod, self).__init__(None, None)
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
self.spec = "PERIOD"
|
||||||
|
|
||||||
|
|
||||||
|
class YearPeriod(Period):
|
||||||
|
"""A year period."""
|
||||||
|
def __init__(self, year: int):
|
||||||
|
"""Constructs a year period.
|
||||||
|
|
||||||
|
:param year: The year.
|
||||||
|
"""
|
||||||
|
start: datetime.date = datetime.date(year, 1, 1)
|
||||||
|
end: datetime.date = datetime.date(year, 12, 31)
|
||||||
|
super(YearPeriod, self).__init__(start, end)
|
||||||
|
self.spec = str(year)
|
||||||
|
self.is_a_year = True
|
||||||
|
|
||||||
|
def _set_properties(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_period_spec(text: str) \
|
||||||
|
-> tuple[datetime.date | None, datetime.date | None]:
|
||||||
|
"""Parses the period specification.
|
||||||
|
|
||||||
|
:param text: The period specification.
|
||||||
|
:return: The start and end day of the period. The start and end day
|
||||||
|
may be None.
|
||||||
|
:raise ValueError: When the date is invalid.
|
||||||
|
"""
|
||||||
|
if text == "this-month":
|
||||||
|
today: datetime.date = datetime.date.today()
|
||||||
|
return datetime.date(today.year, today.month, 1), _month_end(today)
|
||||||
|
if text == "-":
|
||||||
|
return None, None
|
||||||
|
m = re.match(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$", text)
|
||||||
|
if m is not None:
|
||||||
|
return __get_start(m[1], m[2], m[3]), \
|
||||||
|
__get_end(m[1], m[2], m[3])
|
||||||
|
m = re.match(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?-$", text)
|
||||||
|
if m is not None:
|
||||||
|
return __get_start(m[1], m[2], m[3]), None
|
||||||
|
m = re.match(r"-^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$", text)
|
||||||
|
if m is not None:
|
||||||
|
return None, __get_end(m[1], m[2], m[3])
|
||||||
|
m = re.match(r"^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?-(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$", text)
|
||||||
|
if m is not None:
|
||||||
|
return __get_start(m[1], m[2], m[3]), \
|
||||||
|
__get_end(m[4], m[5], m[6])
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
|
||||||
|
def __get_start(year: str, month: str | None, day: str | None)\
|
||||||
|
-> datetime.date:
|
||||||
|
"""Returns the start of the period from the date representation.
|
||||||
|
|
||||||
|
:param year: The year.
|
||||||
|
:param month: The month, if any.
|
||||||
|
:param day: The day, if any.
|
||||||
|
:return: The start of the period.
|
||||||
|
:raise ValueError: When the date is invalid.
|
||||||
|
"""
|
||||||
|
if day is not None:
|
||||||
|
return datetime.date(int(year), int(month), int(day))
|
||||||
|
if month is not None:
|
||||||
|
return datetime.date(int(year), int(month), 1)
|
||||||
|
return datetime.date(int(year), 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_end(year: str, month: str | None, day: str | None)\
|
||||||
|
-> datetime.date:
|
||||||
|
"""Returns the end of the period from the date representation.
|
||||||
|
|
||||||
|
:param year: The year.
|
||||||
|
:param month: The month, if any.
|
||||||
|
:param day: The day, if any.
|
||||||
|
:return: The end of the period.
|
||||||
|
:raise ValueError: When the date is invalid.
|
||||||
|
"""
|
||||||
|
if day is not None:
|
||||||
|
return datetime.date(int(year), int(month), int(day))
|
||||||
|
if month is not None:
|
||||||
|
year_n: int = int(year)
|
||||||
|
month_n: int = int(month)
|
||||||
|
day_n: int = calendar.monthrange(year_n, month_n)[1]
|
||||||
|
return datetime.date(year_n, month_n, day_n)
|
||||||
|
return datetime.date(int(year), 12, 31)
|
||||||
|
|
||||||
|
|
||||||
|
def _month_end(date: datetime.date) -> datetime.date:
|
||||||
|
"""Returns the end day of month for a date.
|
||||||
|
|
||||||
|
:param date: The date.
|
||||||
|
:return: The end day of the month of that day.
|
||||||
|
"""
|
||||||
|
day: int = calendar.monthrange(date.year, date.month)[1]
|
||||||
|
return datetime.date(date.year, date.month, day)
|
118
src/accounting/report/period_choosers.py
Normal file
118
src/accounting/report/period_choosers.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# 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 period choosers.
|
||||||
|
|
||||||
|
This file is largely taken from the NanoParma ERP project, first written in
|
||||||
|
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
|
||||||
|
from accounting.models import Transaction
|
||||||
|
from .period import YearPeriod, Period, ThisMonth, LastMonth, SinceLastMonth, \
|
||||||
|
ThisYear, LastYear, Today, Yesterday, TemplatePeriod
|
||||||
|
|
||||||
|
|
||||||
|
class PeriodChooser(ABC):
|
||||||
|
"""The period chooser."""
|
||||||
|
|
||||||
|
def __init__(self, start: date | None):
|
||||||
|
"""Constructs a period chooser.
|
||||||
|
|
||||||
|
:param start: The start of the period.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Shortcut periods
|
||||||
|
self.this_month_url: str = self._url_for(ThisMonth())
|
||||||
|
"""The URL for this month."""
|
||||||
|
self.last_month_url: str = self._url_for(LastMonth())
|
||||||
|
"""The URL for last month."""
|
||||||
|
self.since_last_month_url: str = self._url_for(SinceLastMonth())
|
||||||
|
"""The URL since last mint."""
|
||||||
|
self.this_year_url: str = self._url_for(ThisYear())
|
||||||
|
"""The URL for this year."""
|
||||||
|
self.last_year_url: str = self._url_for(LastYear())
|
||||||
|
"""The URL for last year."""
|
||||||
|
self.today_url: str = self._url_for(Today())
|
||||||
|
"""The URL for today."""
|
||||||
|
self.yesterday_url: str = self._url_for(Yesterday())
|
||||||
|
"""The URL for yesterday."""
|
||||||
|
self.all_url: str = self._url_for(Period(None, None))
|
||||||
|
"""The URL for all period."""
|
||||||
|
self.url_template: str = self._url_for(TemplatePeriod())
|
||||||
|
"""The URL template."""
|
||||||
|
|
||||||
|
# Attributes
|
||||||
|
self.data_start: date | None = start
|
||||||
|
"""The start of the data."""
|
||||||
|
self.has_data: bool = start is not None
|
||||||
|
"""Whether there is any data."""
|
||||||
|
self.has_last_month: bool = False
|
||||||
|
"""Where there is data in last month."""
|
||||||
|
self.has_last_year: bool = False
|
||||||
|
"""Whether there is data in last year."""
|
||||||
|
self.has_yesterday: bool = False
|
||||||
|
"""Whether there is data in yesterday."""
|
||||||
|
self.available_years: t.Iterator[int] = []
|
||||||
|
"""The available years."""
|
||||||
|
|
||||||
|
if self.has_data is not None:
|
||||||
|
today: date = date.today()
|
||||||
|
self.has_last_month = start < date(today.year, today.month, 1)
|
||||||
|
self.has_last_year = start.year < today.year
|
||||||
|
self.has_yesterday = start < today
|
||||||
|
self.available_years: t.Iterator[int] = []
|
||||||
|
if start.year < today.year - 1:
|
||||||
|
self.available_years \
|
||||||
|
= reversed(range(start.year, today.year - 1))
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _url_for(self, period: Period) -> str:
|
||||||
|
"""Returns the URL for a period.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
:return: The URL for the period.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def year_url(self, year: int) -> str:
|
||||||
|
"""Returns the period URL of a year.
|
||||||
|
|
||||||
|
:param year: The year
|
||||||
|
:return: The period URL of the year.
|
||||||
|
"""
|
||||||
|
return self._url_for(YearPeriod(year))
|
||||||
|
|
||||||
|
|
||||||
|
class JournalPeriodChooser(PeriodChooser):
|
||||||
|
"""The journal period chooser."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Constructs the journal period chooser."""
|
||||||
|
first: Transaction | None \
|
||||||
|
= Transaction.query.order_by(Transaction.date).first()
|
||||||
|
super(JournalPeriodChooser, self).__init__(
|
||||||
|
None if first is None else first.date)
|
||||||
|
|
||||||
|
def _url_for(self, period: Period) -> str:
|
||||||
|
if period.is_default:
|
||||||
|
return url_for("accounting.report.journal-default")
|
||||||
|
return url_for("accounting.report.journal", period=period)
|
108
src/accounting/report/report_chooser.py
Normal file
108
src/accounting/report/report_chooser.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# 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 report chooser.
|
||||||
|
|
||||||
|
This file is largely taken from the NanoParma ERP project, first written in
|
||||||
|
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
from flask_babel import LazyString
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.locale import gettext
|
||||||
|
from accounting.models import Currency
|
||||||
|
from accounting.template_globals import default_currency_code
|
||||||
|
from .period import Period
|
||||||
|
|
||||||
|
|
||||||
|
class ReportType(Enum):
|
||||||
|
"""The report types."""
|
||||||
|
JOURNAL: str = "journal"
|
||||||
|
"""The journal."""
|
||||||
|
|
||||||
|
|
||||||
|
class ReportLink:
|
||||||
|
"""A link of a report."""
|
||||||
|
|
||||||
|
def __init__(self, name: str | LazyString, url: str):
|
||||||
|
"""Constructs a report.
|
||||||
|
|
||||||
|
:param name: The report name.
|
||||||
|
:param url: The URL.
|
||||||
|
"""
|
||||||
|
self.name: str | LazyString = name
|
||||||
|
"""The report name."""
|
||||||
|
self.url: str = url
|
||||||
|
"""The URL."""
|
||||||
|
self.is_active: bool = False
|
||||||
|
"""Whether the report is the current report."""
|
||||||
|
|
||||||
|
|
||||||
|
class ReportChooser:
|
||||||
|
"""The report chooser."""
|
||||||
|
|
||||||
|
def __init__(self, active_report: ReportType,
|
||||||
|
period: Period | None = None,
|
||||||
|
currency: Currency | None = None):
|
||||||
|
"""Constructs the report chooser.
|
||||||
|
|
||||||
|
:param active_report: The active report.
|
||||||
|
:param period: The period.
|
||||||
|
:param currency: The currency.
|
||||||
|
"""
|
||||||
|
self.__active_report: ReportType = active_report
|
||||||
|
"""The currently active report."""
|
||||||
|
self.__period: Period = Period.get_instance() if period is None \
|
||||||
|
else period
|
||||||
|
"""The period."""
|
||||||
|
self.__currency: Currency = db.session.get(
|
||||||
|
Currency, default_currency_code()) \
|
||||||
|
if currency is None else currency
|
||||||
|
"""The currency."""
|
||||||
|
self.__reports: list[ReportLink] = []
|
||||||
|
"""The links to the reports."""
|
||||||
|
self.__reports.append(self.__journal)
|
||||||
|
self.current_report: str | LazyString = ""
|
||||||
|
"""The name of the current report."""
|
||||||
|
for report in self.__reports:
|
||||||
|
if report.is_active:
|
||||||
|
self.current_report = report.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __journal(self) -> ReportLink:
|
||||||
|
"""Returns the journal.
|
||||||
|
|
||||||
|
:return: The journal.
|
||||||
|
"""
|
||||||
|
url: str = url_for("accounting.report.journal-default") \
|
||||||
|
if self.__period.is_default \
|
||||||
|
else url_for("accounting.report.journal", period=self.__period)
|
||||||
|
report = ReportLink(gettext("Journal"), url)
|
||||||
|
if self.__active_report == ReportType.JOURNAL:
|
||||||
|
report.is_active = True
|
||||||
|
return report
|
||||||
|
|
||||||
|
def __iter__(self) -> t.Iterator[ReportLink]:
|
||||||
|
"""Returns the iteration of the reports.
|
||||||
|
|
||||||
|
:return: The iteration of the reports.
|
||||||
|
"""
|
||||||
|
return iter(self.__reports)
|
64
src/accounting/report/report_rows.py
Normal file
64
src/accounting/report/report_rows.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# 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 rows of the reports.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
from decimal import Decimal
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from accounting.models import JournalEntry, Transaction, Account, Currency
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRow(ABC):
|
||||||
|
"""A report row."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def as_dict(self) -> dict[str, t.Any]:
|
||||||
|
"""Returns the row as a dictionary.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class JournalRow(ReportRow):
|
||||||
|
"""A row in the journal report."""
|
||||||
|
|
||||||
|
def __init__(self, entry: JournalEntry, transaction: Transaction,
|
||||||
|
account: Account, currency: Currency):
|
||||||
|
"""Constructs the row in the journal report.
|
||||||
|
|
||||||
|
:param entry: The journal entry.
|
||||||
|
:param transaction: The transaction.
|
||||||
|
:param account: The account.
|
||||||
|
:param currency: The currency.
|
||||||
|
"""
|
||||||
|
self.is_debit: bool = entry.is_debit
|
||||||
|
self.summary: str | None = entry.summary
|
||||||
|
self.amount: Decimal = entry.amount
|
||||||
|
self.transaction: Account = transaction
|
||||||
|
self.account: Account = account
|
||||||
|
self.currency: Currency = currency
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, t.Any]:
|
||||||
|
return {"date": self.transaction.date.isoformat(),
|
||||||
|
"currency": self.currency.name,
|
||||||
|
"account": str(self.account),
|
||||||
|
"summary": self.summary,
|
||||||
|
"debit": self.amount if self.is_debit else None,
|
||||||
|
"credit": None if self.is_debit else self.amount,
|
||||||
|
"note": self.transaction.note}
|
183
src/accounting/report/reports.py
Normal file
183
src/accounting/report/reports.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||||
|
|
||||||
|
# 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 reports.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import Response, render_template
|
||||||
|
from flask_sqlalchemy.query import Query
|
||||||
|
|
||||||
|
from accounting import db
|
||||||
|
from accounting.models import JournalEntry, Transaction, Account, Currency
|
||||||
|
from accounting.transaction.dispatcher import TXN_TYPE_OBJ, TransactionTypes
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
from .period import Period
|
||||||
|
from .period_choosers import PeriodChooser, \
|
||||||
|
JournalPeriodChooser
|
||||||
|
from .report_chooser import ReportChooser, ReportType
|
||||||
|
from .report_rows import ReportRow, JournalRow
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryReport(ABC):
|
||||||
|
"""A report based on a journal entry."""
|
||||||
|
|
||||||
|
def __init__(self, period: Period):
|
||||||
|
"""Constructs a journal.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
"""
|
||||||
|
self.period: Period = period
|
||||||
|
"""The period."""
|
||||||
|
self._entries: list[JournalEntry] = self.get_entries()
|
||||||
|
"""The journal entries."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_entries(self) -> list[JournalEntry]:
|
||||||
|
"""Returns the journal entries.
|
||||||
|
|
||||||
|
:return: The journal entries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def entries_to_rows(self, entries: list[JournalEntry]) -> list[ReportRow]:
|
||||||
|
"""Converts the journal entries into report rows.
|
||||||
|
|
||||||
|
:param entries: The journal entries.
|
||||||
|
:return: The report rows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def csv_field_names(self) -> list[str]:
|
||||||
|
"""Returns the CSV field names.
|
||||||
|
|
||||||
|
:return: The CSV field names.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def csv_filename(self) -> str:
|
||||||
|
"""Returns the CSV file name.
|
||||||
|
|
||||||
|
:return: The CSV file name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def period_chooser(self) -> PeriodChooser:
|
||||||
|
"""Returns the period chooser.
|
||||||
|
|
||||||
|
:return: The period chooser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
"""Returns the report chooser.
|
||||||
|
|
||||||
|
:return: The report chooser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def as_html_page(self) -> str:
|
||||||
|
"""Returns the report as an HTML page.
|
||||||
|
|
||||||
|
:return: The report as an HTML page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def txn_types(self) -> TransactionTypes:
|
||||||
|
"""Returns the transaction types.
|
||||||
|
|
||||||
|
:return: The transaction types.
|
||||||
|
"""
|
||||||
|
return TXN_TYPE_OBJ
|
||||||
|
|
||||||
|
def as_csv_download(self) -> Response:
|
||||||
|
"""Returns the journal entries as CSV download.
|
||||||
|
|
||||||
|
:return: The CSV download response.
|
||||||
|
"""
|
||||||
|
with StringIO() as fp:
|
||||||
|
writer: csv.DictWriter = csv.DictWriter(
|
||||||
|
fp, fieldnames=self.csv_field_names)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows([x.as_dict()
|
||||||
|
for x in self.entries_to_rows(self._entries)])
|
||||||
|
fp.seek(0)
|
||||||
|
response: Response = Response(fp.read(), mimetype="text/csv")
|
||||||
|
response.headers["Content-Disposition"] \
|
||||||
|
= f"attachment; filename={self.csv_filename}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class Journal(JournalEntryReport):
|
||||||
|
"""A journal."""
|
||||||
|
|
||||||
|
def get_entries(self) -> list[JournalEntry]:
|
||||||
|
conditions: list[sa.BinaryExpression] = []
|
||||||
|
if self.period.start is not None:
|
||||||
|
conditions.append(Transaction.date >= self.period.start)
|
||||||
|
if self.period.end is not None:
|
||||||
|
conditions.append(Transaction.date <= self.period.end)
|
||||||
|
query: Query = db.session.query(JournalEntry).join(Transaction)
|
||||||
|
if len(conditions) > 0:
|
||||||
|
query = query.filter(*conditions)
|
||||||
|
return query.order_by(Transaction.date,
|
||||||
|
JournalEntry.is_debit.desc(),
|
||||||
|
JournalEntry.no).all()
|
||||||
|
|
||||||
|
def entries_to_rows(self, entries: list[JournalEntry]) -> list[ReportRow]:
|
||||||
|
transactions: dict[int, Transaction] \
|
||||||
|
= {x.id: x for x in Transaction.query.filter(
|
||||||
|
Transaction.id.in_({x.transaction_id for x in entries}))}
|
||||||
|
accounts: dict[int, Account] \
|
||||||
|
= {x.id: x for x in Account.query.filter(
|
||||||
|
Account.id.in_({x.account_id for x in entries}))}
|
||||||
|
currencies: dict[int, Currency] \
|
||||||
|
= {x.code: x for x in Currency.query.filter(
|
||||||
|
Currency.code.in_({x.currency_code for x in entries}))}
|
||||||
|
return [JournalRow(x, transactions[x.transaction_id],
|
||||||
|
accounts[x.account_id], currencies[x.currency_code])
|
||||||
|
for x in entries]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def csv_field_names(self) -> list[str]:
|
||||||
|
return ["date", "currency", "account", "summary", "debit", "credit",
|
||||||
|
"note"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def csv_filename(self) -> str:
|
||||||
|
return f"journal-{self.period.spec}.csv"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def period_chooser(self) -> PeriodChooser:
|
||||||
|
return JournalPeriodChooser()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def report_chooser(self) -> ReportChooser:
|
||||||
|
return ReportChooser(ReportType.JOURNAL, self.period)
|
||||||
|
|
||||||
|
def as_html_page(self) -> str:
|
||||||
|
pagination: Pagination = Pagination[JournalEntry](self._entries)
|
||||||
|
return render_template("accounting/report/journal.html",
|
||||||
|
list=self.entries_to_rows(pagination.list),
|
||||||
|
pagination=pagination, report=self)
|
60
src/accounting/report/views.py
Normal file
60
src/accounting/report/views.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||||
|
|
||||||
|
# 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 report management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, Response
|
||||||
|
|
||||||
|
from accounting.utils.permission import has_permission, can_view
|
||||||
|
from .period import Period
|
||||||
|
from .reports import Journal
|
||||||
|
|
||||||
|
bp: Blueprint = Blueprint("report", __name__)
|
||||||
|
"""The view blueprint for the reports."""
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("journal", endpoint="journal-default")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_default_journal_list() -> str | Response:
|
||||||
|
"""Returns the journal.
|
||||||
|
|
||||||
|
:return: The journal in the period.
|
||||||
|
"""
|
||||||
|
return __get_journal_list(Period.get_instance())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("journal/<period:period>", endpoint="journal")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def get_journal_list(period: Period) -> str | Response:
|
||||||
|
"""Returns the journal.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
:return: The journal in the period.
|
||||||
|
"""
|
||||||
|
return __get_journal_list(period)
|
||||||
|
|
||||||
|
|
||||||
|
def __get_journal_list(period: Period) -> str | Response:
|
||||||
|
"""Returns journal.
|
||||||
|
|
||||||
|
:param period: The period.
|
||||||
|
:return: The journal in the period.
|
||||||
|
"""
|
||||||
|
report: Journal = Journal(period)
|
||||||
|
if "as" in request.args and request.args["as"] == "csv":
|
||||||
|
return report.as_csv_download()
|
||||||
|
return report.as_html_page()
|
@ -109,6 +109,20 @@
|
|||||||
border-top: thick double slategray;
|
border-top: thick double slategray;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The accounting report */
|
||||||
|
.accounting-ledger-table thead {
|
||||||
|
border-bottom: 1px double black;
|
||||||
|
}
|
||||||
|
.accounting-amount {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
td.accounting-amount {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.accounting-mobile-journal-credit {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* 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;
|
||||||
|
399
src/accounting/static/js/period-chooser.js
Normal file
399
src/accounting/static/js/period-chooser.js
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* period-chooser.js: The JavaScript for the period chooser
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 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/4
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initializes the page JavaScript.
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
new PeriodChooser();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The period chooser.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
class PeriodChooser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix of the HTML ID and class
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
prefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The modal of the period chooser
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
modal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab planes
|
||||||
|
* @type {{month: MonthTab, year: YearTab, day: DayTab, custom: CustomTab}}
|
||||||
|
*/
|
||||||
|
tabPlanes = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs the period chooser.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
this.prefix = "accounting-period-chooser";
|
||||||
|
this.modal = document.getElementById(this.prefix + "-modal");
|
||||||
|
for (const cls of [MonthTab, YearTab, DayTab, CustomTab]) {
|
||||||
|
const tab = new cls(this);
|
||||||
|
this.tabPlanes[tab.tabId()] = tab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tab plane.
|
||||||
|
*
|
||||||
|
* @abstract
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class TabPlane {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The period chooser
|
||||||
|
* @type {PeriodChooser}
|
||||||
|
*/
|
||||||
|
chooser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix of the HTML ID and class
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
prefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab
|
||||||
|
* @type {HTMLSpanElement}
|
||||||
|
*/
|
||||||
|
#tab;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#page;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a tab plane.
|
||||||
|
*
|
||||||
|
* @param chooser {PeriodChooser} the period chooser
|
||||||
|
*/
|
||||||
|
constructor(chooser) {
|
||||||
|
this.chooser = chooser;
|
||||||
|
this.prefix = "accounting-period-chooser-" + this.tabId();
|
||||||
|
this.#tab = document.getElementById(this.prefix + "-tab");
|
||||||
|
this.#page = document.getElementById(this.prefix + "-page");
|
||||||
|
this.#tab.onclick = () => this.#switchToMe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab ID
|
||||||
|
*
|
||||||
|
* @return {string}
|
||||||
|
* @abstract
|
||||||
|
*/
|
||||||
|
tabId() { throw new Error("Method not implemented.") };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches to the tab plane.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
#switchToMe() {
|
||||||
|
for (const tabPlane of Object.values(this.chooser.tabPlanes)) {
|
||||||
|
tabPlane.#tab.classList.remove("active")
|
||||||
|
tabPlane.#tab.ariaCurrent = "false";
|
||||||
|
tabPlane.#page.classList.add("d-none");
|
||||||
|
tabPlane.#page.ariaCurrent = "false";
|
||||||
|
}
|
||||||
|
this.#tab.classList.add("active");
|
||||||
|
this.#tab.ariaCurrent = "page";
|
||||||
|
this.#page.classList.remove("d-none");
|
||||||
|
this.#page.ariaCurrent = "page";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The month tab plane.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class MonthTab extends TabPlane {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a tab plane.
|
||||||
|
*
|
||||||
|
* @param chooser {PeriodChooser} the period chooser
|
||||||
|
*/
|
||||||
|
constructor(chooser) {
|
||||||
|
super(chooser);
|
||||||
|
const monthChooser = document.getElementById(this.prefix + "-chooser");
|
||||||
|
let start = monthChooser.dataset.start;
|
||||||
|
new tempusDominus.TempusDominus(monthChooser, {
|
||||||
|
restrictions: {
|
||||||
|
minDate: start,
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
inline: true,
|
||||||
|
components: {
|
||||||
|
date: false,
|
||||||
|
clock: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultDate: monthChooser.dataset.default,
|
||||||
|
});
|
||||||
|
monthChooser.addEventListener(tempusDominus.Namespace.events.change, (e) => {
|
||||||
|
const date = e.detail.date;
|
||||||
|
const year = date.year;
|
||||||
|
const month = date.month + 1;
|
||||||
|
const period = month < 10? year + "-0" + month: year + "-" + month;
|
||||||
|
window.location = chooser.modal.dataset.urlTemplate
|
||||||
|
.replaceAll("PERIOD", period);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab ID
|
||||||
|
*
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
tabId() {
|
||||||
|
return "month";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The year tab plane.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class YearTab extends TabPlane {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab ID
|
||||||
|
*
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
tabId() {
|
||||||
|
return "year";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day tab plane.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class DayTab extends TabPlane {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day input
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error of the date input
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#dateError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a tab plane.
|
||||||
|
*
|
||||||
|
* @param chooser {PeriodChooser} the period chooser
|
||||||
|
*/
|
||||||
|
constructor(chooser) {
|
||||||
|
super(chooser);
|
||||||
|
this.#date = document.getElementById(this.prefix + "-date");
|
||||||
|
this.#dateError = document.getElementById(this.prefix + "-date-error");
|
||||||
|
this.#date.onchange = () => {
|
||||||
|
if (this.#validateDate()) {
|
||||||
|
window.location = chooser.modal.dataset.urlTemplate
|
||||||
|
.replaceAll("PERIOD", this.#date.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the date.
|
||||||
|
*
|
||||||
|
* @return {boolean} true if valid, or false otherwise
|
||||||
|
*/
|
||||||
|
#validateDate() {
|
||||||
|
if (this.#date.value === "") {
|
||||||
|
this.#date.classList.add("is-invalid");
|
||||||
|
this.#dateError.innerText = A_("Please fill in the date.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.#date.value < this.#date.min) {
|
||||||
|
this.#date.classList.add("is-invalid");
|
||||||
|
this.#dateError.innerText = A_("The date is too early.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#date.classList.remove("is-invalid");
|
||||||
|
this.#dateError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab ID
|
||||||
|
*
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
tabId() {
|
||||||
|
return "day";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The custom tab plane.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class CustomTab extends TabPlane {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The start of the period
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#start;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error of the start
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#startError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The end of the period
|
||||||
|
* @type {HTMLInputElement}
|
||||||
|
*/
|
||||||
|
#end;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error of the end
|
||||||
|
* @type {HTMLDivElement}
|
||||||
|
*/
|
||||||
|
#endError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The confirm button
|
||||||
|
* @type {HTMLButtonElement}
|
||||||
|
*/
|
||||||
|
#conform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a tab plane.
|
||||||
|
*
|
||||||
|
* @param chooser {PeriodChooser} the period chooser
|
||||||
|
*/
|
||||||
|
constructor(chooser) {
|
||||||
|
super(chooser);
|
||||||
|
this.#start = document.getElementById(this.prefix + "-start");
|
||||||
|
this.#startError = document.getElementById(this.prefix + "-start-error");
|
||||||
|
this.#end = document.getElementById(this.prefix + "-end");
|
||||||
|
this.#endError = document.getElementById(this.prefix + "-end-error");
|
||||||
|
this.#conform = document.getElementById(this.prefix + "-confirm");
|
||||||
|
this.#start.onchange = () => {
|
||||||
|
if (this.#validateStart()) {
|
||||||
|
this.#end.min = this.#start.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.#end.onchange = () => {
|
||||||
|
if (this.#validateEnd()) {
|
||||||
|
this.#start.max = this.#end.value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.#conform.onclick = () => {
|
||||||
|
let isValid = true;
|
||||||
|
isValid = this.#validateStart() && isValid;
|
||||||
|
isValid = this.#validateEnd() && isValid;
|
||||||
|
if (isValid) {
|
||||||
|
window.location = chooser.modal.dataset.urlTemplate
|
||||||
|
.replaceAll("PERIOD", this.#start.value + "-" + this.#end.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the start of the period.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#validateStart() {
|
||||||
|
if (this.#start.value === "") {
|
||||||
|
this.#start.classList.add("is-invalid");
|
||||||
|
this.#startError.innerText = A_("Please fill in the start date.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.#start.value < this.#start.min) {
|
||||||
|
this.#start.classList.add("is-invalid");
|
||||||
|
this.#startError.innerText = A_("The start date is too early.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.#start.value > this.#start.max) {
|
||||||
|
this.#start.classList.add("is-invalid");
|
||||||
|
this.#startError.innerText = A_("The start date cannot be beyond the end date.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#start.classList.remove("is-invalid");
|
||||||
|
this.#startError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the end of the period.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if valid, or false otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
#validateEnd() {
|
||||||
|
this.#end.value = this.#end.value.trim();
|
||||||
|
if (this.#end.value === "") {
|
||||||
|
this.#end.classList.add("is-invalid");
|
||||||
|
this.#endError.innerText = A_("Please fill in the end date.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.#end.value < this.#end.min) {
|
||||||
|
this.#end.classList.add("is-invalid");
|
||||||
|
this.#endError.innerText = A_("The end date cannot be beyond the start date.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#end.classList.remove("is-invalid");
|
||||||
|
this.#endError.innerText = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab ID
|
||||||
|
*
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
tabId() {
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
}
|
@ -122,12 +122,6 @@ class SummaryEditor {
|
|||||||
*/
|
*/
|
||||||
#formSummary;
|
#formSummary;
|
||||||
|
|
||||||
/**
|
|
||||||
* The tab plane classes
|
|
||||||
* @type {typeof TabPlane[]}
|
|
||||||
*/
|
|
||||||
#TAB_CLASES = [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tab planes
|
* The tab planes
|
||||||
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}}
|
* @type {{general: GeneralTagTab, travel: GeneralTripTab, bus: BusTripTab, regular: RegularPaymentTab, annotation: AnnotationTab}}
|
||||||
@ -157,7 +151,7 @@ class SummaryEditor {
|
|||||||
this.#formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
this.#formSummaryControl = document.getElementById("accounting-entry-form-summary-control");
|
||||||
this.#formSummary = document.getElementById("accounting-entry-form-summary");
|
this.#formSummary = document.getElementById("accounting-entry-form-summary");
|
||||||
|
|
||||||
for (const cls of this.#TAB_CLASES) {
|
for (const cls of [GeneralTagTab, GeneralTripTab, BusTripTab, RegularPaymentTab, AnnotationTab]) {
|
||||||
const tab = new cls(this);
|
const tab = new cls(this);
|
||||||
this.tabPlanes[tab.tabId()] = tab;
|
this.tabPlanes[tab.tabId()] = tab;
|
||||||
}
|
}
|
||||||
|
41
src/accounting/static/js/table-row-link.js
Normal file
41
src/accounting/static/js/table-row-link.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* table-row-link.js: The JavaScript for table rows as links.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 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/4
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initializes the page JavaScript.
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
initializeTableRowLinks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the table rows as links.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function initializeTableRowLinks() {
|
||||||
|
const rows = Array.from(document.getElementsByClassName("accounting-clickable accounting-table-row-link"));
|
||||||
|
for (const row of rows) {
|
||||||
|
row.onclick = () => {
|
||||||
|
window.location = row.dataset.href;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,150 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
period-chooser.html: The period chooser
|
||||||
|
|
||||||
|
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/4
|
||||||
|
#}
|
||||||
|
<div id="accounting-period-chooser-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-period-chooser-modal-label" aria-hidden="true" data-url-template="{{ period_chooser.url_template }}">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="accounting-period-chooser-modal-label">{{ A_("Period Chooser") }}</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{# Tab navigation #}
|
||||||
|
<ul class="nav nav-tabs mb-2">
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-period-chooser-month-tab" class="nav-link {% if period.is_type_month %} active {% endif %} accounting-clickable" aria-current="{% if period.is_type_month %} page {% else %} false {% endif %}" data-tab-id="month">
|
||||||
|
{{ A_("Month") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-period-chooser-year-tab" class="nav-link {% if period.is_a_year %} active {% endif %} accounting-clickable" aria-current="{% if period.is_a_year %} page {% else %} false {% endif %}" data-tab-id="year">
|
||||||
|
{{ A_("Year") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-period-chooser-day-tab" class="nav-link {% if period.is_a_day %} active {% endif %} accounting-clickable" aria-current="{% if period.is_a_day %} page {% else %} false {% endif %}" data-tab-id="day">
|
||||||
|
{{ A_("Day") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<span id="accounting-period-chooser-custom-tab" class="nav-link {% if period.is_type_arbitrary %} active {% endif %} accounting-clickable" aria-current="{% if period.is_type_arbitrary %} page {% else %} false {% endif %}" data-tab-id="custom">
|
||||||
|
{{ A_("Custom") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{# The month periods #}
|
||||||
|
<div id="accounting-period-chooser-month-page" {% if period.is_type_month %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-month-tab">
|
||||||
|
<div>
|
||||||
|
<a class="btn {% if period.is_this_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.this_month_url }}">
|
||||||
|
{{ A_("This month") }}
|
||||||
|
</a>
|
||||||
|
{% if period_chooser.has_last_month %}
|
||||||
|
<a class="btn {% if period.is_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.last_month_url }}">
|
||||||
|
{{ A_("Last month") }}
|
||||||
|
</a>
|
||||||
|
<a class="btn {% if period.is_since_last_month %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.since_last_month_url }}">
|
||||||
|
{{ A_("Since last month") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if period_chooser.has_data %}
|
||||||
|
<div id="accounting-period-chooser-month-chooser" class="mt-3" data-start="{{ period_chooser.data_start }}" data-default="{{ period.start }}"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The year periods #}
|
||||||
|
<div id="accounting-period-chooser-year-page" {% if period.is_a_year %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-year-tab">
|
||||||
|
<a class="btn {% if period.is_this_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.this_year_url }}">
|
||||||
|
{{ A_("This year") }}
|
||||||
|
</a>
|
||||||
|
{% if period_chooser.has_last_year %}
|
||||||
|
<a class="btn {% if period.is_last_year %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.last_year_url }}">
|
||||||
|
{{ A_("Last year") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if period_chooser.available_years %}
|
||||||
|
<ul class="nav nav-pills mt-3">
|
||||||
|
{% for year in period_chooser.available_years %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if period.is_year(year) %} active {% endif %}" href="{{ period_chooser.year_url(year) }}">{{ year }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The day periods #}
|
||||||
|
<div id="accounting-period-chooser-day-page" {% if period.is_a_day %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-day-tab">
|
||||||
|
<div>
|
||||||
|
<a class="btn {% if period.is_today %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.today_url }}">
|
||||||
|
{{ A_("Today") }}
|
||||||
|
</a>
|
||||||
|
{% if period_chooser.has_yesterday %}
|
||||||
|
<a class="btn {% if period.is_yesterday %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.yesterday_url }}">
|
||||||
|
{{ A_("Yesterday") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if period_chooser.has_data %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input id="accounting-period-chooser-day-date" class="form-control" type="date" value="{{ "" if period.start is none else period.start }}" min="{{ period_chooser.data_start }}" required="required">
|
||||||
|
<label for="accounting-period-chooser-day-date" class="form-label">{{ A_("Date") }}</label>
|
||||||
|
<div id="accounting-period-chooser-day-date-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# The custom periods #}
|
||||||
|
<div id="accounting-period-chooser-custom-page" {% if period.is_type_arbitrary %} aria-current="page" {% else %} class="d-none" aria-current="false" {% endif %} aria-labelledby="accounting-period-chooser-custom-tab">
|
||||||
|
<div>
|
||||||
|
<a class="btn {% if period.is_all %} btn-primary {% else %} btn-outline-primary {% endif %}" role="button" href="{{ period_chooser.all_url }}">
|
||||||
|
{{ A_("All") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% if period_chooser.has_data %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input id="accounting-period-chooser-custom-start" class="form-control" type="date" value="{{ "" if period.start is none else period.start }}" min="{{ period_chooser.data_start }}" max="{{ period.end }}" required="required">
|
||||||
|
<label for="accounting-period-chooser-custom-start" class="form-label">{{ A_("From") }}</label>
|
||||||
|
<div id="accounting-period-chooser-custom-start-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input id="accounting-period-chooser-custom-end" class="form-control" type="date" value="{{ "" if period.end is none else period.end }}" min="{{ period.start }}" required="required">
|
||||||
|
<label for="accounting-period-chooser-custom-end" class="form-label">{{ A_("To") }}</label>
|
||||||
|
<div id="accounting-period-chooser-custom-end-error" class="invalid-feedback"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button id="accounting-period-chooser-custom-confirm" class="btn btn-primary" type="submit">
|
||||||
|
{{ A_("Confirm") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,33 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
report-chooser.html: The report chooser
|
||||||
|
|
||||||
|
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/4
|
||||||
|
#}
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button id="accounting-report-chooser" class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fa-solid fa-book"></i>
|
||||||
|
<span class="d-none d-md-inline">{{ report_chooser.current_report }}</span>
|
||||||
|
<span class="d-md-none">{{ A_("Report") }}</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="accounting-report-chooser">
|
||||||
|
{% for report in report_chooser %}
|
||||||
|
<li><a class="dropdown-item {% if report.is_active %} active {% endif %}" href="{{ report.url }}">{{ report.name }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
146
src/accounting/templates/accounting/report/journal.html
Normal file
146
src/accounting/templates/accounting/report/journal.html
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
journal.html: The journal
|
||||||
|
|
||||||
|
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/4
|
||||||
|
#}
|
||||||
|
{% 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>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/table-row-link.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ _("Journal %(period)s", period=report.period.desc|title) }}{% 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.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.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 %}
|
||||||
|
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
|
||||||
|
<i class="fa-solid fa-calendar-day"></i>
|
||||||
|
{{ report.period.desc }}
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-primary" role="button" href="?as=csv">
|
||||||
|
<i class="fa-solid fa-download"></i>
|
||||||
|
{{ A_("Download") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with types = report.txn_types %}
|
||||||
|
{% include "accounting/transaction/include/add-new-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 %}
|
||||||
|
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-period-chooser-modal">
|
||||||
|
<i class="fa-solid fa-calendar-day"></i>
|
||||||
|
{{ A_("Period") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with period = report.period, period_chooser = report.period_chooser %}
|
||||||
|
{% include "accounting/report/include/period-chooser.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% if list %}
|
||||||
|
{% include "accounting/include/pagination.html" %}
|
||||||
|
|
||||||
|
<table class="table table-striped table-hover d-none d-md-table accounting-ledger-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{ A_("Date") }}</th>
|
||||||
|
<th scope="col">{{ A_("Currency") }}</th>
|
||||||
|
<th scope="col">{{ A_("Account") }}</th>
|
||||||
|
<th scope="col">{{ A_("Summary") }}</th>
|
||||||
|
<th class="accounting-amount" scope="col">{{ A_("Debit") }}</th>
|
||||||
|
<th class="accounting-amount" scope="col">{{ A_("Credit") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in list %}
|
||||||
|
<tr class="accounting-clickable accounting-table-row-link" data-href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
|
||||||
|
<td>{{ item.transaction.date|accounting_format_date }}</td>
|
||||||
|
<td>{{ item.currency.name }}</td>
|
||||||
|
<td>{{ item.account.title }}</td>
|
||||||
|
<td>{{ "" if item.summary is none else item.summary }}</td>
|
||||||
|
<td class="accounting-amount">{{ "" if not item.is_debit else item.amount|accounting_format_amount }}</td>
|
||||||
|
<td class="accounting-amount">{{ "" if item.is_debit else item.amount|accounting_format_amount }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="list-group d-md-none">
|
||||||
|
{% for item in list %}
|
||||||
|
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=item.transaction)|accounting_append_next }}">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div {% if not item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
|
||||||
|
<div class="text-muted small">
|
||||||
|
{{ item.transaction.date|accounting_format_date }}
|
||||||
|
{{ item.account.title }}
|
||||||
|
{% if item.currency_code != accounting_default_currency_code() %}
|
||||||
|
<span class="badge rounded-pill bg-info">{{ item.currency.code }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if item.summary is not none %}
|
||||||
|
<div>{{ item.summary }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="badge rounded-pill bg-info">{{ item.amount|accounting_format_amount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ A_("There is no data.") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
Loading…
Reference in New Issue
Block a user