Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46c6767a90 | |||
| 381298adb1 | |||
| bfa42000d8 | |||
| 0d02f41417 | |||
| a26f1942f8 | |||
| 0a7bcdd9ec | |||
| b6ea944eb5 | |||
| be9f4f3d83 | |||
| dc42a05959 | |||
| 9c6cc1f3eb | |||
| e6d25882fc | |||
| 970c2e9946 | |||
| 356950e2c7 | |||
| cca3f89bf1 | |||
| 674b0de3b2 | |||
| 29dfc6c5a4 | |||
| aa3bc1ed69 | |||
| 1289d7cba6 | |||
| d62e295dc6 | |||
| 693c5890ca | |||
| 3adcaa61d3 | |||
| aea9dcae79 | |||
| 40278eaf06 | |||
| e00c14f277 | |||
| f20c462685 | |||
| 80ae4bd91c | |||
| 6ee3ee76ea | |||
| 2bfcc8b889 | |||
| 99564c02d0 | |||
| 25d9904180 | |||
| 1cf83adf87 | |||
| 8e3d1f11b5 | |||
| 0ab14aa34d |
@@ -25,6 +25,8 @@ venv
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.claude
|
||||
.codex
|
||||
|
||||
instance
|
||||
flask_session
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ Refer to the `change log`_.
|
||||
Copyright
|
||||
=========
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
Copyright (c) 2023-2024 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -2,6 +2,26 @@ Change Log
|
||||
==========
|
||||
|
||||
|
||||
Version 1.6.1
|
||||
--------------
|
||||
|
||||
Released 2024/12/3
|
||||
|
||||
Fix test cases for compatibility with httpx 0.28.0.
|
||||
|
||||
|
||||
Version 1.6.0
|
||||
--------------
|
||||
|
||||
Released 2024/6/4
|
||||
|
||||
* Updated Python version to 3.12.
|
||||
* Revised the calculation of "today" to use the client's timezone instead of
|
||||
the server's timezone.
|
||||
* Updated the Bootstrap, FontAwesome, and Tempus-Dominus versions in the test
|
||||
site.
|
||||
|
||||
|
||||
Version 1.5.11
|
||||
--------------
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ The following is an example configuration for *Mia! Accounting*.
|
||||
|
||||
from flask import Response, redirect
|
||||
from .auth import current_user()
|
||||
from .modules import User
|
||||
from .modules import Base, User
|
||||
|
||||
def create_app(test_config=None) -> Flask:
|
||||
app: Flask = Flask(__name__)
|
||||
@@ -37,7 +37,11 @@ The following is an example configuration for *Mia! Accounting*.
|
||||
return redirect("/login")
|
||||
|
||||
@property
|
||||
def cls(self) -> t.Type[User]:
|
||||
def base(self) -> type[DeclarativeBase]:
|
||||
return Base
|
||||
|
||||
@property
|
||||
def cls(self) -> type[User]:
|
||||
return User
|
||||
|
||||
@property
|
||||
@@ -49,7 +53,8 @@ The following is an example configuration for *Mia! Accounting*.
|
||||
return current_user()
|
||||
|
||||
def get_by_username(self, username: str) -> User | None:
|
||||
return User.query.filter(User.username == username).first()
|
||||
return db.session.scalar(
|
||||
sa.select(User).where(User.username == username))
|
||||
|
||||
def get_pk(self, user: User) -> int:
|
||||
return user.id
|
||||
|
||||
+6
-7
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
||||
|
||||
# Copyright (c) 2022-2023 imacat.
|
||||
# Copyright (c) 2022-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,7 +20,7 @@ name = "mia-accounting"
|
||||
dynamic = ["version"]
|
||||
description = "A Flask accounting module."
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.12"
|
||||
authors = [
|
||||
{name = "imacat", email = "imacat@mail.imacat.idv.tw"},
|
||||
]
|
||||
@@ -33,18 +33,17 @@ classifiers = [
|
||||
"Topic :: Office/Business :: Financial :: Accounting",
|
||||
]
|
||||
dependencies = [
|
||||
"flask",
|
||||
"Flask",
|
||||
"SQLAlchemy >= 2",
|
||||
"Flask-SQLAlchemy",
|
||||
"Flask-SQLAlchemy-Lite",
|
||||
"Flask-WTF",
|
||||
"Flask-Babel >= 3",
|
||||
"Flask-Babel-JS",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"unittest",
|
||||
"httpx",
|
||||
devel = [
|
||||
"httpx >= 0.28.0",
|
||||
"OpenCC",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,11 +20,11 @@
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_sqlalchemy_lite import SQLAlchemy
|
||||
|
||||
from accounting.utils.user import UserUtilityInterface
|
||||
from .utils.user import UserUtilityInterface
|
||||
|
||||
VERSION: str = "1.5.11"
|
||||
VERSION: str = "1.6.1"
|
||||
"""The package version."""
|
||||
db: SQLAlchemy = SQLAlchemy()
|
||||
"""The database instance."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -23,11 +23,11 @@ from typing import Any
|
||||
import click
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import BaseAccount, Account, AccountL10n
|
||||
from accounting.utils.user import get_user_pk
|
||||
from .. import db
|
||||
from ..models import BaseAccount, Account, AccountL10n
|
||||
from ..utils.user import get_user_pk
|
||||
|
||||
AccountData = tuple[int, str, int, str, str, str, bool]
|
||||
type AccountData = tuple[int, str, int, str, str, str, bool]
|
||||
"""The format of the account data, as a list of (ID, base account code, number,
|
||||
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
|
||||
|
||||
@@ -36,13 +36,14 @@ def init_accounts_command(username: str) -> None:
|
||||
"""Initializes the accounts."""
|
||||
creator_pk: int = get_user_pk(username)
|
||||
|
||||
bases: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(db.func.length(BaseAccount.code) == 4)\
|
||||
.order_by(BaseAccount.code).all()
|
||||
bases: list[BaseAccount] = db.session.scalars(
|
||||
sa.select(BaseAccount).where(sa.func.length(BaseAccount.code) == 4)
|
||||
.order_by(BaseAccount.code)).unique().all()
|
||||
if len(bases) == 0:
|
||||
raise click.Abort
|
||||
|
||||
existing: list[Account] = Account.query.all()
|
||||
existing: list[Account] = \
|
||||
db.session.scalars(sa.select(Account)).unique().all()
|
||||
|
||||
existing_base_code: set[str] = {x.base_code for x in existing}
|
||||
bases_to_add: list[BaseAccount] = [x for x in bases
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,7 +20,7 @@
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting.models import Account
|
||||
from ..models import Account
|
||||
|
||||
|
||||
class AccountConverter(BaseConverter):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -23,12 +23,12 @@ from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField
|
||||
from wtforms.validators import DataRequired, ValidationError
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import BaseAccount, Account
|
||||
from accounting.utils.random_id import new_id
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .. import db
|
||||
from ..locale import lazy_gettext
|
||||
from ..models import BaseAccount, Account
|
||||
from ..utils.random_id import new_id
|
||||
from ..utils.strip_text import strip_text
|
||||
from ..utils.user import get_current_user_pk
|
||||
|
||||
|
||||
class BaseAccountExists:
|
||||
@@ -97,8 +97,9 @@ class AccountForm(FlaskForm):
|
||||
if obj.base_code is not None:
|
||||
sort_accounts_in(obj.base_code, obj.id)
|
||||
sort_accounts_in(self.base_code.data, obj.id)
|
||||
count: int = Account.query\
|
||||
.filter(Account.base_code == self.base_code.data).count()
|
||||
count: int = db.session.scalar(
|
||||
sa.select(sa.func.count(Account.id))
|
||||
.where(Account.base_code == self.base_code.data))
|
||||
obj.base_code = self.base_code.data
|
||||
obj.no = count + 1
|
||||
obj.title = self.title.data
|
||||
@@ -137,9 +138,10 @@ class AccountForm(FlaskForm):
|
||||
|
||||
:return: The selectable base accounts.
|
||||
"""
|
||||
return BaseAccount.query\
|
||||
.filter(sa.func.char_length(BaseAccount.code) == 4)\
|
||||
.order_by(BaseAccount.code).all()
|
||||
return db.session.scalars(
|
||||
sa.select(BaseAccount)
|
||||
.where(sa.func.char_length(BaseAccount.code) == 4)
|
||||
.order_by(BaseAccount.code)).unique()
|
||||
|
||||
|
||||
def sort_accounts_in(base_code: str, exclude: int) -> None:
|
||||
@@ -150,10 +152,10 @@ def sort_accounts_in(base_code: str, exclude: int) -> None:
|
||||
:param exclude: The account ID to exclude.
|
||||
:return: None.
|
||||
"""
|
||||
accounts: list[Account] = Account.query\
|
||||
.filter(Account.base_code == base_code,
|
||||
Account.id != exclude)\
|
||||
.order_by(Account.no).all()
|
||||
accounts: list[Account] = db.session.scalars(
|
||||
sa.select(Account)
|
||||
.where(Account.base_code == base_code, Account.id != exclude)
|
||||
.order_by(Account.no)).unique().all()
|
||||
for i in range(len(accounts)):
|
||||
if accounts[i].no != i + 1:
|
||||
accounts[i].no = i + 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,9 +20,10 @@
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Account, AccountL10n
|
||||
from accounting.utils.query import parse_query_keywords
|
||||
from .. import db
|
||||
from ..locale import gettext
|
||||
from ..models import Account, AccountL10n
|
||||
from ..utils.query import parse_query_keywords
|
||||
|
||||
|
||||
def get_account_query() -> list[Account]:
|
||||
@@ -32,17 +33,20 @@ def get_account_query() -> list[Account]:
|
||||
"""
|
||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||
if len(keywords) == 0:
|
||||
return Account.query.order_by(Account.base_code, Account.no).all()
|
||||
code: sa.BinaryExpression = Account.base_code + "-" \
|
||||
return db.session.scalars(
|
||||
sa.select(Account)
|
||||
.order_by(Account.base_code, Account.no)).unique().all()
|
||||
code: sa.ColumnElement[str] = Account.base_code + "-" \
|
||||
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
|
||||
sa.func.char_length(sa.cast(Account.no,
|
||||
sa.String)) + 1)
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
conditions: list[sa.ColumnElement[bool]] = []
|
||||
for k in keywords:
|
||||
l10n: list[AccountL10n] = AccountL10n.query\
|
||||
.filter(AccountL10n.title.icontains(k)).all()
|
||||
l10n_matches: set[str] = {x.account_id for x in l10n}
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
l10n: list[AccountL10n] = db.session.scalars(
|
||||
sa.select(AccountL10n)
|
||||
.where(AccountL10n.title.icontains(k))).all()
|
||||
l10n_matches: set[int] = {x.account_id for x in l10n}
|
||||
sub_conditions: list[sa.ColumnElement[bool]] \
|
||||
= [Account.base_code.contains(k),
|
||||
Account.title_l10n.icontains(k),
|
||||
code.contains(k),
|
||||
@@ -51,5 +55,6 @@ def get_account_query() -> list[Account]:
|
||||
sub_conditions.append(Account.is_need_offset)
|
||||
conditions.append(sa.or_(*sub_conditions))
|
||||
|
||||
return Account.query.filter(*conditions)\
|
||||
.order_by(Account.base_code, Account.no).all()
|
||||
return db.session.scalars(
|
||||
sa.select(Account).where(*conditions)
|
||||
.order_by(Account.base_code, Account.no)).unique().all()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -21,20 +21,20 @@ from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import Blueprint, render_template, session, redirect, flash, \
|
||||
url_for, request
|
||||
url_for, request, Response
|
||||
from werkzeug.datastructures import ImmutableMultiDict
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Account, BaseAccount
|
||||
from accounting.utils.cast import s
|
||||
from accounting.utils.flash_errors import flash_form_errors
|
||||
from accounting.utils.next_uri import inherit_next, or_next
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import can_view, has_permission, can_edit
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
|
||||
from .queries import get_account_query
|
||||
from .. import db
|
||||
from ..locale import lazy_gettext
|
||||
from ..models import Account, BaseAccount
|
||||
from ..utils.cast import s
|
||||
from ..utils.flash_errors import flash_form_errors
|
||||
from ..utils.next_uri import inherit_next, or_next
|
||||
from ..utils.pagination import Pagination
|
||||
from ..utils.permission import can_view, has_permission, can_edit
|
||||
from ..utils.user import get_current_user_pk
|
||||
|
||||
bp: Blueprint = Blueprint("account", __name__)
|
||||
"""The view blueprint for the account management."""
|
||||
@@ -47,8 +47,8 @@ def list_accounts() -> str:
|
||||
|
||||
:return: The account list.
|
||||
"""
|
||||
accounts: list[BaseAccount] = get_account_query()
|
||||
pagination: Pagination = Pagination[BaseAccount](accounts)
|
||||
accounts: list[Account] = get_account_query()
|
||||
pagination: Pagination = Pagination[Account](accounts)
|
||||
return render_template("accounting/account/list.html",
|
||||
list=pagination.list, pagination=pagination)
|
||||
|
||||
@@ -72,7 +72,7 @@ def show_add_account_form() -> str:
|
||||
|
||||
@bp.post("store", endpoint="store")
|
||||
@has_permission(can_edit)
|
||||
def add_account() -> redirect:
|
||||
def add_account() -> Response:
|
||||
"""Adds an account.
|
||||
|
||||
:return: The redirection to the account detail on success, or the account
|
||||
@@ -123,7 +123,7 @@ def show_account_edit_form(account: Account) -> str:
|
||||
|
||||
@bp.post("<account:account>/update", endpoint="update")
|
||||
@has_permission(can_edit)
|
||||
def update_account(account: Account) -> redirect:
|
||||
def update_account(account: Account) -> Response:
|
||||
"""Updates an account.
|
||||
|
||||
:param account: The account.
|
||||
@@ -150,7 +150,7 @@ def update_account(account: Account) -> redirect:
|
||||
|
||||
@bp.post("<account:account>/delete", endpoint="delete")
|
||||
@has_permission(can_edit)
|
||||
def delete_account(account: Account) -> redirect:
|
||||
def delete_account(account: Account) -> Response:
|
||||
"""Deletes an account.
|
||||
|
||||
:param account: The account.
|
||||
@@ -180,7 +180,7 @@ def show_account_order(base: BaseAccount) -> str:
|
||||
|
||||
@bp.post("bases/<baseAccount:base>", endpoint="sort")
|
||||
@has_permission(can_edit)
|
||||
def sort_accounts(base: BaseAccount) -> redirect:
|
||||
def sort_accounts(base: BaseAccount) -> Response:
|
||||
"""Reorders the accounts under a base account.
|
||||
|
||||
:param base: The base account.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -21,21 +21,20 @@ import csv
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import data_dir
|
||||
from accounting import db
|
||||
from accounting.models import BaseAccount, BaseAccountL10n
|
||||
from accounting.utils.title_case import title_case
|
||||
from .. import db, data_dir
|
||||
from ..models import BaseAccount, BaseAccountL10n
|
||||
from ..utils.title_case import title_case
|
||||
|
||||
|
||||
def init_base_accounts_command() -> None:
|
||||
"""Initializes the base accounts."""
|
||||
if BaseAccount.query.first() is not None:
|
||||
if db.session.scalar(sa.select(BaseAccount)) is not None:
|
||||
return
|
||||
|
||||
with open(data_dir / "base_accounts.csv") as fp:
|
||||
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
||||
account_data: list[dict[str, str]] = [{"code": x["code"],
|
||||
"title_l10n": title_case(x["title"])}
|
||||
account_data: list[dict[str, str]] = \
|
||||
[{"code": x["code"], "title_l10n": title_case(x["title"])}
|
||||
for x in data]
|
||||
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
|
||||
l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,8 +20,8 @@
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import BaseAccount
|
||||
from .. import db
|
||||
from ..models import BaseAccount
|
||||
|
||||
|
||||
class BaseAccountConverter(BaseConverter):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,8 +20,9 @@
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
|
||||
from accounting.models import BaseAccount, BaseAccountL10n
|
||||
from accounting.utils.query import parse_query_keywords
|
||||
from .. import db
|
||||
from ..models import BaseAccount, BaseAccountL10n
|
||||
from ..utils.query import parse_query_keywords
|
||||
|
||||
|
||||
def get_base_account_query() -> list[BaseAccount]:
|
||||
@@ -31,14 +32,17 @@ def get_base_account_query() -> list[BaseAccount]:
|
||||
"""
|
||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||
if len(keywords) == 0:
|
||||
return BaseAccount.query.order_by(BaseAccount.code).all()
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
return db.session.scalars(
|
||||
sa.select(BaseAccount).order_by(BaseAccount.code)).unique().all()
|
||||
conditions: list[sa.ColumnElement[bool]] = []
|
||||
for k in keywords:
|
||||
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\
|
||||
.filter(BaseAccountL10n.title.icontains(k)).all()
|
||||
l10n: list[BaseAccountL10n] = db.session.scalars(
|
||||
sa.select(BaseAccountL10n)
|
||||
.where((BaseAccountL10n.title.icontains(k)))).all()
|
||||
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||
conditions.append(sa.or_(BaseAccount.code.contains(k),
|
||||
BaseAccount.title_l10n.icontains(k),
|
||||
BaseAccount.code.in_(l10n_matches)))
|
||||
return BaseAccount.query.filter(*conditions)\
|
||||
.order_by(BaseAccount.code).all()
|
||||
return db.session.scalars(
|
||||
sa.select(BaseAccount).where(*conditions)
|
||||
.order_by(BaseAccount.code)).unique().all()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -19,10 +19,10 @@
|
||||
"""
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
from accounting.models import BaseAccount
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import has_permission, can_view
|
||||
from .queries import get_base_account_query
|
||||
from ..models import BaseAccount
|
||||
from ..utils.pagination import Pagination
|
||||
from ..utils.permission import has_permission, can_view
|
||||
|
||||
bp: Blueprint = Blueprint("base-account", __name__)
|
||||
"""The view blueprint for the base account management."""
|
||||
@@ -50,4 +50,3 @@ def show_account_detail(account: BaseAccount) -> str:
|
||||
:return: The detail.
|
||||
"""
|
||||
return render_template("accounting/base-account/detail.html", obj=account)
|
||||
|
||||
|
||||
+22
-13
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,16 +20,16 @@
|
||||
import os
|
||||
|
||||
import click
|
||||
import sqlalchemy as sa
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from accounting import db
|
||||
from accounting.account import init_accounts_command
|
||||
from accounting.base_account import init_base_accounts_command
|
||||
from accounting.currency import init_currencies_command
|
||||
from accounting.models import BaseAccount, Account
|
||||
from accounting.utils.title_case import title_case
|
||||
from accounting.utils.user import has_user, get_user_pk
|
||||
import sqlalchemy as sa
|
||||
from . import db
|
||||
from .account import init_accounts_command
|
||||
from .base_account import init_base_accounts_command
|
||||
from .currency import init_currencies_command
|
||||
from .models import BaseAccount, Account
|
||||
from .utils.title_case import title_case
|
||||
from .utils.user import base_cls, has_user, get_user_pk
|
||||
|
||||
|
||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||
@@ -54,13 +54,22 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||
help="The username.", callback=__validate_username,
|
||||
default=lambda: os.getlogin())
|
||||
@click.option("--skip-accounts", is_flag=True, default=False,
|
||||
help="Skip initializing accounts.")
|
||||
@click.option("--skip-currencies", is_flag=True, default=False,
|
||||
help="Skip initializing currencies.")
|
||||
@with_appcontext
|
||||
def init_db_command(username: str) -> None:
|
||||
def init_db_command(username: str, skip_accounts: bool,
|
||||
skip_currencies: bool) -> None:
|
||||
"""Initializes the accounting database."""
|
||||
db.create_all()
|
||||
base_cls.metadata.create_all(db.engine)
|
||||
init_base_accounts_command()
|
||||
if not skip_accounts:
|
||||
init_accounts_command(username)
|
||||
print("OK 1")
|
||||
if not skip_currencies:
|
||||
init_currencies_command(username)
|
||||
print("OK 2")
|
||||
db.session.commit()
|
||||
click.echo("Accounting database initialized.")
|
||||
|
||||
@@ -74,12 +83,12 @@ def titleize_command(username: str) -> None:
|
||||
"""Capitalize the account titles."""
|
||||
updater_pk: int = get_user_pk(username)
|
||||
updated: int = 0
|
||||
for base in BaseAccount.query:
|
||||
for base in db.session.scalars(sa.select(BaseAccount)).unique():
|
||||
new_title: str = title_case(base.title_l10n)
|
||||
if base.title_l10n != new_title:
|
||||
base.title_l10n = new_title
|
||||
updated = updated + 1
|
||||
for account in Account.query:
|
||||
for account in db.session.scalars(sa.select(Account)).unique():
|
||||
if account.title_l10n.lower() == account.base.title_l10n.lower():
|
||||
new_title: str = title_case(account.title_l10n)
|
||||
if account.title_l10n != new_title:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,14 +22,15 @@ from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db, data_dir
|
||||
from accounting.models import Currency, CurrencyL10n
|
||||
from accounting.utils.user import get_user_pk
|
||||
from .. import db, data_dir
|
||||
from ..models import Currency, CurrencyL10n
|
||||
from ..utils.user import get_user_pk
|
||||
|
||||
|
||||
def init_currencies_command(username: str) -> None:
|
||||
"""Initializes the currencies."""
|
||||
existing_codes: set[str] = {x.code for x in Currency.query.all()}
|
||||
existing_codes: set[str] = \
|
||||
{x.code for x in db.session.scalars(sa.select(Currency)).unique()}
|
||||
|
||||
with open(data_dir / "currencies.csv") as fp:
|
||||
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,8 +20,8 @@
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Currency
|
||||
from .. import db
|
||||
from ..models import Currency
|
||||
|
||||
|
||||
class CurrencyConverter(BaseConverter):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -21,11 +21,11 @@ from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, ValidationError
|
||||
from wtforms.validators import DataRequired, Regexp, NoneOf
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .. import db
|
||||
from ..locale import lazy_gettext
|
||||
from ..models import Currency
|
||||
from ..utils.strip_text import strip_text
|
||||
from ..utils.user import get_current_user_pk
|
||||
|
||||
|
||||
class CodeUnique:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,8 +20,9 @@
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
|
||||
from accounting.models import Currency, CurrencyL10n
|
||||
from accounting.utils.query import parse_query_keywords
|
||||
from .. import db
|
||||
from ..models import Currency, CurrencyL10n
|
||||
from ..utils.query import parse_query_keywords
|
||||
|
||||
|
||||
def get_currency_query() -> list[Currency]:
|
||||
@@ -31,14 +32,17 @@ def get_currency_query() -> list[Currency]:
|
||||
"""
|
||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||
if len(keywords) == 0:
|
||||
return Currency.query.order_by(Currency.code).all()
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
return db.session.scalars(
|
||||
sa.select(Currency).order_by(Currency.code)).unique().all()
|
||||
conditions: list[sa.ColumnElement[bool]] = []
|
||||
for k in keywords:
|
||||
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
||||
.filter(CurrencyL10n.name.icontains(k)).all()
|
||||
l10n: list[CurrencyL10n] = db.session.scalars(
|
||||
sa.select(CurrencyL10n)
|
||||
.where(CurrencyL10n.name.icontains(k))).all()
|
||||
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||
conditions.append(sa.or_(Currency.code.icontains(k),
|
||||
Currency.name_l10n.icontains(k),
|
||||
Currency.code.in_(l10n_matches)))
|
||||
return Currency.query.filter(*conditions)\
|
||||
.order_by(Currency.code).all()
|
||||
return db.session.scalars(
|
||||
sa.select(Currency).where(*conditions)
|
||||
.order_by(Currency.code)).unique().all()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -21,20 +21,20 @@ from urllib.parse import urlencode, parse_qsl
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import Blueprint, render_template, redirect, session, request, \
|
||||
flash, url_for
|
||||
flash, url_for, Response
|
||||
from werkzeug.datastructures import ImmutableMultiDict
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency
|
||||
from accounting.utils.cast import s
|
||||
from accounting.utils.flash_errors import flash_form_errors
|
||||
from accounting.utils.next_uri import inherit_next, or_next
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .forms import CurrencyForm
|
||||
from .queries import get_currency_query
|
||||
from .. import db
|
||||
from ..locale import lazy_gettext
|
||||
from ..models import Currency
|
||||
from ..utils.cast import s
|
||||
from ..utils.flash_errors import flash_form_errors
|
||||
from ..utils.next_uri import inherit_next, or_next
|
||||
from ..utils.pagination import Pagination
|
||||
from ..utils.permission import has_permission, can_view, can_edit
|
||||
from ..utils.user import get_current_user_pk
|
||||
|
||||
bp: Blueprint = Blueprint("currency", __name__)
|
||||
"""The view blueprint for the currency management."""
|
||||
@@ -74,7 +74,7 @@ def show_add_currency_form() -> str:
|
||||
|
||||
@bp.post("store", endpoint="store")
|
||||
@has_permission(can_edit)
|
||||
def add_currency() -> redirect:
|
||||
def add_currency() -> Response:
|
||||
"""Adds a currency.
|
||||
|
||||
:return: The redirection to the currency detail on success, or the currency
|
||||
@@ -125,7 +125,7 @@ def show_currency_edit_form(currency: Currency) -> str:
|
||||
|
||||
@bp.post("<currency:currency>/update", endpoint="update")
|
||||
@has_permission(can_edit)
|
||||
def update_currency(currency: Currency) -> redirect:
|
||||
def update_currency(currency: Currency) -> Response:
|
||||
"""Updates a currency.
|
||||
|
||||
:param currency: The currency.
|
||||
@@ -153,7 +153,7 @@ def update_currency(currency: Currency) -> redirect:
|
||||
|
||||
@bp.post("<currency:currency>/delete", endpoint="delete")
|
||||
@has_permission(can_edit)
|
||||
def delete_currency(currency: Currency) -> redirect:
|
||||
def delete_currency(currency: Currency) -> Response:
|
||||
"""Deletes a currency.
|
||||
|
||||
:param currency: The currency.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -24,10 +24,9 @@ from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, ValidationError
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency, Account
|
||||
|
||||
from . import db
|
||||
from .locale import lazy_gettext
|
||||
from .models import Currency, Account
|
||||
|
||||
ACCOUNT_REQUIRED: DataRequired = DataRequired(
|
||||
lazy_gettext("Please select the account."))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,9 +22,9 @@ import datetime as dt
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import JournalEntry
|
||||
from accounting.utils.journal_entry_types import JournalEntryType
|
||||
from .. import db
|
||||
from ..models import JournalEntry
|
||||
from ..utils.journal_entry_types import JournalEntryType
|
||||
|
||||
|
||||
class JournalEntryConverter(BaseConverter):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -26,13 +26,13 @@ from wtforms import StringField, ValidationError, FieldList, IntegerField, \
|
||||
BooleanField, FormField
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
from accounting import db
|
||||
from accounting.forms import CurrencyExists
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import JournalEntryLineItem
|
||||
from accounting.utils.offset_alias import offset_alias
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
|
||||
from ... import db
|
||||
from ...forms import CurrencyExists
|
||||
from ...locale import lazy_gettext
|
||||
from ...models import JournalEntryLineItem
|
||||
from ...utils.offset_alias import offset_alias
|
||||
from ...utils.strip_text import strip_text
|
||||
|
||||
CURRENCY_REQUIRED: DataRequired = DataRequired(
|
||||
lazy_gettext("Please select the currency."))
|
||||
@@ -55,7 +55,7 @@ class SameCurrencyAsOriginalLineItems:
|
||||
return
|
||||
original_line_item_currency_codes: set[str] = set(db.session.scalars(
|
||||
sa.select(JournalEntryLineItem.currency_code)
|
||||
.filter(JournalEntryLineItem.id.in_(original_line_item_id))).all())
|
||||
.where(JournalEntryLineItem.id.in_(original_line_item_id))).all())
|
||||
for currency_code in original_line_item_currency_codes:
|
||||
if field.data != currency_code:
|
||||
raise ValidationError(lazy_gettext(
|
||||
@@ -72,17 +72,17 @@ class KeepCurrencyWhenHavingOffset:
|
||||
if field.data is None:
|
||||
return
|
||||
offset: sa.Alias = offset_alias()
|
||||
original_line_items: list[JournalEntryLineItem]\
|
||||
= JournalEntryLineItem.query\
|
||||
original_line_items: list[JournalEntryLineItem] = db.session.scalars(
|
||||
sa.select(JournalEntryLineItem)
|
||||
.join(offset,
|
||||
JournalEntryLineItem.id == offset.c.original_line_item_id,
|
||||
isouter=True)\
|
||||
.filter(JournalEntryLineItem.id
|
||||
isouter=True)
|
||||
.where(JournalEntryLineItem.id
|
||||
.in_({x.id.data for x in form.line_items
|
||||
if x.id.data is not None}))\
|
||||
if x.id.data is not None}))
|
||||
.group_by(JournalEntryLineItem.id,
|
||||
JournalEntryLineItem.currency_code)\
|
||||
.having(sa.func.count(offset.c.id) > 0).all()
|
||||
JournalEntryLineItem.currency_code)
|
||||
.having(sa.func.count(offset.c.id) > 0)).unique().all()
|
||||
for original_line_item in original_line_items:
|
||||
if original_line_item.currency_code != field.data:
|
||||
raise ValidationError(lazy_gettext(
|
||||
@@ -152,7 +152,7 @@ class CurrencyForm(FlaskForm):
|
||||
line_item_id: set[int] = {x.id.data for x in line_item_forms
|
||||
if x.id.data is not None}
|
||||
select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
|
||||
.filter(JournalEntryLineItem.original_line_item_id
|
||||
.where(JournalEntryLineItem.original_line_item_id
|
||||
.in_(line_item_id))
|
||||
return db.session.scalar(select) > 0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -19,7 +19,6 @@
|
||||
"""
|
||||
import datetime as dt
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TypeVar, Generic, Type
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask_babel import LazyString
|
||||
@@ -28,21 +27,20 @@ from wtforms import DateField, FieldList, FormField, TextAreaField, \
|
||||
BooleanField
|
||||
from wtforms.validators import DataRequired, ValidationError
|
||||
|
||||
from accounting import db
|
||||
from accounting.journal_entry.utils.account_option import AccountOption
|
||||
from accounting.journal_entry.utils.description_editor import DescriptionEditor
|
||||
from accounting.journal_entry.utils.original_line_items import \
|
||||
get_selectable_original_line_items
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
|
||||
JournalEntryCurrency
|
||||
from accounting.utils.random_id import new_id
|
||||
from accounting.utils.strip_text import strip_multiline_text
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .currency import CurrencyForm, CashReceiptCurrencyForm, \
|
||||
CashDisbursementCurrencyForm, TransferCurrencyForm
|
||||
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
|
||||
from .reorder import sort_journal_entries_in
|
||||
from ..utils.account_option import AccountOption
|
||||
from ..utils.description_editor import DescriptionEditor
|
||||
from ..utils.original_line_items import get_selectable_original_line_items
|
||||
from ... import db
|
||||
from ...locale import lazy_gettext
|
||||
from ...models import JournalEntry, Account, JournalEntryLineItem, \
|
||||
JournalEntryCurrency
|
||||
from ...utils.random_id import new_id
|
||||
from ...utils.strip_text import strip_multiline_text
|
||||
from ...utils.user import get_current_user_pk
|
||||
|
||||
DATE_REQUIRED: DataRequired = DataRequired(
|
||||
lazy_gettext("Please fill in the date."))
|
||||
@@ -123,7 +121,7 @@ class JournalEntryForm(FlaskForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.is_modified: bool = False
|
||||
"""Whether the journal entry is modified during populate_obj()."""
|
||||
self.collector: Type[LineItemCollector] = LineItemCollector
|
||||
self.collector: type[LineItemCollector] = LineItemCollector
|
||||
"""The line item collector. The default is the base abstract
|
||||
collector only to provide the correct type. The subclass forms should
|
||||
provide their own collectors."""
|
||||
@@ -154,15 +152,16 @@ class JournalEntryForm(FlaskForm):
|
||||
self.__set_date(obj, self.date.data)
|
||||
obj.note = self.note.data
|
||||
|
||||
collector_cls: Type[LineItemCollector] = self.collector
|
||||
collector_cls: type[LineItemCollector] = self.collector
|
||||
collector: collector_cls = collector_cls(self, obj)
|
||||
collector.collect()
|
||||
|
||||
to_delete: set[int] = {x.id for x in obj.line_items
|
||||
if x.id not in collector.to_keep}
|
||||
if len(to_delete) > 0:
|
||||
JournalEntryLineItem.query\
|
||||
.filter(JournalEntryLineItem.id.in_(to_delete)).delete()
|
||||
db.session.execute(
|
||||
sa.delete(JournalEntryLineItem)
|
||||
.where(JournalEntryLineItem.id.in_(to_delete)))
|
||||
self.is_modified = True
|
||||
|
||||
if is_new or db.session.is_modified(obj):
|
||||
@@ -197,7 +196,7 @@ class JournalEntryForm(FlaskForm):
|
||||
if self.max_date is not None and new_date == self.max_date:
|
||||
db_min_no: int | None = db.session.scalar(
|
||||
sa.select(sa.func.min(JournalEntry.no))
|
||||
.filter(JournalEntry.date == new_date))
|
||||
.where(JournalEntry.date == new_date))
|
||||
if db_min_no is None:
|
||||
obj.date = new_date
|
||||
obj.no = 1
|
||||
@@ -207,8 +206,9 @@ class JournalEntryForm(FlaskForm):
|
||||
sort_journal_entries_in(new_date)
|
||||
else:
|
||||
sort_journal_entries_in(new_date, obj.id)
|
||||
count: int = JournalEntry.query\
|
||||
.filter(JournalEntry.date == new_date).count()
|
||||
count: int = db.session.scalar(
|
||||
sa.select(sa.func.count(JournalEntry.id))
|
||||
.where(JournalEntry.date == new_date))
|
||||
obj.date = new_date
|
||||
obj.no = count + 1
|
||||
|
||||
@@ -223,7 +223,7 @@ class JournalEntryForm(FlaskForm):
|
||||
if not (x.code[0] == "2" and x.is_need_offset)]
|
||||
in_use: set[int] = set(db.session.scalars(
|
||||
sa.select(JournalEntryLineItem.account_id)
|
||||
.filter(JournalEntryLineItem.is_debit)
|
||||
.where(JournalEntryLineItem.is_debit)
|
||||
.group_by(JournalEntryLineItem.account_id)).all())
|
||||
for account in accounts:
|
||||
account.is_in_use = account.id in in_use
|
||||
@@ -240,7 +240,7 @@ class JournalEntryForm(FlaskForm):
|
||||
if not (x.code[0] == "1" and x.is_need_offset)]
|
||||
in_use: set[int] = set(db.session.scalars(
|
||||
sa.select(JournalEntryLineItem.account_id)
|
||||
.filter(sa.not_(JournalEntryLineItem.is_debit))
|
||||
.where(sa.not_(JournalEntryLineItem.is_debit))
|
||||
.group_by(JournalEntryLineItem.account_id)).all())
|
||||
for account in accounts:
|
||||
account.is_in_use = account.id in in_use
|
||||
@@ -290,7 +290,7 @@ class JournalEntryForm(FlaskForm):
|
||||
return None
|
||||
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
|
||||
.join(JournalEntryLineItem)\
|
||||
.filter(JournalEntryLineItem.id.in_(original_line_item_id))
|
||||
.where(JournalEntryLineItem.id.in_(original_line_item_id))
|
||||
return db.session.scalar(select)
|
||||
|
||||
@property
|
||||
@@ -303,16 +303,12 @@ class JournalEntryForm(FlaskForm):
|
||||
if x.id.data is not None}
|
||||
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
|
||||
.join(JournalEntryLineItem)\
|
||||
.filter(JournalEntryLineItem.original_line_item_id
|
||||
.where(JournalEntryLineItem.original_line_item_id
|
||||
.in_(line_item_id))
|
||||
return db.session.scalar(select)
|
||||
|
||||
|
||||
T = TypeVar("T", bound=JournalEntryForm)
|
||||
"""A journal entry form variant."""
|
||||
|
||||
|
||||
class LineItemCollector(Generic[T], ABC):
|
||||
class LineItemCollector[T: JournalEntryForm](ABC):
|
||||
"""The line item collector."""
|
||||
|
||||
def __init__(self, form: T, obj: JournalEntry):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -27,15 +27,15 @@ from sqlalchemy.orm import selectinload
|
||||
from wtforms import StringField, ValidationError, DecimalField, IntegerField
|
||||
from wtforms.validators import Optional
|
||||
|
||||
from accounting import db
|
||||
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
|
||||
from ... import db
|
||||
from ...forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
|
||||
IsCreditAccount
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Account, JournalEntry, JournalEntryLineItem
|
||||
from accounting.template_filters import format_amount
|
||||
from accounting.utils.random_id import new_id
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from ...locale import lazy_gettext
|
||||
from ...models import Account, JournalEntry, JournalEntryLineItem
|
||||
from ...template_filters import format_amount
|
||||
from ...utils.random_id import new_id
|
||||
from ...utils.strip_text import strip_text
|
||||
from ...utils.user import get_current_user_pk
|
||||
|
||||
|
||||
class OriginalLineItemExists:
|
||||
@@ -202,7 +202,7 @@ class NotExceedingOriginalLineItemNetBalance:
|
||||
else_=-JournalEntryLineItem.amount))
|
||||
offset_total_but_form: Decimal | None = db.session.scalar(
|
||||
sa.select(offset_total_func)
|
||||
.filter(JournalEntryLineItem.original_line_item_id
|
||||
.where(JournalEntryLineItem.original_line_item_id
|
||||
== original_line_item.id,
|
||||
JournalEntryLineItem.id.not_in(existing_line_item_id)))
|
||||
if offset_total_but_form is None:
|
||||
@@ -231,7 +231,7 @@ class NotLessThanOffsetTotal:
|
||||
(JournalEntryLineItem.is_debit != is_debit,
|
||||
JournalEntryLineItem.amount),
|
||||
else_=-JournalEntryLineItem.amount)))\
|
||||
.filter(JournalEntryLineItem.original_line_item_id == form.id.data)
|
||||
.where(JournalEntryLineItem.original_line_item_id == form.id.data)
|
||||
offset_total: Decimal | None = db.session.scalar(select_offset_total)
|
||||
if offset_total is not None and field.data < offset_total:
|
||||
raise ValidationError(lazy_gettext(
|
||||
@@ -353,13 +353,14 @@ class LineItemForm(FlaskForm):
|
||||
def get_offsets() -> list[JournalEntryLineItem]:
|
||||
if not self.is_need_offset or self.id.data is None:
|
||||
return []
|
||||
return JournalEntryLineItem.query.join(JournalEntry)\
|
||||
.filter(JournalEntryLineItem.original_line_item_id
|
||||
== self.id.data)\
|
||||
return db.session.scalars(
|
||||
sa.select(JournalEntryLineItem).join(JournalEntry)
|
||||
.where(JournalEntryLineItem.original_line_item_id
|
||||
== self.id.data)
|
||||
.order_by(JournalEntry.date, JournalEntry.no,
|
||||
JournalEntryLineItem.no)\
|
||||
JournalEntryLineItem.no)
|
||||
.options(selectinload(JournalEntryLineItem.journal_entry),
|
||||
selectinload(JournalEntryLineItem.account)).all()
|
||||
selectinload(JournalEntryLineItem.account))).all()
|
||||
setattr(self, "__offsets", get_offsets())
|
||||
return getattr(self, "__offsets")
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,8 +22,8 @@ import datetime as dt
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import JournalEntry
|
||||
from ... import db
|
||||
from ...models import JournalEntry
|
||||
|
||||
|
||||
def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
|
||||
@@ -34,12 +34,12 @@ def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
|
||||
:param exclude: The journal entry ID to exclude.
|
||||
:return: None.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] = [JournalEntry.date == date]
|
||||
conditions: list[sa.ColumnElement[bool]] = [JournalEntry.date == date]
|
||||
if exclude is not None:
|
||||
conditions.append(JournalEntry.id != exclude)
|
||||
journal_entries: list[JournalEntry] = JournalEntry.query\
|
||||
.filter(*conditions)\
|
||||
.order_by(JournalEntry.no).all()
|
||||
journal_entries: list[JournalEntry] = db.session.scalars(
|
||||
sa.select(JournalEntry).where(*conditions)
|
||||
.order_by(JournalEntry.no)).all()
|
||||
for i in range(len(journal_entries)):
|
||||
if journal_entries[i].no != i + 1:
|
||||
journal_entries[i].no = i + 1
|
||||
@@ -63,8 +63,9 @@ class JournalEntryReorderForm:
|
||||
|
||||
:return:
|
||||
"""
|
||||
journal_entries: list[JournalEntry] = JournalEntry.query\
|
||||
.filter(JournalEntry.date == self.date).all()
|
||||
journal_entries: list[JournalEntry] = db.session.scalars(
|
||||
sa.select(JournalEntry)
|
||||
.where(JournalEntry.date == self.date)).all()
|
||||
|
||||
# Collects the specified order.
|
||||
orders: dict[JournalEntry, int] = {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -17,7 +17,7 @@
|
||||
"""The account option for the journal entry management.
|
||||
|
||||
"""
|
||||
from accounting.models import Account
|
||||
from ...models import Account
|
||||
|
||||
|
||||
class AccountOption:
|
||||
@@ -28,7 +28,7 @@ class AccountOption:
|
||||
|
||||
:param account: The account.
|
||||
"""
|
||||
self.id: str = account.id
|
||||
self.id: int = account.id
|
||||
"""The account ID."""
|
||||
self.code: str = account.code
|
||||
"""The account code."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,9 +22,9 @@ from typing import Literal
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Account, JournalEntryLineItem
|
||||
from accounting.utils.options import options, Recurring
|
||||
from ... import db
|
||||
from ...models import Account, JournalEntryLineItem
|
||||
from ...utils.options import options, Recurring
|
||||
|
||||
|
||||
class DescriptionAccount:
|
||||
@@ -272,15 +272,17 @@ class DescriptionEditor:
|
||||
select: sa.Select = sa.Select(debit_credit, tag_type, tag,
|
||||
JournalEntryLineItem.account_id,
|
||||
sa.func.count().label("freq"))\
|
||||
.filter(JournalEntryLineItem.description.is_not(None),
|
||||
.where(JournalEntryLineItem.description.is_not(None),
|
||||
JournalEntryLineItem.description.like("_%—_%"),
|
||||
JournalEntryLineItem.original_line_item_id.is_(None))\
|
||||
.group_by(debit_credit, tag_type, tag,
|
||||
JournalEntryLineItem.account_id)
|
||||
result: list[sa.Row] = db.session.execute(select).all()
|
||||
accounts: dict[int, Account] \
|
||||
= {x.id: x for x in Account.query
|
||||
.filter(Account.id.in_({x.account_id for x in result})).all()}
|
||||
= {x.id: x for x in db.session.scalars(
|
||||
sa.select(Account)
|
||||
.where(Account.id.in_({x.account_id for x in result})))
|
||||
.unique()}
|
||||
debit_credit_dict: dict[Literal["debit", "credit"],
|
||||
DescriptionDebitCredit] \
|
||||
= {x.debit_credit: x for x in {self.debit, self.credit}}
|
||||
@@ -315,18 +317,19 @@ class DescriptionEditor:
|
||||
if len(codes) == 0:
|
||||
return {}
|
||||
|
||||
def get_condition(code0: str) -> sa.BinaryExpression:
|
||||
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
|
||||
def get_condition(code0: str) -> sa.ColumnElement[bool]:
|
||||
m: re.Match[str] | None = re.match(r"^(\d{4})-(\d{3})$", code0)
|
||||
assert m is not None, \
|
||||
f"Malformed account code \"{code0}\" for regular transactions."
|
||||
return sa.and_(Account.base_code == m.group(1),
|
||||
Account.no == int(m.group(2)))
|
||||
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
conditions: list[sa.ColumnElement[bool]] \
|
||||
= [get_condition(x) for x in codes]
|
||||
accounts: dict[str, Account] \
|
||||
= {x.code: x for x in
|
||||
Account.query.filter(sa.or_(*conditions)).all()}
|
||||
db.session.scalars(
|
||||
sa.select(Account).where(sa.or_(*conditions))).unique()}
|
||||
for code in codes:
|
||||
assert code in accounts, \
|
||||
f"Unknown account \"{code}\" for regular transactions."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -18,18 +18,16 @@
|
||||
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Type
|
||||
|
||||
from flask import render_template, request, abort
|
||||
from flask_wtf import FlaskForm
|
||||
|
||||
from accounting.journal_entry.forms import JournalEntryForm, \
|
||||
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
|
||||
TransferJournalEntryForm
|
||||
from accounting.journal_entry.forms.line_item import LineItemForm
|
||||
from accounting.models import JournalEntry
|
||||
from accounting.template_globals import default_currency_code
|
||||
from accounting.utils.journal_entry_types import JournalEntryType
|
||||
from ..forms import JournalEntryForm, CashReceiptJournalEntryForm, \
|
||||
CashDisbursementJournalEntryForm, TransferJournalEntryForm
|
||||
from ..forms.line_item import LineItemForm
|
||||
from ...models import JournalEntry
|
||||
from ...template_globals import default_currency_code
|
||||
from ...utils.journal_entry_types import JournalEntryType
|
||||
|
||||
|
||||
class JournalEntryOperator(ABC):
|
||||
@@ -39,7 +37,7 @@ class JournalEntryOperator(ABC):
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def form(self) -> Type[JournalEntryForm]:
|
||||
def form(self) -> type[JournalEntryForm]:
|
||||
"""Returns the form class.
|
||||
|
||||
:return: The form class.
|
||||
@@ -100,7 +98,7 @@ class CashReceiptJournalEntry(JournalEntryOperator):
|
||||
"""The order when checking the journal entry operator."""
|
||||
|
||||
@property
|
||||
def form(self) -> Type[JournalEntryForm]:
|
||||
def form(self) -> type[JournalEntryForm]:
|
||||
"""Returns the form class.
|
||||
|
||||
:return: The form class.
|
||||
@@ -170,7 +168,7 @@ class CashDisbursementJournalEntry(JournalEntryOperator):
|
||||
"""The order when checking the journal entry operator."""
|
||||
|
||||
@property
|
||||
def form(self) -> Type[JournalEntryForm]:
|
||||
def form(self) -> type[JournalEntryForm]:
|
||||
"""Returns the form class.
|
||||
|
||||
:return: The form class.
|
||||
@@ -243,7 +241,7 @@ class TransferJournalEntry(JournalEntryOperator):
|
||||
"""The order when checking the journal entry operator."""
|
||||
|
||||
@property
|
||||
def form(self) -> Type[JournalEntryForm]:
|
||||
def form(self) -> type[JournalEntryForm]:
|
||||
"""Returns the form class.
|
||||
|
||||
:return: The form class.
|
||||
@@ -334,3 +332,4 @@ def get_journal_entry_op(journal_entry: JournalEntry,
|
||||
key=lambda x: x.CHECK_ORDER):
|
||||
if journal_entry_type.is_my_type(journal_entry):
|
||||
return journal_entry_type
|
||||
assert False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,9 +22,9 @@ from decimal import Decimal
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Account, JournalEntry, JournalEntryLineItem
|
||||
from accounting.utils.offset_alias import offset_alias
|
||||
from ... import db
|
||||
from ...models import Account, JournalEntry, JournalEntryLineItem
|
||||
from ...utils.offset_alias import offset_alias
|
||||
|
||||
|
||||
def get_selectable_original_line_items(
|
||||
@@ -46,8 +46,8 @@ def get_selectable_original_line_items(
|
||||
(offset.c.id.in_(line_item_id_on_form), 0),
|
||||
(offset.c.is_debit == JournalEntryLineItem.is_debit, offset.c.amount),
|
||||
else_=-offset.c.amount))).label("net_balance")
|
||||
conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
|
||||
sub_conditions: list[sa.BinaryExpression] = []
|
||||
conditions: list[sa.ColumnElement[bool]] = [Account.is_need_offset]
|
||||
sub_conditions: list[sa.ColumnElement[bool]] = []
|
||||
if is_payable:
|
||||
sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
|
||||
sa.not_(JournalEntryLineItem.is_debit)))
|
||||
@@ -61,20 +61,21 @@ def get_selectable_original_line_items(
|
||||
.join(offset,
|
||||
JournalEntryLineItem.id == offset.c.original_line_item_id,
|
||||
isouter=True)\
|
||||
.filter(*conditions)\
|
||||
.where(*conditions)\
|
||||
.group_by(JournalEntryLineItem.id)\
|
||||
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
|
||||
net_balances: dict[int, Decimal] \
|
||||
= {x.id: x.net_balance
|
||||
for x in db.session.execute(select_net_balances).all()}
|
||||
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\
|
||||
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\
|
||||
.join(JournalEntry)\
|
||||
for x in db.session.execute(select_net_balances)}
|
||||
line_items: list[JournalEntryLineItem] = db.session.scalars(
|
||||
sa.select(JournalEntryLineItem)
|
||||
.where(JournalEntryLineItem.id.in_({x for x in net_balances}))
|
||||
.join(JournalEntry)
|
||||
.order_by(JournalEntry.date, JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\
|
||||
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
|
||||
.options(selectinload(JournalEntryLineItem.currency),
|
||||
selectinload(JournalEntryLineItem.account),
|
||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||
selectinload(JournalEntryLineItem.journal_entry))).all()
|
||||
line_items.reverse()
|
||||
for line_item in line_items:
|
||||
line_item.net_balance = line_item.amount \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,23 +22,24 @@ from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import Blueprint, render_template, session, redirect, request, \
|
||||
flash, url_for
|
||||
flash, url_for, Response
|
||||
from werkzeug.datastructures import ImmutableMultiDict
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import JournalEntry
|
||||
from accounting.utils.cast import s
|
||||
from accounting.utils.flash_errors import flash_form_errors
|
||||
from accounting.utils.journal_entry_types import JournalEntryType
|
||||
from accounting.utils.next_uri import inherit_next, or_next
|
||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .forms import sort_journal_entries_in, JournalEntryReorderForm
|
||||
from .template_filters import with_type, to_transfer, format_amount_input, \
|
||||
text2html
|
||||
from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
|
||||
get_journal_entry_op
|
||||
from .. import db
|
||||
from ..locale import lazy_gettext
|
||||
from ..models import JournalEntry
|
||||
from ..utils.cast import s
|
||||
from ..utils.flash_errors import flash_form_errors
|
||||
from ..utils.journal_entry_types import JournalEntryType
|
||||
from ..utils.next_uri import inherit_next, or_next
|
||||
from ..utils.permission import has_permission, can_view, can_edit
|
||||
from ..utils.timezone import get_tz_today
|
||||
from ..utils.user import get_current_user_pk
|
||||
|
||||
bp: Blueprint = Blueprint("journal-entry", __name__)
|
||||
"""The view blueprint for the journal entry management."""
|
||||
@@ -67,13 +68,13 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
|
||||
form.validate()
|
||||
else:
|
||||
form = journal_entry_op.form()
|
||||
form.date.data = dt.date.today()
|
||||
form.date.data = get_tz_today()
|
||||
return journal_entry_op.render_create_template(form)
|
||||
|
||||
|
||||
@bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store")
|
||||
@has_permission(can_edit)
|
||||
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect:
|
||||
def add_journal_entry(journal_entry_type: JournalEntryType) -> Response:
|
||||
"""Adds a journal entry.
|
||||
|
||||
:param journal_entry_type: The journal entry type.
|
||||
@@ -135,7 +136,7 @@ def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
|
||||
|
||||
@bp.post("<journalEntry:journal_entry>/update", endpoint="update")
|
||||
@has_permission(can_edit)
|
||||
def update_journal_entry(journal_entry: JournalEntry) -> redirect:
|
||||
def update_journal_entry(journal_entry: JournalEntry) -> Response:
|
||||
"""Updates a journal entry.
|
||||
|
||||
:param journal_entry: The journal entry.
|
||||
@@ -168,7 +169,7 @@ def update_journal_entry(journal_entry: JournalEntry) -> redirect:
|
||||
|
||||
@bp.post("<journalEntry:journal_entry>/delete", endpoint="delete")
|
||||
@has_permission(can_edit)
|
||||
def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
|
||||
def delete_journal_entry(journal_entry: JournalEntry) -> Response:
|
||||
"""Deletes a journal entry.
|
||||
|
||||
:param journal_entry: The journal entry.
|
||||
@@ -194,16 +195,16 @@ def show_journal_entry_order(date: dt.date) -> str:
|
||||
:param date: The date.
|
||||
:return: The order of the journal entries in the date.
|
||||
"""
|
||||
journal_entries: list[JournalEntry] = JournalEntry.query \
|
||||
.filter(JournalEntry.date == date) \
|
||||
.order_by(JournalEntry.no).all()
|
||||
journal_entries: list[JournalEntry] = db.session.scalars(
|
||||
sa.select(JournalEntry).where(JournalEntry.date == date)
|
||||
.order_by(JournalEntry.no)).all()
|
||||
return render_template("accounting/journal-entry/order.html",
|
||||
date=date, list=journal_entries)
|
||||
|
||||
|
||||
@bp.post("dates/<date:date>", endpoint="sort")
|
||||
@has_permission(can_edit)
|
||||
def sort_journal_entries(date: dt.date) -> redirect:
|
||||
def sort_journal_entries(date: dt.date) -> Response:
|
||||
"""Reorders the journal entries in a date.
|
||||
|
||||
:param date: The date.
|
||||
|
||||
+112
-100
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,20 +22,19 @@ from __future__ import annotations
|
||||
import datetime as dt
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Type, Self
|
||||
from typing import Self
|
||||
|
||||
import sqlalchemy as sa
|
||||
from babel import Locale
|
||||
from flask_babel import get_locale, get_babel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.utils.user import user_cls, user_pk_column
|
||||
from . import db
|
||||
from .locale import gettext
|
||||
from .utils.user import base_cls, user_cls, user_pk_column
|
||||
|
||||
|
||||
class BaseAccount(db.Model):
|
||||
class BaseAccount(base_cls):
|
||||
"""A base account."""
|
||||
__tablename__ = "accounting_base_accounts"
|
||||
"""The table name."""
|
||||
@@ -44,9 +43,9 @@ class BaseAccount(db.Model):
|
||||
title_l10n: Mapped[str] = mapped_column("title")
|
||||
"""The title."""
|
||||
l10n: Mapped[list[BaseAccountL10n]] \
|
||||
= db.relationship(back_populates="account", lazy=False)
|
||||
= relationship(back_populates="account", lazy=False)
|
||||
"""The localized titles."""
|
||||
accounts: Mapped[list[Account]] = db.relationship(back_populates="base")
|
||||
accounts: Mapped[list[Account]] = relationship(back_populates="base")
|
||||
"""The descendant accounts under the base account."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -79,16 +78,16 @@ class BaseAccount(db.Model):
|
||||
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
||||
|
||||
|
||||
class BaseAccountL10n(db.Model):
|
||||
class BaseAccountL10n(base_cls):
|
||||
"""A localized base account title."""
|
||||
__tablename__ = "accounting_base_accounts_l10n"
|
||||
"""The table name."""
|
||||
account_code: Mapped[str] \
|
||||
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
||||
= mapped_column(sa.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
primary_key=True)
|
||||
"""The account code."""
|
||||
account: Mapped[BaseAccount] = db.relationship(back_populates="l10n")
|
||||
account: Mapped[BaseAccount] = relationship(back_populates="l10n")
|
||||
"""The account."""
|
||||
locale: Mapped[str] = mapped_column(primary_key=True)
|
||||
"""The locale."""
|
||||
@@ -96,47 +95,47 @@ class BaseAccountL10n(db.Model):
|
||||
"""The localized title."""
|
||||
|
||||
|
||||
class Account(db.Model):
|
||||
class Account(base_cls):
|
||||
"""An account."""
|
||||
__tablename__ = "accounting_accounts"
|
||||
"""The table name."""
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
|
||||
"""The account ID."""
|
||||
base_code: Mapped[str] \
|
||||
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
||||
= mapped_column(sa.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
||||
ondelete="CASCADE"))
|
||||
"""The code of the base account."""
|
||||
base: Mapped[BaseAccount] = db.relationship(back_populates="accounts")
|
||||
base: Mapped[BaseAccount] = relationship(back_populates="accounts")
|
||||
"""The base account."""
|
||||
no: Mapped[int] = mapped_column(default=text("1"))
|
||||
no: Mapped[int] = mapped_column(default=sa.text("1"))
|
||||
"""The account number under the base account."""
|
||||
title_l10n: Mapped[str] = mapped_column("title")
|
||||
"""The title."""
|
||||
is_need_offset: Mapped[bool] = mapped_column(default=False)
|
||||
"""Whether the journal entry line items of this account need offset."""
|
||||
created_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
= mapped_column(sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now())
|
||||
"""The date and time when this record was created."""
|
||||
created_by_id: Mapped[int] \
|
||||
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
"""The ID of the user who created the record."""
|
||||
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
|
||||
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
|
||||
"""The user who created the record."""
|
||||
updated_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
= mapped_column(sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now())
|
||||
"""The date and time when this record was last updated."""
|
||||
updated_by_id: Mapped[int] \
|
||||
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
"""The ID of the last user who updated the record."""
|
||||
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
|
||||
updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
|
||||
"""The last user who updated the record."""
|
||||
l10n: Mapped[list[AccountL10n]] \
|
||||
= db.relationship(back_populates="account", lazy=False)
|
||||
= relationship(back_populates="account", lazy=False)
|
||||
"""The localized titles."""
|
||||
line_items: Mapped[list[JournalEntryLineItem]] \
|
||||
= db.relationship(back_populates="account")
|
||||
= relationship(back_populates="account")
|
||||
"""The journal entry line items."""
|
||||
|
||||
CASH_CODE: str = "1111-001"
|
||||
@@ -268,9 +267,10 @@ class Account(db.Model):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
AccountL10n.query.filter(AccountL10n.account == self).delete()
|
||||
cls: Type[Self] = self.__class__
|
||||
cls.query.filter(cls.id == self.id).delete()
|
||||
db.session.execute(sa.delete(AccountL10n)
|
||||
.where(AccountL10n.account == self))
|
||||
cls: type[Self] = self.__class__
|
||||
db.session.execute(sa.delete(cls).where(cls.id == self.id))
|
||||
|
||||
@classmethod
|
||||
def find_by_code(cls, code: str) -> Self | None:
|
||||
@@ -279,11 +279,12 @@ class Account(db.Model):
|
||||
:param code: The code.
|
||||
:return: The account, or None if this account does not exist.
|
||||
"""
|
||||
m = re.match(r"^([1-9]{4})-(\d{3})$", code)
|
||||
m: re.Match[str] | None = re.match(r"^([1-9]{4})-(\d{3})$", code)
|
||||
if m is None:
|
||||
return None
|
||||
return cls.query.filter(cls.base_code == m.group(1),
|
||||
cls.no == int(m.group(2))).first()
|
||||
return db.session.scalar(
|
||||
sa.select(cls).where(cls.base_code == m.group(1),
|
||||
cls.no == int(m.group(2))))
|
||||
|
||||
@classmethod
|
||||
def selectable_debit(cls) -> list[Self]:
|
||||
@@ -292,7 +293,9 @@ class Account(db.Model):
|
||||
|
||||
:return: The selectable debit accounts.
|
||||
"""
|
||||
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
||||
return db.session.scalars(
|
||||
sa.select(cls)
|
||||
.where(sa.or_(cls.base_code.startswith("1"),
|
||||
sa.and_(cls.base_code.startswith("2"),
|
||||
sa.not_(cls.is_need_offset)),
|
||||
cls.base_code.startswith("3"),
|
||||
@@ -304,8 +307,8 @@ class Account(db.Model):
|
||||
cls.base_code.startswith("78"),
|
||||
cls.base_code.startswith("8"),
|
||||
cls.base_code.startswith("9")),
|
||||
cls.base_code != "3353")\
|
||||
.order_by(cls.base_code, cls.no).all()
|
||||
cls.base_code != "3353")
|
||||
.order_by(cls.base_code, cls.no)).unique().all()
|
||||
|
||||
@classmethod
|
||||
def selectable_credit(cls) -> list[Self]:
|
||||
@@ -314,7 +317,9 @@ class Account(db.Model):
|
||||
|
||||
:return: The selectable debit accounts.
|
||||
"""
|
||||
return cls.query.filter(sa.or_(sa.and_(cls.base_code.startswith("1"),
|
||||
return db.session.scalars(
|
||||
sa.select(cls)
|
||||
.where(sa.or_(sa.and_(cls.base_code.startswith("1"),
|
||||
sa.not_(cls.is_need_offset)),
|
||||
cls.base_code.startswith("2"),
|
||||
cls.base_code.startswith("3"),
|
||||
@@ -325,8 +330,8 @@ class Account(db.Model):
|
||||
cls.base_code.startswith("74"),
|
||||
cls.base_code.startswith("8"),
|
||||
cls.base_code.startswith("9")),
|
||||
cls.base_code != "3353")\
|
||||
.order_by(cls.base_code, cls.no).all()
|
||||
cls.base_code != "3353")
|
||||
.order_by(cls.base_code, cls.no)).unique().all()
|
||||
|
||||
@classmethod
|
||||
def cash(cls) -> Self:
|
||||
@@ -334,7 +339,9 @@ class Account(db.Model):
|
||||
|
||||
:return: The cash account
|
||||
"""
|
||||
return cls.find_by_code(cls.CASH_CODE)
|
||||
account: Self | None = cls.find_by_code(cls.CASH_CODE)
|
||||
assert account is not None
|
||||
return account
|
||||
|
||||
@classmethod
|
||||
def accumulated_change(cls) -> Self:
|
||||
@@ -342,19 +349,21 @@ class Account(db.Model):
|
||||
|
||||
:return: The accumulated-change account
|
||||
"""
|
||||
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
|
||||
account: Self | None = cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
|
||||
assert account is not None
|
||||
return account
|
||||
|
||||
|
||||
class AccountL10n(db.Model):
|
||||
class AccountL10n(base_cls):
|
||||
"""A localized account title."""
|
||||
__tablename__ = "accounting_accounts_l10n"
|
||||
"""The table name."""
|
||||
account_id: Mapped[int] \
|
||||
= mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE",
|
||||
= mapped_column(sa.ForeignKey(Account.id, onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
primary_key=True)
|
||||
"""The account ID."""
|
||||
account: Mapped[Account] = db.relationship(back_populates="l10n")
|
||||
account: Mapped[Account] = relationship(back_populates="l10n")
|
||||
"""The account."""
|
||||
locale: Mapped[str] = mapped_column(primary_key=True)
|
||||
"""The locale."""
|
||||
@@ -362,7 +371,7 @@ class AccountL10n(db.Model):
|
||||
"""The localized title."""
|
||||
|
||||
|
||||
class Currency(db.Model):
|
||||
class Currency(base_cls):
|
||||
"""A currency."""
|
||||
__tablename__ = "accounting_currencies"
|
||||
"""The table name."""
|
||||
@@ -371,29 +380,29 @@ class Currency(db.Model):
|
||||
name_l10n: Mapped[str] = mapped_column("name")
|
||||
"""The currency name."""
|
||||
created_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
= mapped_column(sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now())
|
||||
"""The date and time when this record was created."""
|
||||
created_by_id: Mapped[int] \
|
||||
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
"""The ID of the user who created the record."""
|
||||
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
|
||||
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
|
||||
"""The user who created the record."""
|
||||
updated_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
= mapped_column(sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now())
|
||||
"""The date and time when this record was last updated."""
|
||||
updated_by_id: Mapped[int] \
|
||||
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
"""The ID of the last user who updated the record."""
|
||||
updated_by: Mapped[user_cls] \
|
||||
= db.relationship(foreign_keys=updated_by_id)
|
||||
= relationship(foreign_keys=updated_by_id)
|
||||
"""The last user who updated the record."""
|
||||
l10n: Mapped[list[CurrencyL10n]] \
|
||||
= db.relationship(back_populates="currency", lazy=False)
|
||||
= relationship(back_populates="currency", lazy=False)
|
||||
"""The localized names."""
|
||||
line_items: Mapped[list[JournalEntryLineItem]] \
|
||||
= db.relationship(back_populates="currency")
|
||||
= relationship(back_populates="currency")
|
||||
"""The journal entry line items."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -458,7 +467,7 @@ class Currency(db.Model):
|
||||
|
||||
:return: True if the currency can be deleted, or False otherwise.
|
||||
"""
|
||||
from accounting.template_globals import default_currency_code
|
||||
from .template_globals import default_currency_code
|
||||
if self.code == default_currency_code():
|
||||
return False
|
||||
return len(self.line_items) == 0
|
||||
@@ -468,21 +477,22 @@ class Currency(db.Model):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
|
||||
cls: Type[Self] = self.__class__
|
||||
cls.query.filter(cls.code == self.code).delete()
|
||||
db.session.execute(
|
||||
sa.delete(CurrencyL10n)
|
||||
.where(CurrencyL10n.currency_code == self.code))
|
||||
db.session.delete(self)
|
||||
|
||||
|
||||
class CurrencyL10n(db.Model):
|
||||
class CurrencyL10n(base_cls):
|
||||
"""A localized currency name."""
|
||||
__tablename__ = "accounting_currencies_l10n"
|
||||
"""The table name."""
|
||||
currency_code: Mapped[str] \
|
||||
= mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE",
|
||||
= mapped_column(sa.ForeignKey(Currency.code, onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
primary_key=True)
|
||||
"""The currency code."""
|
||||
currency: Mapped[Currency] = db.relationship(back_populates="l10n")
|
||||
currency: Mapped[Currency] = relationship(back_populates="l10n")
|
||||
"""The currency."""
|
||||
locale: Mapped[str] = mapped_column(primary_key=True)
|
||||
"""The locale."""
|
||||
@@ -533,7 +543,7 @@ class JournalEntryCurrency:
|
||||
return sum([x.amount for x in self.credit])
|
||||
|
||||
|
||||
class JournalEntry(db.Model):
|
||||
class JournalEntry(base_cls):
|
||||
"""A journal entry."""
|
||||
__tablename__ = "accounting_journal_entries"
|
||||
"""The table name."""
|
||||
@@ -541,30 +551,30 @@ class JournalEntry(db.Model):
|
||||
"""The journal entry ID."""
|
||||
date: Mapped[dt.date]
|
||||
"""The date."""
|
||||
no: Mapped[int] = mapped_column(default=text("1"))
|
||||
no: Mapped[int] = mapped_column(default=sa.text("1"))
|
||||
"""The journal entry number under the date."""
|
||||
note: Mapped[str | None]
|
||||
"""The note."""
|
||||
created_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
= mapped_column(sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now())
|
||||
"""The date and time when this record was created."""
|
||||
created_by_id: Mapped[int] \
|
||||
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
"""The ID of the user who created the record."""
|
||||
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
|
||||
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
|
||||
"""The user who created the record."""
|
||||
updated_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
= mapped_column(sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now())
|
||||
"""The date and time when this record was last updated."""
|
||||
updated_by_id: Mapped[int] \
|
||||
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
"""The ID of the last user who updated the record."""
|
||||
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
|
||||
updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
|
||||
"""The last user who updated the record."""
|
||||
line_items: Mapped[list[JournalEntryLineItem]] \
|
||||
= db.relationship(back_populates="journal_entry")
|
||||
= relationship(back_populates="journal_entry")
|
||||
"""The line items."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -645,48 +655,49 @@ class JournalEntry(db.Model):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
JournalEntryLineItem.query\
|
||||
.filter(JournalEntryLineItem.journal_entry_id == self.id).delete()
|
||||
db.session.execute(
|
||||
sa.delete(JournalEntryLineItem)
|
||||
.where(JournalEntryLineItem.journal_entry_id == self.id))
|
||||
db.session.delete(self)
|
||||
|
||||
|
||||
class JournalEntryLineItem(db.Model):
|
||||
class JournalEntryLineItem(base_cls):
|
||||
"""A line item in the journal entry."""
|
||||
__tablename__ = "accounting_journal_entry_line_items"
|
||||
"""The table name."""
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
|
||||
"""The line item ID."""
|
||||
journal_entry_id: Mapped[int] \
|
||||
= mapped_column(db.ForeignKey(JournalEntry.id, onupdate="CASCADE",
|
||||
= mapped_column(sa.ForeignKey(JournalEntry.id, onupdate="CASCADE",
|
||||
ondelete="CASCADE"))
|
||||
"""The journal entry ID."""
|
||||
journal_entry: Mapped[JournalEntry] \
|
||||
= db.relationship(back_populates="line_items")
|
||||
= relationship(back_populates="line_items")
|
||||
"""The journal entry."""
|
||||
is_debit: Mapped[bool]
|
||||
"""True for a debit line item, or False for a credit line item."""
|
||||
no: Mapped[int]
|
||||
"""The line item number under the journal entry and debit or credit."""
|
||||
original_line_item_id: Mapped[int | None] \
|
||||
= mapped_column(db.ForeignKey(id, onupdate="CASCADE"))
|
||||
= mapped_column(sa.ForeignKey(id, onupdate="CASCADE"))
|
||||
"""The ID of the original line item."""
|
||||
original_line_item: Mapped[JournalEntryLineItem | None] \
|
||||
= db.relationship(remote_side=id, passive_deletes=True)
|
||||
= relationship(remote_side=id, passive_deletes=True)
|
||||
"""The original line item."""
|
||||
currency_code: Mapped[str] \
|
||||
= mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE"))
|
||||
= mapped_column(sa.ForeignKey(Currency.code, onupdate="CASCADE"))
|
||||
"""The currency code."""
|
||||
currency: Mapped[Currency] = db.relationship(back_populates="line_items")
|
||||
currency: Mapped[Currency] = relationship(back_populates="line_items")
|
||||
"""The currency."""
|
||||
account_id: Mapped[int] \
|
||||
= mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE"))
|
||||
= mapped_column(sa.ForeignKey(Account.id, onupdate="CASCADE"))
|
||||
"""The account ID."""
|
||||
account: Mapped[Account] \
|
||||
= db.relationship(back_populates="line_items", lazy=False)
|
||||
= relationship(back_populates="line_items", lazy=False)
|
||||
"""The account."""
|
||||
description: Mapped[str | None]
|
||||
"""The description."""
|
||||
amount: Mapped[Decimal] = mapped_column(db.Numeric(14, 2))
|
||||
amount: Mapped[Decimal] = mapped_column(sa.Numeric(14, 2))
|
||||
"""The amount."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -695,7 +706,7 @@ class JournalEntryLineItem(db.Model):
|
||||
:return: The string representation of the line item.
|
||||
"""
|
||||
if not hasattr(self, "__str"):
|
||||
from accounting.template_filters import format_date, format_amount
|
||||
from .template_filters import format_date, format_amount
|
||||
setattr(self, "__str",
|
||||
gettext("%(date)s %(description)s %(amount)s",
|
||||
date=format_date(self.journal_entry.date),
|
||||
@@ -811,11 +822,12 @@ class JournalEntryLineItem(db.Model):
|
||||
:return: The offset items.
|
||||
"""
|
||||
if not hasattr(self, "__offsets"):
|
||||
cls: Type[Self] = self.__class__
|
||||
offsets: list[Self] = cls.query.join(JournalEntry)\
|
||||
.filter(JournalEntryLineItem.original_line_item_id == self.id)\
|
||||
cls: type[Self] = self.__class__
|
||||
offsets: list[Self] = db.session.scalars(
|
||||
sa.select(cls).join(JournalEntry)
|
||||
.where(cls.original_line_item_id == self.id)
|
||||
.order_by(JournalEntry.date, JournalEntry.no,
|
||||
cls.is_debit, cls.no).all()
|
||||
cls.is_debit, cls.no)).unique().all()
|
||||
setattr(self, "__offsets", offsets)
|
||||
return getattr(self, "__offsets")
|
||||
|
||||
@@ -876,29 +888,29 @@ class JournalEntryLineItem(db.Model):
|
||||
format_amount(self.amount)]
|
||||
|
||||
|
||||
class Option(db.Model):
|
||||
class Option(base_cls):
|
||||
"""An option."""
|
||||
__tablename__ = "accounting_options"
|
||||
"""The table name."""
|
||||
name: Mapped[str] = mapped_column(primary_key=True)
|
||||
"""The name."""
|
||||
value: Mapped[str] = mapped_column(db.Text)
|
||||
value: Mapped[str] = mapped_column(sa.Text)
|
||||
"""The option value."""
|
||||
created_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
= mapped_column(sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now())
|
||||
"""The date and time when this record was created."""
|
||||
created_by_id: Mapped[int] \
|
||||
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
"""The ID of the user who created the record."""
|
||||
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
|
||||
created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
|
||||
"""The user who created the record."""
|
||||
updated_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
= mapped_column(sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now())
|
||||
"""The date and time when this record was last updated."""
|
||||
updated_by_id: Mapped[int] \
|
||||
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
= mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
|
||||
"""The ID of the last user who updated the record."""
|
||||
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
|
||||
updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
|
||||
"""The last user who updated the record."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -23,13 +23,13 @@ from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, FieldList, FormField, IntegerField
|
||||
from wtforms.validators import DataRequired, ValidationError
|
||||
|
||||
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
|
||||
from ..forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
|
||||
IsDebitAccount, IsCreditAccount
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Account
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.options import Options
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from ..locale import lazy_gettext
|
||||
from ..models import Account
|
||||
from ..utils.current_account import CurrentAccount
|
||||
from ..utils.options import Options
|
||||
from ..utils.strip_text import strip_text
|
||||
|
||||
|
||||
class CurrentAccountExists:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,16 +20,16 @@
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
from flask import Blueprint, render_template, redirect, session, request, \
|
||||
flash, url_for
|
||||
flash, url_for, Response
|
||||
from werkzeug.datastructures import ImmutableMultiDict
|
||||
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.utils.cast import s
|
||||
from accounting.utils.flash_errors import flash_form_errors
|
||||
from accounting.utils.next_uri import inherit_next
|
||||
from accounting.utils.options import options
|
||||
from accounting.utils.permission import has_permission, can_admin
|
||||
from .forms import OptionForm
|
||||
from ..locale import lazy_gettext
|
||||
from ..utils.cast import s
|
||||
from ..utils.flash_errors import flash_form_errors
|
||||
from ..utils.next_uri import inherit_next
|
||||
from ..utils.options import options
|
||||
from ..utils.permission import has_permission, can_admin
|
||||
|
||||
bp: Blueprint = Blueprint("option", __name__)
|
||||
"""The view blueprint for the currency management."""
|
||||
@@ -64,7 +64,7 @@ def show_option_form() -> str:
|
||||
|
||||
@bp.post("update", endpoint="update")
|
||||
@has_permission(can_admin)
|
||||
def update_options() -> redirect:
|
||||
def update_options() -> Response:
|
||||
"""Updates the options.
|
||||
|
||||
:return: The redirection to the option form.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,9 +22,9 @@ import re
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from accounting.models import Account
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from .period import Period, get_period
|
||||
from ..models import Account
|
||||
from ..utils.current_account import CurrentAccount
|
||||
|
||||
|
||||
class PeriodConverter(BaseConverter):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -23,10 +23,14 @@ This file is largely taken from the NanoParma ERP project, first written in
|
||||
import datetime as dt
|
||||
from collections.abc import Callable
|
||||
|
||||
from accounting.models import JournalEntry
|
||||
import sqlalchemy as sa
|
||||
|
||||
from .period import Period
|
||||
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
||||
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
|
||||
from ... import db
|
||||
from ...models import JournalEntry
|
||||
from ...utils.timezone import get_tz_today
|
||||
|
||||
|
||||
class PeriodChooser:
|
||||
@@ -61,8 +65,8 @@ class PeriodChooser:
|
||||
self.url_template: str = get_url(TemplatePeriod())
|
||||
"""The URL template."""
|
||||
|
||||
first: JournalEntry | None \
|
||||
= JournalEntry.query.order_by(JournalEntry.date).first()
|
||||
first: JournalEntry | None = db.session.scalar(
|
||||
sa.select(JournalEntry).order_by(JournalEntry.date))
|
||||
start: dt.date | None = None if first is None else first.date
|
||||
|
||||
# Attributes
|
||||
@@ -80,7 +84,7 @@ class PeriodChooser:
|
||||
"""The available years."""
|
||||
|
||||
if self.has_data:
|
||||
today: dt.date = dt.date.today()
|
||||
today: dt.date = get_tz_today()
|
||||
self.has_last_month = start < dt.date(today.year, today.month, 1)
|
||||
self.has_last_year = start.year < today.year
|
||||
self.has_yesterday = start < today
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -19,7 +19,7 @@
|
||||
"""
|
||||
import datetime as dt
|
||||
|
||||
from accounting.locale import gettext
|
||||
from ...locale import gettext
|
||||
|
||||
|
||||
def get_desc(start: dt.date | None, end: dt.date | None) -> str:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -21,7 +21,6 @@ import calendar
|
||||
import datetime as dt
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Type
|
||||
|
||||
from .period import Period
|
||||
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
|
||||
@@ -40,7 +39,7 @@ def get_period(spec: str | None = None) -> Period:
|
||||
"""
|
||||
if spec is None:
|
||||
return ThisMonth()
|
||||
named_periods: dict[str, Type[Callable[[], Period]]] = {
|
||||
named_periods: dict[str, type[Callable[[], Period]]] = {
|
||||
"this-month": lambda: ThisMonth(),
|
||||
"last-month": lambda: LastMonth(),
|
||||
"since-last-month": lambda: SinceLastMonth(),
|
||||
@@ -68,6 +67,7 @@ def __parse_spec(text: str) -> tuple[dt.date | None, dt.date | None]:
|
||||
"""
|
||||
if text == "-":
|
||||
return None, None
|
||||
m: re.Match[str] | None
|
||||
m = re.match(f"^{DATE_SPEC_RE}$", text)
|
||||
if m is not None:
|
||||
return __get_start(m[1], m[2], m[3]), \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -19,15 +19,16 @@
|
||||
"""
|
||||
import datetime as dt
|
||||
|
||||
from accounting.locale import gettext
|
||||
from .month_end import month_end
|
||||
from .period import Period
|
||||
from ...locale import gettext
|
||||
from ...utils.timezone import get_tz_today
|
||||
|
||||
|
||||
class ThisMonth(Period):
|
||||
"""The period of this month."""
|
||||
def __init__(self):
|
||||
today: dt.date = dt.date.today()
|
||||
today: dt.date = get_tz_today()
|
||||
this_month_start: dt.date = dt.date(today.year, today.month, 1)
|
||||
super().__init__(this_month_start, month_end(today))
|
||||
self.is_default = True
|
||||
@@ -43,7 +44,7 @@ class ThisMonth(Period):
|
||||
class LastMonth(Period):
|
||||
"""The period of this month."""
|
||||
def __init__(self):
|
||||
today: dt.date = dt.date.today()
|
||||
today: dt.date = get_tz_today()
|
||||
year: int = today.year
|
||||
month: int = today.month - 1
|
||||
if month < 1:
|
||||
@@ -63,7 +64,7 @@ class LastMonth(Period):
|
||||
class SinceLastMonth(Period):
|
||||
"""The period of this month."""
|
||||
def __init__(self):
|
||||
today: dt.date = dt.date.today()
|
||||
today: dt.date = get_tz_today()
|
||||
year: int = today.year
|
||||
month: int = today.month - 1
|
||||
if month < 1:
|
||||
@@ -82,7 +83,7 @@ class SinceLastMonth(Period):
|
||||
class ThisYear(Period):
|
||||
"""The period of this year."""
|
||||
def __init__(self):
|
||||
year: int = dt.date.today().year
|
||||
year: int = get_tz_today().year
|
||||
start: dt.date = dt.date(year, 1, 1)
|
||||
end: dt.date = dt.date(year, 12, 31)
|
||||
super().__init__(start, end)
|
||||
@@ -97,7 +98,7 @@ class ThisYear(Period):
|
||||
class LastYear(Period):
|
||||
"""The period of last year."""
|
||||
def __init__(self):
|
||||
year: int = dt.date.today().year
|
||||
year: int = get_tz_today().year
|
||||
start: dt.date = dt.date(year - 1, 1, 1)
|
||||
end: dt.date = dt.date(year - 1, 12, 31)
|
||||
super().__init__(start, end)
|
||||
@@ -112,7 +113,7 @@ class LastYear(Period):
|
||||
class Today(Period):
|
||||
"""The period of today."""
|
||||
def __init__(self):
|
||||
today: dt.date = dt.date.today()
|
||||
today: dt.date = get_tz_today()
|
||||
super().__init__(today, today)
|
||||
self.is_today = True
|
||||
|
||||
@@ -125,7 +126,7 @@ class Today(Period):
|
||||
class Yesterday(Period):
|
||||
"""The period of yesterday."""
|
||||
def __init__(self):
|
||||
yesterday: dt.date = dt.date.today() - dt.timedelta(days=1)
|
||||
yesterday: dt.date = get_tz_today() - dt.timedelta(days=1)
|
||||
super().__init__(yesterday, yesterday)
|
||||
self.is_yesterday = True
|
||||
|
||||
|
||||
@@ -27,12 +27,18 @@ def get_spec(start: dt.date | None, end: dt.date | None) -> str:
|
||||
:param end: The end of the period.
|
||||
:return: The period specification.
|
||||
"""
|
||||
if start is None and end is None:
|
||||
return "-"
|
||||
if end is None:
|
||||
return __get_since_spec(start)
|
||||
if start is None:
|
||||
return __get_until_spec(end)
|
||||
return "-" if end is None else __get_until_spec(end)
|
||||
return __get_since_spec(start) if end is None else __get_spec(start, end)
|
||||
|
||||
|
||||
def __get_spec(start: dt.date, end: dt.date) -> str:
|
||||
"""Returns the period specification with both start and end.
|
||||
|
||||
:param start: The start of the period.
|
||||
:param end: The end of the period.
|
||||
:return: The period specification.
|
||||
"""
|
||||
try:
|
||||
return __get_year_spec(start, end)
|
||||
except ValueError:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,20 +22,18 @@ from decimal import Decimal
|
||||
import sqlalchemy as sa
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
|
||||
from ..period import Period, PeriodChooser
|
||||
from ..utils.base_page_params import BasePageParams
|
||||
from ..utils.base_report import BaseReport
|
||||
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
|
||||
from ..utils.option_link import OptionLink
|
||||
from ..utils.report_chooser import ReportChooser
|
||||
from ..utils.report_type import ReportType
|
||||
from ..utils.urls import ledger_url, balance_sheet_url, income_statement_url
|
||||
from ... import db
|
||||
from ...locale import gettext
|
||||
from ...models import Currency, BaseAccount, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||
period_spec
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.urls import ledger_url, balance_sheet_url, \
|
||||
income_statement_url
|
||||
|
||||
|
||||
class ReportAccount:
|
||||
@@ -121,9 +119,9 @@ class AccountCollector:
|
||||
|
||||
:return: The balances.
|
||||
"""
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
sub_conditions: list[sa.ColumnElement[bool]] \
|
||||
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
conditions: list[sa.ColumnElement[bool]] \
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
sa.or_(*sub_conditions)]
|
||||
if self.__period.end is not None:
|
||||
@@ -135,16 +133,17 @@ class AccountCollector:
|
||||
= sa.select(Account.id, Account.base_code, Account.no,
|
||||
balance_func)\
|
||||
.join(JournalEntry).join(Account)\
|
||||
.filter(*conditions)\
|
||||
.where(*conditions)\
|
||||
.group_by(Account.id, Account.base_code, Account.no)\
|
||||
.having(balance_func != 0)\
|
||||
.order_by(Account.base_code, Account.no)
|
||||
account_balances: list[sa.Row] \
|
||||
= db.session.execute(select_balance).all()
|
||||
self.__all_accounts: list[Account] = Account.query\
|
||||
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
|
||||
self.__all_accounts: list[Account] = db.session.scalars(
|
||||
sa.select(Account)
|
||||
.where(sa.or_(Account.id.in_({x.id for x in account_balances}),
|
||||
Account.base_code == "3351",
|
||||
Account.base_code == "3353")).all()
|
||||
Account.base_code == "3353"))).unique().all()
|
||||
"""The accounts."""
|
||||
account_by_id: dict[int, Account] \
|
||||
= {x.id: x for x in self.__all_accounts}
|
||||
@@ -180,7 +179,7 @@ class AccountCollector:
|
||||
"""
|
||||
if self.__period.start is None:
|
||||
return None
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
conditions: list[sa.ColumnElement[bool]] \
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
JournalEntry.date < self.__period.start]
|
||||
return self.__query_balance(conditions)
|
||||
@@ -199,7 +198,7 @@ class AccountCollector:
|
||||
|
||||
:return: The net income or loss for current period.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
conditions: list[sa.ColumnElement[bool]] \
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(JournalEntry.date >= self.__period.start)
|
||||
@@ -208,7 +207,7 @@ class AccountCollector:
|
||||
return self.__query_balance(conditions)
|
||||
|
||||
@staticmethod
|
||||
def __query_balance(conditions: list[sa.BinaryExpression])\
|
||||
def __query_balance(conditions: list[sa.ColumnElement[bool]])\
|
||||
-> Decimal:
|
||||
"""Queries the balance.
|
||||
|
||||
@@ -221,7 +220,7 @@ class AccountCollector:
|
||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||
else_=-JournalEntryLineItem.amount))
|
||||
select_balance: sa.Select = sa.select(balance_func)\
|
||||
.join(JournalEntry).join(Account).filter(*conditions)
|
||||
.join(JournalEntry).join(Account).where(*conditions)
|
||||
return db.session.scalar(select_balance)
|
||||
|
||||
def __add_owner_s_equity(self, code: str, amount: Decimal | None,
|
||||
@@ -385,11 +384,13 @@ class BalanceSheet(BaseReport):
|
||||
balances: list[ReportAccount] = AccountCollector(
|
||||
self.__currency, self.__period).accounts
|
||||
|
||||
titles: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(BaseAccount.code.in_({"1", "2", "3"})).all()
|
||||
subtitles: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(BaseAccount.code.in_({x.account.base_code[:2]
|
||||
for x in balances})).all()
|
||||
titles: list[BaseAccount] = db.session.scalars(
|
||||
sa.select(BaseAccount)
|
||||
.where(BaseAccount.code.in_({"1", "2", "3"}))).unique().all()
|
||||
subtitle_codes: set[str] = {x.account.base_code[:2] for x in balances}
|
||||
subtitles: list[BaseAccount] = db.session.scalars(
|
||||
sa.select(BaseAccount)
|
||||
.where(BaseAccount.code.in_(subtitle_codes))).unique().all()
|
||||
|
||||
sections: dict[str, Section] = {x.code: Section(x) for x in titles}
|
||||
subsections: dict[str, Subsection] = {x.code: Subsection(x)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -24,21 +24,19 @@ import sqlalchemy as sa
|
||||
from flask import url_for, render_template, Response
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||
period_spec
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.urls import income_expenses_url
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.pagination import Pagination
|
||||
from ..period import Period, PeriodChooser
|
||||
from ..utils.base_page_params import BasePageParams
|
||||
from ..utils.base_report import BaseReport
|
||||
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
|
||||
from ..utils.option_link import OptionLink
|
||||
from ..utils.report_chooser import ReportChooser
|
||||
from ..utils.report_type import ReportType
|
||||
from ..utils.urls import income_expenses_url
|
||||
from ... import db
|
||||
from ...locale import gettext
|
||||
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||
from ...utils.current_account import CurrentAccount
|
||||
from ...utils.pagination import Pagination
|
||||
|
||||
|
||||
class ReportLineItem:
|
||||
@@ -119,12 +117,12 @@ class LineItemCollector:
|
||||
balance_func: sa.Function = sa.func.sum(sa.case(
|
||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||
else_=-JournalEntryLineItem.amount))
|
||||
select: sa.Select = sa.Select(balance_func)\
|
||||
select: sa.Select[tuple[Decimal]] = sa.Select(balance_func)\
|
||||
.join(JournalEntry).join(Account)\
|
||||
.filter(JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
.where(JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
self.__account_condition,
|
||||
JournalEntry.date < self.__period.start)
|
||||
balance: int | None = db.session.scalar(select)
|
||||
balance: Decimal | None = db.session.scalar(select)
|
||||
if balance is None:
|
||||
return None
|
||||
line_item: ReportLineItem = ReportLineItem()
|
||||
@@ -144,7 +142,7 @@ class LineItemCollector:
|
||||
|
||||
:return: The line items.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
conditions: list[sa.ColumnElement[bool]] \
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
self.__account_condition]
|
||||
if self.__period.start is not None:
|
||||
@@ -152,12 +150,12 @@ class LineItemCollector:
|
||||
if self.__period.end is not None:
|
||||
conditions.append(JournalEntry.date <= self.__period.end)
|
||||
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
|
||||
join(JournalEntryLineItem).join(Account).filter(*conditions)
|
||||
join(JournalEntryLineItem).join(Account).where(*conditions)
|
||||
|
||||
return [ReportLineItem(x)
|
||||
for x in JournalEntryLineItem.query
|
||||
return [ReportLineItem(x) for x in db.session.scalars(
|
||||
sa.select(JournalEntryLineItem)
|
||||
.join(JournalEntry).join(Account)
|
||||
.filter(JournalEntryLineItem.journal_entry_id
|
||||
.where(JournalEntryLineItem.journal_entry_id
|
||||
.in_(journal_entry_with_account),
|
||||
JournalEntryLineItem.currency_code
|
||||
== self.__currency.code,
|
||||
@@ -167,10 +165,10 @@ class LineItemCollector:
|
||||
JournalEntryLineItem.is_debit,
|
||||
JournalEntryLineItem.no)
|
||||
.options(selectinload(JournalEntryLineItem.account),
|
||||
selectinload(JournalEntryLineItem.journal_entry))]
|
||||
selectinload(JournalEntryLineItem.journal_entry)))]
|
||||
|
||||
@property
|
||||
def __account_condition(self) -> sa.BinaryExpression:
|
||||
def __account_condition(self) -> sa.ColumnElement[bool]:
|
||||
if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
|
||||
return CurrentAccount.sql_condition()
|
||||
return Account.id == self.__account.id
|
||||
@@ -345,7 +343,7 @@ class PageParams(BasePageParams):
|
||||
self.account.id == 0)]
|
||||
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
|
||||
.join(Account)\
|
||||
.filter(JournalEntryLineItem.currency_code == self.currency.code,
|
||||
.where(JournalEntryLineItem.currency_code == self.currency.code,
|
||||
CurrentAccount.sql_condition())\
|
||||
.group_by(JournalEntryLineItem.account_id)
|
||||
options.extend([OptionLink(str(x),
|
||||
@@ -354,8 +352,10 @@ class PageParams(BasePageParams):
|
||||
CurrentAccount(x),
|
||||
self.period),
|
||||
x.id == self.account.id)
|
||||
for x in Account.query.filter(Account.id.in_(in_use))
|
||||
.order_by(Account.base_code, Account.no).all()])
|
||||
for x in db.session.scalars(
|
||||
sa.select(Account).where(Account.id.in_(in_use))
|
||||
.order_by(Account.base_code, Account.no))
|
||||
.unique()])
|
||||
return options
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,19 +22,18 @@ from decimal import Decimal
|
||||
import sqlalchemy as sa
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \
|
||||
from ..period import Period, PeriodChooser
|
||||
from ..utils.base_page_params import BasePageParams
|
||||
from ..utils.base_report import BaseReport
|
||||
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
|
||||
from ..utils.option_link import OptionLink
|
||||
from ..utils.report_chooser import ReportChooser
|
||||
from ..utils.report_type import ReportType
|
||||
from ..utils.urls import ledger_url, income_statement_url
|
||||
from ... import db
|
||||
from ...locale import gettext
|
||||
from ...models import Currency, BaseAccount, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||
period_spec
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.urls import ledger_url, income_statement_url
|
||||
|
||||
|
||||
class ReportAccount:
|
||||
@@ -219,11 +218,14 @@ class IncomeStatement(BaseReport):
|
||||
"""
|
||||
balances: list[ReportAccount] = self.__query_balances()
|
||||
|
||||
titles: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all()
|
||||
subtitles: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(BaseAccount.code.in_({x.account.base_code[:2]
|
||||
for x in balances})).all()
|
||||
title_codes: set[str] = {"4", "5", "6", "7", "8", "9"}
|
||||
titles: list[BaseAccount] = db.session.scalars(
|
||||
sa.select(BaseAccount)
|
||||
.where(BaseAccount.code.in_(title_codes))).unique().all()
|
||||
subtitle_codes: set[str] = {x.account.base_code[:2] for x in balances}
|
||||
subtitles: list[BaseAccount] = db.session.scalars(
|
||||
sa.select(BaseAccount)
|
||||
.where(BaseAccount.code.in_(subtitle_codes))).unique().all()
|
||||
|
||||
total_titles: dict[str, str] \
|
||||
= {"4": gettext("Total Operating Revenue"),
|
||||
@@ -254,9 +256,9 @@ class IncomeStatement(BaseReport):
|
||||
|
||||
:return: The balances.
|
||||
"""
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
sub_conditions: list[sa.ColumnElement[bool]] \
|
||||
= [Account.base_code.startswith(str(x)) for x in range(4, 10)]
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
conditions: list[sa.ColumnElement[bool]] \
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
sa.or_(*sub_conditions)]
|
||||
if self.__period.start is not None:
|
||||
@@ -268,14 +270,15 @@ class IncomeStatement(BaseReport):
|
||||
else_=JournalEntryLineItem.amount)).label("balance")
|
||||
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||
.join(JournalEntry).join(Account)\
|
||||
.filter(*conditions)\
|
||||
.where(*conditions)\
|
||||
.group_by(Account.id)\
|
||||
.having(balance_func != 0)\
|
||||
.order_by(Account.base_code, Account.no)
|
||||
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
||||
accounts: dict[int, Account] \
|
||||
= {x.id: x for x in Account.query
|
||||
.filter(Account.id.in_([x.id for x in balances])).all()}
|
||||
= {x.id: x for x in db.session.scalars(
|
||||
sa.select(Account)
|
||||
.where(Account.id.in_([x.id for x in balances]))).unique()}
|
||||
return [ReportAccount(account=accounts[x.id],
|
||||
amount=x.balance,
|
||||
url=ledger_url(self.__currency,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -24,18 +24,17 @@ import sqlalchemy as sa
|
||||
from flask import render_template, Response
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||
period_spec
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.urls import journal_url
|
||||
from accounting.utils.pagination import Pagination
|
||||
from ..period import Period, PeriodChooser
|
||||
from ..utils.base_page_params import BasePageParams
|
||||
from ..utils.base_report import BaseReport
|
||||
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
|
||||
from ..utils.report_chooser import ReportChooser
|
||||
from ..utils.report_type import ReportType
|
||||
from ..utils.urls import journal_url
|
||||
from ... import db
|
||||
from ...locale import gettext
|
||||
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||
from ...utils.pagination import Pagination
|
||||
|
||||
|
||||
class ReportLineItem:
|
||||
@@ -185,20 +184,21 @@ class Journal(BaseReport):
|
||||
|
||||
:return: The line items.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
conditions: list[sa.ColumnElement[bool]] = []
|
||||
if self.__period.start is not None:
|
||||
conditions.append(JournalEntry.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(JournalEntry.date <= self.__period.end)
|
||||
return JournalEntryLineItem.query.join(JournalEntry)\
|
||||
.filter(*conditions)\
|
||||
return db.session.scalars(
|
||||
sa.select(JournalEntryLineItem).join(JournalEntry)
|
||||
.where(*conditions)
|
||||
.order_by(JournalEntry.date,
|
||||
JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit.desc(),
|
||||
JournalEntryLineItem.no)\
|
||||
JournalEntryLineItem.no)
|
||||
.options(selectinload(JournalEntryLineItem.account),
|
||||
selectinload(JournalEntryLineItem.currency),
|
||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||
selectinload(JournalEntryLineItem.journal_entry))).all()
|
||||
|
||||
def csv(self) -> Response:
|
||||
"""Returns the report as CSV for download.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -24,20 +24,18 @@ import sqlalchemy as sa
|
||||
from flask import url_for, render_template, Response
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||
period_spec
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.urls import ledger_url
|
||||
from accounting.utils.pagination import Pagination
|
||||
from ..period import Period, PeriodChooser
|
||||
from ..utils.base_page_params import BasePageParams
|
||||
from ..utils.base_report import BaseReport
|
||||
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
|
||||
from ..utils.option_link import OptionLink
|
||||
from ..utils.report_chooser import ReportChooser
|
||||
from ..utils.report_type import ReportType
|
||||
from ..utils.urls import ledger_url
|
||||
from ... import db
|
||||
from ...locale import gettext
|
||||
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||
from ...utils.pagination import Pagination
|
||||
|
||||
|
||||
class ReportLineItem:
|
||||
@@ -117,7 +115,7 @@ class LineItemCollector:
|
||||
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
|
||||
else_=-JournalEntryLineItem.amount))
|
||||
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
|
||||
.filter(JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
.where(JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
JournalEntryLineItem.account_id == self.__account.id,
|
||||
JournalEntry.date < self.__period.start)
|
||||
balance: int | None = db.session.scalar(select)
|
||||
@@ -139,22 +137,22 @@ class LineItemCollector:
|
||||
|
||||
:return: The line items.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
conditions: list[sa.ColumnElement[bool]] \
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code,
|
||||
JournalEntryLineItem.account_id == self.__account.id]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(JournalEntry.date >= self.__period.start)
|
||||
if self.__period.end is not None:
|
||||
conditions.append(JournalEntry.date <= self.__period.end)
|
||||
return [ReportLineItem(x) for x in JournalEntryLineItem.query
|
||||
.join(JournalEntry)
|
||||
.filter(*conditions)
|
||||
return [ReportLineItem(x) for x in db.session.scalars(
|
||||
sa.select(JournalEntryLineItem).join(JournalEntry)
|
||||
.where(*conditions)
|
||||
.order_by(JournalEntry.date,
|
||||
JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit.desc(),
|
||||
JournalEntryLineItem.no)
|
||||
.options(selectinload(JournalEntryLineItem.journal_entry))
|
||||
.all()]
|
||||
.options(selectinload(JournalEntryLineItem.journal_entry)))
|
||||
.unique()]
|
||||
|
||||
def __get_total(self) -> ReportLineItem | None:
|
||||
"""Composes the total line item.
|
||||
@@ -310,12 +308,13 @@ class PageParams(BasePageParams):
|
||||
:return: The account options.
|
||||
"""
|
||||
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
|
||||
.filter(JournalEntryLineItem.currency_code == self.currency.code)\
|
||||
.where(JournalEntryLineItem.currency_code == self.currency.code)\
|
||||
.group_by(JournalEntryLineItem.account_id)
|
||||
return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
|
||||
x.id == self.account.id)
|
||||
for x in Account.query.filter(Account.id.in_(in_use))
|
||||
.order_by(Account.base_code, Account.no).all()]
|
||||
for x in db.session.scalars(
|
||||
sa.select(Account).where(Account.id.in_(in_use))
|
||||
.order_by(Account.base_code, Account.no)).unique()]
|
||||
|
||||
|
||||
class Ledger(BaseReport):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -24,17 +24,18 @@ import sqlalchemy as sa
|
||||
from flask import Response, render_template, request
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
|
||||
JournalEntry, JournalEntryLineItem
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import csv_download
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.query import parse_query_keywords
|
||||
from .journal import get_csv_rows
|
||||
from ..utils.base_page_params import BasePageParams
|
||||
from ..utils.base_report import BaseReport
|
||||
from ..utils.csv_export import csv_download
|
||||
from ..utils.report_chooser import ReportChooser
|
||||
from ..utils.report_type import ReportType
|
||||
from ... import db
|
||||
from ...locale import gettext
|
||||
from ...models import Currency, CurrencyL10n, Account, AccountL10n, \
|
||||
JournalEntry, JournalEntryLineItem
|
||||
from ...utils.pagination import Pagination
|
||||
from ...utils.query import parse_query_keywords
|
||||
|
||||
|
||||
class LineItemCollector:
|
||||
@@ -53,9 +54,9 @@ class LineItemCollector:
|
||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||
if len(keywords) == 0:
|
||||
return []
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
conditions: list[sa.ColumnElement[bool]] = []
|
||||
for k in keywords:
|
||||
sub_conditions: list[sa.BinaryExpression] \
|
||||
sub_conditions: list[sa.ColumnElement[bool]] \
|
||||
= [JournalEntryLineItem.description.icontains(k),
|
||||
JournalEntryLineItem.account_id.in_(
|
||||
self.__get_account_condition(k)),
|
||||
@@ -69,15 +70,16 @@ class LineItemCollector:
|
||||
except ArithmeticError:
|
||||
pass
|
||||
conditions.append(sa.or_(*sub_conditions))
|
||||
return JournalEntryLineItem.query.join(JournalEntry)\
|
||||
.filter(*conditions)\
|
||||
return db.session.scalars(
|
||||
sa.select(JournalEntryLineItem).join(JournalEntry)
|
||||
.where(*conditions)
|
||||
.order_by(JournalEntry.date,
|
||||
JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit,
|
||||
JournalEntryLineItem.no)\
|
||||
JournalEntryLineItem.no)
|
||||
.options(selectinload(JournalEntryLineItem.account),
|
||||
selectinload(JournalEntryLineItem.currency),
|
||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||
selectinload(JournalEntryLineItem.journal_entry))).all()
|
||||
|
||||
@staticmethod
|
||||
def __get_account_condition(k: str) -> sa.Select:
|
||||
@@ -86,20 +88,20 @@ class LineItemCollector:
|
||||
:param k: The keyword.
|
||||
:return: The condition to filter the account.
|
||||
"""
|
||||
code: sa.BinaryExpression = Account.base_code + "-" \
|
||||
code: sa.ColumnElement[str] = 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.icontains(k))
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
.where(AccountL10n.title.icontains(k))
|
||||
conditions: list[sa.ColumnElement[bool]] \
|
||||
= [Account.base_code.contains(k),
|
||||
Account.title_l10n.icontains(k),
|
||||
code.contains(k),
|
||||
Account.id.in_(select_l10n)]
|
||||
if k in gettext("Needs Offset"):
|
||||
conditions.append(Account.is_need_offset)
|
||||
return sa.select(Account.id).filter(sa.or_(*conditions))
|
||||
return sa.select(Account.id).where(sa.or_(*conditions))
|
||||
|
||||
@staticmethod
|
||||
def __get_currency_condition(k: str) -> sa.Select:
|
||||
@@ -109,9 +111,9 @@ class LineItemCollector:
|
||||
:return: The condition to filter the currency.
|
||||
"""
|
||||
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
|
||||
.filter(CurrencyL10n.name.icontains(k))
|
||||
return sa.select(Currency.code).filter(
|
||||
sa.or_(Currency.code.icontains(k),
|
||||
.where(CurrencyL10n.name.icontains(k))
|
||||
return sa.select(Currency.code)\
|
||||
.where(sa.or_(Currency.code.icontains(k),
|
||||
Currency.name_l10n.icontains(k),
|
||||
Currency.code.in_(select_l10n)))
|
||||
|
||||
@@ -122,7 +124,7 @@ class LineItemCollector:
|
||||
:param k: The keyword.
|
||||
:return: The condition to filter the journal entry.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
conditions: list[sa.ColumnElement[bool]] \
|
||||
= [JournalEntry.note.icontains(k)]
|
||||
date: dt.datetime
|
||||
try:
|
||||
@@ -153,7 +155,7 @@ class LineItemCollector:
|
||||
sa.extract("day", JournalEntry.date) == date.day))
|
||||
except ValueError:
|
||||
pass
|
||||
return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
|
||||
return sa.select(JournalEntry.id).where(sa.or_(*conditions))
|
||||
|
||||
|
||||
class PageParams(BasePageParams):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,19 +22,17 @@ from decimal import Decimal
|
||||
import sqlalchemy as sa
|
||||
from flask import Response, render_template
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.period import Period, PeriodChooser
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
|
||||
period_spec
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.urls import ledger_url, trial_balance_url
|
||||
from ..period import Period, PeriodChooser
|
||||
from ..utils.base_page_params import BasePageParams
|
||||
from ..utils.base_report import BaseReport
|
||||
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
|
||||
from ..utils.option_link import OptionLink
|
||||
from ..utils.report_chooser import ReportChooser
|
||||
from ..utils.report_type import ReportType
|
||||
from ..utils.urls import ledger_url, trial_balance_url
|
||||
from ... import db
|
||||
from ...locale import gettext
|
||||
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||
|
||||
|
||||
class ReportAccount:
|
||||
@@ -178,7 +176,7 @@ class TrialBalance(BaseReport):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
conditions: list[sa.ColumnElement[bool]] \
|
||||
= [JournalEntryLineItem.currency_code == self.__currency.code]
|
||||
if self.__period.start is not None:
|
||||
conditions.append(JournalEntry.date >= self.__period.start)
|
||||
@@ -189,14 +187,15 @@ class TrialBalance(BaseReport):
|
||||
else_=-JournalEntryLineItem.amount)).label("balance")
|
||||
select_balances: sa.Select = sa.select(Account.id, balance_func)\
|
||||
.join(JournalEntry).join(Account)\
|
||||
.filter(*conditions)\
|
||||
.where(*conditions)\
|
||||
.group_by(Account.id)\
|
||||
.having(balance_func != 0)\
|
||||
.order_by(Account.base_code, Account.no)
|
||||
balances: list[sa.Row] = db.session.execute(select_balances).all()
|
||||
accounts: dict[int, Account] \
|
||||
= {x.id: x for x in Account.query
|
||||
.filter(Account.id.in_([x.id for x in balances])).all()}
|
||||
= {x.id: x for x in db.session.scalars(
|
||||
sa.select(Account)
|
||||
.where(Account.id.in_([x.id for x in balances]))).unique()}
|
||||
self.__accounts = [ReportAccount(account=accounts[x.id],
|
||||
amount=x.balance,
|
||||
url=ledger_url(self.__currency,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,22 +20,22 @@
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import render_template, Response
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.unapplied import get_accounts_with_unapplied, \
|
||||
get_net_balances
|
||||
from accounting.report.utils.urls import unapplied_url
|
||||
from accounting.utils.pagination import Pagination
|
||||
from ..utils.base_page_params import BasePageParams
|
||||
from ..utils.base_report import BaseReport
|
||||
from ..utils.csv_export import BaseCSVRow, csv_download
|
||||
from ..utils.option_link import OptionLink
|
||||
from ..utils.report_chooser import ReportChooser
|
||||
from ..utils.report_type import ReportType
|
||||
from ..utils.unapplied import get_accounts_with_unapplied, get_net_balances
|
||||
from ..utils.urls import unapplied_url
|
||||
from ... import db
|
||||
from ...locale import gettext
|
||||
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||
from ...utils.pagination import Pagination
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
@@ -178,13 +178,14 @@ class UnappliedOriginalLineItems(BaseReport):
|
||||
"""
|
||||
net_balances: dict[int, Decimal | None] \
|
||||
= get_net_balances(self.__currency, self.__account)
|
||||
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query \
|
||||
.join(Account).join(JournalEntry) \
|
||||
.filter(JournalEntryLineItem.id.in_(net_balances)) \
|
||||
line_items: list[JournalEntryLineItem] = db.session.scalars(
|
||||
sa.select(JournalEntryLineItem).join(Account).join(JournalEntry)
|
||||
.where(JournalEntryLineItem.id.in_(net_balances))
|
||||
.order_by(JournalEntry.date, JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
|
||||
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
|
||||
.options(selectinload(JournalEntryLineItem.currency),
|
||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||
selectinload(JournalEntryLineItem.journal_entry)))\
|
||||
.unique().all()
|
||||
for line_item in line_items:
|
||||
line_item.net_balance = line_item.amount \
|
||||
if net_balances[line_item.id] is None \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,16 +22,16 @@ from decimal import Decimal
|
||||
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.unapplied import get_accounts_with_unapplied
|
||||
from accounting.report.utils.urls import unapplied_url
|
||||
from ..utils.base_page_params import BasePageParams
|
||||
from ..utils.base_report import BaseReport
|
||||
from ..utils.csv_export import BaseCSVRow, csv_download
|
||||
from ..utils.option_link import OptionLink
|
||||
from ..utils.report_chooser import ReportChooser
|
||||
from ..utils.report_type import ReportType
|
||||
from ..utils.unapplied import get_accounts_with_unapplied
|
||||
from ..utils.urls import unapplied_url
|
||||
from ...locale import gettext
|
||||
from ...models import Currency, Account
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -23,18 +23,18 @@ from decimal import Decimal
|
||||
from flask import render_template, Response
|
||||
from flask_babel import LazyString
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account, JournalEntryLineItem
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
|
||||
from accounting.report.utils.offset_matcher import OffsetMatcher, OffsetPair
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.unmatched import get_accounts_with_unmatched
|
||||
from accounting.report.utils.urls import unmatched_url
|
||||
from accounting.utils.pagination import Pagination
|
||||
from ..utils.base_page_params import BasePageParams
|
||||
from ..utils.base_report import BaseReport
|
||||
from ..utils.csv_export import BaseCSVRow, csv_download
|
||||
from ..utils.offset_matcher import OffsetMatcher, OffsetPair
|
||||
from ..utils.option_link import OptionLink
|
||||
from ..utils.report_chooser import ReportChooser
|
||||
from ..utils.report_type import ReportType
|
||||
from ..utils.unmatched import get_accounts_with_unmatched
|
||||
from ..utils.urls import unmatched_url
|
||||
from ...locale import gettext
|
||||
from ...models import Currency, Account, JournalEntryLineItem
|
||||
from ...utils.pagination import Pagination
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,16 +22,16 @@ from decimal import Decimal
|
||||
|
||||
from flask import render_template, Response
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.utils.base_page_params import BasePageParams
|
||||
from accounting.report.utils.base_report import BaseReport
|
||||
from accounting.report.utils.csv_export import BaseCSVRow, csv_download
|
||||
from accounting.report.utils.option_link import OptionLink
|
||||
from accounting.report.utils.report_chooser import ReportChooser
|
||||
from accounting.report.utils.report_type import ReportType
|
||||
from accounting.report.utils.unmatched import get_accounts_with_unmatched
|
||||
from accounting.report.utils.urls import unmatched_url
|
||||
from ..utils.base_page_params import BasePageParams
|
||||
from ..utils.base_report import BaseReport
|
||||
from ..utils.csv_export import BaseCSVRow, csv_download
|
||||
from ..utils.option_link import OptionLink
|
||||
from ..utils.report_chooser import ReportChooser
|
||||
from ..utils.report_type import ReportType
|
||||
from ..utils.unmatched import get_accounts_with_unmatched
|
||||
from ..utils.urls import unmatched_url
|
||||
from ...locale import gettext
|
||||
from ...models import Currency, Account
|
||||
|
||||
|
||||
class CSVRow(BaseCSVRow):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -19,7 +19,7 @@
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
from accounting.template_filters import format_amount as core_format_amount
|
||||
from ..template_filters import format_amount as core_format_amount
|
||||
|
||||
|
||||
def format_amount(value: Decimal | None) -> str | None:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -19,18 +19,17 @@
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from typing import Type
|
||||
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
|
||||
urlunparse
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Currency, JournalEntryLineItem
|
||||
from accounting.utils.journal_entry_types import JournalEntryType
|
||||
from .option_link import OptionLink
|
||||
from .report_chooser import ReportChooser
|
||||
from ... import db
|
||||
from ...models import Currency, JournalEntryLineItem
|
||||
from ...utils.journal_entry_types import JournalEntryType
|
||||
|
||||
|
||||
class BasePageParams(ABC):
|
||||
@@ -53,7 +52,7 @@ class BasePageParams(ABC):
|
||||
"""
|
||||
|
||||
@property
|
||||
def journal_entry_types(self) -> Type[JournalEntryType]:
|
||||
def journal_entry_types(self) -> type[JournalEntryType]:
|
||||
"""Returns the journal entry types.
|
||||
|
||||
:return: The journal entry types.
|
||||
@@ -85,5 +84,6 @@ class BasePageParams(ABC):
|
||||
sa.select(JournalEntryLineItem.currency_code)
|
||||
.group_by(JournalEntryLineItem.currency_code)).all())
|
||||
return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
|
||||
for x in Currency.query.filter(Currency.code.in_(in_use))
|
||||
.order_by(Currency.code).all()]
|
||||
for x in db.session.scalars(
|
||||
sa.select(Currency).where(Currency.code.in_(in_use))
|
||||
.order_by(Currency.code)).unique()]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -26,7 +26,7 @@ from urllib.parse import quote
|
||||
|
||||
from flask import Response
|
||||
|
||||
from accounting.report.period import Period
|
||||
from ..period import Period
|
||||
|
||||
|
||||
class BaseCSVRow(ABC):
|
||||
@@ -66,15 +66,19 @@ def period_spec(period: Period) -> str:
|
||||
"""
|
||||
start: str | None = __get_start_str(period.start)
|
||||
end: str | None = __get_end_str(period.end)
|
||||
if period.start is None and period.end is None:
|
||||
return "all-time"
|
||||
if start == end:
|
||||
return start
|
||||
if period.start is None:
|
||||
return f"until-{end}"
|
||||
if period.end is None:
|
||||
return f"since-{start}"
|
||||
return f"{start}-{end}"
|
||||
if start is None:
|
||||
return "all-time" if end is None else f"until-{end}"
|
||||
return f"since-{start}" if end is None else __get_spec(start, end)
|
||||
|
||||
|
||||
def __get_spec(start: str, end: str) -> str:
|
||||
"""Constructs the period specification with both start and end
|
||||
|
||||
:param start: The start date.
|
||||
:param end: The end date.
|
||||
:return: The period specification.
|
||||
"""
|
||||
return start if start == end else f"{start}-{end}"
|
||||
|
||||
|
||||
def __get_start_str(start: dt.date | None) -> str | None:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -23,10 +23,10 @@ import sqlalchemy as sa
|
||||
from flask_babel import LazyString
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.report.utils.unapplied import get_net_balances
|
||||
from ..utils.unapplied import get_net_balances
|
||||
from ... import db
|
||||
from ...locale import lazy_gettext
|
||||
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||
|
||||
|
||||
class OffsetPair:
|
||||
@@ -54,7 +54,7 @@ class OffsetMatcher:
|
||||
:param currency: The currency.
|
||||
:param account: The account.
|
||||
"""
|
||||
self.__currency: Account = currency
|
||||
self.__currency: Currency = currency
|
||||
"""The currency."""
|
||||
self.__account: Account = account
|
||||
"""The account."""
|
||||
@@ -105,7 +105,7 @@ class OffsetMatcher:
|
||||
"""
|
||||
net_balances: dict[int, Decimal | None] \
|
||||
= get_net_balances(self.__currency, self.__account)
|
||||
unmatched_offset_condition: sa.BinaryExpression \
|
||||
unmatched_offset_condition: sa.ColumnElement[bool] \
|
||||
= sa.and_(Account.id == self.__account.id,
|
||||
JournalEntryLineItem.currency_code
|
||||
== self.__currency.code,
|
||||
@@ -114,14 +114,15 @@ class OffsetMatcher:
|
||||
JournalEntryLineItem.is_debit),
|
||||
sa.and_(Account.base_code.startswith("1"),
|
||||
sa.not_(JournalEntryLineItem.is_debit))))
|
||||
self.line_items = JournalEntryLineItem.query \
|
||||
.join(Account).join(JournalEntry) \
|
||||
.filter(sa.or_(JournalEntryLineItem.id.in_(net_balances),
|
||||
unmatched_offset_condition)) \
|
||||
self.line_items = db.session.scalars(
|
||||
sa.select(JournalEntryLineItem).join(Account).join(JournalEntry)
|
||||
.where(sa.or_(JournalEntryLineItem.id.in_(net_balances),
|
||||
unmatched_offset_condition))
|
||||
.order_by(JournalEntry.date, JournalEntry.no,
|
||||
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \
|
||||
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
|
||||
.options(selectinload(JournalEntryLineItem.currency),
|
||||
selectinload(JournalEntryLineItem.journal_entry)).all()
|
||||
selectinload(JournalEntryLineItem.journal_entry)))\
|
||||
.unique().all()
|
||||
for line_item in self.line_items:
|
||||
line_item.is_offset = line_item.id not in net_balances
|
||||
self.unapplied = [x for x in self.line_items if not x.is_offset]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -25,18 +25,18 @@ from collections.abc import Iterator
|
||||
|
||||
from flask_babel import LazyString
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period, get_period
|
||||
from accounting.template_globals import default_currency_code
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.permission import can_edit
|
||||
from .option_link import OptionLink
|
||||
from .report_type import ReportType
|
||||
from .urls import journal_url, ledger_url, income_expenses_url, \
|
||||
trial_balance_url, income_statement_url, balance_sheet_url, \
|
||||
unapplied_url, unmatched_url
|
||||
from ..period import Period, get_period
|
||||
from ... import db
|
||||
from ...locale import gettext
|
||||
from ...models import Currency, Account
|
||||
from ...template_globals import default_currency_code
|
||||
from ...utils.current_account import CurrentAccount
|
||||
from ...utils.permission import can_edit
|
||||
|
||||
|
||||
class ReportChooser:
|
||||
|
||||
@@ -22,21 +22,21 @@ from enum import Enum
|
||||
|
||||
class ReportType(Enum):
|
||||
"""The report types."""
|
||||
JOURNAL: str = "journal"
|
||||
JOURNAL = "journal"
|
||||
"""The journal."""
|
||||
LEDGER: str = "ledger"
|
||||
LEDGER = "ledger"
|
||||
"""The ledger."""
|
||||
INCOME_EXPENSES: str = "income-expenses"
|
||||
INCOME_EXPENSES = "income-expenses"
|
||||
"""The income and expenses log."""
|
||||
TRIAL_BALANCE: str = "trial-balance"
|
||||
TRIAL_BALANCE = "trial-balance"
|
||||
"""The trial balance."""
|
||||
INCOME_STATEMENT: str = "income-statement"
|
||||
INCOME_STATEMENT = "income-statement"
|
||||
"""The income statement."""
|
||||
BALANCE_SHEET: str = "balance-sheet"
|
||||
BALANCE_SHEET = "balance-sheet"
|
||||
"""The balance sheet."""
|
||||
UNAPPLIED: str = "unapplied"
|
||||
UNAPPLIED = "unapplied"
|
||||
"""The unapplied original line items."""
|
||||
UNMATCHED: str = "unmatched"
|
||||
UNMATCHED = "unmatched"
|
||||
"""The unmatched offsets."""
|
||||
SEARCH: str = "search"
|
||||
SEARCH = "search"
|
||||
"""The search."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -21,10 +21,9 @@ from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from accounting.utils.offset_alias import offset_alias
|
||||
from ... import db
|
||||
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||
from ...utils.offset_alias import offset_alias
|
||||
|
||||
|
||||
def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
|
||||
@@ -46,7 +45,7 @@ def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
|
||||
.join(offset,
|
||||
JournalEntryLineItem.id == offset.c.original_line_item_id,
|
||||
isouter=True)\
|
||||
.filter(Account.is_need_offset,
|
||||
.where(Account.is_need_offset,
|
||||
JournalEntryLineItem.currency_code == currency.code,
|
||||
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||
sa.not_(JournalEntryLineItem.is_debit)),
|
||||
@@ -59,13 +58,14 @@ def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
|
||||
= sa.func.count(JournalEntryLineItem.id).label("count")
|
||||
select: sa.Select = sa.select(Account.id, count_func)\
|
||||
.join(JournalEntryLineItem, isouter=True)\
|
||||
.filter(JournalEntryLineItem.id.in_(select_unapplied))\
|
||||
.where(JournalEntryLineItem.id.in_(select_unapplied))\
|
||||
.group_by(Account.id)\
|
||||
.having(count_func > 0)
|
||||
counts: dict[int, int] \
|
||||
= {x.id: x.count for x in db.session.execute(select)}
|
||||
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
|
||||
.order_by(Account.base_code, Account.no).all()
|
||||
accounts: list[Account] = db.session.scalars(
|
||||
sa.select(Account).where(Account.id.in_(counts))
|
||||
.order_by(Account.base_code, Account.no)).unique().all()
|
||||
for account in accounts:
|
||||
account.count = counts[account.id]
|
||||
return accounts
|
||||
@@ -92,7 +92,7 @@ def get_net_balances(currency: Currency, account: Account) \
|
||||
.join(offset,
|
||||
JournalEntryLineItem.id == offset.c.original_line_item_id,
|
||||
isouter=True) \
|
||||
.filter(Account.id == account.id,
|
||||
.where(Account.id == account.id,
|
||||
JournalEntryLineItem.currency_code == currency.code,
|
||||
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||
sa.not_(JournalEntryLineItem.is_debit)),
|
||||
@@ -101,4 +101,4 @@ def get_net_balances(currency: Currency, account: Account) \
|
||||
.group_by(JournalEntryLineItem.id) \
|
||||
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
|
||||
return {x.id: x.net_balance
|
||||
for x in db.session.execute(select_net_balances).all()}
|
||||
for x in db.session.execute(select_net_balances)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -19,9 +19,8 @@
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Currency, Account, JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
from ... import db
|
||||
from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
|
||||
|
||||
|
||||
def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
|
||||
@@ -36,7 +35,7 @@ def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
|
||||
select: sa.Select = sa.select(Account.id, count_func)\
|
||||
.select_from(Account)\
|
||||
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
|
||||
.filter(Account.is_need_offset,
|
||||
.where(Account.is_need_offset,
|
||||
JournalEntryLineItem.currency_code == currency.code,
|
||||
JournalEntryLineItem.original_line_item_id.is_(None),
|
||||
sa.or_(sa.and_(Account.base_code.startswith("2"),
|
||||
@@ -47,8 +46,9 @@ def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
|
||||
.having(count_func > 0)
|
||||
counts: dict[int, int] \
|
||||
= {x.id: x.count for x in db.session.execute(select)}
|
||||
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\
|
||||
.order_by(Account.base_code, Account.no).all()
|
||||
accounts: list[Account] = db.session.scalars(
|
||||
sa.select(Account).where(Account.id.in_(counts))
|
||||
.order_by(Account.base_code, Account.no)).unique().all()
|
||||
for account in accounts:
|
||||
account.count = counts[account.id]
|
||||
return accounts
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -19,11 +19,11 @@
|
||||
"""
|
||||
from flask import url_for
|
||||
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.report.period import Period
|
||||
from accounting.template_globals import default_currency_code
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.options import options
|
||||
from ...models import Currency, Account
|
||||
from ...report.period import Period
|
||||
from ...template_globals import default_currency_code
|
||||
from ...utils.current_account import CurrentAccount
|
||||
from ...utils.options import options
|
||||
|
||||
|
||||
def journal_url(period: Period) \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -19,15 +19,6 @@
|
||||
"""
|
||||
from flask import Blueprint, request, Response, redirect, flash
|
||||
|
||||
from accounting import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import Currency, Account
|
||||
from accounting.template_globals import default_currency_code
|
||||
from accounting.utils.cast import s
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.next_uri import or_next
|
||||
from accounting.utils.options import options
|
||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||
from .period import Period, get_period
|
||||
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
|
||||
IncomeStatement, BalanceSheet, Search
|
||||
@@ -38,6 +29,15 @@ from .reports.unmatched_accounts import AccountsWithUnmatchedOffsets
|
||||
from .template_filters import format_amount
|
||||
from .utils.offset_matcher import OffsetMatcher
|
||||
from .utils.urls import unmatched_url
|
||||
from .. import db
|
||||
from ..locale import lazy_gettext
|
||||
from ..models import Currency, Account
|
||||
from ..template_globals import default_currency_code
|
||||
from ..utils.cast import s
|
||||
from ..utils.current_account import CurrentAccount
|
||||
from ..utils.next_uri import or_next
|
||||
from ..utils.options import options
|
||||
from ..utils.permission import has_permission, can_view, can_edit
|
||||
|
||||
bp: Blueprint = Blueprint("accounting-report", __name__)
|
||||
"""The view blueprint for the reports."""
|
||||
@@ -391,7 +391,7 @@ def get_unmatched(currency: Currency, account: Account) -> str | Response:
|
||||
@bp.post("match-offsets/<currency:currency>/<needOffsetAccount:account>",
|
||||
endpoint="match-offsets")
|
||||
@has_permission(can_edit)
|
||||
def match_offsets(currency: Currency, account: Account) -> redirect:
|
||||
def match_offsets(currency: Currency, account: Account) -> Response:
|
||||
"""Matches the original line items with their offsets.
|
||||
|
||||
:return: Redirection to the view of the unmatched offsets.
|
||||
|
||||
@@ -339,6 +339,7 @@ class BaseAccountSelector {
|
||||
/**
|
||||
* A base account option.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class BaseAccountOption {
|
||||
|
||||
|
||||
@@ -344,6 +344,7 @@ class DescriptionEditor {
|
||||
/**
|
||||
* An account option in the description editor.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class DescriptionEditorAccount extends JournalEntryAccount {
|
||||
|
||||
@@ -415,6 +416,7 @@ class DescriptionEditorAccount extends JournalEntryAccount {
|
||||
/**
|
||||
* A suggested account.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {
|
||||
|
||||
@@ -432,6 +434,7 @@ class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {
|
||||
/**
|
||||
* The account option that is specified or confirmed by the user.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount {
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
/**
|
||||
* The account selector.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class JournalEntryAccountSelector {
|
||||
|
||||
@@ -89,7 +90,7 @@ class JournalEntryAccountSelector {
|
||||
* @param debitCredit {string} either "debit" or "credit"
|
||||
*/
|
||||
constructor(lineItemEditor, debitCredit) {
|
||||
this.lineItemEditor = lineItemEditor
|
||||
this.lineItemEditor = lineItemEditor;
|
||||
this.#debitCredit = debitCredit;
|
||||
const prefix = `accounting-account-selector-${debitCredit}`;
|
||||
this.#query = document.getElementById(`${prefix}-query`);
|
||||
@@ -187,6 +188,7 @@ class JournalEntryAccountSelector {
|
||||
/**
|
||||
* An account option
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class JournalEntryAccountOption {
|
||||
|
||||
|
||||
@@ -175,6 +175,7 @@ class OptionForm {
|
||||
/**
|
||||
* The recurring expenses or incomes sub-form.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class RecurringExpenseIncomeSubForm {
|
||||
|
||||
@@ -350,6 +351,7 @@ class RecurringExpenseIncomeSubForm {
|
||||
/**
|
||||
* A recurring item sub-form.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class RecurringItemSubForm {
|
||||
|
||||
@@ -551,6 +553,7 @@ class RecurringItemSubForm {
|
||||
/**
|
||||
* The recurring item editor.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class RecurringItemEditor {
|
||||
|
||||
@@ -829,6 +832,7 @@ class RecurringItemEditor {
|
||||
/**
|
||||
* The account selector for the recurring item editor.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class RecurringAccountSelector {
|
||||
|
||||
@@ -941,6 +945,7 @@ class RecurringAccountSelector {
|
||||
/**
|
||||
* An account in the account selector for the recurring item editor.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class RecurringAccount {
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
/**
|
||||
* The original line item selector.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class OriginalLineItemSelector {
|
||||
|
||||
@@ -190,6 +191,7 @@ class OriginalLineItemSelector {
|
||||
/**
|
||||
* An original line item.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class OriginalLineItem {
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* period-chooser.js: The JavaScript for the period chooser
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2023 imacat.
|
||||
/* Copyright (c) 2023-2026 imacat.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -30,6 +30,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
/**
|
||||
* The period chooser.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
class PeriodChooser {
|
||||
|
||||
@@ -320,7 +321,7 @@ class CustomTab extends TabPlane {
|
||||
* The confirm button
|
||||
* @type {HTMLButtonElement}
|
||||
*/
|
||||
#conform;
|
||||
#confirm;
|
||||
|
||||
/**
|
||||
* Constructs a tab plane.
|
||||
@@ -333,7 +334,7 @@ class CustomTab extends TabPlane {
|
||||
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.#confirm = document.getElementById(`${this.prefix}-confirm`);
|
||||
if (this.#start !== null) {
|
||||
this.#start.onchange = () => {
|
||||
if (this.#validateStart()) {
|
||||
@@ -345,7 +346,7 @@ class CustomTab extends TabPlane {
|
||||
this.#start.max = this.#end.value;
|
||||
}
|
||||
};
|
||||
this.#conform.onclick = () => {
|
||||
this.#confirm.onclick = () => {
|
||||
let isValid = true;
|
||||
isValid = this.#validateStart() && isValid;
|
||||
isValid = this.#validateEnd() && isValid;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/* The Mia! Accounting Project
|
||||
* timezone.js: The JavaScript for the timezone
|
||||
*/
|
||||
|
||||
/* Copyright (c) 2024 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: 2024/6/4
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setTimeZone();
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets the time zone.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function setTimeZone() {
|
||||
document.cookie = `accounting-tz=${Intl.DateTimeFormat().resolvedOptions().timeZone}; SameSite=Strict`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -23,7 +23,8 @@ from typing import Any
|
||||
|
||||
from flask_babel import get_locale
|
||||
|
||||
from accounting.locale import gettext
|
||||
from .locale import gettext
|
||||
from .utils.timezone import get_tz_today
|
||||
|
||||
|
||||
def format_amount(value: Decimal | None) -> str | None:
|
||||
@@ -47,7 +48,7 @@ def format_date(value: dt.date) -> str:
|
||||
:param value: The date.
|
||||
:return: The human-friendly date text.
|
||||
"""
|
||||
today: dt.date = dt.date.today()
|
||||
today: dt.date = get_tz_today()
|
||||
if value == today:
|
||||
return gettext("Today")
|
||||
if value == today - dt.timedelta(days=1):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -17,8 +17,11 @@
|
||||
"""The template globals.
|
||||
|
||||
"""
|
||||
from accounting.models import Currency
|
||||
from accounting.utils.options import options
|
||||
import sqlalchemy as sa
|
||||
|
||||
from . import db
|
||||
from .models import Currency
|
||||
from .utils.options import options
|
||||
|
||||
|
||||
def currency_options() -> list[Currency]:
|
||||
@@ -26,7 +29,8 @@ def currency_options() -> list[Currency]:
|
||||
|
||||
:return: The currency options.
|
||||
"""
|
||||
return Currency.query.order_by(Currency.code).all()
|
||||
return db.session.scalars(
|
||||
sa.select(Currency).order_by(Currency.code)).unique().all()
|
||||
|
||||
|
||||
def default_currency_code() -> str:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
The Mia! Accounting Project
|
||||
base.html: The application-wide base template.
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
Copyright (c) 2023-2024 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -27,5 +27,6 @@ First written: 2023/1/27
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for("accounting.babel_catalog") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/timezone.js") }}"></script>
|
||||
{% block accounting_scripts %}{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
The Mia! Accounting Project
|
||||
detail.html: The cash disbursement journal entry detail
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
Copyright (c) 2023-2026 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -21,7 +21,7 @@ First written: 2023/2/26
|
||||
#}
|
||||
{% extends "accounting/journal-entry/include/detail.html" %}
|
||||
|
||||
{% block as_trasfer %}
|
||||
{% block as_transfer %}
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
|
||||
<i class="fa-solid fa-table-columns"></i>
|
||||
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
The Mia! Accounting Project
|
||||
detail.html: The account detail
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
Copyright (c) 2023-2026 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -41,7 +41,7 @@ First written: 2023/2/26
|
||||
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
|
||||
</a>
|
||||
{% if accounting_can_edit() %}
|
||||
{% block as_trasfer %}{% endblock %}
|
||||
{% block as_transfer %}{% endblock %}
|
||||
{% if obj.can_delete %}
|
||||
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
The Mia! Accounting Project
|
||||
detail.html: The cash receipt journal entry detail
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
Copyright (c) 2023-2026 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -21,7 +21,7 @@ First written: 2023/2/26
|
||||
#}
|
||||
{% extends "accounting/journal-entry/include/detail.html" %}
|
||||
|
||||
{% block as_trasfer %}
|
||||
{% block as_transfer %}
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
|
||||
<i class="fa-solid fa-table-columns"></i>
|
||||
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
|
||||
@@ -41,7 +41,7 @@ First written: 2023/2/26
|
||||
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div>{{ currency.debit_total|accounting_format_amount }}</div>
|
||||
<div>{{ currency.credit_total|accounting_format_amount }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
The Mia! Accounting Project
|
||||
detail.html: The transfer journal entry detail
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
Copyright (c) 2023-2026 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -53,7 +53,7 @@ First written: 2023/2/26
|
||||
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>{{ A_("Total") }}</div>
|
||||
<div>{{ currency.debit_total|accounting_format_amount }}</div>
|
||||
<div>{{ currency.credit_total|accounting_format_amount }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{#
|
||||
The Mia! Accounting Project
|
||||
income-statement.html: The income statement
|
||||
balance-sheet.html: The balance sheet
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
Copyright (c) 2023-2026 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
The Mia! Accounting Project
|
||||
unapplied-accounts.html: The account list with unapplied original line items
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
Copyright (c) 2023-2026 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -23,7 +23,6 @@ First written: 2023/4/8
|
||||
|
||||
{% 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 %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unapplied Items") }}{% else %}{{ A_("Accounts With Unapplied Items in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
The Mia! Accounting Project
|
||||
unapplied.html: The unapplied original line items
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
Copyright (c) 2023-2026 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -23,7 +23,6 @@ First written: 2023/4/7
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unapplied Items of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unapplied Items of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
The Mia! Accounting Project
|
||||
unmatched-accounts.html: The account list with unmatched offsets
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
Copyright (c) 2023-2026 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -23,7 +23,6 @@ First written: 2023/4/17
|
||||
|
||||
{% 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 %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unmatched Offsets") }}{% else %}{{ A_("Accounts With Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
The Mia! Accounting Project
|
||||
unmatched.html: The unmatched offsets
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
Copyright (c) 2023-2026 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -23,7 +23,6 @@ First written: 2023/4/17
|
||||
|
||||
{% 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 %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unmatched Offsets of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -21,8 +21,9 @@ from typing import Self
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting.locale import gettext
|
||||
from accounting.models import Account
|
||||
from .. import db
|
||||
from ..locale import gettext
|
||||
from ..models import Account
|
||||
|
||||
|
||||
class CurrentAccount:
|
||||
@@ -59,7 +60,7 @@ class CurrentAccount:
|
||||
|
||||
:return: The pseudo account for all current assets and liabilities.
|
||||
"""
|
||||
account: cls = cls()
|
||||
account: Self = cls()
|
||||
account.id = 0
|
||||
account.code = cls.CURRENT_AL_CODE
|
||||
account.title = gettext("current assets and liabilities")
|
||||
@@ -73,14 +74,15 @@ class CurrentAccount:
|
||||
:return: The current assets and liabilities accounts.
|
||||
"""
|
||||
accounts: list[cls] = [cls.current_assets_and_liabilities()]
|
||||
accounts.extend([CurrentAccount(x)
|
||||
for x in Account.query
|
||||
.filter(cls.sql_condition())
|
||||
.order_by(Account.base_code, Account.no)])
|
||||
accounts.extend([cls(x)
|
||||
for x in db.session.scalars(
|
||||
sa.select(Account).where(cls.sql_condition())
|
||||
.order_by(Account.base_code, Account.no))
|
||||
.unique()])
|
||||
return accounts
|
||||
|
||||
@classmethod
|
||||
def sql_condition(cls) -> sa.BinaryExpression:
|
||||
def sql_condition(cls) -> sa.ColumnElement[bool]:
|
||||
"""Returns the SQL condition for the current assets and liabilities
|
||||
accounts.
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ from enum import Enum
|
||||
|
||||
class JournalEntryType(Enum):
|
||||
"""The journal entry types."""
|
||||
CASH_RECEIPT: str = "receipt"
|
||||
CASH_RECEIPT = "receipt"
|
||||
"""The cash receipt journal entry."""
|
||||
CASH_DISBURSEMENT: str = "disbursement"
|
||||
CASH_DISBURSEMENT = "disbursement"
|
||||
"""The cash disbursement journal entry."""
|
||||
TRANSFER: str = "transfer"
|
||||
TRANSFER = "transfer"
|
||||
"""The transfer journal entry."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/15
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -21,7 +21,7 @@ from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting.models import JournalEntryLineItem
|
||||
from ..models import JournalEntryLineItem
|
||||
|
||||
|
||||
def offset_alias() -> sa.Alias:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -21,10 +21,10 @@ import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from accounting import db
|
||||
from accounting.models import Option, Account, Currency
|
||||
from accounting.utils.current_account import CurrentAccount
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .current_account import CurrentAccount
|
||||
from .user import get_current_user_pk
|
||||
from .. import db
|
||||
from ..models import Option, Account, Currency
|
||||
|
||||
|
||||
class RecurringItem:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -19,14 +19,13 @@
|
||||
This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
from typing import TypeVar, Generic
|
||||
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
|
||||
ParseResult
|
||||
|
||||
from flask import request
|
||||
from werkzeug.routing import RequestRedirect
|
||||
|
||||
from accounting.locale import pgettext
|
||||
from ..locale import pgettext
|
||||
|
||||
|
||||
class Link:
|
||||
@@ -62,11 +61,8 @@ class Redirection(RequestRedirect):
|
||||
DEFAULT_PAGE_SIZE: int = 10
|
||||
"""The default page size."""
|
||||
|
||||
T = TypeVar("T")
|
||||
"""The pagination item type."""
|
||||
|
||||
|
||||
class Pagination(Generic[T]):
|
||||
class Pagination[T]:
|
||||
"""The pagination utility."""
|
||||
|
||||
def __init__(self, items: list[T], is_reversed: bool = False):
|
||||
@@ -92,7 +88,7 @@ class Pagination(Generic[T]):
|
||||
"""The options to the number of items in a page."""
|
||||
|
||||
|
||||
class AbstractPagination(Generic[T]):
|
||||
class AbstractPagination[T]:
|
||||
"""An abstract pagination."""
|
||||
|
||||
def __init__(self):
|
||||
@@ -109,12 +105,12 @@ class AbstractPagination(Generic[T]):
|
||||
"""The options to the number of items in a page."""
|
||||
|
||||
|
||||
class EmptyPagination(AbstractPagination[T]):
|
||||
class EmptyPagination[T](AbstractPagination[T]):
|
||||
"""The pagination from empty data."""
|
||||
pass
|
||||
|
||||
|
||||
class NonEmptyPagination(AbstractPagination[T]):
|
||||
class NonEmptyPagination[T](AbstractPagination[T]):
|
||||
"""The pagination with real data."""
|
||||
PAGE_SIZE_OPTION_VALUES: list[int] = [10, 100, 200]
|
||||
"""The page size options."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -23,7 +23,7 @@ from collections.abc import Callable
|
||||
|
||||
from flask import abort, Blueprint, Response
|
||||
|
||||
from accounting.utils.user import get_current_user, UserUtilityInterface
|
||||
from .user import get_current_user, UserUtilityInterface
|
||||
|
||||
|
||||
def has_permission(rule: Callable[[], bool]) -> Callable:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -35,7 +35,8 @@ def parse_query_keywords(q: str | None) -> list[str]:
|
||||
return []
|
||||
keywords: list[str] = []
|
||||
while True:
|
||||
m: re.Match
|
||||
m: re.Match[str] | None
|
||||
assert q is not None
|
||||
m = re.match(r"\"([^\"]+)\"\s+(.+)$", q)
|
||||
if m is not None:
|
||||
keywords.append(m.group(1))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,12 +20,12 @@ This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
from secrets import randbelow
|
||||
from typing import Type
|
||||
|
||||
from accounting import db
|
||||
from .. import db
|
||||
from ..utils.user import base_cls
|
||||
|
||||
|
||||
def new_id(cls: Type[db.Model]):
|
||||
def new_id(cls: type[base_cls]):
|
||||
"""Generates and returns a new, unused random ID for the data model.
|
||||
|
||||
:param cls: The data model.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2024/6/4
|
||||
|
||||
# Copyright (c) 2024-2026 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 timezone utility.
|
||||
|
||||
This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
import datetime as dt
|
||||
|
||||
import pytz
|
||||
from flask import request
|
||||
|
||||
|
||||
def get_tz_today() -> dt.date:
|
||||
"""Returns today in the client timezone.
|
||||
|
||||
:return: today in the client timezone.
|
||||
"""
|
||||
tz_name: str | None = request.cookies.get("accounting-tz")
|
||||
if tz_name is None:
|
||||
return dt.date.today()
|
||||
return dt.datetime.now(tz=pytz.timezone(tz_name)).date()
|
||||
@@ -48,7 +48,7 @@ def title_case(s: str) -> str:
|
||||
return re.sub(r"\w+", __cap_word, s)
|
||||
|
||||
|
||||
def __cap_word(m: re.Match) -> str:
|
||||
def __cap_word(m: re.Match[str]) -> str:
|
||||
"""Capitalize a matched title word.
|
||||
|
||||
:param m: The matched title word.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,17 +20,13 @@ This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TypeVar, Generic, Type
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import g, Response
|
||||
from flask_sqlalchemy.model import Model
|
||||
|
||||
T = TypeVar("T", bound=Model)
|
||||
"""The user data model data type."""
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class UserUtilityInterface(Generic[T], ABC):
|
||||
class UserUtilityInterface[T: DeclarativeBase](ABC):
|
||||
"""The interface for the user utilities."""
|
||||
|
||||
@abstractmethod
|
||||
@@ -73,7 +69,15 @@ class UserUtilityInterface(Generic[T], ABC):
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def cls(self) -> Type[T]:
|
||||
def base(self) -> type[DeclarativeBase]:
|
||||
"""Returns the base data model.
|
||||
|
||||
:return: The base data model.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def cls(self) -> type[T]:
|
||||
"""Returns the class of the user data model.
|
||||
|
||||
:return: The class of the user data model.
|
||||
@@ -113,7 +117,9 @@ class UserUtilityInterface(Generic[T], ABC):
|
||||
|
||||
__user_utils: UserUtilityInterface
|
||||
"""The user utilities."""
|
||||
user_cls: Type[Model] = Model
|
||||
base_cls = DeclarativeBase
|
||||
"""The base data model."""
|
||||
type user_cls = DeclarativeBase
|
||||
"""The user class."""
|
||||
user_pk_column: sa.Column = sa.Column(sa.Integer)
|
||||
"""The primary key column of the user class."""
|
||||
@@ -125,8 +131,9 @@ def init_user_utils(utils: UserUtilityInterface) -> None:
|
||||
:param utils: The user utilities.
|
||||
:return: None.
|
||||
"""
|
||||
global __user_utils, user_cls, user_pk_column
|
||||
global __user_utils, base_cls, user_cls, user_pk_column
|
||||
__user_utils = utils
|
||||
base_cls = utils.base
|
||||
user_cls = utils.cls
|
||||
user_pk_column = utils.pk_column
|
||||
|
||||
|
||||
+56
-31
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -21,6 +21,7 @@ import datetime as dt
|
||||
import unittest
|
||||
|
||||
import httpx
|
||||
import sqlalchemy as sa
|
||||
from flask import Flask
|
||||
|
||||
from accounting.utils.next_uri import encode_next
|
||||
@@ -72,14 +73,10 @@ class AccountTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.__app: Flask = create_test_app()
|
||||
self.__app: Flask = create_test_app(is_skip_accounts=True)
|
||||
"""The Flask application."""
|
||||
|
||||
with self.__app.app_context():
|
||||
from accounting.models import Account, AccountL10n
|
||||
AccountL10n.query.delete()
|
||||
Account.query.delete()
|
||||
db.session.commit()
|
||||
self.__encoded_next_uri: str = encode_next(NEXT_URI)
|
||||
"""The encoded next URI."""
|
||||
|
||||
@@ -105,6 +102,15 @@ class AccountTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.headers["Location"],
|
||||
f"{PREFIX}/{BANK.code}")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Tears down the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
with self.__app.app_context():
|
||||
db.engine.dispose()
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
@@ -270,7 +276,9 @@ class AccountTestCase(unittest.TestCase):
|
||||
response: httpx.Response
|
||||
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Account.query.all()},
|
||||
self.assertEqual(
|
||||
{x.code
|
||||
for x in db.session.scalars(sa.select(Account)).unique()},
|
||||
{CASH.code, BANK.code})
|
||||
|
||||
# Missing CSRF token
|
||||
@@ -329,8 +337,8 @@ class AccountTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
|
||||
# Success, with spaces to be stripped
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
response = self.__client.post(
|
||||
store_uri, data={"csrf_token": self.__csrf_token,
|
||||
"base_code": f" {STOCK.base_code} ",
|
||||
"title": f" {STOCK.title} "})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
@@ -347,7 +355,9 @@ class AccountTestCase(unittest.TestCase):
|
||||
|
||||
# Success under the same base, with order in a mess.
|
||||
with self.__app.app_context():
|
||||
stock_2: Account = Account.find_by_code(f"{STOCK.base_code}-002")
|
||||
stock_2: Account | None = \
|
||||
Account.find_by_code(f"{STOCK.base_code}-002")
|
||||
self.assertIsNotNone(stock_2)
|
||||
stock_2.no = 66
|
||||
db.session.commit()
|
||||
|
||||
@@ -360,12 +370,14 @@ class AccountTestCase(unittest.TestCase):
|
||||
f"{PREFIX}/{STOCK.base_code}-003")
|
||||
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Account.query.all()},
|
||||
self.assertEqual(
|
||||
{x.code
|
||||
for x in db.session.scalars(sa.select(Account)).unique()},
|
||||
{CASH.code, BANK.code, STOCK.code,
|
||||
f"{STOCK.base_code}-002",
|
||||
f"{STOCK.base_code}-003"})
|
||||
f"{STOCK.base_code}-002", f"{STOCK.base_code}-003"})
|
||||
|
||||
account: Account = Account.find_by_code(STOCK.code)
|
||||
account: Account | None = Account.find_by_code(STOCK.code)
|
||||
self.assertIsNotNone(account)
|
||||
self.assertEqual(account.base_code, STOCK.base_code)
|
||||
self.assertEqual(account.title_l10n, STOCK.title)
|
||||
|
||||
@@ -390,7 +402,8 @@ class AccountTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.__app.app_context():
|
||||
account: Account = Account.find_by_code(CASH.code)
|
||||
account: Account | None = Account.find_by_code(CASH.code)
|
||||
self.assertIsNotNone(account)
|
||||
self.assertEqual(account.base_code, CASH.base_code)
|
||||
self.assertEqual(account.title_l10n, f"{CASH.title}-1")
|
||||
|
||||
@@ -457,7 +470,7 @@ class AccountTestCase(unittest.TestCase):
|
||||
from accounting.models import Account
|
||||
detail_uri: str = f"{PREFIX}/{CASH.code}"
|
||||
update_uri: str = f"{PREFIX}/{CASH.code}/update"
|
||||
account: Account
|
||||
account: Account | None
|
||||
response: httpx.Response
|
||||
|
||||
response = self.__client.post(update_uri,
|
||||
@@ -499,11 +512,12 @@ class AccountTestCase(unittest.TestCase):
|
||||
csrf_token: str = get_csrf_token(client)
|
||||
detail_uri: str = f"{PREFIX}/{CASH.code}"
|
||||
update_uri: str = f"{PREFIX}/{CASH.code}/update"
|
||||
account: Account
|
||||
account: Account | None
|
||||
response: httpx.Response
|
||||
|
||||
with self.__app.app_context():
|
||||
account = Account.find_by_code(CASH.code)
|
||||
self.assertIsNotNone(account)
|
||||
self.assertEqual(account.created_by.username, editor_username)
|
||||
self.assertEqual(account.updated_by.username, editor_username)
|
||||
|
||||
@@ -529,11 +543,12 @@ class AccountTestCase(unittest.TestCase):
|
||||
from accounting.models import Account
|
||||
detail_uri: str = f"{PREFIX}/{CASH.code}"
|
||||
update_uri: str = f"{PREFIX}/{CASH.code}/update"
|
||||
account: Account
|
||||
account: Account | None
|
||||
response: httpx.Response
|
||||
|
||||
with self.__app.app_context():
|
||||
account = Account.find_by_code(CASH.code)
|
||||
self.assertIsNotNone(account)
|
||||
self.assertEqual(account.title_l10n, CASH.title)
|
||||
self.assertEqual(account.l10n, [])
|
||||
|
||||
@@ -548,6 +563,7 @@ class AccountTestCase(unittest.TestCase):
|
||||
|
||||
with self.__app.app_context():
|
||||
account = Account.find_by_code(CASH.code)
|
||||
self.assertIsNotNone(account)
|
||||
self.assertEqual(account.title_l10n, CASH.title)
|
||||
self.assertEqual({(x.locale, x.title) for x in account.l10n},
|
||||
{("zh_Hant", f"{CASH.title}-zh_Hant")})
|
||||
@@ -569,8 +585,8 @@ class AccountTestCase(unittest.TestCase):
|
||||
|
||||
set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant")
|
||||
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
response = self.__client.post(
|
||||
update_uri, data={"csrf_token": self.__csrf_token,
|
||||
"base_code": CASH.base_code,
|
||||
"title": f"{CASH.title}-zh_Hant-2"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
@@ -609,7 +625,9 @@ class AccountTestCase(unittest.TestCase):
|
||||
"currency-1-credit-1-amount": "20"})
|
||||
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Account.query.all()},
|
||||
self.assertEqual(
|
||||
{x.code
|
||||
for x in db.session.scalars(sa.select(Account)).unique()},
|
||||
{CASH.code, PETTY.code, BANK.code})
|
||||
|
||||
# Cannot delete the cash account
|
||||
@@ -633,7 +651,9 @@ class AccountTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.headers["Location"], list_uri)
|
||||
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Account.query.all()},
|
||||
self.assertEqual(
|
||||
{x.code
|
||||
for x in db.session.scalars(sa.select(Account)).unique()},
|
||||
{CASH.code, BANK.code})
|
||||
|
||||
response = self.__client.get(detail_uri)
|
||||
@@ -651,8 +671,8 @@ class AccountTestCase(unittest.TestCase):
|
||||
response: httpx.Response
|
||||
|
||||
for i in range(2, 6):
|
||||
response = self.__client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
response = self.__client.post(
|
||||
f"{PREFIX}/store", data={"csrf_token": self.__csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "Title"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
@@ -660,15 +680,20 @@ class AccountTestCase(unittest.TestCase):
|
||||
f"{PREFIX}/1111-00{i}")
|
||||
|
||||
with self.__app.app_context():
|
||||
account_1: Account = Account.find_by_code("1111-001")
|
||||
account_1: Account | None = Account.find_by_code("1111-001")
|
||||
self.assertIsNotNone(account_1)
|
||||
id_1: int = account_1.id
|
||||
account_2: Account = Account.find_by_code("1111-002")
|
||||
account_2: Account | None = Account.find_by_code("1111-002")
|
||||
self.assertIsNotNone(account_2)
|
||||
id_2: int = account_2.id
|
||||
account_3: Account = Account.find_by_code("1111-003")
|
||||
account_3: Account | None = Account.find_by_code("1111-003")
|
||||
self.assertIsNotNone(account_3)
|
||||
id_3: int = account_3.id
|
||||
account_4: Account = Account.find_by_code("1111-004")
|
||||
account_4: Account | None = Account.find_by_code("1111-004")
|
||||
self.assertIsNotNone(account_4)
|
||||
id_4: int = account_4.id
|
||||
account_5: Account = Account.find_by_code("1111-005")
|
||||
account_5: Account | None = Account.find_by_code("1111-005")
|
||||
self.assertIsNotNone(account_5)
|
||||
id_5: int = account_5.id
|
||||
account_1.no = 3
|
||||
account_2.no = 5
|
||||
@@ -702,8 +727,8 @@ class AccountTestCase(unittest.TestCase):
|
||||
response: httpx.Response
|
||||
|
||||
for i in range(2, 6):
|
||||
response = self.__client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
response = self.__client.post(
|
||||
f"{PREFIX}/store", data={"csrf_token": self.__csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "Title"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -22,6 +22,7 @@ import unittest
|
||||
import httpx
|
||||
from flask import Flask
|
||||
|
||||
from test_site import db
|
||||
from testlib import create_test_app, get_client
|
||||
|
||||
LIST_URI: str = "/accounting/base-accounts"
|
||||
@@ -42,6 +43,15 @@ class BaseAccountTestCase(unittest.TestCase):
|
||||
self.__app: Flask = create_test_app()
|
||||
"""The Flask application."""
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Tears down the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
with self.__app.app_context():
|
||||
db.engine.dispose()
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
|
||||
+32
-17
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -29,7 +29,7 @@ from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
from sqlalchemy.sql.ddl import DropTable
|
||||
|
||||
from test_site import db
|
||||
from test_site import db, Base
|
||||
from testlib import create_test_app
|
||||
|
||||
|
||||
@@ -45,6 +45,15 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
self.__app: Flask = create_test_app()
|
||||
"""The Flask application."""
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Tears down the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
with self.__app.app_context():
|
||||
db.engine.dispose()
|
||||
|
||||
def test_init_db(self) -> None:
|
||||
"""Tests the "accounting-init-db" console command.
|
||||
|
||||
@@ -54,7 +63,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
# Drop every accounting table, to see if accounting-init-db
|
||||
# recreates them correctly.
|
||||
tables: list[sa.Table] \
|
||||
= [db.metadata.tables[x] for x in db.metadata.tables
|
||||
= [Base.metadata.tables[x] for x in Base.metadata.tables
|
||||
if x.startswith("accounting_")]
|
||||
for table in tables:
|
||||
db.session.execute(DropTable(table))
|
||||
@@ -84,7 +93,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
|
||||
with open(data_dir / "base_accounts.csv") as fp:
|
||||
rows: list[dict[str, str]] = list(csv.DictReader(fp))
|
||||
data: dict[dict[str, Any]] \
|
||||
data: dict[str, dict[str, Any]] \
|
||||
= {x["code"]: {"code": x["code"],
|
||||
"title": x["title"],
|
||||
"l10n": {y[5:]: x[y]
|
||||
@@ -92,7 +101,8 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
for x in rows}
|
||||
|
||||
with self.__app.app_context():
|
||||
accounts: list[BaseAccount] = BaseAccount.query.all()
|
||||
accounts: list[BaseAccount] = db.session.scalars(
|
||||
sa.select(BaseAccount)).unique().all()
|
||||
|
||||
self.assertEqual(len(accounts), len(data))
|
||||
for account in accounts:
|
||||
@@ -132,10 +142,14 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
from accounting.models import BaseAccount, Account, AccountL10n
|
||||
|
||||
with self.__app.app_context():
|
||||
bases: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(sa.func.char_length(BaseAccount.code) == 4).all()
|
||||
accounts: list[Account] = Account.query.all()
|
||||
l10n: list[AccountL10n] = AccountL10n.query.all()
|
||||
bases: list[BaseAccount] = db.session.scalars(
|
||||
sa.select(BaseAccount)
|
||||
.where(sa.func.char_length(BaseAccount.code) == 4))\
|
||||
.unique().all()
|
||||
accounts: list[Account] = db.session.scalars(
|
||||
sa.select(Account)).unique().all()
|
||||
l10n: list[AccountL10n] = db.session.scalars(
|
||||
sa.select(AccountL10n)).all()
|
||||
|
||||
self.assertEqual({x.code for x in bases},
|
||||
{x.base_code for x in accounts})
|
||||
@@ -158,7 +172,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
from accounting.models import Currency
|
||||
|
||||
with open(data_dir / "currencies.csv") as fp:
|
||||
data: dict[dict[str, Any]] \
|
||||
data: dict[str, dict[str, Any]] \
|
||||
= {x["code"]: {"code": x["code"],
|
||||
"name": x["name"],
|
||||
"l10n": {y[5:]: x[y]
|
||||
@@ -166,7 +180,8 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
for x in csv.DictReader(fp)}
|
||||
|
||||
with self.__app.app_context():
|
||||
currencies: list[Currency] = Currency.query.all()
|
||||
currencies: list[Currency] = db.session.scalars(
|
||||
sa.select(Currency)).unique().all()
|
||||
|
||||
self.assertEqual(len(currencies), len(data))
|
||||
for currency in currencies:
|
||||
@@ -192,7 +207,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
with self.__app.app_context():
|
||||
# Resets the accounts.
|
||||
tables: list[sa.Table] \
|
||||
= [db.metadata.tables[x] for x in db.metadata.tables
|
||||
= [Base.metadata.tables[x] for x in Base.metadata.tables
|
||||
if x.startswith("accounting_")]
|
||||
for table in tables:
|
||||
db.session.execute(DropTable(table))
|
||||
@@ -207,9 +222,9 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
result.output + str(result.exception))
|
||||
|
||||
# Turns the titles into lowercase.
|
||||
for base in BaseAccount.query:
|
||||
for base in db.session.scalars(sa.select(BaseAccount)).unique():
|
||||
base.title_l10n = base.title_l10n.lower()
|
||||
for account in Account.query:
|
||||
for account in db.session.scalars(sa.select(Account)).unique():
|
||||
account.title_l10n = account.title_l10n.lower()
|
||||
account.created_at \
|
||||
= account.created_at - dt.timedelta(seconds=5)
|
||||
@@ -221,7 +236,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
new_account: Account = Account(
|
||||
id=new_id(Account),
|
||||
base_code="1112",
|
||||
no="2",
|
||||
no=2,
|
||||
title_l10n=custom_title,
|
||||
is_need_offset=False,
|
||||
created_by_id=creator_pk,
|
||||
@@ -233,9 +248,9 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
args=["accounting-titleize", "-u", "editor"])
|
||||
self.assertEqual(result.exit_code, 0,
|
||||
result.output + str(result.exception))
|
||||
for base in BaseAccount.query:
|
||||
for base in db.session.scalars(sa.select(BaseAccount)).unique():
|
||||
self.__test_title_case(base.title_l10n)
|
||||
for account in Account.query:
|
||||
for account in db.session.scalars(sa.select(Account)).unique():
|
||||
if account.id != new_account.id:
|
||||
self.__test_title_case(account.title_l10n)
|
||||
self.assertNotEqual(account.created_at, account.updated_at)
|
||||
|
||||
+24
-12
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -21,6 +21,7 @@ import datetime as dt
|
||||
import unittest
|
||||
|
||||
import httpx
|
||||
import sqlalchemy as sa
|
||||
from flask import Flask
|
||||
|
||||
from accounting.utils.next_uri import encode_next
|
||||
@@ -65,15 +66,9 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.__app: Flask = create_test_app()
|
||||
self.__app: Flask = create_test_app(is_skip_currencies=True)
|
||||
"""The Flask application."""
|
||||
|
||||
with self.__app.app_context():
|
||||
from accounting.models import Currency, CurrencyL10n
|
||||
CurrencyL10n.query.delete()
|
||||
Currency.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
self.__client: httpx.Client = get_client(self.__app, "editor")
|
||||
"""The user client."""
|
||||
self.__csrf_token: str = get_csrf_token(self.__client)
|
||||
@@ -94,6 +89,15 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Tears down the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
with self.__app.app_context():
|
||||
db.engine.dispose()
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
@@ -218,7 +222,9 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
response: httpx.Response
|
||||
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Currency.query.all()},
|
||||
self.assertEqual(
|
||||
{x.code
|
||||
for x in db.session.scalars(sa.select(Currency)).unique()},
|
||||
{USD.code, EUR.code})
|
||||
|
||||
# Missing CSRF token
|
||||
@@ -284,7 +290,9 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Currency.query.all()},
|
||||
self.assertEqual(
|
||||
{x.code
|
||||
for x in db.session.scalars(sa.select(Currency)).unique()},
|
||||
{USD.code, EUR.code, TWD.code})
|
||||
|
||||
currency: Currency = db.session.get(Currency, TWD.code)
|
||||
@@ -551,7 +559,9 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
"currency-1-credit-1-amount": "20"})
|
||||
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Currency.query.all()},
|
||||
self.assertEqual(
|
||||
{x.code
|
||||
for x in db.session.scalars(sa.select(Currency)).unique()},
|
||||
{USD.code, EUR.code, JPY.code})
|
||||
|
||||
# Cannot delete the default currency
|
||||
@@ -575,7 +585,9 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.headers["Location"], list_uri)
|
||||
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Currency.query.all()},
|
||||
self.assertEqual(
|
||||
{x.code
|
||||
for x in db.session.scalars(sa.select(Currency)).unique()},
|
||||
{USD.code, EUR.code})
|
||||
|
||||
response = self.__client.get(detail_uri)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/28
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -24,6 +24,7 @@ import httpx
|
||||
from flask import Flask
|
||||
|
||||
from accounting.utils.next_uri import encode_next
|
||||
from test_site import db
|
||||
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
|
||||
get_csrf_token, add_journal_entry
|
||||
|
||||
@@ -41,9 +42,6 @@ class DescriptionEditorTestCase(unittest.TestCase):
|
||||
"""The Flask application."""
|
||||
|
||||
with self.__app.app_context():
|
||||
from accounting.models import JournalEntry, JournalEntryLineItem
|
||||
JournalEntry.query.delete()
|
||||
JournalEntryLineItem.query.delete()
|
||||
self.__encoded_next_uri: str = encode_next(NEXT_URI)
|
||||
"""The encoded next URI."""
|
||||
|
||||
@@ -52,6 +50,15 @@ class DescriptionEditorTestCase(unittest.TestCase):
|
||||
self.__csrf_token: str = get_csrf_token(self.__client)
|
||||
"""The CSRF token."""
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Tears down the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
with self.__app.app_context():
|
||||
db.engine.dispose()
|
||||
|
||||
def test_description_editor(self) -> None:
|
||||
"""Test the description editor.
|
||||
|
||||
|
||||
+37
-14
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/24
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -52,9 +52,6 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
|
||||
"""The Flask application."""
|
||||
|
||||
with self.__app.app_context():
|
||||
from accounting.models import JournalEntry, JournalEntryLineItem
|
||||
JournalEntry.query.delete()
|
||||
JournalEntryLineItem.query.delete()
|
||||
self.__encoded_next_uri: str = encode_next(NEXT_URI)
|
||||
"""The encoded next URI."""
|
||||
|
||||
@@ -63,6 +60,15 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
|
||||
self.__csrf_token: str = get_csrf_token(self.__client)
|
||||
"""The CSRF token."""
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Tears down the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
with self.__app.app_context():
|
||||
db.engine.dispose()
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
@@ -677,9 +683,6 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
|
||||
"""The Flask application."""
|
||||
|
||||
with self.__app.app_context():
|
||||
from accounting.models import JournalEntry, JournalEntryLineItem
|
||||
JournalEntry.query.delete()
|
||||
JournalEntryLineItem.query.delete()
|
||||
self.__encoded_next_uri: str = encode_next(NEXT_URI)
|
||||
"""The encoded next URI."""
|
||||
|
||||
@@ -688,6 +691,15 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
|
||||
self.__csrf_token: str = get_csrf_token(self.__client)
|
||||
"""The CSRF token."""
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Tears down the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
with self.__app.app_context():
|
||||
db.engine.dispose()
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
@@ -1277,10 +1289,6 @@ class TransferJournalEntryTestCase(unittest.TestCase):
|
||||
"""The Flask application."""
|
||||
|
||||
with self.__app.app_context():
|
||||
from accounting.models import JournalEntry, \
|
||||
JournalEntryLineItem
|
||||
JournalEntry.query.delete()
|
||||
JournalEntryLineItem.query.delete()
|
||||
self.__encoded_next_uri: str = encode_next(NEXT_URI)
|
||||
"""The encoded next URI."""
|
||||
|
||||
@@ -1289,6 +1297,15 @@ class TransferJournalEntryTestCase(unittest.TestCase):
|
||||
self.__csrf_token: str = get_csrf_token(self.__client)
|
||||
"""The CSRF token."""
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Tears down the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
with self.__app.app_context():
|
||||
db.engine.dispose()
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
@@ -2158,9 +2175,6 @@ class JournalEntryReorderTestCase(unittest.TestCase):
|
||||
"""The Flask application."""
|
||||
|
||||
with self.__app.app_context():
|
||||
from accounting.models import JournalEntry, JournalEntryLineItem
|
||||
JournalEntry.query.delete()
|
||||
JournalEntryLineItem.query.delete()
|
||||
self.__encoded_next_uri: str = encode_next(NEXT_URI)
|
||||
"""The encoded next URI."""
|
||||
|
||||
@@ -2169,6 +2183,15 @@ class JournalEntryReorderTestCase(unittest.TestCase):
|
||||
self.__csrf_token: str = get_csrf_token(self.__client)
|
||||
"""The CSRF token."""
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Tears down the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
with self.__app.app_context():
|
||||
db.engine.dispose()
|
||||
|
||||
def test_change_date(self) -> None:
|
||||
"""Tests to change the date of a journal entry.
|
||||
|
||||
|
||||
+10
-4
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/11
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -49,9 +49,6 @@ class OffsetTestCase(unittest.TestCase):
|
||||
"""The Flask application."""
|
||||
|
||||
with self.__app.app_context():
|
||||
from accounting.models import JournalEntry, JournalEntryLineItem
|
||||
JournalEntry.query.delete()
|
||||
JournalEntryLineItem.query.delete()
|
||||
self.__encoded_next_uri: str = encode_next(NEXT_URI)
|
||||
"""The encoded next URI."""
|
||||
|
||||
@@ -63,6 +60,15 @@ class OffsetTestCase(unittest.TestCase):
|
||||
"""The offset test data."""
|
||||
self.__data.populate()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Tears down the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
with self.__app.app_context():
|
||||
db.engine.dispose()
|
||||
|
||||
def test_add_receivable_offset(self) -> None:
|
||||
"""Tests to add the receivable offset.
|
||||
|
||||
|
||||
+10
-3
@@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2026 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -45,8 +45,6 @@ class OptionTestCase(unittest.TestCase):
|
||||
"""The Flask application."""
|
||||
|
||||
with self.__app.app_context():
|
||||
from accounting.models import Option
|
||||
Option.query.delete()
|
||||
self.__encoded_next_uri: str = encode_next(NEXT_URI)
|
||||
"""The encoded next URI."""
|
||||
|
||||
@@ -55,6 +53,15 @@ class OptionTestCase(unittest.TestCase):
|
||||
self.__csrf_token: str = get_csrf_token(self.__client)
|
||||
"""The CSRF token."""
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Tears down the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
with self.__app.app_context():
|
||||
db.engine.dispose()
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user