diff --git a/src/accounting/static/js/summary-helper.js b/src/accounting/static/js/summary-helper.js new file mode 100644 index 0000000..0f8d91d --- /dev/null +++ b/src/accounting/static/js/summary-helper.js @@ -0,0 +1,531 @@ +/* The Mia! Accounting Flask Project + * summary-helper.js: The JavaScript for the summary helper + */ + +/* Copyright (c) 2023 imacat. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* Author: imacat@mail.imacat.idv.tw (imacat) + * First written: 2023/2/28 + */ + +// Initializes the page JavaScript. +document.addEventListener("DOMContentLoaded", function () { + SummaryHelper.initialize(); +}); + +/** + * A summary helper. + * + */ +class SummaryHelper { + + /** + * The entry type + * @type {string} + */ + #entryType; + + /** + * The prefix of the HTML ID and class + * @type {string} + */ + #prefix; + + /** + * The default tab ID + * @type {string} + */ + #defaultTabId; + + /** + * Constructs a summary helper. + * + * @param form {HTMLFormElement} the summary helper form + */ + constructor(form) { + this.#entryType = form.dataset.entryType; + this.#prefix = "accounting-summary-helper-" + form.dataset.entryType; + this.#defaultTabId = form.dataset.defaultTabId; + this.#init(); + } + + /** + * Initializes the summary helper. + * + */ + #init() { + const helper = this; + const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab")); + tabs.forEach(function (tab) { + tab.onclick = function () { + helper.#switchToTab(tab.dataset.tabId); + } + }); + this.#initializeGeneralTagHelper(); + this.#initializeGeneralTripHelper(); + this.#initializeBusTripHelper(); + this.#initializeNumberHelper(); + this.#initializeSubmission(); + } + + /** + * Switches to a tab. + * + * @param tabId {string} the tab ID. + */ + #switchToTab(tabId) { + const tabs = Array.from(document.getElementsByClassName(this.#prefix + "-tab")); + const pages = Array.from(document.getElementsByClassName(this.#prefix + "-page")); + tabs.forEach(function (tab) { + if (tab.dataset.tabId === tabId) { + tab.classList.add("active"); + tab.ariaCurrent = "page"; + } else { + tab.classList.remove("active"); + tab.ariaCurrent = "false"; + } + }); + pages.forEach(function (page) { + if (page.dataset.tabId === tabId) { + page.classList.remove("d-none"); + page.ariaCurrent = "page"; + } else { + page.classList.add("d-none"); + page.ariaCurrent = "false"; + } + }); + } + + /** + * Initializes the general tag helper. + * + */ + #initializeGeneralTagHelper() { + const buttons = Array.from(document.getElementsByClassName(this.#prefix + "-general-btn-tag")); + const summary = document.getElementById(this.#prefix + "-summary"); + const tag = document.getElementById(this.#prefix + "-general-tag"); + const updateSummary = function () { + const pos = summary.value.indexOf("—"); + const prefix = tag.value === ""? "": tag.value + "—"; + if (pos === -1) { + summary.value = prefix + summary.value; + } else { + summary.value = prefix + summary.value.substring(pos + 1); + } + } + buttons.forEach(function (button) { + button.onclick = function () { + buttons.forEach(function (otherButton) { + otherButton.classList.remove("btn-primary"); + otherButton.classList.add("btn-outline-primary"); + }); + button.classList.remove("btn-outline-primary"); + button.classList.add("btn-primary"); + tag.value = button.dataset.value; + updateSummary(); + }; + }); + tag.onchange = function () { + buttons.forEach(function (button) { + if (button.dataset.value === tag.value) { + button.classList.remove("btn-outline-primary"); + button.classList.add("btn-primary"); + } else { + button.classList.remove("btn-primary"); + button.classList.add("btn-outline-primary"); + } + }); + updateSummary(); + }; + } + + /** + * Initializes the general trip helper. + * + */ + #initializeGeneralTripHelper() { + const buttons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-btn-tag")); + const summary = document.getElementById(this.#prefix + "-summary"); + const tag = document.getElementById(this.#prefix + "-travel-tag"); + const from = document.getElementById(this.#prefix + "-travel-from"); + const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction")) + const to = document.getElementById(this.#prefix + "-travel-to"); + const updateSummary = function () { + let direction; + for (const button of directionButtons) { + if (button.classList.contains("btn-primary")) { + direction = button.dataset.arrow; + break; + } + } + summary.value = tag.value + "—" + from.value + direction + to.value; + }; + buttons.forEach(function (button) { + button.onclick = function () { + buttons.forEach(function (otherButton) { + otherButton.classList.remove("btn-primary"); + otherButton.classList.add("btn-outline-primary"); + }); + button.classList.remove("btn-outline-primary"); + button.classList.add("btn-primary"); + tag.value = button.dataset.value; + updateSummary(); + }; + }); + tag.onchange = function () { + buttons.forEach(function (button) { + if (button.dataset.value === tag.value) { + button.classList.remove("btn-outline-primary"); + button.classList.add("btn-primary"); + } else { + button.classList.remove("btn-primary"); + button.classList.add("btn-outline-primary"); + } + }); + updateSummary(); + }; + from.onchange = updateSummary; + directionButtons.forEach(function (button) { + button.onclick = function () { + directionButtons.forEach(function (otherButton) { + otherButton.classList.remove("btn-primary"); + otherButton.classList.add("btn-outline-primary"); + }); + button.classList.remove("btn-outline-primary"); + button.classList.add("btn-primary"); + updateSummary(); + }; + }); + to.onchange = updateSummary; + } + + /** + * Initializes the bus trip helper. + * + */ + #initializeBusTripHelper() { + const buttons = Array.from(document.getElementsByClassName(this.#prefix + "-bus-btn-tag")); + const summary = document.getElementById(this.#prefix + "-summary"); + const tag = document.getElementById(this.#prefix + "-bus-tag"); + const route = document.getElementById(this.#prefix + "-bus-route"); + const from = document.getElementById(this.#prefix + "-bus-from"); + const to = document.getElementById(this.#prefix + "-bus-to"); + const updateSummary = function () { + summary.value = tag.value + "—" + route.value + "—" + from.value + "→" + to.value; + }; + buttons.forEach(function (button) { + button.onclick = function () { + buttons.forEach(function (otherButton) { + otherButton.classList.remove("btn-primary"); + otherButton.classList.add("btn-outline-primary"); + }); + button.classList.remove("btn-outline-primary"); + button.classList.add("btn-primary"); + tag.value = button.dataset.value; + updateSummary(); + }; + }); + tag.onchange = function () { + buttons.forEach(function (button) { + if (button.dataset.value === tag.value) { + button.classList.remove("btn-outline-primary"); + button.classList.add("btn-primary"); + } else { + button.classList.remove("btn-primary"); + button.classList.add("btn-outline-primary"); + } + }); + updateSummary(); + }; + route.onchange = updateSummary; + from.onchange = updateSummary; + to.onchange = updateSummary; + } + + /** + * Initializes the number helper. + * + */ + #initializeNumberHelper() { + const summary = document.getElementById(this.#prefix + "-summary"); + const number = document.getElementById(this.#prefix + "-number"); + number.onchange = function () { + const found = summary.value.match(/^(.+)×(\d+)$/); + if (found !== null) { + summary.value = found[1]; + } + if (number.value > 1) { + summary.value = summary.value + "×" + String(number.value); + } + }; + } + + /** + * Initializes the summary submission + * + */ + #initializeSubmission() { + const form = document.getElementById(this.#prefix); + const helper = this; + form.onsubmit = function () { + if (helper.#validate()) { + helper.#submit(); + } + return false; + }; + } + + /** + * Validates the form. + * + * @return {boolean} true if valid, or false otherwise + */ + #validate() { + return true; + } + + /** + * Submits the summary. + * + */ + #submit() { + const summary = document.getElementById(this.#prefix + "-summary"); + const formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); + const formSummary = document.getElementById("accounting-entry-form-summary"); + const helperModal = document.getElementById(this.#prefix + "-modal"); + const entryModal = document.getElementById("accounting-entry-form-modal"); + if (summary.value === "") { + formSummaryControl.classList.remove("accounting-not-empty"); + } else { + formSummaryControl.classList.add("accounting-not-empty"); + } + formSummary.dataset.value = summary.value; + formSummary.innerText = summary.value; + bootstrap.Modal.getInstance(helperModal).hide(); + bootstrap.Modal.getOrCreateInstance(entryModal).show(); + } + + /** + * Initializes the summary help when it is shown. + * + * @param isNew {boolean} true for adding a new journal entry, or false otherwise + */ + initShow(isNew) { + const closeButtons = Array.from(document.getElementsByClassName(this.#prefix + "-close")); + closeButtons.forEach(function (button) { + if (isNew) { + button.dataset.bsTarget = ""; + } else { + button.dataset.bsTarget = "#accounting-entry-form-modal"; + } + }); + this.#reset(); + if (!isNew) { + this.#populate(); + } + } + + /** + * Resets the summary helper. + * + */ + #reset() { + const inputs = Array.from(document.getElementsByClassName(this.#prefix + "-input")); + const tagButtons = Array.from(document.getElementsByClassName(this.#prefix + "-btn-tag")); + const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction")); + inputs.forEach(function (input) { + input.value = ""; + input.classList.remove("is-invalid"); + }); + tagButtons.forEach(function (btnTag) { + btnTag.classList.remove("btn-primary"); + btnTag.classList.add("btn-outline-primary"); + }); + directionButtons.forEach(function (btnDirection) { + if (btnDirection.classList.contains("accounting-default")) { + btnDirection.classList.remove("btn-outline-primary"); + btnDirection.classList.add("btn-primary"); + } else { + btnDirection.classList.add("btn-outline-primary"); + btnDirection.classList.remove("btn-primary"); + } + }); + this.#switchToTab(this.#defaultTabId); + } + + /** + * Populates the summary helper from the journal entry form. + * + */ + #populate() { + const formSummary = document.getElementById("accounting-entry-form-summary"); + const summary = document.getElementById(this.#prefix + "-summary"); + summary.value = formSummary.dataset.value; + const pos = summary.value.indexOf("—"); + if (pos === -1) { + return; + } + let found; + found = summary.value.match(/^(.+?)—(.+?)—(.+?)→(.+?)(?:×(\d+)?)?$/); + if (found !== null) { + return this.#populateBusTrip(found[1], found[2], found[3], found[4], found[5]); + } + found = summary.value.match(/^(.+?)—(.+?)([→↔])(.+?)(?:×(\d+)?)?$/); + if (found !== null) { + return this.#populateGeneralTrip(found[1], found[2], found[3], found[4], found[5]); + } + found = summary.value.match(/^(.+?)—.+?(?:×(\d+)?)?$/); + if (found !== null) { + return this.#populateGeneralTag(found[1], found[2]); + } + } + + /** + * Populates a bus trip. + * + * @param tagName {string} the tag name + * @param routeName {string} the route name or route number + * @param fromName {string} the name of the origin + * @param toName {string} the name of the destination + * @param numberStr {string|undefined} the number of items, if any + */ + #populateBusTrip(tagName, routeName, fromName, toName, numberStr) { + const tag = document.getElementById(this.#prefix + "-bus-tag"); + const route = document.getElementById(this.#prefix + "-bus-route"); + const from = document.getElementById(this.#prefix + "-bus-from"); + const to = document.getElementById(this.#prefix + "-bus-to"); + const number = document.getElementById(this.#prefix + "-number"); + const buttons = Array.from(document.getElementsByClassName(this.#prefix + "-bus-btn-tag")); + tag.value = tagName; + route.value = routeName; + from.value = fromName; + to.value = toName; + if (numberStr !== undefined) { + number.value = parseInt(numberStr); + } + buttons.forEach(function (button) { + if (button.dataset.value === tagName) { + button.classList.remove("btn-outline-primary"); + button.classList.add("btn-primary"); + } + }); + this.#switchToTab("bus"); + } + + /** + * Populates a general trip. + * + * @param tagName {string} the tag name + * @param fromName {string} the name of the origin + * @param direction {string} the direction arrow, either "→" or "↔" + * @param toName {string} the name of the destination + * @param numberStr {string|undefined} the number of items, if any + */ + #populateGeneralTrip(tagName, fromName, direction, toName, numberStr) { + const tag = document.getElementById(this.#prefix + "-travel-tag"); + const from = document.getElementById(this.#prefix + "-travel-from"); + const to = document.getElementById(this.#prefix + "-travel-to"); + const number = document.getElementById(this.#prefix + "-number"); + const buttons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-btn-tag")); + const directionButtons = Array.from(document.getElementsByClassName(this.#prefix + "-travel-direction")); + tag.value = tagName; + from.value = fromName; + directionButtons.forEach(function (btnDirection) { + if (btnDirection.dataset.arrow === direction) { + btnDirection.classList.remove("btn-outline-primary"); + btnDirection.classList.add("btn-primary"); + } else { + btnDirection.classList.add("btn-outline-primary"); + btnDirection.classList.remove("btn-primary"); + } + }); + to.value = toName; + if (numberStr !== undefined) { + number.value = parseInt(numberStr); + } + buttons.forEach(function (button) { + if (button.dataset.value === tagName) { + button.classList.remove("btn-outline-primary"); + button.classList.add("btn-primary"); + } + }); + this.#switchToTab("travel"); + } + + /** + * Populates a general tag. + * + * @param tagName {string} the tag name + * @param numberStr {string|undefined} the number of items, if any + */ + #populateGeneralTag(tagName, numberStr) { + const tag = document.getElementById(this.#prefix + "-general-tag"); + const number = document.getElementById(this.#prefix + "-number"); + const buttons = Array.from(document.getElementsByClassName(this.#prefix + "-general-btn-tag")); + tag.value = tagName; + if (numberStr !== undefined) { + number.value = parseInt(numberStr); + } + buttons.forEach(function (button) { + if (button.dataset.value === tagName) { + button.classList.remove("btn-outline-primary"); + button.classList.add("btn-primary"); + } + }); + this.#switchToTab("general"); + } + + /** + * The summary helpers. + * @type {{debit: SummaryHelper, credit: SummaryHelper}} + */ + static #helpers = {} + + /** + * Initializes the summary helpers. + * + */ + static initialize() { + const forms = Array.from(document.getElementsByClassName("accounting-summary-helper")); + for (const form of forms) { + const helper = new SummaryHelper(form); + this.#helpers[helper.#entryType] = helper; + } + this.#initializeTransactionForm(); + } + + /** + * Initializes the transaction form. + * + */ + static #initializeTransactionForm() { + const entryForm = document.getElementById("accounting-entry-form"); + const formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); + const helpers = this.#helpers; + formSummaryControl.onclick = function () { + helpers[entryForm.dataset.entryType].initShow(false); + }; + } + + /** + * Initializes the summary helper for a new journal entry. + * + * @param entryType {string} the entry type, either "debit" or "credit" + */ + static initializeNewJournalEntry(entryType) { + this.#helpers[entryType].initShow(true); + } +} diff --git a/src/accounting/static/js/transaction-form.js b/src/accounting/static/js/transaction-form.js index 5bc183e..539266e 100644 --- a/src/accounting/static/js/transaction-form.js +++ b/src/accounting/static/js/transaction-form.js @@ -157,6 +157,7 @@ function initializeNewEntryButton(button) { const formAccountControl = document.getElementById("accounting-entry-form-account-control"); const formAccount = document.getElementById("accounting-entry-form-account"); const formAccountError = document.getElementById("accounting-entry-form-account-error") + const formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); const formSummary = document.getElementById("accounting-entry-form-summary"); const formSummaryError = document.getElementById("accounting-entry-form-summary-error"); const formAmount = document.getElementById("accounting-entry-form-amount"); @@ -165,19 +166,23 @@ function initializeNewEntryButton(button) { entryForm.dataset.currencyIndex = button.dataset.currencyIndex; entryForm.dataset.entryType = button.dataset.entryType; entryForm.dataset.entryIndex = button.dataset.entryIndex; - formAccountControl.classList.remove("accounting-not-empty") + formAccountControl.classList.remove("accounting-not-empty"); formAccountControl.classList.remove("is-invalid"); formAccountControl.dataset.bsTarget = button.dataset.accountModal; formAccount.innerText = ""; formAccount.dataset.code = ""; formAccount.dataset.text = ""; formAccountError.innerText = ""; - formSummary.value = ""; - formSummary.classList.remove("is-invalid"); + formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + button.dataset.entryType + "-modal"; + formSummaryControl.classList.remove("accounting-not-empty"); + formSummaryControl.classList.remove("is-invalid"); + formSummary.dataset.value = ""; + formSummary.innerText = "" formSummaryError.innerText = "" formAmount.value = ""; formAmount.classList.remove("is-invalid"); formAmountError.innerText = ""; + SummaryHelper.initializeNewJournalEntry(button.dataset.entryType); }; } @@ -209,6 +214,7 @@ function initializeJournalEntry(entry) { const control = document.getElementById(entry.dataset.prefix + "-control"); const formAccountControl = document.getElementById("accounting-entry-form-account-control"); const formAccount = document.getElementById("accounting-entry-form-account"); + const formSummaryControl = document.getElementById("accounting-entry-form-summary-control"); const formSummary = document.getElementById("accounting-entry-form-summary"); const formAmount = document.getElementById("accounting-entry-form-amount"); control.onclick = function () { @@ -224,7 +230,14 @@ function initializeJournalEntry(entry) { formAccount.innerText = accountCode.dataset.text; formAccount.dataset.code = accountCode.value; formAccount.dataset.text = accountCode.dataset.text; - formSummary.value = summary.value; + formSummaryControl.dataset.bsTarget = "#accounting-summary-helper-" + entry.dataset.entryType + "-modal"; + if (summary.value === "") { + formSummaryControl.classList.remove("accounting-not-empty"); + } else { + formSummaryControl.classList.add("accounting-not-empty"); + } + formSummary.dataset.value = summary.value; + formSummary.innerText = summary.value; formAmount.value = amount.value; validateJournalEntryForm(); }; @@ -239,7 +252,6 @@ function initializeJournalEntryFormModal() { const entryForm = document.getElementById("accounting-entry-form"); const formAccountControl = document.getElementById("accounting-entry-form-account-control"); const formAccount = document.getElementById("accounting-entry-form-account"); - const formSummary = document.getElementById("accounting-entry-form-summary"); const formAmount = document.getElementById("accounting-entry-form-amount"); const modal = document.getElementById("accounting-entry-form-modal"); formAccountControl.onclick = function () { @@ -268,7 +280,6 @@ function initializeJournalEntryFormModal() { btnClear.disabled = false; } }; - formSummary.onchange = validateJournalEntrySummary; formAmount.onchange = validateJournalEntryAmount; entryForm.onsubmit = function () { if (validateJournalEntryForm()) { @@ -320,10 +331,9 @@ function validateJournalEntryAccount() { * @private */ function validateJournalEntrySummary() { - const field = document.getElementById("accounting-entry-form-summary"); + const control = document.getElementById("accounting-entry-form-summary-control"); const error = document.getElementById("accounting-entry-form-summary-error"); - field.value = field.value.trim(); - field.classList.remove("is-invalid"); + control.classList.remove("is-invalid"); error.innerText = ""; return true; } @@ -393,8 +403,8 @@ function saveJournalEntryForm() { accountCode.value = formAccount.dataset.code; accountCode.dataset.text = formAccount.dataset.text; accountText.innerText = formAccount.dataset.text; - summary.value = formSummary.value; - summaryText.innerText = formSummary.value; + summary.value = formSummary.dataset.value; + summaryText.innerText = formSummary.dataset.value; amount.value = formAmount.value; amountText.innerText = formatDecimal(new Decimal(formAmount.value)); if (entryForm.dataset.entryIndex === "new") { diff --git a/src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html b/src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html index 543360b..400e6ba 100644 --- a/src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html +++ b/src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html @@ -70,7 +70,7 @@ First written: 2023/2/25
- diff --git a/src/accounting/templates/accounting/transaction/include/entry-form-modal.html b/src/accounting/templates/accounting/transaction/include/entry-form-modal.html index 32fc5e3..0dc44d2 100644 --- a/src/accounting/templates/accounting/transaction/include/entry-form-modal.html +++ b/src/accounting/templates/accounting/transaction/include/entry-form-modal.html @@ -36,9 +36,11 @@ First written: 2023/2/25
-
- - +
+
+ +
+
diff --git a/src/accounting/templates/accounting/transaction/include/form.html b/src/accounting/templates/accounting/transaction/include/form.html index c09ac1b..f25fd9c 100644 --- a/src/accounting/templates/accounting/transaction/include/form.html +++ b/src/accounting/templates/accounting/transaction/include/form.html @@ -24,6 +24,7 @@ First written: 2023/2/26 {% block accounting_scripts %} + {% endblock %} {% block content %} @@ -85,6 +86,9 @@ First written: 2023/2/26 {% include "accounting/transaction/include/entry-form-modal.html" %} +{% for summary_helper in form.summary_helper.types %} + {% include "accounting/transaction/include/summary-helper-modal.html" %} +{% endfor %} {% block account_selector_modals %}{% endblock %} {% endblock %} diff --git a/src/accounting/templates/accounting/transaction/include/summary-helper-modal.html b/src/accounting/templates/accounting/transaction/include/summary-helper-modal.html new file mode 100644 index 0000000..0eb20db --- /dev/null +++ b/src/accounting/templates/accounting/transaction/include/summary-helper-modal.html @@ -0,0 +1,171 @@ +{# +The Mia! Accounting Flask Project +entry-form-modal.html: The modal of the summary helper + + Copyright (c) 2023 imacat. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Author: imacat@mail.imacat.idv.tw (imacat) +First written: 2023/2/28 +#} +
+ +
diff --git a/src/accounting/templates/accounting/transaction/income/include/form-currency-item.html b/src/accounting/templates/accounting/transaction/income/include/form-currency-item.html index 9569007..6e35e69 100644 --- a/src/accounting/templates/accounting/transaction/income/include/form-currency-item.html +++ b/src/accounting/templates/accounting/transaction/income/include/form-currency-item.html @@ -70,7 +70,7 @@ First written: 2023/2/25
- diff --git a/src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html b/src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html index 6ea3e40..3ccd592 100644 --- a/src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html +++ b/src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html @@ -72,7 +72,7 @@ First written: 2023/2/25
- @@ -112,7 +112,7 @@ First written: 2023/2/25
- diff --git a/src/accounting/transaction/forms.py b/src/accounting/transaction/forms.py index 20610c9..5faafea 100644 --- a/src/accounting/transaction/forms.py +++ b/src/accounting/transaction/forms.py @@ -364,6 +364,14 @@ class TransactionForm(FlaskForm): return [x for x in self.currencies.errors if isinstance(x, str) or isinstance(x, LazyString)] + @property + def summary_helper(self) -> SummaryHelper: + """Returns the summary helper. + + :return: The summary helper. + """ + return SummaryHelper() + T = t.TypeVar("T", bound=TransactionForm) """A transaction form variant.""" diff --git a/src/accounting/transaction/summary_helper.py b/src/accounting/transaction/summary_helper.py new file mode 100644 index 0000000..7fe2695 --- /dev/null +++ b/src/accounting/transaction/summary_helper.py @@ -0,0 +1,227 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The summary helper. + +""" +import typing as t + +import sqlalchemy as sa + +from accounting import db +from accounting.models import Account, JournalEntry + + +class SummaryAccount: + """An account for a summary tag.""" + + def __init__(self, account: Account, freq: int): + """Constructs an account for a summary tag. + + :param account: The account. + :param freq: The frequency of the tag with the account. + """ + self.__account: Account = account + """The account.""" + self.code: str = account.code + """The account code.""" + self.freq: int = freq + """The frequency of the tag with the account.""" + + def __str__(self) -> str: + """Returns the string representation of the account. + + :return: The string representation of the account. + """ + return str(self.__account) + + def add_freq(self, freq: int) -> None: + """Adds the frequency of an account. + + :param freq: The frequency of the tag name with the account. + :return: None. + """ + self.freq = self.freq + freq + + +class SummaryTag: + """A summary tag.""" + + def __init__(self, name: str): + """Constructs a summary tag. + + :param name: The tag name. + """ + self.name: str = name + """The tag name.""" + self.__account_dict: dict[int, SummaryAccount] = {} + """The accounts that come with the tag, in the order of their + frequency.""" + self.freq: int = 0 + """The frequency of the tag.""" + + def __str__(self) -> str: + """Returns the string representation of the tag. + + :return: The string representation of the tag. + """ + return self.name + + def add_account(self, account: Account, freq: int): + """Adds an account. + + :param account: The associated account. + :param freq: The frequency of the tag name with the account. + :return: None. + """ + self.__account_dict[account.id] = SummaryAccount(account, freq) + self.freq = self.freq + freq + + @property + def accounts(self) -> list[SummaryAccount]: + """Returns the accounts by the order of their frequencies. + + :return: The accounts by the order of their frequencies. + """ + return sorted(self.__account_dict.values(), key=lambda x: -x.freq) + + +class SummaryType: + """A summary type""" + + def __init__(self, type_id: t.Literal["general", "travel", "bus"]): + """Constructs a summary type. + + :param type_id: The type ID, either "general", "travel", or "bus". + """ + self.id: t.Literal["general", "travel", "bus"] = type_id + """The type ID.""" + self.__tag_dict: dict[str, SummaryTag] = {} + """A dictionary from the tag name to their corresponding tag.""" + + def add_tag(self, name: str, account: Account, freq: int) -> None: + """Adds a tag. + + :param name: The tag name. + :param account: The associated account. + :param freq: The frequency of the tag name with the account. + :return: None. + """ + if name not in self.__tag_dict: + self.__tag_dict[name] = SummaryTag(name) + self.__tag_dict[name].add_account(account, freq) + + @property + def tags(self) -> list[SummaryTag]: + """Returns the tags by the order of their frequencies. + + :return: The tags by the order of their frequencies. + """ + return sorted(self.__tag_dict.values(), key=lambda x: -x.freq) + + +class SummaryEntryType: + """A summary type""" + + def __init__(self, entry_type_id: t.Literal["debit", "credit"]): + """Constructs a summary entry type. + + :param entry_type_id: The entry type ID, either "debit" or "credit". + """ + self.type: t.Literal["debit", "credit"] = entry_type_id + """The entry type.""" + self.general: SummaryType = SummaryType("general") + """The general tags.""" + self.travel: SummaryType = SummaryType("travel") + """The travel tags.""" + self.bus: SummaryType = SummaryType("bus") + """The bus tags.""" + self.__type_dict: dict[t.Literal["general", "travel", "bus"], + SummaryType] \ + = {x.id: x for x in {self.general, self.travel, self.bus}} + """A dictionary from the type ID to the corresponding tags.""" + + def add_tag(self, tag_type: t.Literal["general", "travel", "bus"], + name: str, account: Account, freq: int) -> None: + """Adds a tag. + + :param tag_type: The tag type, either "general", "travel", or "bus". + :param name: The name. + :param account: The associated account. + :param freq: The frequency of the tag name with the account. + :return: None. + """ + self.__type_dict[tag_type].add_tag(name, account, freq) + + +class SummaryHelper: + """The summary helper.""" + + def __init__(self): + """Constructs the summary helper.""" + self.debit: SummaryEntryType = SummaryEntryType("debit") + """The debit tags.""" + self.credit: SummaryEntryType = SummaryEntryType("credit") + """The credit tags.""" + self.types: set[SummaryEntryType] = {self.debit, self.credit} + """The tags categorized by the entry types.""" + entry_type: sa.Label = sa.case((JournalEntry.is_debit, "debit"), + else_="credit").label("entry_type") + tag_type: sa.Label = sa.case( + (JournalEntry.summary.like("_%—_%—_%→_%"), "bus"), + (sa.or_(JournalEntry.summary.like("_%—_%→_%"), + JournalEntry.summary.like("_%—_%↔_%")), "travel"), + else_="general").label("tag_type") + tag: sa.Label = get_prefix(JournalEntry.summary, "—").label("tag") + select: sa.Select = sa.Select(entry_type, tag_type, tag, + JournalEntry.account_id, + sa.func.count().label("freq"))\ + .filter(JournalEntry.summary.is_not(None), + JournalEntry.summary.like("_%—_%"))\ + .group_by(entry_type, tag_type, tag, JournalEntry.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()} + entry_type_dict: dict[t.Literal["debit", "credit"], SummaryEntryType] \ + = {x.type: x for x in self.types} + for row in result: + entry_type_dict[row.entry_type].add_tag( + row.tag_type, row.tag, accounts[row.account_id], row.freq) + + +def get_prefix(string: str | sa.Column, separator: str | sa.Column) \ + -> sa.Function: + """Returns the SQL function to find the prefix of a string. + + :param string: The string. + :param separator: The separator. + :return: The position of the substring, starting from 1. + """ + return sa.func.substr(string, 0, get_position(string, separator)) + + +def get_position(string: str | sa.Column, substring: str | sa.Column) \ + -> sa.Function: + """Returns the SQL function to find the position of a substring. + + :param string: The string. + :param substring: The substring. + :return: The position of the substring, starting from 1. + """ + if db.engine.name == "postgresql": + return sa.func.strpos(string, substring) + return sa.func.instr(string, substring) diff --git a/tests/test_summary_helper.py b/tests/test_summary_helper.py new file mode 100644 index 0000000..7a635ce --- /dev/null +++ b/tests/test_summary_helper.py @@ -0,0 +1,327 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/28 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The test for the summary helper. + +""" +import unittest +from datetime import date + +from click.testing import Result +from flask import Flask +from flask.testing import FlaskCliRunner + +from test_site import create_app +from testlib import get_client +from testlib_txn import Accounts, NEXT_URI, add_txn + + +class SummeryHelperTestCase(unittest.TestCase): + """The summary helper test case.""" + + def setUp(self) -> None: + """Sets up the test. + This is run once per test. + + :return: None. + """ + self.app: Flask = create_app(is_testing=True) + + runner: FlaskCliRunner = self.app.test_cli_runner() + with self.app.app_context(): + from accounting.models import BaseAccount, Transaction, \ + JournalEntry + result: Result + result = runner.invoke(args="init-db") + self.assertEqual(result.exit_code, 0) + if BaseAccount.query.first() is None: + result = runner.invoke(args="accounting-init-base") + self.assertEqual(result.exit_code, 0) + result = runner.invoke(args=["accounting-init-currencies", + "-u", "editor"]) + self.assertEqual(result.exit_code, 0) + result = runner.invoke(args=["accounting-init-accounts", + "-u", "editor"]) + self.assertEqual(result.exit_code, 0) + Transaction.query.delete() + JournalEntry.query.delete() + + self.client, self.csrf_token = get_client(self.app, "editor") + + def test_summary_helper(self) -> None: + """Test the summary helper. + + :return: None. + """ + from accounting.transaction.summary_helper import SummaryHelper + for form in get_form_data(self.csrf_token): + add_txn(self.client, form) + with self.app.app_context(): + helper: SummaryHelper = SummaryHelper() + + # Debit-General + self.assertEqual(len(helper.debit.general.tags), 2) + self.assertEqual(helper.debit.general.tags[0].name, "Lunch") + self.assertEqual(len(helper.debit.general.tags[0].accounts), 2) + self.assertEqual(helper.debit.general.tags[0].accounts[0].code, + Accounts.MEAL) + self.assertEqual(helper.debit.general.tags[0].accounts[1].code, + Accounts.PAYABLE) + self.assertEqual(helper.debit.general.tags[1].name, "Dinner") + self.assertEqual(len(helper.debit.general.tags[1].accounts), 2) + self.assertEqual(helper.debit.general.tags[1].accounts[0].code, + Accounts.MEAL) + self.assertEqual(helper.debit.general.tags[1].accounts[1].code, + Accounts.PAYABLE) + + # Debit-Travel + self.assertEqual(len(helper.debit.travel.tags), 3) + self.assertEqual(helper.debit.travel.tags[0].name, "Bike") + self.assertEqual(len(helper.debit.travel.tags[0].accounts), 1) + self.assertEqual(helper.debit.travel.tags[0].accounts[0].code, + Accounts.TRAVEL) + self.assertEqual(helper.debit.travel.tags[1].name, "Taxi") + self.assertEqual(len(helper.debit.travel.tags[1].accounts), 1) + self.assertEqual(helper.debit.travel.tags[1].accounts[0].code, + Accounts.TRAVEL) + self.assertEqual(helper.debit.travel.tags[2].name, "Airplane") + self.assertEqual(len(helper.debit.travel.tags[2].accounts), 1) + self.assertEqual(helper.debit.travel.tags[2].accounts[0].code, + Accounts.TRAVEL) + + # Debit-Bus + self.assertEqual(len(helper.debit.bus.tags), 2) + self.assertEqual(helper.debit.bus.tags[0].name, "Train") + self.assertEqual(len(helper.debit.bus.tags[0].accounts), 1) + self.assertEqual(helper.debit.bus.tags[0].accounts[0].code, + Accounts.TRAVEL) + self.assertEqual(helper.debit.bus.tags[1].name, "Bus") + self.assertEqual(len(helper.debit.bus.tags[1].accounts), 1) + self.assertEqual(helper.debit.bus.tags[1].accounts[0].code, + Accounts.TRAVEL) + + # Credit-General + self.assertEqual(len(helper.credit.general.tags), 2) + self.assertEqual(helper.credit.general.tags[0].name, "Lunch") + self.assertEqual(len(helper.credit.general.tags[0].accounts), 3) + self.assertEqual(helper.credit.general.tags[0].accounts[0].code, + Accounts.PAYABLE) + self.assertEqual(helper.credit.general.tags[0].accounts[1].code, + Accounts.BANK) + self.assertEqual(helper.credit.general.tags[0].accounts[2].code, + Accounts.CASH) + self.assertEqual(helper.credit.general.tags[1].name, "Dinner") + self.assertEqual(len(helper.credit.general.tags[1].accounts), 2) + self.assertEqual(helper.credit.general.tags[1].accounts[0].code, + Accounts.BANK) + self.assertEqual(helper.credit.general.tags[1].accounts[1].code, + Accounts.PAYABLE) + + # Credit-Travel + self.assertEqual(len(helper.credit.travel.tags), 2) + self.assertEqual(helper.credit.travel.tags[0].name, "Bike") + self.assertEqual(len(helper.credit.travel.tags[0].accounts), 2) + self.assertEqual(helper.credit.travel.tags[0].accounts[0].code, + Accounts.PAYABLE) + self.assertEqual(helper.credit.travel.tags[0].accounts[1].code, + Accounts.PREPAID) + self.assertEqual(helper.credit.travel.tags[1].name, "Taxi") + self.assertEqual(len(helper.credit.travel.tags[1].accounts), 2) + self.assertEqual(helper.credit.travel.tags[1].accounts[0].code, + Accounts.PAYABLE) + self.assertEqual(helper.credit.travel.tags[1].accounts[1].code, + Accounts.CASH) + + # Credit-Bus + self.assertEqual(len(helper.credit.bus.tags), 2) + self.assertEqual(helper.credit.bus.tags[0].name, "Train") + self.assertEqual(len(helper.credit.bus.tags[0].accounts), 2) + self.assertEqual(helper.credit.bus.tags[0].accounts[0].code, + Accounts.PREPAID) + self.assertEqual(helper.credit.bus.tags[0].accounts[1].code, + Accounts.PAYABLE) + self.assertEqual(helper.credit.bus.tags[1].name, "Bus") + self.assertEqual(len(helper.credit.bus.tags[1].accounts), 1) + self.assertEqual(helper.credit.bus.tags[1].accounts[0].code, + Accounts.PREPAID) + + +def get_form_data(csrf_token: str) -> list[dict[str, str]]: + """Returns the form data for multiple transaction forms. + + :param csrf_token: The CSRF token. + :return: A list of the form data. + """ + txn_date: str = date.today().isoformat() + return [{"csrf_token": csrf_token, + "next": NEXT_URI, + "date": txn_date, + "currency-0-code": "USD", + "currency-0-credit-0-account_code": Accounts.SERVICE, + "currency-0-credit-0-summary": " Salary ", + "currency-0-credit-0-amount": "2500"}, + {"csrf_token": csrf_token, + "next": NEXT_URI, + "date": txn_date, + "currency-0-code": "USD", + "currency-0-debit-0-account_code": Accounts.MEAL, + "currency-0-debit-0-summary": " Lunch—Fish ", + "currency-0-debit-0-amount": "4.7", + "currency-0-credit-0-account_code": Accounts.BANK, + "currency-0-credit-0-summary": " Lunch—Fish ", + "currency-0-credit-0-amount": "4.7", + "currency-0-debit-1-account_code": Accounts.MEAL, + "currency-0-debit-1-summary": " Lunch—Fries ", + "currency-0-debit-1-amount": "2.15", + "currency-0-credit-1-account_code": Accounts.PAYABLE, + "currency-0-credit-1-summary": " Lunch—Fries ", + "currency-0-credit-1-amount": "2.15", + "currency-0-debit-2-account_code": Accounts.MEAL, + "currency-0-debit-2-summary": " Dinner—Hamburger ", + "currency-0-debit-2-amount": "4.25", + "currency-0-credit-2-account_code": Accounts.BANK, + "currency-0-credit-2-summary": " Dinner—Hamburger ", + "currency-0-credit-2-amount": "4.25"}, + {"csrf_token": csrf_token, + "next": NEXT_URI, + "date": txn_date, + "currency-0-code": "USD", + "currency-0-debit-0-account_code": Accounts.MEAL, + "currency-0-debit-0-summary": " Lunch—Salad ", + "currency-0-debit-0-amount": "4.99", + "currency-0-credit-0-account_code": Accounts.CASH, + "currency-0-credit-0-summary": " Lunch—Salad ", + "currency-0-credit-0-amount": "4.99", + "currency-0-debit-1-account_code": Accounts.MEAL, + "currency-0-debit-1-summary": " Dinner—Steak ", + "currency-0-debit-1-amount": "8.28", + "currency-0-credit-1-account_code": Accounts.PAYABLE, + "currency-0-credit-1-summary": " Dinner—Steak ", + "currency-0-credit-1-amount": "8.28"}, + {"csrf_token": csrf_token, + "next": NEXT_URI, + "date": txn_date, + "currency-0-code": "USD", + "currency-0-debit-0-account_code": Accounts.MEAL, + "currency-0-debit-0-summary": " Lunch—Pizza ", + "currency-0-debit-0-amount": "5.49", + "currency-0-credit-0-account_code": Accounts.PAYABLE, + "currency-0-credit-0-summary": " Lunch—Pizza ", + "currency-0-credit-0-amount": "5.49", + "currency-0-debit-1-account_code": Accounts.MEAL, + "currency-0-debit-1-summary": " Lunch—Noodles ", + "currency-0-debit-1-amount": "7.47", + "currency-0-credit-1-account_code": Accounts.PAYABLE, + "currency-0-credit-1-summary": " Lunch—Noodles ", + "currency-0-credit-1-amount": "7.47"}, + {"csrf_token": csrf_token, + "next": NEXT_URI, + "date": txn_date, + "currency-0-code": "USD", + "currency-0-debit-0-account_code": Accounts.TRAVEL, + "currency-0-debit-0-summary": " Airplane—Lake City↔Hill Town ", + "currency-0-debit-0-amount": "800"}, + {"csrf_token": csrf_token, + "next": NEXT_URI, + "date": txn_date, + "currency-0-code": "USD", + "currency-0-debit-0-account_code": Accounts.TRAVEL, + "currency-0-debit-0-summary": " Bus—323—Downtown→Museum ", + "currency-0-debit-0-amount": "2.5", + "currency-0-credit-0-account_code": Accounts.PREPAID, + "currency-0-credit-0-summary": " Bus—323—Downtown→Museum ", + "currency-0-credit-0-amount": "2.5", + "currency-0-debit-1-account_code": Accounts.TRAVEL, + "currency-0-debit-1-summary": " Train—Blue—Museum→Central ", + "currency-0-debit-1-amount": "3.2", + "currency-0-credit-1-account_code": Accounts.PREPAID, + "currency-0-credit-1-summary": " Train—Blue—Museum→Central ", + "currency-0-credit-1-amount": "3.2", + "currency-0-debit-2-account_code": Accounts.TRAVEL, + "currency-0-debit-2-summary": " Train—Green—Central→Mall ", + "currency-0-debit-2-amount": "3.2", + "currency-0-credit-2-account_code": Accounts.PREPAID, + "currency-0-credit-2-summary": " Train—Green—Central→Mall ", + "currency-0-credit-2-amount": "3.2", + "currency-0-debit-3-account_code": Accounts.TRAVEL, + "currency-0-debit-3-summary": " Train—Red—Mall→Museum ", + "currency-0-debit-3-amount": "4.4", + "currency-0-credit-3-account_code": Accounts.PAYABLE, + "currency-0-credit-3-summary": " Train—Red—Mall→Museum ", + "currency-0-credit-3-amount": "4.4"}, + {"csrf_token": csrf_token, + "next": NEXT_URI, + "date": txn_date, + "currency-0-code": "USD", + "currency-0-debit-0-account_code": Accounts.TRAVEL, + "currency-0-debit-0-summary": " Taxi—Museum→Office ", + "currency-0-debit-0-amount": "15.5", + "currency-0-credit-0-account_code": Accounts.CASH, + "currency-0-credit-0-summary": " Taxi—Museum→Office ", + "currency-0-credit-0-amount": "15.5", + "currency-0-debit-1-account_code": Accounts.TRAVEL, + "currency-0-debit-1-summary": " Taxi—Office→Restaurant ", + "currency-0-debit-1-amount": "12", + "currency-0-credit-1-account_code": Accounts.PAYABLE, + "currency-0-credit-1-summary": " Taxi—Office→Restaurant ", + "currency-0-credit-1-amount": "12", + "currency-0-debit-2-account_code": Accounts.TRAVEL, + "currency-0-debit-2-summary": " Taxi—Restaurant→City Hall ", + "currency-0-debit-2-amount": "8", + "currency-0-credit-2-account_code": Accounts.PAYABLE, + "currency-0-credit-2-summary": " Taxi—Restaurant→City Hall ", + "currency-0-credit-2-amount": "8", + "currency-0-debit-3-account_code": Accounts.TRAVEL, + "currency-0-debit-3-summary": " Bike—City Hall→Office ", + "currency-0-debit-3-amount": "3.5", + "currency-0-credit-3-account_code": Accounts.PAYABLE, + "currency-0-credit-3-summary": " Bike—City Hall→Office ", + "currency-0-credit-3-amount": "3.5", + "currency-0-debit-4-account_code": Accounts.TRAVEL, + "currency-0-debit-4-summary": " Bike—Restaurant→Office ", + "currency-0-debit-4-amount": "4", + "currency-0-credit-4-account_code": Accounts.PAYABLE, + "currency-0-credit-4-summary": " Bike—Restaurant→Office ", + "currency-0-credit-4-amount": "4", + "currency-0-debit-5-account_code": Accounts.TRAVEL, + "currency-0-debit-5-summary": " Bike—Office→Theatre ", + "currency-0-debit-5-amount": "1.5", + "currency-0-credit-5-account_code": Accounts.PAYABLE, + "currency-0-credit-5-summary": " Bike—Office→Theatre ", + "currency-0-credit-5-amount": "1.5", + "currency-0-debit-6-account_code": Accounts.TRAVEL, + "currency-0-debit-6-summary": " Bike—Theatre→Home ", + "currency-0-debit-6-amount": "5.5", + "currency-0-credit-6-account_code": Accounts.PREPAID, + "currency-0-credit-6-summary": " Bike—Theatre→Home ", + "currency-0-credit-6-amount": "5.5"}, + {"csrf_token": csrf_token, + "next": NEXT_URI, + "date": txn_date, + "currency-0-code": "USD", + "currency-0-debit-0-account_code": Accounts.PAYABLE, + "currency-0-debit-0-summary": " Dinner—Steak ", + "currency-0-debit-0-amount": "8.28", + "currency-0-credit-0-account_code": Accounts.BANK, + "currency-0-credit-0-summary": " Dinner—Steak ", + "currency-0-credit-0-amount": "8.28", + "currency-0-debit-1-account_code": Accounts.PAYABLE, + "currency-0-debit-1-summary": " Lunch—Pizza ", + "currency-0-debit-1-amount": "5.49", + "currency-0-credit-1-account_code": Accounts.BANK, + "currency-0-credit-1-summary": " Lunch—Pizza ", + "currency-0-credit-1-amount": "5.49"}] + diff --git a/tests/testlib_txn.py b/tests/testlib_txn.py index 212ee11..1216fee 100644 --- a/tests/testlib_txn.py +++ b/tests/testlib_txn.py @@ -39,11 +39,14 @@ class Accounts: """The shortcuts to the common accounts.""" CASH: str = "1111-001" BANK: str = "1113-001" + PREPAID: str = "1258-001" + PAYABLE: str = "2141-001" SALES: str = "4111-001" SERVICE: str = "4611-001" AGENCY: str = "4711-001" OFFICE: str = "6153-001" TRAVEL: str = "6154-001" + MEAL: str = "6172-001" INTEREST: str = "4111-001" DONATION: str = "7481-001" RENT: str = "7482-001"