2020-07-14 10:02:16 +08:00
|
|
|
# The accounting application of the Mia project.
|
|
|
|
# by imacat <imacat@mail.imacat.idv.tw>, 2020/7/13
|
|
|
|
|
|
|
|
# Copyright (c) 2020 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 utilities of the accounting application.
|
|
|
|
|
|
|
|
"""
|
2020-08-05 07:48:50 +08:00
|
|
|
import datetime
|
2020-08-04 09:55:27 +08:00
|
|
|
import json
|
2020-07-28 22:48:42 +08:00
|
|
|
import re
|
2020-08-13 07:25:35 +08:00
|
|
|
from typing import Union, Tuple, List, Optional, Iterable, Mapping, Dict
|
2020-07-14 10:02:16 +08:00
|
|
|
|
2020-07-14 07:41:19 +08:00
|
|
|
from django.conf import settings
|
2020-08-02 14:29:45 +08:00
|
|
|
from django.core.exceptions import ObjectDoesNotExist
|
2020-08-04 09:55:27 +08:00
|
|
|
from django.db.models import Q, Sum, Case, When, F, Count, Max, Min, Value, \
|
|
|
|
CharField
|
|
|
|
from django.db.models.functions import StrIndex, Left
|
2020-07-14 07:41:19 +08:00
|
|
|
from django.urls import reverse
|
2020-07-23 09:40:21 +08:00
|
|
|
from django.utils import timezone
|
2020-08-06 00:41:29 +08:00
|
|
|
from django.utils.translation import gettext as _
|
2020-07-14 07:41:19 +08:00
|
|
|
|
|
|
|
from mia_core.period import Period
|
2020-08-09 13:27:00 +08:00
|
|
|
from mia_core.templatetags.mia_core import smart_month
|
2020-07-23 23:24:42 +08:00
|
|
|
from mia_core.utils import new_pk
|
2020-08-09 13:27:00 +08:00
|
|
|
from .forms import TransactionForm, RecordForm
|
|
|
|
from .models import Account, Transaction, Record
|
2020-07-14 07:41:19 +08:00
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
AccountData = Tuple[Union[str, int], str, str, str]
|
|
|
|
RecordData = Tuple[Union[str, int], Optional[str], int]
|
|
|
|
|
2020-08-12 00:20:02 +08:00
|
|
|
DEFAULT_CASH_ACCOUNT = "1111"
|
|
|
|
CASH_SHORTCUT_ACCOUNTS = ["0", "1111"]
|
|
|
|
DEFAULT_LEDGER_ACCOUNT = "1111"
|
|
|
|
PAYABLE_ACCOUNTS = ["2141", "21413"]
|
|
|
|
EQUIPMENT_ACCOUNTS = ["1441"],
|
|
|
|
|
2020-07-14 07:41:19 +08:00
|
|
|
|
2020-08-03 22:48:43 +08:00
|
|
|
class MonthlySummary:
|
|
|
|
"""A summary record.
|
|
|
|
|
|
|
|
Args:
|
2020-08-13 07:25:35 +08:00
|
|
|
month: The month.
|
|
|
|
label: The text label.
|
|
|
|
credit: The credit amount.
|
|
|
|
debit: The debit amount.
|
|
|
|
balance: The balance.
|
|
|
|
cumulative_balance: The cumulative balance.
|
2020-08-03 22:48:43 +08:00
|
|
|
|
|
|
|
Attributes:
|
|
|
|
month (datetime.date): The month.
|
|
|
|
label (str): The text label.
|
|
|
|
credit (int): The credit amount.
|
|
|
|
debit (int): The debit amount.
|
|
|
|
balance (int): The balance.
|
|
|
|
cumulative_balance (int): The cumulative balance.
|
|
|
|
"""
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def __init__(self, month: datetime.date = None, label: str = None,
|
|
|
|
credit: int = None, debit: int = None, balance: int = None,
|
|
|
|
cumulative_balance: int = None):
|
2020-08-03 22:48:43 +08:00
|
|
|
self.month = month
|
|
|
|
self.label = label
|
|
|
|
self.credit = credit
|
|
|
|
self.debit = debit
|
|
|
|
self.balance = balance
|
|
|
|
self.cumulative_balance = cumulative_balance
|
|
|
|
if self.label is None and self.month is not None:
|
|
|
|
self.label = smart_month(self.month)
|
|
|
|
|
|
|
|
|
2020-07-14 07:41:19 +08:00
|
|
|
class ReportUrl:
|
|
|
|
"""The URL of the accounting reports.
|
|
|
|
|
|
|
|
Args:
|
2020-08-17 23:05:01 +08:00
|
|
|
namespace: The namespace of the current application.
|
2020-08-13 07:25:35 +08:00
|
|
|
cash: The currently-specified account of the
|
2020-08-07 09:41:02 +08:00
|
|
|
cash account or cash summary.
|
2020-08-13 07:25:35 +08:00
|
|
|
ledger: The currently-specified account of the
|
2020-08-07 09:41:02 +08:00
|
|
|
ledger or leger summary.
|
2020-08-13 07:25:35 +08:00
|
|
|
period: The currently-specified period.
|
2020-07-14 07:41:19 +08:00
|
|
|
"""
|
|
|
|
|
2020-08-17 23:05:01 +08:00
|
|
|
def __init__(self, namespace: str, cash: Account = None,
|
|
|
|
ledger: Account = None, period: Period = None,):
|
2020-08-07 09:41:02 +08:00
|
|
|
self._period = Period() if period is None else period
|
2020-08-12 00:20:02 +08:00
|
|
|
self._cash = get_default_cash_account() if cash is None else cash
|
|
|
|
self._ledger = get_default_ledger_account()\
|
2020-08-07 09:41:02 +08:00
|
|
|
if ledger is None else ledger
|
2020-08-17 23:05:01 +08:00
|
|
|
self._namespace = namespace
|
2020-07-14 07:41:19 +08:00
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def cash(self) -> str:
|
2020-08-17 23:05:01 +08:00
|
|
|
return reverse("accounting:cash", args=[self._cash, self._period],
|
|
|
|
current_app=self._namespace)
|
2020-07-14 07:41:19 +08:00
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def cash_summary(self) -> str:
|
2020-08-17 23:05:01 +08:00
|
|
|
return reverse("accounting:cash-summary", args=[self._cash],
|
|
|
|
current_app=self._namespace)
|
2020-07-14 07:41:19 +08:00
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def ledger(self) -> str:
|
2020-08-17 23:05:01 +08:00
|
|
|
return reverse("accounting:ledger", args=[self._ledger, self._period],
|
|
|
|
current_app=self._namespace)
|
2020-07-14 07:41:19 +08:00
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def ledger_summary(self) -> str:
|
2020-08-17 23:05:01 +08:00
|
|
|
return reverse("accounting:ledger-summary", args=[self._ledger],
|
|
|
|
current_app=self._namespace)
|
2020-07-14 07:41:19 +08:00
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def journal(self) -> str:
|
2020-08-17 23:05:01 +08:00
|
|
|
return reverse("accounting:journal", args=[self._period],
|
|
|
|
current_app=self._namespace)
|
2020-07-14 07:41:19 +08:00
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def trial_balance(self) -> str:
|
2020-08-17 23:05:01 +08:00
|
|
|
return reverse("accounting:trial-balance", args=[self._period],
|
|
|
|
current_app=self._namespace)
|
2020-07-14 07:41:19 +08:00
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def income_statement(self) -> str:
|
2020-08-17 23:05:01 +08:00
|
|
|
return reverse("accounting:income-statement", args=[self._period],
|
|
|
|
current_app=self._namespace)
|
2020-07-14 07:41:19 +08:00
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def balance_sheet(self) -> str:
|
2020-08-17 23:05:01 +08:00
|
|
|
return reverse("accounting:balance-sheet", args=[self._period],
|
|
|
|
current_app=self._namespace)
|
2020-07-23 09:40:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
class Populator:
|
|
|
|
"""The helper to populate the accounting data.
|
|
|
|
|
|
|
|
Args:
|
2020-08-13 07:25:35 +08:00
|
|
|
user: The user in action.
|
2020-07-23 09:40:21 +08:00
|
|
|
|
|
|
|
Attributes:
|
|
|
|
user (User): The user in action.
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __init__(self, user):
|
|
|
|
self.user = user
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def add_accounts(self, accounts: List[AccountData]) -> None:
|
2020-07-23 09:40:21 +08:00
|
|
|
"""Adds accounts.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
accounts (tuple[tuple[any]]): Tuples of
|
|
|
|
(code, Traditional Chinese, English, Simplified Chinese)
|
|
|
|
of the accounts.
|
|
|
|
"""
|
|
|
|
for data in accounts:
|
|
|
|
code = data[0]
|
|
|
|
if isinstance(code, int):
|
|
|
|
code = str(code)
|
|
|
|
parent = None if len(code) == 1\
|
|
|
|
else Account.objects.get(code=code[:-1])
|
2020-07-23 23:24:42 +08:00
|
|
|
Account(pk=new_pk(Account), parent=parent, code=code,
|
2020-07-23 09:40:21 +08:00
|
|
|
title_zh_hant=data[1], title_en=data[2],
|
|
|
|
title_zh_hans=data[3],
|
|
|
|
created_by=self.user, updated_by=self.user).save()
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def add_transfer_transaction(self, date: Union[datetime.date, int],
|
|
|
|
debit: List[RecordData],
|
|
|
|
credit: List[RecordData]) -> None:
|
2020-07-23 09:40:21 +08:00
|
|
|
"""Adds a transfer transaction.
|
|
|
|
|
|
|
|
Args:
|
2020-08-13 07:25:35 +08:00
|
|
|
date: The date, or the number of days from
|
2020-07-23 09:40:21 +08:00
|
|
|
today.
|
2020-08-13 07:25:35 +08:00
|
|
|
debit: Tuples of (account, summary, amount) of the debit records.
|
|
|
|
credit: Tuples of (account, summary, amount) of the credit records.
|
2020-07-23 09:40:21 +08:00
|
|
|
"""
|
|
|
|
if isinstance(date, int):
|
|
|
|
date = timezone.localdate() + timezone.timedelta(days=date)
|
|
|
|
order = Transaction.objects.filter(date=date).count() + 1
|
2020-07-23 23:24:42 +08:00
|
|
|
transaction = Transaction(pk=new_pk(Transaction), date=date, ord=order,
|
2020-07-23 09:40:21 +08:00
|
|
|
created_by=self.user, updated_by=self.user)
|
|
|
|
transaction.save()
|
|
|
|
order = 1
|
|
|
|
for data in debit:
|
|
|
|
account = data[0]
|
|
|
|
if isinstance(account, str):
|
|
|
|
account = Account.objects.get(code=account)
|
|
|
|
elif isinstance(account, int):
|
|
|
|
account = Account.objects.get(code=str(account))
|
2020-07-23 23:24:42 +08:00
|
|
|
transaction.record_set.create(pk=new_pk(Record), is_credit=False,
|
2020-07-23 09:40:21 +08:00
|
|
|
ord=order, account=account,
|
|
|
|
summary=data[1], amount=data[2],
|
|
|
|
created_by=self.user,
|
|
|
|
updated_by=self.user)
|
|
|
|
order = order + 1
|
|
|
|
order = 1
|
|
|
|
for data in credit:
|
|
|
|
account = data[0]
|
|
|
|
if isinstance(account, str):
|
|
|
|
account = Account.objects.get(code=account)
|
|
|
|
elif isinstance(account, int):
|
|
|
|
account = Account.objects.get(code=str(account))
|
2020-07-23 23:24:42 +08:00
|
|
|
transaction.record_set.create(pk=new_pk(Record), is_credit=True,
|
2020-07-23 09:40:21 +08:00
|
|
|
ord=order, account=account,
|
|
|
|
summary=data[1], amount=data[2],
|
|
|
|
created_by=self.user,
|
|
|
|
updated_by=self.user)
|
|
|
|
order = order + 1
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def add_income_transaction(self, date: Union[datetime.date, int],
|
|
|
|
credit: List[RecordData]) -> None:
|
2020-07-23 09:40:21 +08:00
|
|
|
"""Adds a cash income transaction.
|
|
|
|
|
|
|
|
Args:
|
2020-08-13 07:25:35 +08:00
|
|
|
date: The date, or the number of days from today.
|
|
|
|
credit: Tuples of (account, summary, amount) of the credit records.
|
2020-07-23 09:40:21 +08:00
|
|
|
"""
|
|
|
|
amount = sum([x[2] for x in credit])
|
2020-08-02 18:13:04 +08:00
|
|
|
self.add_transfer_transaction(
|
2020-08-13 07:25:35 +08:00
|
|
|
date, [(Account.CASH, None, amount)], credit)
|
2020-07-23 09:40:21 +08:00
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def add_expense_transaction(self, date: Union[datetime.date, int],
|
|
|
|
debit: List[RecordData]) -> None:
|
2020-07-23 09:40:21 +08:00
|
|
|
"""Adds a cash income transaction.
|
|
|
|
|
|
|
|
Args:
|
2020-08-13 07:25:35 +08:00
|
|
|
date: The date, or the number of days from today.
|
|
|
|
debit: Tuples of (account, summary, amount) of the debit records.
|
2020-07-23 09:40:21 +08:00
|
|
|
"""
|
|
|
|
amount = sum([x[2] for x in debit])
|
2020-08-02 18:13:04 +08:00
|
|
|
self.add_transfer_transaction(
|
2020-08-13 07:25:35 +08:00
|
|
|
date, debit, [(Account.CASH, None, amount)])
|
2020-07-23 09:57:29 +08:00
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def get_cash_accounts() -> List[Account]:
|
2020-07-23 09:57:29 +08:00
|
|
|
"""Returns the cash accounts.
|
|
|
|
|
|
|
|
Returns:
|
2020-08-13 07:25:35 +08:00
|
|
|
The cash accounts.
|
2020-07-23 09:57:29 +08:00
|
|
|
"""
|
|
|
|
accounts = list(
|
|
|
|
Account.objects
|
|
|
|
.filter(
|
|
|
|
code__in=Record.objects
|
|
|
|
.filter(
|
|
|
|
Q(account__code__startswith="11")
|
|
|
|
| Q(account__code__startswith="12")
|
|
|
|
| Q(account__code__startswith="21")
|
|
|
|
| Q(account__code__startswith="22"))
|
|
|
|
.values("account__code"))
|
|
|
|
.order_by("code"))
|
|
|
|
accounts.insert(0, Account(
|
|
|
|
code="0",
|
2020-08-06 00:41:29 +08:00
|
|
|
title=_("current assets and liabilities"),
|
2020-07-23 09:57:29 +08:00
|
|
|
))
|
|
|
|
return accounts
|
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def get_default_cash_account() -> Account:
|
2020-08-12 00:20:02 +08:00
|
|
|
"""Returns the default cash account.
|
|
|
|
|
|
|
|
Returns:
|
2020-08-13 07:25:35 +08:00
|
|
|
The default cash account.
|
2020-08-12 00:20:02 +08:00
|
|
|
"""
|
|
|
|
try:
|
|
|
|
code = settings.ACCOUNTING["DEFAULT_CASH_ACCOUNT"]
|
|
|
|
except AttributeError:
|
|
|
|
code = DEFAULT_CASH_ACCOUNT
|
|
|
|
except TypeError:
|
|
|
|
code = DEFAULT_CASH_ACCOUNT
|
|
|
|
except KeyError:
|
|
|
|
code = DEFAULT_CASH_ACCOUNT
|
|
|
|
if code == "0":
|
|
|
|
return Account(code="0", title=_("current assets and liabilities"))
|
|
|
|
try:
|
|
|
|
return Account.objects.get(code=code)
|
|
|
|
except Account.DoesNotExist:
|
|
|
|
pass
|
|
|
|
try:
|
|
|
|
return Account.objects.get(code=DEFAULT_CASH_ACCOUNT)
|
|
|
|
except Account.DoesNotExist:
|
|
|
|
pass
|
|
|
|
return Account(code="0", title=_("current assets and liabilities"))
|
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def get_cash_shortcut_accounts() -> List[str]:
|
2020-08-12 00:20:02 +08:00
|
|
|
"""Returns the codes of the shortcut cash accounts.
|
|
|
|
|
|
|
|
Returns:
|
2020-08-13 07:25:35 +08:00
|
|
|
The codes of the shortcut cash accounts.
|
2020-08-12 00:20:02 +08:00
|
|
|
"""
|
|
|
|
try:
|
|
|
|
accounts = settings.ACCOUNTING["CASH_SHORTCUT_ACCOUNTS"]
|
|
|
|
except AttributeError:
|
|
|
|
return CASH_SHORTCUT_ACCOUNTS
|
|
|
|
except TypeError:
|
|
|
|
return CASH_SHORTCUT_ACCOUNTS
|
|
|
|
except KeyError:
|
|
|
|
return CASH_SHORTCUT_ACCOUNTS
|
|
|
|
if not isinstance(accounts, list):
|
|
|
|
return CASH_SHORTCUT_ACCOUNTS
|
|
|
|
return accounts
|
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def get_ledger_accounts() -> List[Account]:
|
2020-07-23 09:57:29 +08:00
|
|
|
"""Returns the accounts for the ledger.
|
|
|
|
|
|
|
|
Returns:
|
2020-08-13 07:25:35 +08:00
|
|
|
The accounts for the ledger.
|
2020-07-23 09:57:29 +08:00
|
|
|
"""
|
2020-08-12 23:48:29 +08:00
|
|
|
"""
|
|
|
|
For SQL one-liner:
|
|
|
|
SELECT s.*
|
2020-07-23 09:57:29 +08:00
|
|
|
FROM accounting_accounts AS s
|
|
|
|
WHERE s.code IN (SELECT s.code
|
|
|
|
FROM accounting_accounts AS s
|
|
|
|
INNER JOIN (SELECT s.code
|
|
|
|
FROM accounting_accounts AS s
|
2020-08-04 01:59:51 +08:00
|
|
|
INNER JOIN accounting_records AS r ON r.account_id = s.id
|
2020-07-23 09:57:29 +08:00
|
|
|
GROUP BY s.code) AS u
|
2020-08-11 00:32:55 +08:00
|
|
|
ON u.code LIKE s.code || '%%'
|
2020-07-23 09:57:29 +08:00
|
|
|
GROUP BY s.code)
|
2020-08-12 23:48:29 +08:00
|
|
|
ORDER BY s.code
|
|
|
|
"""
|
|
|
|
codes = {}
|
|
|
|
for code in [x.code for x in Account.objects
|
2020-08-13 07:25:35 +08:00
|
|
|
.annotate(Count("record"))
|
|
|
|
.filter(record__count__gt=0)]:
|
2020-08-12 23:48:29 +08:00
|
|
|
while len(code) > 0:
|
|
|
|
codes[code] = True
|
|
|
|
code = code[:-1]
|
|
|
|
return Account.objects.filter(code__in=codes).order_by("code")
|
2020-07-23 09:57:29 +08:00
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def get_default_ledger_account() -> Optional[Account]:
|
2020-08-12 00:20:02 +08:00
|
|
|
"""Returns the default ledger account.
|
|
|
|
|
|
|
|
Returns:
|
2020-08-13 07:25:35 +08:00
|
|
|
The default ledger account.
|
2020-08-12 00:20:02 +08:00
|
|
|
"""
|
|
|
|
try:
|
|
|
|
code = settings.ACCOUNTING["DEFAULT_CASH_ACCOUNT"]
|
|
|
|
except AttributeError:
|
|
|
|
code = DEFAULT_CASH_ACCOUNT
|
|
|
|
except TypeError:
|
|
|
|
code = DEFAULT_CASH_ACCOUNT
|
|
|
|
except KeyError:
|
|
|
|
code = DEFAULT_CASH_ACCOUNT
|
|
|
|
try:
|
|
|
|
return Account.objects.get(code=code)
|
|
|
|
except Account.DoesNotExist:
|
|
|
|
pass
|
|
|
|
try:
|
|
|
|
return Account.objects.get(code=DEFAULT_LEDGER_ACCOUNT)
|
|
|
|
except Account.DoesNotExist:
|
|
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def find_imbalanced(records: Iterable[Record]) -> None:
|
2020-07-23 09:57:29 +08:00
|
|
|
""""Finds the records with imbalanced transactions, and sets their
|
|
|
|
is_balanced attribute.
|
|
|
|
|
|
|
|
Args:
|
2020-08-13 07:25:35 +08:00
|
|
|
records: The accounting records.
|
2020-07-23 09:57:29 +08:00
|
|
|
"""
|
2020-07-23 23:15:33 +08:00
|
|
|
imbalanced = [x.pk for x in Transaction.objects
|
2020-07-23 09:57:29 +08:00
|
|
|
.annotate(
|
|
|
|
balance=Sum(Case(
|
|
|
|
When(record__is_credit=True, then=-1),
|
|
|
|
default=1) * F("record__amount")))
|
|
|
|
.filter(~Q(balance=0))]
|
|
|
|
for record in records:
|
2020-07-23 23:15:33 +08:00
|
|
|
record.is_balanced = record.transaction.pk not in imbalanced
|
2020-07-23 09:57:29 +08:00
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def find_order_holes(records: Iterable[Record]) -> None:
|
2020-07-23 09:57:29 +08:00
|
|
|
""""Finds whether the order of the transactions on this day is not
|
|
|
|
1, 2, 3, 4, 5..., and should be reordered, and sets their
|
|
|
|
has_order_holes attributes.
|
|
|
|
|
|
|
|
Args:
|
2020-08-13 07:25:35 +08:00
|
|
|
records: The accounting records.
|
2020-07-23 09:57:29 +08:00
|
|
|
"""
|
|
|
|
holes = [x["date"] for x in Transaction.objects
|
|
|
|
.values("date")
|
|
|
|
.annotate(count=Count("ord"),
|
|
|
|
max=Max("ord"),
|
|
|
|
min=Min("ord"))
|
|
|
|
.filter(~(Q(max=F("count")) & Q(min=1)))] +\
|
|
|
|
[x["date"] for x in Transaction.objects
|
|
|
|
.values("date", "ord")
|
2020-08-01 23:56:41 +08:00
|
|
|
.annotate(count=Count("pk"))
|
2020-07-23 09:57:29 +08:00
|
|
|
.filter(~Q(count=1))]
|
|
|
|
for record in records:
|
2020-08-05 09:36:09 +08:00
|
|
|
record.has_order_hole = record.pk is not None\
|
|
|
|
and record.transaction.date in holes
|
2020-07-28 22:48:42 +08:00
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def find_payable_records(account: Account, records: Iterable[Record]) -> None:
|
2020-08-07 00:58:33 +08:00
|
|
|
"""Finds and sets the whether the payable record is paid.
|
|
|
|
|
|
|
|
Args:
|
2020-08-13 07:25:35 +08:00
|
|
|
account: The current ledger account.
|
|
|
|
records: The accounting records.
|
2020-08-07 00:58:33 +08:00
|
|
|
"""
|
2020-08-12 00:20:02 +08:00
|
|
|
try:
|
|
|
|
payable_accounts = settings.ACCOUNTING["PAYABLE_ACCOUNTS"]
|
|
|
|
except AttributeError:
|
2020-08-07 00:58:33 +08:00
|
|
|
return
|
2020-08-12 00:20:02 +08:00
|
|
|
except TypeError:
|
2020-08-07 00:58:33 +08:00
|
|
|
return
|
2020-08-12 00:20:02 +08:00
|
|
|
except KeyError:
|
|
|
|
return
|
|
|
|
if not isinstance(payable_accounts, list):
|
|
|
|
return
|
|
|
|
if account.code not in payable_accounts:
|
2020-08-07 00:58:33 +08:00
|
|
|
return
|
|
|
|
rows = Record.objects\
|
|
|
|
.filter(
|
2020-08-12 00:20:02 +08:00
|
|
|
account__code__in=payable_accounts,
|
2020-08-07 00:58:33 +08:00
|
|
|
summary__isnull=False)\
|
|
|
|
.values("account__code", "summary")\
|
|
|
|
.annotate(
|
|
|
|
balance=Sum(Case(When(is_credit=True, then=1), default=-1)
|
|
|
|
* F("amount")))\
|
|
|
|
.filter(~Q(balance=0))
|
|
|
|
keys = ["%s-%s" % (x["account__code"], x["summary"]) for x in rows]
|
|
|
|
for x in [x for x in records
|
|
|
|
if x.pk is not None
|
2020-08-13 07:25:35 +08:00
|
|
|
and F"{x.account.code}-{x.summary}" in keys]:
|
2020-08-07 00:58:33 +08:00
|
|
|
x.is_payable = True
|
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def find_existing_equipments(account: Account,
|
|
|
|
records: Iterable[Record]) -> None:
|
2020-08-07 01:06:57 +08:00
|
|
|
"""Finds and sets the equipments that still exist.
|
|
|
|
|
|
|
|
Args:
|
2020-08-13 07:25:35 +08:00
|
|
|
account: The current ledger account.
|
|
|
|
records: The accounting records.
|
2020-08-07 01:06:57 +08:00
|
|
|
"""
|
2020-08-12 00:20:02 +08:00
|
|
|
try:
|
|
|
|
equipment_accounts = settings.ACCOUNTING["EQUIPMENT_ACCOUNTS"]
|
|
|
|
except AttributeError:
|
|
|
|
return
|
|
|
|
except TypeError:
|
|
|
|
return
|
|
|
|
except KeyError:
|
2020-08-07 01:06:57 +08:00
|
|
|
return
|
2020-08-12 00:20:02 +08:00
|
|
|
if not isinstance(equipment_accounts, list):
|
2020-08-07 01:06:57 +08:00
|
|
|
return
|
2020-08-12 00:20:02 +08:00
|
|
|
if account.code not in equipment_accounts:
|
2020-08-07 01:06:57 +08:00
|
|
|
return
|
|
|
|
rows = Record.objects\
|
|
|
|
.filter(
|
2020-08-12 00:20:02 +08:00
|
|
|
account__code__in=equipment_accounts,
|
2020-08-07 01:06:57 +08:00
|
|
|
summary__isnull=False)\
|
|
|
|
.values("account__code", "summary")\
|
|
|
|
.annotate(
|
|
|
|
balance=Sum(Case(When(is_credit=True, then=1), default=-1)
|
|
|
|
* F("amount")))\
|
|
|
|
.filter(~Q(balance=0))
|
|
|
|
keys = ["%s-%s" % (x["account__code"], x["summary"]) for x in rows]
|
|
|
|
for x in [x for x in records
|
|
|
|
if x.pk is not None
|
2020-08-13 07:25:35 +08:00
|
|
|
and F"{x.account.code}-{x.summary}" in keys]:
|
2020-08-07 01:06:57 +08:00
|
|
|
x.is_existing_equipment = True
|
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def get_summary_categories() -> str:
|
2020-08-04 09:55:27 +08:00
|
|
|
"""Finds and returns the summary categories and their corresponding account
|
2020-08-13 07:25:35 +08:00
|
|
|
hints as JSON.
|
2020-08-04 09:55:27 +08:00
|
|
|
|
|
|
|
Returns:
|
2020-08-13 07:25:35 +08:00
|
|
|
The summary categories and their account hints, by their record types
|
|
|
|
and category types.
|
2020-08-04 09:55:27 +08:00
|
|
|
"""
|
2020-08-04 23:26:30 +08:00
|
|
|
rows = Record.objects\
|
|
|
|
.filter(Q(summary__contains="—"),
|
2020-08-04 09:55:27 +08:00
|
|
|
~Q(account__code__startswith="114"),
|
|
|
|
~Q(account__code__startswith="214"),
|
|
|
|
~Q(account__code__startswith="128"),
|
2020-08-04 23:26:30 +08:00
|
|
|
~Q(account__code__startswith="228"))\
|
|
|
|
.annotate(rec_type=Case(When(is_credit=True, then=Value("credit")),
|
|
|
|
default=Value("debit"),
|
|
|
|
output_field=CharField()),
|
|
|
|
cat_type=Case(
|
|
|
|
When(summary__regex=".+—.+—.+→.+", then=Value("bus")),
|
2020-08-07 22:34:24 +08:00
|
|
|
When(summary__regex=".+—.+[→↔].+", then=Value("travel")),
|
2020-08-04 23:26:30 +08:00
|
|
|
default=Value("general"),
|
|
|
|
output_field=CharField()),
|
|
|
|
category=Left("summary",
|
|
|
|
StrIndex("summary", Value("—")) - 1,
|
|
|
|
output_field=CharField()))\
|
|
|
|
.values("rec_type", "cat_type", "category", "account__code")\
|
|
|
|
.annotate(count=Count("category"))\
|
|
|
|
.order_by("rec_type", "cat_type", "category", "-count",
|
|
|
|
"account__code")
|
|
|
|
# Sorts the rows by the record type and the category type
|
|
|
|
categories = {}
|
|
|
|
for row in rows:
|
|
|
|
key = "%s-%s" % (row["rec_type"], row["cat_type"])
|
|
|
|
if key not in categories:
|
|
|
|
categories[key] = {}
|
|
|
|
if row["category"] not in categories[key]:
|
2020-08-05 11:33:15 +08:00
|
|
|
categories[key][row["category"]] = []
|
|
|
|
categories[key][row["category"]].append(row)
|
|
|
|
for key in categories:
|
|
|
|
# Keeps only the first account with most records
|
|
|
|
categories[key] = [categories[key][x][0] for x in categories[key]]
|
|
|
|
# Sorts the categories by the frequency
|
|
|
|
categories[key].sort(key=lambda x: (-x["count"], x["category"]))
|
|
|
|
# Keeps only the category and the account
|
|
|
|
categories[key] = [[x["category"], x["account__code"]]
|
|
|
|
for x in categories[key]]
|
2020-08-04 23:26:30 +08:00
|
|
|
# Converts the dictionary to a list, as the category may not be US-ASCII
|
2020-08-05 11:33:15 +08:00
|
|
|
return json.dumps(categories)
|
2020-08-04 09:55:27 +08:00
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def fill_txn_from_post(txn_type: str, txn: Transaction,
|
|
|
|
post: Mapping[str, str]) -> None:
|
2020-08-02 09:51:17 +08:00
|
|
|
"""Fills the transaction from the POSTed data. The POSTed data must be
|
|
|
|
validated and clean at this moment.
|
2020-07-28 22:48:42 +08:00
|
|
|
|
|
|
|
Args:
|
2020-08-13 07:25:35 +08:00
|
|
|
txn_type: The transaction type.
|
|
|
|
txn: The transaction.
|
|
|
|
post: The POSTed data.
|
2020-07-28 22:48:42 +08:00
|
|
|
"""
|
2020-08-05 07:48:50 +08:00
|
|
|
m = re.match("^([0-9]{4})-([0-9]{2})-([0-9]{2})$", post["date"])
|
|
|
|
txn.date = datetime.date(
|
|
|
|
int(m.group(1)),
|
|
|
|
int(m.group(2)),
|
|
|
|
int(m.group(3)))
|
2020-08-02 02:32:10 +08:00
|
|
|
if "notes" in post:
|
2020-08-02 03:06:31 +08:00
|
|
|
txn.notes = post["notes"]
|
2020-08-02 12:06:19 +08:00
|
|
|
else:
|
|
|
|
txn.notes = None
|
2020-07-28 22:48:42 +08:00
|
|
|
# The records
|
2020-08-02 14:29:45 +08:00
|
|
|
max_no = _find_max_record_no(txn_type, post)
|
2020-07-28 22:48:42 +08:00
|
|
|
records = []
|
2020-08-02 12:07:45 +08:00
|
|
|
for record_type in max_no.keys():
|
|
|
|
for i in range(max_no[record_type]):
|
2020-07-29 09:22:56 +08:00
|
|
|
no = i + 1
|
2020-08-02 12:07:45 +08:00
|
|
|
if F"{record_type}-{no}-id" in post:
|
|
|
|
record = Record.objects.get(pk=post[F"{record_type}-{no}-id"])
|
2020-08-02 09:51:17 +08:00
|
|
|
else:
|
|
|
|
record = Record(
|
2020-08-02 12:07:45 +08:00
|
|
|
is_credit=(record_type == "credit"),
|
2020-08-02 09:51:17 +08:00
|
|
|
transaction=txn)
|
|
|
|
record.ord = no
|
|
|
|
record.account = Account.objects.get(
|
2020-08-02 12:07:45 +08:00
|
|
|
code=post[F"{record_type}-{no}-account"])
|
|
|
|
if F"{record_type}-{no}-summary" in post:
|
|
|
|
record.summary = post[F"{record_type}-{no}-summary"]
|
2020-08-02 12:06:19 +08:00
|
|
|
else:
|
|
|
|
record.summary = None
|
2020-08-02 14:29:45 +08:00
|
|
|
record.amount = int(post[F"{record_type}-{no}-amount"])
|
2020-07-29 09:22:56 +08:00
|
|
|
records.append(record)
|
2020-08-02 14:29:45 +08:00
|
|
|
if txn_type != "transfer":
|
|
|
|
if txn_type == "expense":
|
2020-08-05 08:04:40 +08:00
|
|
|
if len(txn.credit_records) > 0:
|
|
|
|
record = txn.credit_records[0]
|
|
|
|
else:
|
|
|
|
record = Record(is_credit=True, transaction=txn)
|
2020-08-02 14:29:45 +08:00
|
|
|
else:
|
2020-08-05 08:04:40 +08:00
|
|
|
if len(txn.debit_records) > 0:
|
|
|
|
record = txn.debit_records[0]
|
|
|
|
else:
|
|
|
|
record = Record(is_credit=False, transaction=txn)
|
2020-08-02 14:29:45 +08:00
|
|
|
record.ord = 1
|
2020-08-02 18:13:04 +08:00
|
|
|
record.account = Account.objects.get(code=Account.CASH)
|
2020-08-02 14:29:45 +08:00
|
|
|
record.summary = None
|
|
|
|
record.amount = sum([x.amount for x in records])
|
|
|
|
records.append(record)
|
2020-08-02 03:06:31 +08:00
|
|
|
txn.records = records
|
2020-07-29 00:02:34 +08:00
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def sort_post_txn_records(post: Dict[str, str]) -> None:
|
2020-07-29 00:02:34 +08:00
|
|
|
"""Sorts the records in the form by their specified order, so that the
|
|
|
|
form can be used to populate the data to return to the user.
|
|
|
|
|
|
|
|
Args:
|
2020-08-13 07:25:35 +08:00
|
|
|
post: The POSTed form.
|
2020-07-29 00:02:34 +08:00
|
|
|
"""
|
|
|
|
# Collects the available record numbers
|
2020-07-29 09:40:51 +08:00
|
|
|
record_no = {
|
2020-07-29 09:37:15 +08:00
|
|
|
"debit": [],
|
|
|
|
"credit": [],
|
|
|
|
}
|
2020-08-02 03:06:31 +08:00
|
|
|
for key in post.keys():
|
2020-07-29 00:02:34 +08:00
|
|
|
m = re.match(
|
2020-08-01 23:56:41 +08:00
|
|
|
"^(debit|credit)-([1-9][0-9]*)-(id|ord|account|summary|amount)",
|
2020-07-29 09:37:15 +08:00
|
|
|
key)
|
|
|
|
if m is None:
|
|
|
|
continue
|
2020-07-29 09:40:51 +08:00
|
|
|
record_type = m.group(1)
|
2020-07-29 09:37:15 +08:00
|
|
|
no = int(m.group(2))
|
2020-07-29 09:40:51 +08:00
|
|
|
if no not in record_no[record_type]:
|
|
|
|
record_no[record_type].append(no)
|
2020-07-29 00:02:34 +08:00
|
|
|
# Sorts these record numbers by their specified orders
|
2020-07-29 09:40:51 +08:00
|
|
|
for record_type in record_no.keys():
|
2020-07-29 09:37:15 +08:00
|
|
|
orders = {}
|
2020-07-29 09:40:51 +08:00
|
|
|
for no in record_no[record_type]:
|
2020-07-29 09:37:15 +08:00
|
|
|
try:
|
2020-08-02 03:06:31 +08:00
|
|
|
orders[no] = int(post[F"{record_type}-{no}-ord"])
|
2020-07-29 09:37:15 +08:00
|
|
|
except KeyError:
|
|
|
|
orders[no] = 9999
|
|
|
|
except ValueError:
|
|
|
|
orders[no] = 9999
|
2020-08-02 02:40:00 +08:00
|
|
|
record_no[record_type].sort(key=lambda n: orders[n])
|
2020-07-29 09:37:15 +08:00
|
|
|
# Constructs the sorted new form
|
2020-08-02 03:06:31 +08:00
|
|
|
new_post = {}
|
2020-07-29 09:40:51 +08:00
|
|
|
for record_type in record_no.keys():
|
|
|
|
for i in range(len(record_no[record_type])):
|
|
|
|
old_no = record_no[record_type][i]
|
2020-07-29 09:37:15 +08:00
|
|
|
no = i + 1
|
2020-08-02 23:08:13 +08:00
|
|
|
new_post[F"{record_type}-{no}-ord"] = str(no)
|
2020-08-01 23:56:41 +08:00
|
|
|
for attr in ["id", "account", "summary", "amount"]:
|
2020-08-02 03:06:31 +08:00
|
|
|
if F"{record_type}-{old_no}-{attr}" in post:
|
|
|
|
new_post[F"{record_type}-{no}-{attr}"]\
|
|
|
|
= post[F"{record_type}-{old_no}-{attr}"]
|
2020-07-29 00:02:34 +08:00
|
|
|
# Purges the old form and fills it with the new form
|
2020-08-02 03:06:31 +08:00
|
|
|
for x in [x for x in post.keys() if re.match(
|
2020-08-01 23:56:41 +08:00
|
|
|
"^(debit|credit)-([1-9][0-9]*)-(id|ord|account|summary|amount)",
|
2020-07-29 09:38:34 +08:00
|
|
|
x)]:
|
2020-08-02 03:06:31 +08:00
|
|
|
del post[x]
|
|
|
|
for key in new_post.keys():
|
|
|
|
post[key] = new_post[key]
|
2020-07-30 08:04:18 +08:00
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def make_txn_form_from_model(txn_type: str,
|
|
|
|
txn: Transaction) -> TransactionForm:
|
2020-08-01 23:56:41 +08:00
|
|
|
"""Converts a transaction data model to a transaction form.
|
|
|
|
|
|
|
|
Args:
|
2020-08-13 07:25:35 +08:00
|
|
|
txn_type: The transaction type.
|
|
|
|
txn: The transaction data model.
|
2020-08-01 23:56:41 +08:00
|
|
|
|
|
|
|
Returns:
|
2020-08-13 07:25:35 +08:00
|
|
|
The transaction form.
|
2020-08-01 23:56:41 +08:00
|
|
|
"""
|
2020-08-02 03:06:31 +08:00
|
|
|
form = TransactionForm(
|
|
|
|
{x: str(getattr(txn, x)) for x in ["date", "notes"]
|
|
|
|
if getattr(txn, x) is not None})
|
2020-08-02 14:29:45 +08:00
|
|
|
form.transaction = txn if txn.pk is not None else None
|
2020-08-06 15:46:43 +08:00
|
|
|
form.txn_type = txn_type
|
2020-08-02 14:29:45 +08:00
|
|
|
records = []
|
|
|
|
if txn_type != "income":
|
|
|
|
records = records + txn.debit_records
|
|
|
|
if txn_type != "expense":
|
|
|
|
records = records + txn.credit_records
|
|
|
|
for record in records:
|
2020-08-01 23:56:41 +08:00
|
|
|
data = {x: getattr(record, x)
|
|
|
|
for x in ["summary", "amount"]
|
|
|
|
if getattr(record, x) is not None}
|
2020-08-02 14:29:45 +08:00
|
|
|
if record.pk is not None:
|
|
|
|
data["id"] = record.pk
|
2020-08-01 23:56:41 +08:00
|
|
|
try:
|
|
|
|
data["account"] = record.account.code
|
2020-08-02 14:29:45 +08:00
|
|
|
except ObjectDoesNotExist:
|
2020-08-01 23:56:41 +08:00
|
|
|
pass
|
|
|
|
record_form = RecordForm(data)
|
2020-08-12 20:42:31 +08:00
|
|
|
record_form.txn_form = form
|
2020-08-01 23:56:41 +08:00
|
|
|
record_form.is_credit = record.is_credit
|
|
|
|
if record.is_credit:
|
2020-08-02 03:06:31 +08:00
|
|
|
form.credit_records.append(record_form)
|
2020-08-01 23:56:41 +08:00
|
|
|
else:
|
2020-08-02 03:06:31 +08:00
|
|
|
form.debit_records.append(record_form)
|
|
|
|
return form
|
2020-08-01 23:56:41 +08:00
|
|
|
|
|
|
|
|
2020-08-13 07:25:35 +08:00
|
|
|
def _find_max_record_no(txn_type: str,
|
|
|
|
post: Mapping[str, str]) -> Dict[str, int]:
|
2020-08-02 02:37:18 +08:00
|
|
|
"""Finds the max debit and record numbers from the POSTed form.
|
|
|
|
|
|
|
|
Args:
|
2020-08-02 14:29:45 +08:00
|
|
|
txn_type (str): The transaction type.
|
2020-08-02 02:37:18 +08:00
|
|
|
post (dict[str,str]): The POSTed data.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
dict[str,int]: The max debit and record numbers from the POSTed form.
|
|
|
|
|
|
|
|
"""
|
2020-08-02 14:29:45 +08:00
|
|
|
max_no = {}
|
|
|
|
if txn_type != "credit":
|
|
|
|
max_no["debit"] = 0
|
|
|
|
if txn_type != "debit":
|
|
|
|
max_no["credit"] = 0
|
2020-08-02 02:37:18 +08:00
|
|
|
for key in post.keys():
|
|
|
|
m = re.match(
|
|
|
|
"^(debit|credit)-([1-9][0-9]*)-(id|ord|account|summary|amount)$",
|
|
|
|
key)
|
2020-08-02 14:29:45 +08:00
|
|
|
if m is None:
|
|
|
|
continue
|
|
|
|
record_type = m.group(1)
|
|
|
|
if record_type not in max_no:
|
|
|
|
continue
|
|
|
|
no = int(m.group(2))
|
|
|
|
if max_no[record_type] < no:
|
|
|
|
max_no[record_type] = no
|
2020-08-02 02:37:18 +08:00
|
|
|
return max_no
|