154 Commits

Author SHA1 Message Date
329027969a Advanced to version 0.3.0. 2023-02-27 17:23:20 +08:00
9f7a8c9540 Revised the Sphinx documentation. 2023-02-27 17:22:56 +08:00
384bb2c46d Added the dummy commented <ul>...</ul> to the navigation menu and the journal entry sub-form templates, for SonarQube not to complain about incorrect HTML. 2023-02-27 17:20:43 +08:00
cabfe268ce Added the page_37 and page_size_15_default constants in the test_malformed test of the PaginationTestCase test case, for consistency. 2023-02-27 17:15:29 +08:00
26df71014b Added the LIST_URL and DETAIL_URI constants to test_base_account.py, for consistency. 2023-02-27 16:37:01 +08:00
3126ee8153 Added the NEXT_URI constant to test_account.py, for consistency. 2023-02-27 16:37:01 +08:00
cb622f4bad Added the __get_detail_uri function to the "accounting.currency.views" module, for simplicity. 2023-02-27 16:31:41 +08:00
515d39e61c Added the __get_detail_uri and __get_list_uri functions to the "accounting.account.views" module, for simplicity. 2023-02-27 16:29:56 +08:00
952061c4bb Added the TEST_SERVER constant in testlib.py, for consistency. 2023-02-27 16:25:36 +08:00
788225826d Added resource integrity to the decimal.js-light CDN in the test site. 2023-02-27 16:21:30 +08:00
c52081e528 Replaced decimal.js CDN from cdnjs with decimal.js-light CDN from jsDelivr in the base template of the test site. 2023-02-27 16:20:58 +08:00
1f235acdf9 Added resource integrity to the bootstrap CDN in the test site. 2023-02-27 16:07:59 +08:00
0f6c23e1f3 Replaced the regular expression replace with trimEnd() in the validateNote validator in the JavaScript for the transaction form. 2023-02-27 16:03:25 +08:00
488e72679e Revised the NextUriTestCase view, split the test_next_uri test into the two test_next_uri and test_no_next_uri tests, and replaced the decorator with add_url_rule to work around the security audit from SonarQube. 2023-02-27 15:57:39 +08:00
6d43b14862 Added CSRF to the test_next_uri test in the NextUriTestCase test case. 2023-02-27 15:35:35 +08:00
685213cdbb Revised the translation. 2023-02-27 15:29:37 +08:00
05fde3a742 Added the transaction management. 2023-02-27 15:28:45 +08:00
9383f5484f Revised aria-label in the templates, added necessary aria labels, removed excess aria labels, and added localization. 2023-02-27 12:54:41 +08:00
88314e1e45 Revised the regular expression in the find_by_code method of the Account data model. 2023-02-27 12:54:38 +08:00
83b5761bca Replaced the for loop with the for-of loop in the search-as-you-type JavaScript of the account form. 2023-02-27 10:30:21 +08:00
f25c993b75 Revised the translation of the test site. 2023-02-27 10:30:19 +08:00
6d02f8033d Revised the font awesome icon of the accounting application in the navigation menu. 2023-02-27 10:18:29 +08:00
2c367703e4 Removed a debug logging in the JavaScript for the account form. 2023-02-27 10:18:29 +08:00
284b5be128 Fixed the typo "model" to "modal" in the templates. 2023-02-27 10:18:10 +08:00
a672a13789 Revised the strip_text filter to return None when the text is empty. 2023-02-26 08:00:58 +08:00
9af9afd14d Added the height for the textarea with floating labels. 2023-02-26 07:59:25 +08:00
d98e9f8f05 Added the accounting-dragged class to replace the list-group-item-dark class when reordering with drag-and-drop, because the dragged list may not be a list group. 2023-02-26 07:54:23 +08:00
652bddc07a Fixed an error in the onDragOver function in drag-and-drop-reorder.js that sometimes the dragged object may be null. 2023-02-26 07:54:20 +08:00
5a6e4f5b5e Replaced the import for the db object from the accounting model with the test site in test_account.py and test_currency.py. They are the same object, and the db object from the test site is safe at the compile time. 2023-02-25 18:04:32 +08:00
f878ba5535 Revised to rewind the time in the test_update_not_modified tests of the AccountTestCase and CurrencyTestCase test cases, so that the test cases don't have to wait for the time to be different. 2023-02-25 18:04:29 +08:00
e7c36ba13a Revised the type hints in the test_update_not_modified tests of the AccountTestCase and CurrencyTestCase test cases. 2023-02-25 18:04:27 +08:00
4cfe7c7c59 Added the flash_all_errors utility in the "accounting.utils.flash_errors" module to recursively flush all form errors in the sub-forms. 2023-02-25 12:27:55 +08:00
b0b30a8ae6 Fixed the broken action button group in the account list and currency list, by adding a separated action button group for the mobile screens. 2023-02-25 10:37:28 +08:00
2e3633b205 Revised to sort the accounts in the same base before saving an account to a new base, and added the test_change_base_code test to the AccountTestCase test case for this. 2023-02-25 09:44:17 +08:00
d68aa91c33 Removed the redundant post_update methods from the AccountForm and CurrencyForm forms. 2023-02-24 17:18:55 +08:00
3f63fb0bda Fixed a type hint in the populate_obj method of the AccountForm form. 2023-02-24 00:18:55 +08:00
d5af5de3c1 Renamed offset to pay-off, to be clear. 2023-02-23 11:32:55 +08:00
d9c08568cf Revised the test_update_not_modified tests to be more specific in the AccountTestCase and CurrencyTestCase test cases. 2023-02-21 09:38:31 +08:00
a4c89f1494 Added the type hint and the documentation for the obj parameter of the post_update method of the AccountForm and CurrencyForm forms. 2023-02-20 16:08:49 +08:00
a73e3204b9 Renamed the "accounting.utils.next_url" module to "accounting.utils.next_uri". 2023-02-20 16:08:32 +08:00
330a71ebf2 Fixed the logic in the __set_next method in the "accounting.utils.next_url" module. 2023-02-20 08:17:31 +08:00
36b0bb3a0e Revised the import in the "accounting.account.view" module. 2023-02-18 18:40:11 +08:00
2ab60b2224 Replaced "unittest.TestCase.assert*" methods with "assert" in the common test functions, for simplicity. 2023-02-13 19:18:41 +08:00
36f55900c7 Renamed "fh" to "fp" when opening files, following the Python convention. 2023-02-09 00:02:14 +08:00
d99f592cff Merged the "accounting.database" module into the "accounting" module. It has only one member as "db", the database instance, and does not need to be separated into another file. 2023-02-08 11:13:09 +08:00
e24ed61b99 Added 7 currencies. 2023-02-08 11:05:59 +08:00
354f1ff3d8 Moved the currency data from the "accounting.currency.commands" module into the currencies.csv file, separating the code and the data. Rewrote the test case to test against each all the content imported. The locales are read from the CSV file instead of hard-coded in the code, so that the translations are not hard-coded to Mandarin. 2023-02-08 10:50:20 +08:00
d8e0e30c41 Revised the coding style in the test_init test of the BaseAccountCommandTestCase test case. 2023-02-08 10:47:55 +08:00
d58859bcf3 Removed the unused BaseAccountData data type. 2023-02-08 10:39:13 +08:00
40e64c4d2e Rewrote the test_init test of the BaseAccountCommandTestCase test case to test all the content imported. The translations are not hard-coded to Mandarin now. 2023-02-08 10:28:41 +08:00
2aacb67988 Moved the base account data from the "accounting.base_account.commands" module into the data directory as a CSV file, separating the code and the data. The locales are read from the CSV file instead of hard-coded in the code, so that the translations are not hard-coded to Mandarin. 2023-02-08 10:28:02 +08:00
a839c5a41a Fixed the path of the test site in MANIFEST.in. 2023-02-08 09:45:25 +08:00
356d10eb6e Added the test_api_exists test to the CurrencyTestCase test case. 2023-02-07 21:44:58 +08:00
8dc340dbf1 Advanced to version 0.2.0. 2023-02-07 20:55:00 +08:00
4b5b348270 Implemented the incremental search (search-as-you-type) in the base account selector of the account form. 2023-02-07 20:27:25 +08:00
d9585f0e53 Fixed a translated message. 2023-02-07 20:20:18 +08:00
5737d6cef4 Fixed the error message in the Javascript validateAsyncCodeIsDuplicated function in currency-form.js. 2023-02-07 20:20:16 +08:00
1d61fa93d3 Prepend all the HTML ID and class names with "accounting-" to avoid name conflict. 2023-02-07 20:20:01 +08:00
b1c7bc61c4 Renamed the can_view_accounting and can_edit_accounting template functions to accounting_can_view and accounting_can_edit, respectively. 2023-02-07 17:15:05 +08:00
708a434b5d Renamed the append_next, inherit_next, and or_next template filters to accounting_append_next, accounting_inherit_next, and accounting_or_next, to avoid name conflict. 2023-02-07 17:13:06 +08:00
8e524674a3 Added the init_app function to the "accounting.utils.next_url" module to initialize the template filters, and apply it to the init_app function of the accounting application. 2023-02-07 17:10:03 +08:00
699db20308 Revised the init_app function of the "accounting.utils.permission" module to register the "can_view" and "can_edit" functions under the blueprint instead of the whole application. 2023-02-07 17:05:27 +08:00
c3cedf714b Revised the documentation of the init_app function of the "accounting.locale", "accounting.base_account", "accounting.account", and "accounting.currency" modules. 2023-02-07 17:01:08 +08:00
c67ed4471c Fixed the permission so that the template helper also use the wrapper can_view and can_edit, that can_edit always requires the user to log in first. 2023-02-07 16:58:46 +08:00
2d3b9f68b8 Added the missing Material Floating Action Button to add a new currency for the mobile devices to the currency list. 2023-02-07 16:43:42 +08:00
f82278b48a Updated the icon of the currency management in the navigation menu. 2023-02-07 16:36:18 +08:00
85480804e7 Updated the translation. 2023-02-07 16:23:46 +08:00
9e85c14431 Changed the can_edit permission to at least require the user to log in first. 2023-02-07 16:03:13 +08:00
31dc8fab04 Changed the type hint of the "current_user" pseudo property of the AbstractUserUtils class to return None when the user has not logged in. 2023-02-07 16:00:51 +08:00
dc24af1db0 Added the get_current_user function to the "accounting.utils.user" module to retrieve the currently logged-in user and cache it in the current request. 2023-02-07 16:00:47 +08:00
59795635ee Updated the Sphinx documentation. 2023-02-07 11:41:28 +08:00
399afe56c8 Added the initial values for the database instance, the user class and the user primary key column, to allow the Sphinx documentation system to work properly. 2023-02-07 11:40:48 +08:00
16e2a146db Fixed the documentation in the Account data model. 2023-02-07 11:30:25 +08:00
f7ce94902f Revised the AccountTestCase test case, added the test_add, test_basic_update, test_update_not_modified, test_created_updated_by, test_l10n, and test_delete test to replace the simple test_change_base test. 2023-02-07 11:29:09 +08:00
5cf3cb1e11 Added the "is_modified" pseudo property to the Account data model, and applied it to the update_account view, to count the localized titles for modification. 2023-02-07 11:14:15 +08:00
a78057a8c3 Renamed the variable in the test_created_updated_by test of the CurrencyTestCase test case. 2023-02-07 09:47:32 +08:00
0491614ae4 Added the PREFIX constant to simplify the CurrencyTestCase test case. 2023-02-07 09:46:54 +08:00
fb9ff1d7ff Added to validate if the base account is available in the AccountForm form with the BaseAccountAvailable validator. 2023-02-07 09:30:06 +08:00
be10984cbb Fixed the documentation of the BaseAccountExists validator. 2023-02-07 09:28:10 +08:00
7b2089bdfb Revised the currency test cases. 2023-02-07 08:24:24 +08:00
be8dc21c5a Revised the code in the test_l10n test of the CurrencyTestCase test case. 2023-02-07 00:38:41 +08:00
2f8c6f6981 Removed the redundant unique constraint from the AccountL10n and CurrencyL10n data models. 2023-02-07 00:24:36 +08:00
cdd010427b Added documentation to the columns of the AccountL10n data model. 2023-02-07 00:23:45 +08:00
d78b941674 Applied the delete method of the Account data model to the delete_account view, to make things easier. 2023-02-07 00:22:23 +08:00
570c84c196 Added the currency management. 2023-02-07 00:13:33 +08:00
7873e16cc3 Added the editor2 user to the test site. 2023-02-06 23:28:21 +08:00
52351c52bc Revised the imports in test_base_account.py and test_account.py. 2023-02-06 21:45:56 +08:00
591fb4a7ab Replaced the UserClient class and the get_user_client function with the get_client function in the tests, for simplicity. 2023-02-06 21:45:28 +08:00
2a6c5de6d6 Removed the unused clients from the setUp method of the BaseAccountTestCase test case. 2023-02-06 21:37:41 +08:00
6b94cfb908 Removed excess blank lines in test_account.py and test_base_account.py. 2023-02-06 19:57:19 +08:00
eb90e83c98 Removed an unused import from the "accounting" module. 2023-02-06 19:31:06 +08:00
6bf18be455 Revised the coding style in the title setter of the Account data model. 2023-02-06 11:42:22 +08:00
895bca2508 Fixed the documentation of the list_accounts view. 2023-02-06 11:07:18 +08:00
6af29e7df7 Updated the icon to create a new account in the account list. 2023-02-06 10:08:50 +08:00
50f8f06687 Revised the translation. 2023-02-06 09:50:03 +08:00
cd5b1b97fd Added a different the page title of the search result in the base account list and account list, to be clear. 2023-02-06 09:47:19 +08:00
b7dd53d2f9 Added a complex query to the test_malformed test of the QueryKeywordParserTestCase test case. 2023-02-04 14:54:32 +08:00
b07b0e3be4 Added a complex query to the test_default test of the QueryKeywordParserTestCase test case. 2023-02-04 14:53:18 +08:00
e7fb2288ce Revised the parse_query_keywords utility to handle the case with an open double quotation mark without its corresponding close double quotation mark. 2023-02-04 14:51:09 +08:00
17ba7659b6 Removed the CSRF token from the NextUriTestCase test case, for simplicity. 2023-02-04 14:38:25 +08:00
2c8d5e7c8a Revised the translation. 2023-02-04 13:27:04 +08:00
e2f707f696 Replaced gettext with pgettext in the Pagination utility. 2023-02-04 13:26:58 +08:00
b5c0d0b7b3 Added the pgettext function to the "accounting.locale" module. 2023-02-04 13:26:32 +08:00
7fe2bb6135 Removed an excess blank line from the "accounting.utils.pagination" module. 2023-02-04 12:57:38 +08:00
4d870f1dcc Added the page size to the public properties of the Pagination utility. It is used in the pagination template. 2023-02-04 12:55:30 +08:00
16b2eb1c93 Renamed the page_links and page_sizes properties to pages and page_size_options in the Pagination utility. 2023-02-04 12:51:30 +08:00
fd63149066 Revised the pagination utility to handle the empty data. better 2023-02-04 12:19:30 +08:00
a7a432914d Added the empty condition in the __get_page_sizes method of the Pagination utility. 2023-02-04 11:37:00 +08:00
1a44f08b90 Revised the empty condition in the __get_page_links method of the Pagination utility. 2023-02-04 11:36:42 +08:00
3e68cfe690 Removed incorrect documentation in the Pagination utility. 2023-02-04 11:31:09 +08:00
809f2b6df3 Changed the page number and page size properties to private in the Pagination utility. 2023-02-04 11:26:33 +08:00
c286aa8b8b Added the missing parameter in the __uri_set method of the Pagination utility. 2023-02-04 11:24:10 +08:00
1326d9538c Added the missing is_found = True in the __uri_set method of the Pagination utility. 2023-02-04 11:21:22 +08:00
b9cecf343a Added the generic type to the pagination utility in the PaginationTestCase test case. 2023-02-04 11:09:20 +08:00
3d9e6c10da Removed the invalid page number handler in the __set_list method of the Pagination utility. The invalid page numbers are handled and redirected in the __get_page_no method now. 2023-02-04 11:07:04 +08:00
5090e59bb1 Added to redirect when the page size is invalid in the Pagination utility. 2023-02-04 10:55:49 +08:00
62697fb782 Added the exception to the documentation of the constructor of the Pagination utility. 2023-02-04 10:51:07 +08:00
8c462e7b2c Replaced the messy __get_base_uri_params __uri_set_params methods with the unified __uri_set method in the Pagination utility. 2023-02-04 10:49:35 +08:00
90a8229db9 Revised the Pagination so that the page size and page number that are the same as the default values are redirected and removed, too. 2023-02-04 10:37:39 +08:00
8be44ccf5f Renamed the is_needed property to is_paged in the Pagination utility. 2023-02-04 10:26:28 +08:00
511328a0bd Renamed the PageLink class to Link in the "accounting.utils.pagination" module. 2023-02-04 10:18:22 +08:00
0d8cf85ec0 Removed an excess blank line in test_utils.py. 2023-02-04 09:51:19 +08:00
6e212f0e33 Revised the Pagination utility to handle the malformed and illegal page number and page size values. 2023-02-04 09:34:52 +08:00
2fbe137243 Added test_utils.py with the NextUriTestCase, QueryKeywordParserTestCase, and PaginationTestCase test cases for the independent utilities. 2023-02-04 08:12:24 +08:00
f4e2c21ece Added test_temp.py to .gitignore, for temporary tests that should not be committed. 2023-02-04 08:11:52 +08:00
fff07a2552 Removed node_models from .gitignore. 2023-02-03 23:06:28 +08:00
975b00bce9 Advanced to version 0.1.1. 2023-02-03 17:14:47 +08:00
d648538fbb Added onupdate="CASCADE" to the foreign keys. 2023-02-03 17:14:32 +08:00
dde9c38bb8 Fixed the primary key of the Account data model to be not auto-incrementing. 2023-02-03 13:32:19 +08:00
fecf33baa8 Updated the minimal python version to 3.11, as for the use of the typing.Self type hint. 2023-02-03 13:01:03 +08:00
cea2a44226 Added the order and sorting routes to the test_nobody, test_viewer, and test_editor tests of the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
b5d87d2387 Revised to allow the viewers to view the account order page. 2023-02-03 12:57:53 +08:00
784e7bde49 Added the test_reorder test to the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
60280f415d Shortened the variable names in the test_change_base test of the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
f32d268494 Revised the order and sorting routes from "/base/" to "/bases/". 2023-02-03 12:57:53 +08:00
1c1be87f3e Revised the accounting reordering to handle the cases with only one account or no account. 2023-02-03 12:57:53 +08:00
589da0c1c6 Renamed "sorting" to "reorder", and the "sort-form" route to "order". 2023-02-03 12:57:53 +08:00
8363ce6602 Fixed the endpoint name in the account detail template. 2023-02-03 12:57:53 +08:00
6a83f95c9f Added the test_change_base test to the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
7dc754174c Revised the documentation of the views. 2023-02-03 12:57:53 +08:00
5238168b2d Added support to sort the accounts under the same base account. 2023-02-03 12:57:53 +08:00
eeb05b8616 Removed the unique constraint in the Account data model. 2023-02-03 12:57:53 +08:00
9920377266 Added a missing semicolon in account-form.js. 2023-02-03 12:57:53 +08:00
9f9c40c30e Revised the code to find the next number in the populate_obj method of the AccountForm form. 2023-02-03 12:57:53 +08:00
d368c5e062 Renamed the variable in the new_id function from "new" to "obj_id", to be clear. 2023-02-03 12:57:53 +08:00
4aed2f6ba7 Renamed the "testsite" application to "test_site". 2023-02-03 12:57:53 +08:00
6876fdf75e Added the test_editor test to the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
d9624c7be6 Revised the AccountTestCase test case for simplicity. 2023-02-03 12:57:53 +08:00
8364025668 Split the BaseAccountTestCase into BaseAccountCommandTestCase and BaseAccountTestCase, and rewrote the BaseAccountTestCase for simplicity. 2023-02-03 12:57:53 +08:00
dd3690dd6a Added the AccountTestCase test case with the test_nobody and test_viewer tests. 2023-02-03 12:57:53 +08:00
3312c835fd Added the AccountCommandTestCase test case. 2023-02-03 12:57:53 +08:00
fce9d04896 Removed SQLALCHEMY_ECHO from the test site. 2023-02-03 12:57:53 +08:00
c68786f78a Revised the import in the test_init test of the BaseAccountTestCase test case. 2023-02-03 12:57:53 +08:00
581e803707 Moved the user utilities from the "accounting.database" module to the "accounting.utils.users" module, and simplified its use. 2023-02-03 12:57:53 +08:00
108 changed files with 12357 additions and 1230 deletions

2
.gitignore vendored
View File

@ -37,4 +37,4 @@ excludes
*.pot
*.mo
zh_Hans
node_modules
test_temp.py

View File

@ -22,7 +22,7 @@ include docs/source/*
include docs/source/_static/*
include docs/source/_templates/*
include tests/*
include tests/testsite/*
include tests/testsite/templates/*
include tests/testsite/translations/*
include tests/testsite/translations/*/LC_MESSAGES/*
include tests/test_site/*
include tests/test_site/templates/*
include tests/test_site/translations/*
include tests/test_site/translations/*/LC_MESSAGES/*

View File

@ -0,0 +1,53 @@
accounting.account package
==========================
Submodules
----------
accounting.account.commands module
----------------------------------
.. automodule:: accounting.account.commands
:members:
:undoc-members:
:show-inheritance:
accounting.account.converters module
------------------------------------
.. automodule:: accounting.account.converters
:members:
:undoc-members:
:show-inheritance:
accounting.account.forms module
-------------------------------
.. automodule:: accounting.account.forms
:members:
:undoc-members:
:show-inheritance:
accounting.account.query module
-------------------------------
.. automodule:: accounting.account.query
:members:
:undoc-members:
:show-inheritance:
accounting.account.views module
-------------------------------
.. automodule:: accounting.account.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.account
:members:
:undoc-members:
:show-inheritance:

View File

@ -12,18 +12,10 @@ accounting.base\_account.commands module
:undoc-members:
:show-inheritance:
accounting.base\_account.database module
----------------------------------------
accounting.base\_account.converters module
------------------------------------------
.. automodule:: accounting.base_account.database
:members:
:undoc-members:
:show-inheritance:
accounting.base\_account.models module
--------------------------------------
.. automodule:: accounting.base_account.models
.. automodule:: accounting.base_account.converters
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,53 @@
accounting.currency package
===========================
Submodules
----------
accounting.currency.commands module
-----------------------------------
.. automodule:: accounting.currency.commands
:members:
:undoc-members:
:show-inheritance:
accounting.currency.converters module
-------------------------------------
.. automodule:: accounting.currency.converters
:members:
:undoc-members:
:show-inheritance:
accounting.currency.forms module
--------------------------------
.. automodule:: accounting.currency.forms
:members:
:undoc-members:
:show-inheritance:
accounting.currency.query module
--------------------------------
.. automodule:: accounting.currency.query
:members:
:undoc-members:
:show-inheritance:
accounting.currency.views module
--------------------------------
.. automodule:: accounting.currency.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.currency
:members:
:undoc-members:
:show-inheritance:

View File

@ -7,7 +7,10 @@ Subpackages
.. toctree::
:maxdepth: 4
accounting.account
accounting.base_account
accounting.currency
accounting.transaction
accounting.utils
Submodules
@ -21,6 +24,14 @@ accounting.locale module
:undoc-members:
:show-inheritance:
accounting.models module
------------------------
.. automodule:: accounting.models
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------

View File

@ -0,0 +1,61 @@
accounting.transaction package
==============================
Submodules
----------
accounting.transaction.converters module
----------------------------------------
.. automodule:: accounting.transaction.converters
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.dispatcher module
----------------------------------------
.. automodule:: accounting.transaction.dispatcher
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.forms module
-----------------------------------
.. automodule:: accounting.transaction.forms
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.query module
-----------------------------------
.. automodule:: accounting.transaction.query
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.template module
--------------------------------------
.. automodule:: accounting.transaction.template
:members:
:undoc-members:
:show-inheritance:
accounting.transaction.views module
-----------------------------------
.. automodule:: accounting.transaction.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.transaction
:members:
:undoc-members:
:show-inheritance:

View File

@ -4,6 +4,22 @@ accounting.utils package
Submodules
----------
accounting.utils.flash\_errors module
-------------------------------------
.. automodule:: accounting.utils.flash_errors
:members:
:undoc-members:
:show-inheritance:
accounting.utils.next\_uri module
---------------------------------
.. automodule:: accounting.utils.next_uri
:members:
:undoc-members:
:show-inheritance:
accounting.utils.pagination module
----------------------------------
@ -28,6 +44,30 @@ accounting.utils.query module
:undoc-members:
:show-inheritance:
accounting.utils.random\_id module
----------------------------------
.. automodule:: accounting.utils.random_id
:members:
:undoc-members:
:show-inheritance:
accounting.utils.strip\_text module
-----------------------------------
.. automodule:: accounting.utils.strip_text
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module
----------------------------
.. automodule:: accounting.utils.user
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------

View File

@ -17,7 +17,7 @@
[metadata]
name = mia-accounting-flask
version = 0.1.0
version = 0.3.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project.
@ -36,7 +36,7 @@ classifiers =
[options]
package_dir =
= src
python_requires = >=3.10
python_requires = >=3.11
install_requires =
flask
Flask-SQLAlchemy
@ -53,3 +53,4 @@ accounting =
static/**
templates/**
translations/*/LC_MESSAGES/*.mo
data/**

View File

@ -18,55 +18,17 @@
"""
import typing as t
from abc import ABC, abstractmethod
from pathlib import Path
import sqlalchemy as sa
from flask import Flask, Blueprint
from flask_sqlalchemy.model import Model
from flask_sqlalchemy import SQLAlchemy
T = t.TypeVar("T", bound=Model)
from accounting.utils.user import AbstractUserUtils
class AbstractUserUtils(t.Generic[T], ABC):
"""The abstract user utilities."""
@property
@abstractmethod
def cls(self) -> t.Type[T]:
"""Returns the user class.
:return: The user class.
"""
@property
@abstractmethod
def pk_column(self) -> sa.Column:
"""Returns the primary key column.
:return: The primary key column.
"""
@property
@abstractmethod
def current_user(self) -> T:
"""Returns the current user.
:return: The current user.
"""
@abstractmethod
def get_by_username(self, username: str) -> T | None:
"""Returns the user by her username.
:return: The user by her username, or None if the user was not found.
"""
@abstractmethod
def get_pk(self, user: T) -> int:
"""Returns the primary key of the user.
:return: The primary key of the user.
"""
db: SQLAlchemy = SQLAlchemy()
"""The database instance."""
data_dir: Path = Path(__file__).parent / "data"
"""The data directory."""
def init_app(app: Flask, user_utils: AbstractUserUtils,
@ -86,8 +48,10 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
"""
# The database instance must be set before loading everything
# in the application.
from .database import set_db
set_db(app.extensions["sqlalchemy"], user_utils)
global db
db = app.extensions["sqlalchemy"]
from .utils.user import init_user_utils
init_user_utils(user_utils)
bp: Blueprint = Blueprint("accounting", __name__,
url_prefix=url_prefix,
@ -98,7 +62,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
locale.init_app(app, bp)
from .utils import permission
permission.init_app(app, can_view_func, can_edit_func)
permission.init_app(bp, can_view_func, can_edit_func)
from . import base_account
base_account.init_app(app, bp)
@ -106,9 +70,13 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
from . import account
account.init_app(app, bp)
from .utils.next_url import append_next, inherit_next, or_next
bp.add_app_template_filter(append_next, "append_next")
bp.add_app_template_filter(inherit_next, "inherit_next")
bp.add_app_template_filter(or_next, "or_next")
from . import currency
currency.init_app(app, bp)
from . import transaction
transaction.init_app(app, bp)
from .utils import next_uri
next_uri.init_app(bp)
app.register_blueprint(bp)

View File

@ -23,8 +23,8 @@ from flask import Flask, Blueprint
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
:param bp: The blueprint of the accounting application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .converters import AccountConverter

View File

@ -24,12 +24,13 @@ from secrets import randbelow
import click
from flask.cli import with_appcontext
from accounting.database import db, user_utils
from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import has_user, get_user_pk
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-offset-needed) tuples."""
English, Traditional Chinese, Simplified Chinese, is-pay-off-needed) tuples."""
def __validate_username(ctx: click.core.Context, param: click.core.Option,
@ -45,8 +46,7 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
user: user_utils.cls | None = user_utils.get_by_username(value)
if user is None:
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@ -58,7 +58,7 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
@with_appcontext
def init_accounts_command(username: str) -> None:
"""Initializes the accounts."""
creator_pk: int = user_utils.get_pk(user_utils.get_by_username(username))
creator_pk: int = get_user_pk(username)
bases: list[BaseAccount] = BaseAccount.query\
.filter(db.func.length(BaseAccount.code) == 4)\
@ -93,10 +93,10 @@ def init_accounts_command(username: str) -> None:
data: list[AccountData] = []
for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \
is_pay_off_needed: bool = True if re.match("^[12]1[34]", base.code) \
else False
data.append((get_new_id(), base.code, 1, base.title_l10n,
l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed))
l10n["zh_Hant"], l10n["zh_Hans"], is_pay_off_needed))
__add_accounting_accounts(data, creator_pk)
click.echo(F"{len(data)} added. Accounting accounts initialized.")
@ -113,7 +113,7 @@ def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
base_code=x[1],
no=x[2],
title_l10n=x[3],
is_offset_needed=x[6],
is_pay_off_needed=x[6],
created_by_id=creator_pk,
updated_by_id=creator_pk)
for x in data]

View File

@ -18,19 +18,21 @@
"""
import sqlalchemy as sa
from flask import request
from flask_wtf import FlaskForm
from wtforms import StringField, BooleanField
from wtforms.validators import DataRequired, ValidationError
from accounting.database import db, user_utils
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
class BaseAccountExists:
"""The validator to check if the base account code exists."""
"""The validator to check if the base account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data == "":
@ -40,20 +42,32 @@ class BaseAccountExists:
"The base account does not exist."))
class BaseAccountAvailable:
"""The validator to check if the base account is available."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data == "":
return
if len(field.data) != 4:
raise ValidationError(lazy_gettext(
"The base account is not available."))
class AccountForm(FlaskForm):
"""The form to create or edit an account."""
base_code = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext("Please select the base account.")),
BaseAccountExists()])
BaseAccountExists(),
BaseAccountAvailable()])
"""The code of the base account."""
title = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
"""The title."""
is_offset_needed = BooleanField()
"""Whether the the entries of this account need offsets."""
is_pay_off_needed = BooleanField()
"""Whether the the entries of this account need pay-off."""
def populate_obj(self, obj: Account) -> None:
"""Populates the form data into an account object.
@ -62,32 +76,30 @@ class AccountForm(FlaskForm):
:return: None.
"""
is_new: bool = obj.id is None
prev_base_code: str | None = obj.base_code
if is_new:
obj.id = new_id(Account)
obj.base_code = self.base_code.data
if prev_base_code != self.base_code.data:
last_same_base: Account = Account.query\
.filter(Account.base_code == self.base_code.data)\
.order_by(Account.base_code.desc()).first()
obj.no = 1 if last_same_base is None else last_same_base.no + 1
if obj.base_code != self.base_code.data:
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()
obj.base_code = self.base_code.data
obj.no = count + 1
obj.title = self.title.data
obj.is_offset_needed = self.is_offset_needed.data
obj.is_pay_off_needed = self.is_pay_off_needed.data
if is_new:
current_user_pk: int = user_utils.get_pk(user_utils.current_user)
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
if prev_base_code is not None \
and prev_base_code != self.base_code.data:
setattr(self, "__post_update",
lambda: sort_accounts_in(prev_base_code, obj.id))
def post_update(self, obj) -> None:
def post_update(self, obj: Account) -> None:
"""The post-processing after the update.
:param obj: The account object.
:return: None
"""
current_user_pk: int = user_utils.get_pk(user_utils.current_user)
current_user_pk: int = get_current_user_pk()
obj.updated_by_id = current_user_pk
obj.updated_at = sa.func.now()
if hasattr(self, "__post_update"):
@ -127,3 +139,48 @@ def sort_accounts_in(base_code: str, exclude: int) -> None:
for i in range(len(accounts)):
if accounts[i].no != i + 1:
accounts[i].no = i + 1
class AccountReorderForm:
"""The form to reorder the accounts."""
def __init__(self, base: BaseAccount):
"""Constructs the form to reorder the accounts under a base account.
:param base: The base account.
"""
self.base: BaseAccount = base
self.is_modified: bool = False
def save_order(self) -> None:
"""Saves the order of the account.
:return:
"""
accounts: list[Account] = self.base.accounts
# Collects the specified order.
orders: dict[Account, int] = {}
for account in accounts:
if f"{account.id}-no" in request.form:
try:
orders[account] = int(request.form[f"{account.id}-no"])
except ValueError:
pass
# Missing and invalid orders are appended to the end.
missing: list[Account] = [x for x in accounts if x not in orders]
if len(missing) > 0:
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
for account in missing:
orders[account] = next_no
# Sort by the specified order first, and their original order.
accounts = sorted(accounts, key=lambda x: (orders[x], x.no, x.code))
# Update the orders.
with db.session.no_autoflush:
for i in range(len(accounts)):
if accounts[i].no != i + 1:
accounts[i].no = i + 1
self.is_modified = True

View File

@ -47,8 +47,8 @@ def get_account_query() -> list[Account]:
Account.title_l10n.contains(k),
code.contains(k),
Account.id.in_(l10n_matches)]
if k in gettext("Offset needed"):
sub_conditions.append(Account.is_offset_needed)
if k in gettext("Pay-off needed"):
sub_conditions.append(Account.is_pay_off_needed)
conditions.append(sa.or_(*sub_conditions))
return Account.query.filter(*conditions)\

View File

@ -19,17 +19,21 @@
"""
from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, flash, \
url_for, request
from werkzeug.datastructures import ImmutableMultiDict
from accounting.database import db
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, BaseAccount
from accounting.utils.next_url import inherit_next, or_next
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 .forms import AccountForm, sort_accounts_in
from accounting.utils.user import get_current_user_pk
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
from .query import get_account_query
bp: Blueprint = Blueprint("account", __name__)
"""The view blueprint for the account management."""
@ -38,11 +42,10 @@ bp: Blueprint = Blueprint("account", __name__)
@bp.get("", endpoint="list")
@has_permission(can_view)
def list_accounts() -> str:
"""Lists the base accounts.
"""Lists the accounts.
:return: The account list.
"""
from .query import get_account_query
accounts: list[BaseAccount] = get_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts)
return render_template("accounting/account/list.html",
@ -76,9 +79,7 @@ def add_account() -> redirect:
"""
form = AccountForm(request.form)
if not form.validate():
for key in form.errors:
for error in form.errors[key]:
flash(error, "error")
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.account.create")))
account: Account = Account()
@ -86,8 +87,7 @@ def add_account() -> redirect:
db.session.add(account)
db.session.commit()
flash(lazy_gettext("The account is added successfully"), "success")
return redirect(inherit_next(url_for("accounting.account.detail",
account=account)))
return redirect(inherit_next(__get_detail_uri(account)))
@bp.get("/<account:account>", endpoint="detail")
@ -95,7 +95,8 @@ def add_account() -> redirect:
def show_account_detail(account: Account) -> str:
"""Shows the account detail.
:return: The account detail.
:param account: The account.
:return: The detail.
"""
return render_template("accounting/account/detail.html", obj=account)
@ -105,7 +106,8 @@ def show_account_detail(account: Account) -> str:
def show_account_edit_form(account: Account) -> str:
"""Shows the form to edit an account.
:return: The form to edit an account.
:param account: The account.
:return: The form to edit the account.
"""
form: AccountForm
if "form" in session:
@ -123,28 +125,26 @@ def show_account_edit_form(account: Account) -> str:
def update_account(account: Account) -> redirect:
"""Updates an account.
:param account: The account.
:return: The redirection to the account detail on success, or the account
edit form on error.
"""
form = AccountForm(request.form)
if not form.validate():
for key in form.errors:
for error in form.errors[key]:
flash(error, "error")
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.account.edit",
account=account)))
with db.session.no_autoflush:
form.populate_obj(account)
if not db.session.is_modified(account):
if not account.is_modified:
flash(lazy_gettext("The account was not modified."), "success")
return redirect(inherit_next(url_for("accounting.account.detail",
account=account)))
form.post_update(account)
return redirect(inherit_next(__get_detail_uri(account)))
account.updated_by_id = get_current_user_pk()
account.updated_at = sa.func.now()
db.session.commit()
flash(lazy_gettext("The account is updated successfully."), "success")
return redirect(inherit_next(url_for("accounting.account.detail",
account=account)))
return redirect(inherit_next(__get_detail_uri(account)))
@bp.post("/<account:account>/delete", endpoint="delete")
@ -152,13 +152,59 @@ def update_account(account: Account) -> redirect:
def delete_account(account: Account) -> redirect:
"""Deletes an account.
:param account: The account.
:return: The redirection to the account list on success, or the account
detail on error.
"""
for l10n in account.l10n:
db.session.delete(l10n)
db.session.delete(account)
account.delete()
sort_accounts_in(account.base_code, account.id)
db.session.commit()
flash(lazy_gettext("The account is deleted successfully."), "success")
return redirect(or_next(url_for("accounting.account.list")))
return redirect(or_next(__get_list_uri()))
@bp.get("/bases/<baseAccount:base>", endpoint="order")
@has_permission(can_view)
def show_account_order(base: BaseAccount) -> str:
"""Shows the order of the accounts under a same base account.
:param base: The base account.
:return: The order of the accounts under the base account.
"""
return render_template("accounting/account/order.html", base=base)
@bp.post("/bases/<baseAccount:base>", endpoint="sort")
@has_permission(can_edit)
def sort_accounts(base: BaseAccount) -> redirect:
"""Reorders the accounts under a base account.
:param base: The base account.
:return: The redirection to the incoming account or the account list. The
reordering operation does not fail.
"""
form: AccountReorderForm = AccountReorderForm(base)
form.save_order()
if not form.is_modified:
flash(lazy_gettext("The order was not modified."), "success")
return redirect(or_next(__get_list_uri()))
db.session.commit()
flash(lazy_gettext("The order is updated successfully."), "success")
return redirect(or_next(__get_list_uri()))
def __get_detail_uri(account: Account) -> str:
"""Returns the detail URI of an account.
:param account: The account.
:return: The detail URI of the account.
"""
return url_for("accounting.account.detail", account=account)
def __get_list_uri() -> str:
"""Returns the account list URI.
:return: The account list URI.
"""
return url_for("accounting.account.list")

View File

@ -23,8 +23,8 @@ from flask import Flask, Blueprint
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
:param bp: The blueprint of the accounting application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .converters import BaseAccountConverter

View File

@ -17,16 +17,15 @@
"""The console commands for the base account management.
"""
import csv
import click
from flask.cli import with_appcontext
from accounting.database import db
from accounting import data_dir
from accounting import db
from accounting.models import BaseAccount, BaseAccountL10n
BaseAccountData = tuple[int, str, str, str]
"""The format of the base account data, as a list of (code, English,
Traditional Chinese, Simplified Chinese) tuples."""
@click.command("accounting-init-base")
@with_appcontext
@ -36,674 +35,17 @@ def init_base_accounts_command() -> None:
click.echo("Base accounts already exist.")
raise click.Abort
db.session.bulk_save_objects(
[BaseAccount(code=str(x[0]), title_l10n=x[1]) for x in DATA])
db.session.bulk_save_objects(
[BaseAccountL10n(account_code=x[0], locale=y[0], title=y[1])
for x in DATA for y in (("zh_Hant", x[2]), ("zh_Hans", x[3]))])
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": 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"],
"locale": y,
"title": x[f"l10n-{y}"]}
for x in data for y in locales]
db.session.bulk_insert_mappings(BaseAccount, account_data)
db.session.bulk_insert_mappings(BaseAccountL10n, l10n_data)
db.session.commit()
click.echo("Base accounts initialized.")
DATA: list[BaseAccountData] = [
(1, "assets", "資產", "资产"),
(2, "liabilities", "負債", "负债"),
(3, "owners equity", "業主權益", "业主权益"),
(4, "operating revenue", "營業收入", "营业收入"),
(5, "operating costs", "營業成本", "营业成本"),
(6, "operating expenses", "營業費用", "营业费用"),
(7, "non-operating revenue and expenses, other income (expense)",
"營業外收入及費用", "营业外收入及费用"),
(8, "income tax expense (or benefit)", "所得稅費用(或利益)",
"所得税费用(或利益)"),
(9, "nonrecurring gain or loss", "非經常營業損益", "非经常营业损益"),
(11, "current assets", "流動資產", "流动资产"),
(12, "current assets", "流動資產", "流动资产"),
(13, "funds and long-term investments", "基金及長期投資", "基金及长期投资"),
(14, "property , plant, and equipment", "固定資產", "固定资产"),
(15, "property , plant, and equipment", "固定資產", "固定资产"),
(16, "depletable assets", "遞耗資產", "递耗资产"),
(17, "intangible assets", "無形資產", "无形资产"),
(18, "other assets", "其他資產", "其他资产"),
(21, "current liabilities", "流動負債", "流动负债"),
(22, "current liabilities", "流動負債", "流动负债"),
(23, "long-term liabilities", "長期負債", "长期负债"),
(28, "other liabilities", "其他負債", "其他负债"),
(31, "capital", "資本", "资本"),
(32, "additional paid-in capital", "資本公積", "资本公积"),
(33, "retained earnings (accumulated deficit)", "保留盈餘(或累積虧損)",
"保留盈余(或累积亏损)"),
(34, "equity adjustments", "權益調整", "权益调整"),
(35, "treasury stock", "庫藏股", "库藏股"),
(36, "minority interest", "少數股權", "少数股权"),
(41, "sales revenue", "銷貨收入", "销货收入"),
(46, "service revenue", "勞務收入", "劳务收入"),
(47, "agency revenue", "業務收入", "业务收入"),
(48, "other operating revenue", "其他營業收入", "其他营业收入"),
(51, "cost of goods sold", "銷貨成本", "销货成本"),
(56, "service costs", "勞務成本", "劳务成本"),
(57, "agency costs", "業務成本", "业务成本"),
(58, "other operating costs", "其他營業成本", "其他营业成本"),
(61, "selling expenses", "推銷費用", "推销费用"),
(62, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
(63, "research and development expenses", "研究發展費用", "研究发展费用"),
(71, "non-operating revenue", "營業外收入", "营业外收入"),
(72, "non-operating revenue", "營業外收入", "营业外收入"),
(73, "non-operating revenue", "營業外收入", "营业外收入"),
(74, "non-operating revenue", "營業外收入", "营业外收入"),
(75, "non-operating expenses", "營業外費用", "营业外费用"),
(76, "non-operating expenses", "營業外費用", "营业外费用"),
(77, "non-operating expenses", "營業外費用", "营业外费用"),
(78, "non-operating expenses", "營業外費用", "营业外费用"),
(81, "income tax expense (or benefit)", "所得稅費用(或利益)",
"所得税费用(或利益)"),
(91, "gain (loss) from discontinued operations", "停業部門損益",
"停业部门损益"),
(92, "extraordinary gain or loss", "非常損益", "非常损益"),
(93, "cumulative effect of changes in accounting principles",
"會計原則變動累積影響數", "会计原则变动累积影响数"),
(94, "minority interest income", "少數股權淨利", "少数股权净利"),
(111, "cash and cash equivalents", "現金及約當現金", "现金及约当现金"),
(112, "short-term investments", "短期投資", "短期投资"),
(113, "notes receivable", "應收票據", "应收票据"),
(114, "accounts receivable", "應收帳款", "应收帐款"),
(118, "other receivables", "其他應收款", "其他应收款"),
(121, "inventories", "存貨", "存货"),
(122, "inventories", "存貨", "存货"),
(125, "prepaid expenses", "預付費用", "预付费用"),
(126, "prepayments", "預付款項", "预付款项"),
(128, "other current assets", "其他流動資產", "其他流动资产"),
(129, "other current assets", "其他流動資產", "其他流动资产"),
(131, "funds", "基金", "基金"),
(132, "long-term investments", "長期投資", "长期投资"),
(141, "land", "土地", "土地"),
(142, "land improvements", "土地改良物", "土地改良物"),
(143, "buildings", "房屋及建物", "房屋及建物"),
(144, "machinery and equipment", "機(器)具及設備", "机(器)具及设备"),
(145, "machinery and equipment", "機(器)具及設備", "机(器)具及设备"),
(146, "machinery and equipment", "機(器)具及設備", "机(器)具及设备"),
(151, "leased assets", "租賃資產", "租赁资产"),
(152, "leasehold improvements", "租賃權益改良", "租赁权益改良"),
(156, "construction in progress and prepayments for equipment",
"未完工程及預付購置設備款", "未完工程及预付购置设备款"),
(158, "miscellaneous property, plant, and equipment", "雜項固定資產",
"杂项固定资产"),
(161, "depletable assets", "遞耗資產", "递耗资产"),
(171, "trademarks", "商標權", "商标权"),
(172, "patents", "專利權", "专利权"),
(173, "franchise", "特許權", "特许权"),
(174, "copyright", "著作權", "著作权"),
(175, "computer software", "電腦軟體", "电脑软体"),
(176, "goodwill", "商譽", "商誉"),
(177, "organization costs", "開辦費", "开办费"),
(178, "other intangibles", "其他無形資產", "其他无形资产"),
(181, "deferred assets", "遞延資產", "递延资产"),
(182, "idle assets", "閒置資產", "闲置资产"),
(184, "long-term notes , accounts and overdue receivables",
"長期應收票據及款項與催收帳款", "长期应收票据及款项与催收帐款"),
(185, "assets leased to others", "出租資產", "出租资产"),
(186, "refundable deposit", "存出保證金", "存出保证金"),
(188, "miscellaneous assets", "雜項資產", "杂项资产"),
(211, "short-term borrowings (debt)", "短期借款", "短期借款"),
(212, "short-term notes and bills payable", "應付短期票券", "应付短期票券"),
(213, "notes payable", "應付票據", "应付票据"),
(214, "accounts pay able", "應付帳款", "应付帐款"),
(216, "income taxes payable", "應付所得稅", "应付所得税"),
(217, "accrued expenses", "應付費用", "应付费用"),
(218, "other payables", "其他應付款", "其他应付款"),
(219, "other payables", "其他應付款", "其他应付款"),
(226, "advance receipts", "預收款項", "预收款项"),
(227, "long-term liabilities -current portion",
"一年或一營業週期內到期長期負債", "一年或一营业周期内到期长期负债"),
(228, "other current liabilities", "其他流動負債",
"其他流动负债"),
(229, "other current liabilities", "其他流動負債",
"其他流动负债"),
(231, "corporate bonds payable", "應付公司債", "应付公司债"),
(232, "long-term loans payable", "長期借款", "长期借款"),
(233, "long-term notes and accounts payable", "長期應付票據及款項",
"长期应付票据及款项"),
(234, "accrued liabilities for land value increment tax",
"估計應付土地增值稅", "估计应付土地增值税"),
(235, "accrued pension liabilities", "應計退休金負債", "应计退休金负债"),
(238, "other long-term liabilities", "其他長期負債", "其他长期负债"),
(281, "deferred liabilities", "遞延負債", "递延负债"),
(286, "deposits received", "存入保證金", "存入保证金"),
(288, "miscellaneous liabilities", "雜項負債", "杂项负债"),
(311, "capital", "資本(或股本)", "资本(或股本)"),
(321, "paid-in capital in excess of par", "股票溢價", "股票溢价"),
(323, "capital surplus from assets revaluation", "資產重估增值準備",
"资产重估增值准备"),
(324, "capital surplus from gain on disposal of assets", "處分資產溢價公積",
"处分资产溢价公积"),
(325, "capital surplus from business combination", "合併公積", "合并公积"),
(326, "donated surplus", "受贈公積", "受赠公积"),
(328, "other additional paid-in capital", "其他資本公積", "其他资本公积"),
(331, "legal reserve", "法定盈餘公積", "法定盈余公积"),
(332, "special reserve", "特別盈餘公積", "特别盈余公积"),
(335, "retained earnings-unappropriated (or accumulated deficit)",
"未分配盈餘(或累積虧損)", "未分配盈余(或累积亏损)"),
(341,
"unrealized loss on market value decline of long-term equity investments",
"長期股權投資未實現跌價損失", "长期股权投资未实现跌价损失"),
(342, "cumulative translation adjustment", "累積換算調整數", "累积换算调整数"),
(343, "net loss not recognized as pension cost", "未認列為退休金成本之淨損失",
"未认列为退休金成本之净损失"),
(351, "treasury stock", "庫藏股", "库藏股"),
(361, "minority interest", "少數股權", "少数股权"),
(411, "sales revenue", "銷貨收入", "销货收入"),
(417, "sales return", "銷貨退回", "销货退回"),
(419, "sales allowances", "銷貨折讓", "销货折让"),
(461, "service revenue", "勞務收入", "劳务收入"),
(471, "agency revenue", "業務收入", "业务收入"),
(488, "other operating revenue", "其他營業收入—其他", "其他营业收入—其他"),
(511, "cost of goods sold", "銷貨成本", "销货成本"),
(512, "purchases", "進貨", "进货"),
(513, "materials purchased", "進料", "进料"),
(514, "direct labor", "直接人工", "直接人工"),
(515, "manufacturing overhead", "製造費用", "制造费用"),
(516, "manufacturing overhead", "製造費用", "制造费用"),
(517, "manufacturing overhead", "製造費用", "制造费用"),
(518, "manufacturing overhead", "製造費用", "制造费用"),
(561, "service costs", "勞務成本", "劳务成本"),
(571, "agency costs", "業務成本", "业务成本"),
(588, "other operating costs-other", "其他營業成本—其他", "其他营业成本—其他"),
(615, "selling expenses", "推銷費用", "推销费用"),
(616, "selling expenses", "推銷費用", "推销费用"),
(617, "selling expenses", "推銷費用", "推销费用"),
(618, "selling expenses", "推銷費用", "推销费用"),
(625, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
(626, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
(627, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
(628, "general & administrative expenses", "管理及總務費用", "管理及总务费用"),
(635, "research and development expenses", "研究發展費用", "研究发展费用"),
(636, "research and development expenses", "研究發展費用", "研究发展费用"),
(637, "research and development expenses", "研究發展費用", "研究发展费用"),
(638, "research and development expenses", "研究發展費用", "研究发展费用"),
(711, "interest revenue", "利息收入", "利息收入"),
(712, "investment income", "投資收益", "投资收益"),
(713, "foreign exchange gain", "兌換利益", "兑换利益"),
(714, "gain on disposal of investments", "處分投資收益", "处分投资收益"),
(715, "gain on disposal of assets", "處分資產溢價收入", "处分资产溢价收入"),
(748, "other non-operating revenue", "其他營業外收入", "其他营业外收入"),
(751, "interest expense", "利息費用", "利息费用"),
(752, "investment loss", "投資損失", "投资损失"),
(753, "foreign exchange loss", "兌換損失", "兑换损失"),
(754, "loss on disposal of investments", "處分投資損失", "处分投资损失"),
(755, "loss on disposal of assets", "處分資產損失", "处分资产损失"),
(788, "other non-operating expenses", "其他營業外費用", "其他营业外费用"),
(811, "income tax expense (or benefit)", "所得稅費用(或利益)",
"所得税费用(或利益)"),
(911, "income (loss) from operations of discontinued segments",
"停業部門損益—停業前營業損益", "停业部门损益—停业前营业损益"),
(912, "gain (loss) from disposal of discontinued segments",
"停業部門損益—處分損益", "停业部门损益—处分损益"),
(921, "extraordinary gain or loss", "非常損益", "非常损益"),
(931, "cumulative effect of changes in accounting principles",
"會計原則變動累積影響數", "会计原则变动累积影响数"),
(941, "minority interest income", "少數股權淨利", "少数股权净利"),
(1111, "cash on hand", "庫存現金", "库存现金"),
(1112, "petty cash/revolving funds", "零用金/週轉金", "零用金/周转金"),
(1113, "cash in banks", "銀行存款", "银行存款"),
(1116, "cash in transit", "在途現金", "在途现金"),
(1117, "cash equivalents", "約當現金", "约当现金"),
(1118, "other cash and cash equivalents", "其他現金及約當現金",
"其他现金及约当现金"),
(1121, "short-term investments stock", "短期投資—股票", "短期投资—股票"),
(1122, "short-term investments short-term notes and bills",
"短期投資—短期票券", "短期投资—短期票券"),
(1123, "short-term investments government bonds", "短期投資—政府債券",
"短期投资—政府债券"),
(1124, "short-term investments beneficiary certificates",
"短期投資—受益憑證", "短期投资—受益凭证"),
(1125, "short-term investments corporate bonds", "短期投資—公司債",
"短期投资—公司债"),
(1128, "short-term investments other", "短期投資—其他", "短期投资—其他"),
(1129, "allowance for reduction of short-term investment to market",
"備抵短期投資跌價損失", "备抵短期投资跌价损失"),
(1131, "notes receivable", "應收票據", "应收票据"),
(1132, "discounted notes receivable", "應收票據貼現", "应收票据贴现"),
(1137, "notes receivable related parties", "應收票據—關係人",
"应收票据—关系人"),
(1138, "other notes receivable", "其他應收票據", "其他应收票据"),
(1139, "allowance for uncollectible accounts notes receivable",
"備抵呆帳-應收票據", "备抵呆帐-应收票据"),
(1141, "accounts receivable", "應收帳款", "应收帐款"),
(1142, "installment accounts receivable", "應收分期帳款",
"应收分期帐款"),
(1147, "accounts receivable related parties", "應收帳款—關係人",
"应收帐款—关系人"),
(1149, "allowance for uncollectible accounts accounts receivable",
"備抵呆帳-應收帳款", "备抵呆帐-应收帐款"),
(1181, "forward exchange contract receivable", "應收出售遠匯款",
"应收出售远汇款"),
(1182, "forward exchange contract receivable foreign currencies",
"應收遠匯款—外幣", "应收远汇款—外币"),
(1183, "discount on forward ex-change contract", "買賣遠匯折價",
"买卖远汇折价"),
(1184, "earned revenue receivable", "應收收益", "应收收益"),
(1185, "income tax refund receivable", "應收退稅款", "应收退税款"),
(1187, "other receivables related parties", "其他應收款—關係人",
"其他应收款—关系人"),
(1188, "other receivables other", "其他應收款—其他", "其他应收款—其他"),
(1189, "allowance for uncollectible accounts other receivables",
"備抵呆帳—其他應收款", "备抵呆帐—其他应收款"),
(1211, "merchandise inventory", "商品存貨", "商品存货"),
(1212, "consigned goods", "寄銷商品", "寄销商品"),
(1213, "goods in transit", "在途商品", "在途商品"),
(1219, "allowance for reduction of inventory to market", "備抵存貨跌價損失",
"备抵存货跌价损失"),
(1221, "finished goods", "製成品", "制成品"),
(1222, "consigned finished goods", "寄銷製成品", "寄销制成品"),
(1223, "by-products", "副產品", "副产品"),
(1224, "work in process", "在製品", "在制品"),
(1225, "work in process outsourced", "委外加工", "委外加工"),
(1226, "raw materials", "原料", "原料"),
(1227, "supplies", "物料", "物料"),
(1228, "materials and supplies in transit", "在途原物料", "在途原物料"),
(1229, "allowance for reduction of inventory to market", "備抵存貨跌價損失",
"备抵存货跌价损失"),
(1251, "prepaid payroll", "預付薪資", "预付薪资"),
(1252, "prepaid rents", "預付租金", "预付租金"),
(1253, "prepaid insurance", "預付保險費", "预付保险费"),
(1254, "office supplies", "用品盤存", "用品盘存"),
(1255, "prepaid income tax", "預付所得稅", "预付所得税"),
(1258, "other prepaid expenses", "其他預付費用", "其他预付费用"),
(1261, "prepayment for purchases", "預付貨款", "预付货款"),
(1268, "other prepayments", "其他預付款項", "其他预付款项"),
(1281, "VAT paid ( or input tax)", "進項稅額", "进项税额"),
(1282, "excess VAT paid (or overpaid VAT)", "留抵稅額", "留抵税额"),
(1283, "temporary payments", "暫付款", "暂付款"),
(1284, "payment on behalf of others", "代付款", "代付款"),
(1285, "advances to employees", "員工借支", "员工借支"),
(1286, "refundable deposits", "存出保證金", "存出保证金"),
(1287, "certificate of deposit-restricted", "受限制存款", "受限制存款"),
(1291, "deferred income tax assets", "遞延所得稅資產", "递延所得税资产"),
(1292, "deferred foreign exchange losses", "遞延兌換損失", "递延兑换损失"),
(1293, "owners (stockholders) current account", "業主(股東)往來",
"业主(股东)往来"),
(1294, "current account with others", "同業往來", "同业往来"),
(1298, "other current assets other", "其他流動資產—其他",
"其他流动资产—其他"),
(1311, "redemption fund (or sinking fund)", "償債基金", "偿债基金"),
(1312, "fund for improvement and expansion", "改良及擴充基金",
"改良及扩充基金"),
(1313, "contingency fund", "意外損失準備基金", "意外损失准备基金"),
(1314, "pension fund", "退休基金", "退休基金"),
(1318, "other funds", "其他基金", "其他基金"),
(1321, "long-term equity investments", "長期股權投資", "长期股权投资"),
(1322, "long-term bond investments", "長期債券投資", "长期债券投资"),
(1323, "long-term real estate in-vestments", "長期不動產投資",
"长期不动产投资"),
(1324, "cash surrender value of life insurance", "人壽保險現金解約價值",
"人寿保险现金解约价值"),
(1328, "other long-term investments", "其他長期投資", "其他长期投资"),
(1329,
"allowance for excess of cost over market value of long-term investments",
"備抵長期投資跌價損失", "备抵长期投资跌价损失"),
(1411, "land", "土地", "土地"),
(1418, "land revaluation increments", "土地—重估增值", "土地—重估增值"),
(1421, "land improvements", "土地改良物", "土地改良物"),
(1428, "land improvements revaluation increments", "土地改良物—重估增值",
"土地改良物—重估增值"),
(1429, "accumulated depreciation land improvements", "累積折舊—土地改良物",
"累积折旧—土地改良物"),
(1431, "buildings", "房屋及建物", "房屋及建物"),
(1438, "buildings revaluation increments", "房屋及建物—重估增值",
"房屋及建物—重估增值"),
(1439, "accumulated depreciation buildings", "累積折舊—房屋及建物",
"累积折旧—房屋及建物"),
(1441, "machinery", "機(器)具", "机(器)具"),
(1448, "machinery revaluation increments", "機(器)具—重估增值",
"机(器)具—重估增值"),
(1449, "accumulated depreciation machinery", "累積折舊—機(器)具",
"累积折旧—机(器)具"),
(1511, "leased assets", "租賃資產", "租赁资产"),
(1519, "accumulated depreciation leased assets", "累積折舊—租賃資產",
"累积折旧—租赁资产"),
(1521, "leasehold improvements", "租賃權益改良", "租赁权益改良"),
(1529, "accumulated depreciation leasehold improvements",
"累積折舊—租賃權益改良", "累积折旧—租赁权益改良"),
(1561, "construction in progress", "未完工程", "未完工程"),
(1562, "prepayment for equipment", "預付購置設備款", "预付购置设备款"),
(1581, "miscellaneous property, plant, and equipment", "雜項固定資產",
"杂项固定资产"),
(1588,
"miscellaneous property, plant, and equipment revaluation increments",
"雜項固定資產—重估增值", "杂项固定资产—重估增值"),
(1589,
"accumulated depreciation miscellaneous property, plant, and equipment",
"累積折舊—雜項固定資產", "累积折旧—杂项固定资产"),
(1611, "natural resources", "天然資源", "天然资源"),
(1618, "natural resources revaluation increments", "天然資源—重估增值",
"天然资源—重估增值"),
(1619, "accumulated depletion natural resources", "累積折耗—天然資源",
"累积折耗—天然资源"),
(1711, "trademarks", "商標權", "商标权"),
(1721, "patents", "專利權", "专利权"),
(1731, "franchise", "特許權", "特许权"),
(1741, "copyright", "著作權", "著作权"),
(1751, "computer software cost", "電腦軟體", "电脑软体"),
(1761, "goodwill", "商譽", "商誉"),
(1771, "organization costs", "開辦費", "开办费"),
(1781, "deferred pension costs", "遞延退休金成本", "递延退休金成本"),
(1782, "leasehold improvements", "租賃權益改良", "租赁权益改良"),
(1788, "other intangible assets other", "其他無形資產—其他",
"其他无形资产—其他"),
(1811, "deferred bond issuance costs", "債券發行成本", "债券发行成本"),
(1812, "long-term prepaid rent", "長期預付租金", "长期预付租金"),
(1813, "long-term prepaid insurance", "長期預付保險費", "长期预付保险费"),
(1814, "deferred income tax assets", "遞延所得稅資產", "递延所得税资产"),
(1815, "prepaid pension cost", "預付退休金", "预付退休金"),
(1818, "other deferred assets", "其他遞延資產", "其他递延资产"),
(1821, "idle assets", "閒置資產", "闲置资产"),
(1841, "long-term notes receivable", "長期應收票據", "长期应收票据"),
(1842, "long-term accounts receivable", "長期應收帳款", "长期应收帐款"),
(1843, "overdue receivables", "催收帳款", "催收帐款"),
(1847,
"long-term notes, accounts and overdue receivables related parties",
"長期應收票據及款項與催收帳款—關係人", "长期应收票据及款项与催收帐款—关系人"),
(1848, "other long-term receivables", "其他長期應收款項", "其他长期应收款项"),
(1849,
"allowance for uncollectible accounts long-term notes, accounts and"
" overdue receivables",
"備抵呆帳—長期應收票據及款項與催收帳款", "备抵呆帐—长期应收票据及款项与催收帐款"),
(1851, "assets leased to others", "出租資產", "出租资产"),
(1858, "assets leased to others incremental value from revaluation",
"出租資產—重估增值", "出租资产—重估增值"),
(1859, "accumulated depreciation assets leased to others",
"累積折舊—出租資產", "累积折旧—出租资产"),
(1861, "refundable deposits", "存出保證金", "存出保证金"),
(1881, "certificate of deposit restricted", "受限制存款", "受限制存款"),
(1888, "miscellaneous assets other", "雜項資產—其他", "杂项资产—其他"),
(2111, "bank overdraft", "銀行透支", "银行透支"),
(2112, "bank loan", "銀行借款", "银行借款"),
(2114, "short-term borrowings owners", "短期借款—業主", "短期借款—业主"),
(2115, "short-term borrowings employees", "短期借款—員工", "短期借款—员工"),
(2117, "short-term borrowings related parties", "短期借款—關係人",
"短期借款—关系人"),
(2118, "short-term borrowings other", "短期借款—其他", "短期借款—其他"),
(2121, "commercial paper payable", "應付商業本票", "应付商业本票"),
(2122, "bank acceptance", "銀行承兌匯票", "银行承兑汇票"),
(2128, "other short-term notes and bills payable", "其他應付短期票券",
"其他应付短期票券"),
(2129, "discount on short-term notes and bills payable", "應付短期票券折價",
"应付短期票券折价"),
(2131, "notes payable", "應付票據", "应付票据"),
(2137, "notes payable related parties", "應付票據—關係人",
"应付票据—关系人"),
(2138, "other notes payable", "其他應付票據", "其他应付票据"),
(2141, "accounts payable", "應付帳款", "应付帐款"),
(2147, "accounts payable related parties", "應付帳款—關係人",
"应付帐款—关系人"),
(2161, "income tax payable", "應付所得稅", "应付所得税"),
(2171, "accrued payroll", "應付薪工", "应付薪工"),
(2172, "accrued rent payable", "應付租金", "应付租金"),
(2173, "accrued interest payable", "應付利息", "应付利息"),
(2174, "accrued VAT payable", "應付營業稅", "应付营业税"),
(2175, "accrued taxes payable other", "應付稅捐—其他", "应付税捐—其他"),
(2178, "other accrued expenses payable", "其他應付費用", "其他应付费用"),
(2181, "forward exchange contract payable", "應付購入遠匯款", "应付购入远汇款"),
(2182, "forward exchange contract payable foreign currencies",
"應付遠匯款—外幣", "应付远汇款—外币"),
(2183, "premium on forward exchange contract", "買賣遠匯溢價", "买卖远汇溢价"),
(2184, "payables on land and building purchased", "應付土地房屋款",
"应付土地房屋款"),
(2185, "Payables on equipment", "應付設備款", "应付设备款"),
(2187, "other payables related parties", "其他應付款—關係人",
"其他应付款—关系人"),
(2191, "dividend payable", "應付股利", "应付股利"),
(2192, "bonus payable", "應付紅利", "应付红利"),
(2193, "compensation payable to directors and supervisors", "應付董監事酬勞",
"应付董监事酬劳"),
(2198, "other payables other", "其他應付款—其他", "其他应付款—其他"),
(2261, "sales revenue received in advance", "預收貨款", "预收货款"),
(2262, "revenue received in advance", "預收收入", "预收收入"),
(2268, "other advance receipts", "其他預收款", "其他预收款"),
(2271, "corporate bonds payable current portion",
"一年或一營業週期內到期公司債", "一年或一营业周期内到期公司债"),
(2272, "long-term loans payable current portion",
"一年或一營業週期內到期長期借款", "一年或一营业周期内到期长期借款"),
(2273,
"long-term notes and accounts payable due within one year or one"
" operating cycle",
"一年或一營業週期內到期長期應付票據及款項",
"一年或一营业周期内到期长期应付票据及款项"),
(2277,
"long-term notes and accounts payables to related parties current"
" portion",
"一年或一營業週期內到期長期應付票據及款項—關係人",
"一年或一营业周期内到期长期应付票据及款项—关系人"),
(2278, "other long-term liabilities current portion",
"其他一年或一營業週期內到期長期負債", "其他一年或一营业周期内到期长期负债"),
(2281, "VAT received (or output tax)", "銷項稅額", "销项税额"),
(2283, "temporary receipts", "暫收款", "暂收款"),
(2284, "receipts under custody", "代收款", "代收款"),
(2285, "estimated warranty liabilities", "估計售後服務/保固負債",
"估计售后服务/保固负债"),
(2291, "deferred income tax liabilities", "遞延所得稅負債", "递延所得税负债"),
(2292, "deferred foreign exchange gain", "遞延兌換利益", "递延兑换利益"),
(2293, "owners current account", "業主(股東)往來", "业主(股东)往来"),
(2294, "current account with others", "同業往來", "同业往来"),
(2298, "other current liabilities others", "其他流動負債—其他",
"其他流动负债—其他"),
(2311, "corporate bonds payable", "應付公司債", "应付公司债"),
(2319, "premium (discount) on corporate bonds payable",
"應付公司債溢(折)價", "应付公司债溢(折)价"),
(2321, "long-term loans payable bank", "長期銀行借款", "长期银行借款"),
(2324, "long-term loans payable owners", "長期借款—業主", "长期借款—业主"),
(2325, "long-term loans payable employees", "長期借款—員工",
"长期借款—员工"),
(2327, "long-term loans payable related parties", "長期借款—關係人",
"长期借款—关系人"),
(2328, "long-term loans payable other", "長期借款—其他", "长期借款—其他"),
(2331, "long-term notes payable", "長期應付票據", "长期应付票据"),
(2332, "long-term accounts pay-able", "長期應付帳款", "长期应付帐款"),
(2333, "long-term capital lease liabilities", "長期應付租賃負債",
"长期应付租赁负债"),
(2337, "Long-term notes and accounts payable related parties",
"長期應付票據及款項—關係人", "长期应付票据及款项—关系人"),
(2338, "other long-term payables", "其他長期應付款項", "其他长期应付款项"),
(2341, "estimated accrued land value incremental tax pay-able",
"估計應付土地增值稅", "估计应付土地增值税"),
(2351, "accrued pension liabilities", "應計退休金負債", "应计退休金负债"),
(2388, "other long-term liabilities other", "其他長期負債—其他",
"其他长期负债—其他"),
(2811, "deferred revenue", "遞延收入", "递延收入"),
(2814, "deferred income tax liabilities", "遞延所得稅負債", "递延所得税负债"),
(2818, "other deferred liabilities", "其他遞延負債", "其他递延负债"),
(2861, "guarantee deposit received", "存入保證金", "存入保证金"),
(2888, "miscellaneous liabilities other", "雜項負債—其他", "杂项负债—其他"),
(3111, "capital common stock", "普通股股本", "普通股股本"),
(3112, "capital preferred stock", "特別股股本", "特别股股本"),
(3113, "capital collected in advance", "預收股本", "预收股本"),
(3114, "stock dividends to be distributed", "待分配股票股利",
"待分配股票股利"),
(3115, "capital", "資本", "资本"),
(3211, "paid-in capital in excess of par- common stock", "普通股股票溢價",
"普通股股票溢价"),
(3212, "paid-in capital in excess of par- preferred stock", "特別股股票溢價",
"特别股股票溢价"),
(3231, "capital surplus from assets revaluation", "資產重估增值準備",
"资产重估增值准备"),
(3241, "capital surplus from gain on disposal of assets", "處分資產溢價公積",
"处分资产溢价公积"),
(3251, "capital surplus from business combination", "合併公積", "合并公积"),
(3261, "donated surplus", "受贈公積", "受赠公积"),
(3281, "additional paid-in capital from investee under equity method",
"權益法長期股權投資資本公積", "权益法长期股权投资资本公积"),
(3282, "additional paid-in capital treasury stock trans-actions",
"資本公積—庫藏股票交易", "资本公积—库藏股票交易"),
(3311, "legal reserve", "法定盈餘公積", "法定盈余公积"),
(3321, "contingency reserve", "意外損失準備", "意外损失准备"),
(3322, "improvement and expansion reserve", "改良擴充準備", "改良扩充准备"),
(3323, "special reserve for redemption of liabilities", "償債準備",
"偿债准备"),
(3328, "other special reserve", "其他特別盈餘公積", "其他特别盈余公积"),
(3351, "accumulated profit or loss", "累積盈虧", "累积盈亏"),
(3352, "prior period adjustments", "前期損益調整", "前期损益调整"),
(3353, "net income or loss for current period", "本期損益", "本期损益"),
(3411,
"unrealized loss on market value decline of long-term equity investments",
"長期股權投資未實現跌價損失", "长期股权投资未实现跌价损失"),
(3421, "cumulative translation adjustments", "累積換算調整數",
"累积换算调整数"),
(3431, "net loss not recognized as pension costs",
"未認列為退休金成本之淨損失", "未认列为退休金成本之净损失"),
(3511, "treasury stock", "庫藏股", "库藏股"),
(3611, "minority interest", "少數股權", "少数股权"),
(4111, "sales revenue", "銷貨收入", "销货收入"),
(4112, "installment sales revenue", "分期付款銷貨收入", "分期付款销货收入"),
(4171, "sales return", "銷貨退回", "销货退回"),
(4191, "sales discounts and allowances", "銷貨折讓", "销货折让"),
(4611, "service revenue", "勞務收入", "劳务收入"),
(4711, "agency revenue", "業務收入", "业务收入"),
(4888, "other operating revenue other", "其他營業收入—其他",
"其他营业收入—其他"),
(5111, "cost of goods sold", "銷貨成本", "销货成本"),
(5112, "installment cost of goods sold", "分期付款銷貨成本",
"分期付款销货成本"),
(5121, "purchases", "進貨", "进货"),
(5122, "purchase expenses", "進貨費用", "进货费用"),
(5123, "purchase returns", "進貨退出", "进货退出"),
(5124, "charges on purchased merchandise", "進貨折讓", "进货折让"),
(5131, "material purchased", "進料", "进料"),
(5132, "charges on purchased material", "進料費用", "进料费用"),
(5133, "material purchase returns", "進料退出", "进料退出"),
(5134, "material purchase allowances", "進料折讓", "进料折让"),
(5141, "direct labor", "直接人工", "直接人工"),
(5151, "indirect labor", "間接人工", "间接人工"),
(5152, "rent expense, rent", "租金支出", "租金支出"),
(5153, "office supplies (expense)", "文具用品", "文具用品"),
(5154, "travelling expense, travel", "旅費", "旅费"),
(5155, "shipping expenses, freight", "運費", "运费"),
(5156, "postage (expenses)", "郵電費", "邮电费"),
(5157, "repair (s) and maintenance (expense )", "修繕費", "修缮费"),
(5158, "packing expenses", "包裝費", "包装费"),
(5161, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"),
(5162, "insurance (expense)", "保險費", "保险费"),
(5163, "manufacturing overhead outsourced", "加工費", "加工费"),
(5166, "taxes", "稅捐", "税捐"),
(5168, "depreciation expense", "折舊", "折旧"),
(5169, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"),
(5172, "meal (expenses)", "伙食費", "伙食费"),
(5173, "employee benefits/welfare", "職工福利", "职工福利"),
(5176, "training (expense)", "訓練費", "训练费"),
(5177, "indirect materials", "間接材料", "间接材料"),
(5188, "other manufacturing expenses", "其他製造費用", "其他制造费用"),
(5611, "service costs", "勞務成本", "劳务成本"),
(5711, "agency costs", "業務成本", "业务成本"),
(5888, "other operating costs other", "其他營業成本—其他",
"其他营业成本—其他"),
(6151, "payroll expense", "薪資支出", "薪资支出"),
(6152, "rent expense, rent", "租金支出", "租金支出"),
(6153, "office supplies (expense)", "文具用品", "文具用品"),
(6154, "travelling expense, travel", "旅費", "旅费"),
(6155, "shipping expenses, freight", "運費", "运费"),
(6156, "postage (expenses)", "郵電費", "邮电费"),
(6157, "repair (s) and maintenance (expense)", "修繕費", "修缮费"),
(6159, "advertisement expense, advertisement", "廣告費", "广告费"),
(6161, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"),
(6162, "insurance (expense)", "保險費", "保险费"),
(6164, "entertainment (expense)", "交際費", "交际费"),
(6165, "donation (expense)", "捐贈", "捐赠"),
(6166, "taxes", "稅捐", "税捐"),
(6167, "loss on uncollectible accounts", "呆帳損失", "呆帐损失"),
(6168, "depreciation expense", "折舊", "折旧"),
(6169, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"),
(6172, "meal (expenses)", "伙食費", "伙食费"),
(6173, "employee benefits/welfare", "職工福利", "职工福利"),
(6175, "commission (expense)", "佣金支出", "佣金支出"),
(6176, "training (expense)", "訓練費", "训练费"),
(6188, "other selling expenses", "其他推銷費用", "其他推销费用"),
(6251, "payroll expense", "薪資支出", "薪资支出"),
(6252, "rent expense, rent", "租金支出", "租金支出"),
(6253, "office supplies", "文具用品", "文具用品"),
(6254, "travelling expense, travel", "旅費", "旅费"),
(6255, "shipping expenses,freight", "運費", "运费"),
(6256, "postage (expenses)", "郵電費", "邮电费"),
(6257, "repair (s) and maintenance (expense)", "修繕費", "修缮费"),
(6259, "advertisement expense, advertisement", "廣告費", "广告费"),
(6261, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"),
(6262, "insurance (expense)", "保險費", "保险费"),
(6264, "entertainment (expense)", "交際費", "交际费"),
(6265, "donation (expense)", "捐贈", "捐赠"),
(6266, "taxes", "稅捐", "税捐"),
(6267, "loss on uncollectible accounts", "呆帳損失", "呆帐损失"),
(6268, "depreciation expense", "折舊", "折旧"),
(6269, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"),
(6271, "loss on export sales", "外銷損失", "外销损失"),
(6272, "meal (expenses)", "伙食費", "伙食费"),
(6273, "employee benefits/welfare", "職工福利", "职工福利"),
(6274, "research and development expense", "研究發展費用", "研究发展费用"),
(6275, "commission (expense)", "佣金支出", "佣金支出"),
(6276, "training (expense)", "訓練費", "训练费"),
(6278, "professional service fees", "勞務費", "劳务费"),
(6288, "other general and administrative expenses", "其他管理及總務費用",
"其他管理及总务费用"),
(6351, "payroll expense", "薪資支出", "薪资支出"),
(6352, "rent expense, rent", "租金支出", "租金支出"),
(6353, "office supplies", "文具用品", "文具用品"),
(6354, "travelling expense, travel", "旅費", "旅费"),
(6355, "shipping expenses, freight", "運費", "运费"),
(6356, "postage (expenses)", "郵電費", "邮电费"),
(6357, "repair (s) and maintenance (expense)", "修繕費", "修缮费"),
(6361, "utilities (expense)", "水電瓦斯費", "水电瓦斯费"),
(6362, "insurance (expense)", "保險費", "保险费"),
(6364, "entertainment (expense)", "交際費", "交际费"),
(6366, "taxes", "稅捐", "税捐"),
(6368, "depreciation expense", "折舊", "折旧"),
(6369, "various amortization", "各項耗竭及攤提", "各项耗竭及摊提"),
(6372, "meal (expenses)", "伙食費", "伙食费"),
(6373, "employee benefits/welfare", "職工福利", "职工福利"),
(6376, "training (expense)", "訓練費", "训练费"),
(6378, "other research and development expenses", "其他研究發展費用",
"其他研究发展费用"),
(7111, "interest revenue/income", "利息收入", "利息收入"),
(7121, "investment income recognized under equity method",
"權益法認列之投資收益", "权益法认列之投资收益"),
(7122, "dividends income", "股利收入", "股利收入"),
(7123, "gain on market price recovery of short-term investment",
"短期投資市價回升利益", "短期投资市价回升利益"),
(7131, "foreign exchange gain", "兌換利益", "兑换利益"),
(7141, "gain on disposal of investments", "處分投資收益", "处分投资收益"),
(7151, "gain on disposal of assets", "處分資產溢價收入", "处分资产溢价收入"),
(7481, "donation income", "捐贈收入", "捐赠收入"),
(7482, "rent revenue/income", "租金收入", "租金收入"),
(7483, "commission revenue/income", "佣金收入", "佣金收入"),
(7484, "revenue from sale of scraps", "出售下腳及廢料收入",
"出售下脚及废料收入"),
(7485, "gain on physical inventory", "存貨盤盈", "存货盘盈"),
(7486, "gain from price recovery of inventory", "存貨跌價回升利益",
"存货跌价回升利益"),
(7487, "gain on reversal of bad debts", "壞帳轉回利益", "坏帐转回利益"),
(7488, "other non-operating revenue other items", "其他營業外收入—其他",
"其他营业外收入—其他"),
(7511, "interest expense", "利息費用", "利息费用"),
(7521, "investment loss recognized under equity method",
"權益法認列之投資損失", "权益法认列之投资损失"),
(7523, "unrealized loss on reduction of short-term investments to market",
"短期投資未實現跌價損失", "短期投资未实现跌价损失"),
(7531, "foreign exchange loss", "兌換損失", "兑换损失"),
(7541, "loss on disposal of investments", "處分投資損失", "处分投资损失"),
(7551, "loss on disposal of assets", "處分資產損失", "处分资产损失"),
(7881, "loss on work stoppages", "停工損失", "停工损失"),
(7882, "casualty loss", "災害損失", "灾害损失"),
(7885, "loss on physical inventory", "存貨盤損", "存货盘损"),
(7886,
"loss for market price decline and obsolete and slow-moving inventories",
"存貨跌價及呆滯損失", "存货跌价及呆滞损失"),
(7888, "other non-operating expenses other", "其他營業外費用—其他",
"其他营业外费用—其他"),
(8111, "income tax expense ( or benefit)", "所得稅費用(或利益)",
"所得税费用(或利益)"),
(9111, "income (loss) from operations of discontinued segment",
"停業部門損益—停業前營業損益", "停业部门损益—停业前营业损益"),
(9121, "gain (loss) from disposal of discontinued segment",
"停業部門損益—處分損益", "停业部门损益—处分损益"),
(9211, "extraordinary gain or loss", "非常損益", "非常损益"),
(9311, "cumulative effect of changes in accounting principles",
"會計原則變動累積影響數", "会计原则变动累积影响数"),
(9411, "minority interest income", "少數股權淨利", "少数股权净利"),
]
"""The base account data."""

View File

@ -20,7 +20,7 @@
from flask import abort
from werkzeug.routing import BaseConverter
from accounting.database import db
from accounting import db
from accounting.models import BaseAccount

View File

@ -46,7 +46,8 @@ def list_accounts() -> str:
def show_account_detail(account: BaseAccount) -> str:
"""Shows the account detail.
:return: The account detail.
:param account: The account.
:return: The detail.
"""
return render_template("accounting/base-account/detail.html", obj=account)

View File

@ -0,0 +1,38 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# 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 currency management.
"""
from flask import Flask, Blueprint
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .converters import CurrencyConverter
app.url_map.converters["currency"] = CurrencyConverter
from .views import bp as currency_bp, api_bp as currency_api_bp
bp.register_blueprint(currency_bp, url_prefix="/currencies")
bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
from .commands import init_currencies_command
app.cli.add_command(init_currencies_command)

View File

@ -0,0 +1,84 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# 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 console commands for the currency management.
"""
import csv
import os
import typing as t
import click
from flask.cli import with_appcontext
from accounting import db, data_dir
from accounting.models import Currency, CurrencyL10n
from accounting.utils.user import has_user, get_user_pk
CurrencyData = tuple[str, str, str, str]
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-currencies")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_currencies_command(username: str) -> None:
"""Initializes the currencies."""
existing_codes: set[str] = {x.code for x in Currency.query.all()}
with open(data_dir / "currencies.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
to_add: list[dict[str, str]] = [x for x in data
if x["code"] not in existing_codes]
if len(to_add) == 0:
click.echo("No more currency to add.")
return
creator_pk: int = get_user_pk(username)
currency_data: list[dict[str, t.Any]] = [{"code": x["code"],
"name_l10n": x["name"],
"created_by_id": creator_pk,
"updated_by_id": creator_pk}
for x in to_add]
locales: list[str] = [x[5:] for x in to_add[0] if x.startswith("l10n-")]
l10n_data: list[dict[str, str]] = [{"currency_code": x["code"],
"locale": y,
"name": x[f"l10n-{y}"]}
for x in to_add for y in locales]
db.session.bulk_insert_mappings(Currency, currency_data)
db.session.bulk_insert_mappings(CurrencyL10n, l10n_data)
db.session.commit()
click.echo(F"{len(to_add)} added. Currencies initialized.")

View File

@ -0,0 +1,48 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The path converters for the currency management.
"""
from flask import abort
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import Currency
class CurrencyConverter(BaseConverter):
"""The currency converter to convert the currency code and to the
corresponding currency in the routes."""
def to_python(self, value: str) -> Currency:
"""Converts a currency code to a currency.
:param value: The currency code.
:return: The corresponding currency.
"""
currency: Currency | None = db.session.get(Currency, value)
if currency is None:
abort(404)
return currency
def to_url(self, value: Currency) -> str:
"""Converts a currency to its code.
:param value: The currency.
:return: The code.
"""
return value.code

View File

@ -0,0 +1,83 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# 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 forms for the currency management.
"""
from __future__ import annotations
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
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency."""
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
"""The reserved codes that are not available."""
class CodeUnique:
"""The validator to check if the code is unique."""
def __call__(self, form: CurrencyForm, field: StringField) -> None:
if field.data == "":
return
if form.obj_code is not None and form.obj_code == field.data:
return
if db.session.get(Currency, field.data) is not None:
raise ValidationError(lazy_gettext(
"Code conflicts with another currency."))
code = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the code.")),
Regexp(r"^[A-Z]{3}$",
message=lazy_gettext(
"Code can only be composed of 3 upper-cased"
" letters.")),
NoneOf(CODE_BLOCKLIST, message=lazy_gettext(
"This code is not available.")),
CodeUnique()])
"""The code. It may not conflict with another currency."""
name = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
"""The name."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.obj_code: str | None = None
"""The current code of the currency, or None when adding a new
currency."""
def populate_obj(self, obj: Currency) -> None:
"""Populates the form data into a currency object.
:param obj: The currency object.
:return: None.
"""
is_new: bool = obj.code is None
obj.code = self.code.data
obj.name = self.name.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk

View File

@ -0,0 +1,44 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# 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 currency query.
"""
import sqlalchemy as sa
from flask import request
from accounting.models import Currency, CurrencyL10n
from accounting.utils.query import parse_query_keywords
def get_currency_query() -> list[Currency]:
"""Returns the base accounts, optionally filtered by the query.
:return: The base accounts.
"""
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] = []
for k in keywords:
l10n: list[CurrencyL10n] = CurrencyL10n.query\
.filter(CurrencyL10n.name.contains(k)).all()
l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(Currency.code.contains(k),
Currency.name_l10n.contains(k),
Currency.code.in_(l10n_matches)))
return Currency.query.filter(*conditions)\
.order_by(Currency.code).all()

View File

@ -0,0 +1,185 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The views for the currency management.
"""
from urllib.parse import urlencode, parse_qsl
import sqlalchemy as sa
from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
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
bp: Blueprint = Blueprint("currency", __name__)
"""The view blueprint for the currency management."""
api_bp: Blueprint = Blueprint("currency-api", __name__)
"""The view blueprint for the currency management API."""
@bp.get("", endpoint="list")
@has_permission(can_view)
def list_currencies() -> str:
"""Lists the currencies.
:return: The currency list.
"""
from .query import get_currency_query
currencies: list[Currency] = get_currency_query()
pagination: Pagination = Pagination[Currency](currencies)
return render_template("accounting/currency/list.html",
list=pagination.list, pagination=pagination)
@bp.get("/create", endpoint="create")
@has_permission(can_edit)
def show_add_currency_form() -> str:
"""Shows the form to add a currency.
:return: The form to add a currency.
"""
if "form" in session:
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = CurrencyForm()
return render_template("accounting/currency/create.html",
form=form)
@bp.post("/store", endpoint="store")
@has_permission(can_edit)
def add_currency() -> redirect:
"""Adds a currency.
:return: The redirection to the currency detail on success, or the currency
creation form on error.
"""
form = CurrencyForm(request.form)
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.currency.create")))
currency: Currency = Currency()
form.populate_obj(currency)
db.session.add(currency)
db.session.commit()
flash(lazy_gettext("The currency is added successfully"), "success")
return redirect(inherit_next(__get_detail_uri(currency)))
@bp.get("/<currency:currency>", endpoint="detail")
@has_permission(can_view)
def show_currency_detail(currency: Currency) -> str:
"""Shows the currency detail.
:param currency: The currency.
:return: The detail.
"""
return render_template("accounting/currency/detail.html", obj=currency)
@bp.get("/<currency:currency>/edit", endpoint="edit")
@has_permission(can_edit)
def show_currency_edit_form(currency: Currency) -> str:
"""Shows the form to edit a currency.
:param currency: The currency.
:return: The form to edit the currency.
"""
form: CurrencyForm
if "form" in session:
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = CurrencyForm(obj=currency)
return render_template("accounting/currency/edit.html",
currency=currency, form=form)
@bp.post("/<currency:currency>/update", endpoint="update")
@has_permission(can_edit)
def update_currency(currency: Currency) -> redirect:
"""Updates a currency.
:param currency: The currency.
:return: The redirection to the currency detail on success, or the currency
edit form on error.
"""
form = CurrencyForm(request.form)
form.obj_code = currency.code
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.currency.edit",
currency=currency)))
with db.session.no_autoflush:
form.populate_obj(currency)
if not currency.is_modified:
flash(lazy_gettext("The currency was not modified."), "success")
return redirect(inherit_next(__get_detail_uri(currency)))
currency.updated_by_id = get_current_user_pk()
currency.updated_at = sa.func.now()
db.session.commit()
flash(lazy_gettext("The currency is updated successfully."), "success")
return redirect(inherit_next(__get_detail_uri(currency)))
@bp.post("/<currency:currency>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_currency(currency: Currency) -> redirect:
"""Deletes a currency.
:param currency: The currency.
:return: The redirection to the currency list on success, or the currency
detail on error.
"""
currency.delete()
db.session.commit()
flash(lazy_gettext("The currency is deleted successfully."), "success")
return redirect(or_next(url_for("accounting.currency.list")))
@api_bp.get("/exists-code", endpoint="exists")
@has_permission(can_edit)
def exists_code() -> dict[str, bool]:
"""Validates whether a currency code exists.
:return: Whether the currency code exists.
"""
return {"exists": db.session.get(Currency, request.args["q"]) is not None}
def __get_detail_uri(currency: Currency) -> str:
"""Returns the detail URI of a currency.
:param currency: The currency.
:return: The detail URI of the currency.
"""
return url_for("accounting.currency.detail", currency=currency)

View File

@ -0,0 +1,528 @@
code,title,l10n-zh_Hant,l10n-zh_Hans
1,assets,資產,资产
2,liabilities,負債,负债
3,owners equity,業主權益,业主权益
4,operating revenue,營業收入,营业收入
5,operating costs,營業成本,营业成本
6,operating expenses,營業費用,营业费用
7,"non-operating revenue and expenses, other income (expense)",營業外收入及費用,营业外收入及费用
8,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益)
9,nonrecurring gain or loss,非經常營業損益,非经常营业损益
11,current assets,流動資產,流动资产
12,current assets,流動資產,流动资产
13,funds and long-term investments,基金及長期投資,基金及长期投资
14,"property , plant, and equipment",固定資產,固定资产
15,"property , plant, and equipment",固定資產,固定资产
16,depletable assets,遞耗資產,递耗资产
17,intangible assets,無形資產,无形资产
18,other assets,其他資產,其他资产
21,current liabilities,流動負債,流动负债
22,current liabilities,流動負債,流动负债
23,long-term liabilities,長期負債,长期负债
28,other liabilities,其他負債,其他负债
31,capital,資本,资本
32,additional paid-in capital,資本公積,资本公积
33,retained earnings (accumulated deficit),保留盈餘(或累積虧損),保留盈余(或累积亏损)
34,equity adjustments,權益調整,权益调整
35,treasury stock,庫藏股,库藏股
36,minority interest,少數股權,少数股权
41,sales revenue,銷貨收入,销货收入
46,service revenue,勞務收入,劳务收入
47,agency revenue,業務收入,业务收入
48,other operating revenue,其他營業收入,其他营业收入
51,cost of goods sold,銷貨成本,销货成本
56,service costs,勞務成本,劳务成本
57,agency costs,業務成本,业务成本
58,other operating costs,其他營業成本,其他营业成本
61,selling expenses,推銷費用,推销费用
62,general & administrative expenses,管理及總務費用,管理及总务费用
63,research and development expenses,研究發展費用,研究发展费用
71,non-operating revenue,營業外收入,营业外收入
72,non-operating revenue,營業外收入,营业外收入
73,non-operating revenue,營業外收入,营业外收入
74,non-operating revenue,營業外收入,营业外收入
75,non-operating expenses,營業外費用,营业外费用
76,non-operating expenses,營業外費用,营业外费用
77,non-operating expenses,營業外費用,营业外费用
78,non-operating expenses,營業外費用,营业外费用
81,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益)
91,gain (loss) from discontinued operations,停業部門損益,停业部门损益
92,extraordinary gain or loss,非常損益,非常损益
93,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数
94,minority interest income,少數股權淨利,少数股权净利
111,cash and cash equivalents,現金及約當現金,现金及约当现金
112,short-term investments,短期投資,短期投资
113,notes receivable,應收票據,应收票据
114,accounts receivable,應收帳款,应收帐款
118,other receivables,其他應收款,其他应收款
121,inventories,存貨,存货
122,inventories,存貨,存货
125,prepaid expenses,預付費用,预付费用
126,prepayments,預付款項,预付款项
128,other current assets,其他流動資產,其他流动资产
129,other current assets,其他流動資產,其他流动资产
131,funds,基金,基金
132,long-term investments,長期投資,长期投资
141,land,土地,土地
142,land improvements,土地改良物,土地改良物
143,buildings,房屋及建物,房屋及建物
144,machinery and equipment,機(器)具及設備,机(器)具及设备
145,machinery and equipment,機(器)具及設備,机(器)具及设备
146,machinery and equipment,機(器)具及設備,机(器)具及设备
151,leased assets,租賃資產,租赁资产
152,leasehold improvements,租賃權益改良,租赁权益改良
156,construction in progress and prepayments for equipment,未完工程及預付購置設備款,未完工程及预付购置设备款
158,"miscellaneous property, plant, and equipment",雜項固定資產,杂项固定资产
161,depletable assets,遞耗資產,递耗资产
171,trademarks,商標權,商标权
172,patents,專利權,专利权
173,franchise,特許權,特许权
174,copyright,著作權,著作权
175,computer software,電腦軟體,电脑软体
176,goodwill,商譽,商誉
177,organization costs,開辦費,开办费
178,other intangibles,其他無形資產,其他无形资产
181,deferred assets,遞延資產,递延资产
182,idle assets,閒置資產,闲置资产
184,"long-term notes , accounts and overdue receivables",長期應收票據及款項與催收帳款,长期应收票据及款项与催收帐款
185,assets leased to others,出租資產,出租资产
186,refundable deposit,存出保證金,存出保证金
188,miscellaneous assets,雜項資產,杂项资产
211,short-term borrowings (debt),短期借款,短期借款
212,short-term notes and bills payable,應付短期票券,应付短期票券
213,notes payable,應付票據,应付票据
214,accounts pay able,應付帳款,应付帐款
216,income taxes payable,應付所得稅,应付所得税
217,accrued expenses,應付費用,应付费用
218,other payables,其他應付款,其他应付款
219,other payables,其他應付款,其他应付款
226,advance receipts,預收款項,预收款项
227,long-term liabilities -current portion,一年或一營業週期內到期長期負債,一年或一营业周期内到期长期负债
228,other current liabilities,其他流動負債,其他流动负债
229,other current liabilities,其他流動負債,其他流动负债
231,corporate bonds payable,應付公司債,应付公司债
232,long-term loans payable,長期借款,长期借款
233,long-term notes and accounts payable,長期應付票據及款項,长期应付票据及款项
234,accrued liabilities for land value increment tax,估計應付土地增值稅,估计应付土地增值税
235,accrued pension liabilities,應計退休金負債,应计退休金负债
238,other long-term liabilities,其他長期負債,其他长期负债
281,deferred liabilities,遞延負債,递延负债
286,deposits received,存入保證金,存入保证金
288,miscellaneous liabilities,雜項負債,杂项负债
311,capital,資本(或股本),资本(或股本)
321,paid-in capital in excess of par,股票溢價,股票溢价
323,capital surplus from assets revaluation,資產重估增值準備,资产重估增值准备
324,capital surplus from gain on disposal of assets,處分資產溢價公積,处分资产溢价公积
325,capital surplus from business combination,合併公積,合并公积
326,donated surplus,受贈公積,受赠公积
328,other additional paid-in capital,其他資本公積,其他资本公积
331,legal reserve,法定盈餘公積,法定盈余公积
332,special reserve,特別盈餘公積,特别盈余公积
335,retained earnings-unappropriated (or accumulated deficit),未分配盈餘(或累積虧損),未分配盈余(或累积亏损)
341,unrealized loss on market value decline of long-term equity investments,長期股權投資未實現跌價損失,长期股权投资未实现跌价损失
342,cumulative translation adjustment,累積換算調整數,累积换算调整数
343,net loss not recognized as pension cost,未認列為退休金成本之淨損失,未认列为退休金成本之净损失
351,treasury stock,庫藏股,库藏股
361,minority interest,少數股權,少数股权
411,sales revenue,銷貨收入,销货收入
417,sales return,銷貨退回,销货退回
419,sales allowances,銷貨折讓,销货折让
461,service revenue,勞務收入,劳务收入
471,agency revenue,業務收入,业务收入
488,other operating revenue,其他營業收入—其他,其他营业收入—其他
511,cost of goods sold,銷貨成本,销货成本
512,purchases,進貨,进货
513,materials purchased,進料,进料
514,direct labor,直接人工,直接人工
515,manufacturing overhead,製造費用,制造费用
516,manufacturing overhead,製造費用,制造费用
517,manufacturing overhead,製造費用,制造费用
518,manufacturing overhead,製造費用,制造费用
561,service costs,勞務成本,劳务成本
571,agency costs,業務成本,业务成本
588,other operating costs-other,其他營業成本—其他,其他营业成本—其他
615,selling expenses,推銷費用,推销费用
616,selling expenses,推銷費用,推销费用
617,selling expenses,推銷費用,推销费用
618,selling expenses,推銷費用,推销费用
625,general & administrative expenses,管理及總務費用,管理及总务费用
626,general & administrative expenses,管理及總務費用,管理及总务费用
627,general & administrative expenses,管理及總務費用,管理及总务费用
628,general & administrative expenses,管理及總務費用,管理及总务费用
635,research and development expenses,研究發展費用,研究发展费用
636,research and development expenses,研究發展費用,研究发展费用
637,research and development expenses,研究發展費用,研究发展费用
638,research and development expenses,研究發展費用,研究发展费用
711,interest revenue,利息收入,利息收入
712,investment income,投資收益,投资收益
713,foreign exchange gain,兌換利益,兑换利益
714,gain on disposal of investments,處分投資收益,处分投资收益
715,gain on disposal of assets,處分資產溢價收入,处分资产溢价收入
748,other non-operating revenue,其他營業外收入,其他营业外收入
751,interest expense,利息費用,利息费用
752,investment loss,投資損失,投资损失
753,foreign exchange loss,兌換損失,兑换损失
754,loss on disposal of investments,處分投資損失,处分投资损失
755,loss on disposal of assets,處分資產損失,处分资产损失
788,other non-operating expenses,其他營業外費用,其他营业外费用
811,income tax expense (or benefit),所得稅費用(或利益),所得税费用(或利益)
911,income (loss) from operations of discontinued segments,停業部門損益—停業前營業損益,停业部门损益—停业前营业损益
912,gain (loss) from disposal of discontinued segments,停業部門損益—處分損益,停业部门损益—处分损益
921,extraordinary gain or loss,非常損益,非常损益
931,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数
941,minority interest income,少數股權淨利,少数股权净利
1111,cash on hand,庫存現金,库存现金
1112,petty cash/revolving funds,零用金/週轉金,零用金/周转金
1113,cash in banks,銀行存款,银行存款
1116,cash in transit,在途現金,在途现金
1117,cash equivalents,約當現金,约当现金
1118,other cash and cash equivalents,其他現金及約當現金,其他现金及约当现金
1121,short-term investments stock,短期投資—股票,短期投资—股票
1122,short-term investments short-term notes and bills,短期投資—短期票券,短期投资—短期票券
1123,short-term investments government bonds,短期投資—政府債券,短期投资—政府债券
1124,short-term investments beneficiary certificates,短期投資—受益憑證,短期投资—受益凭证
1125,short-term investments corporate bonds,短期投資—公司債,短期投资—公司债
1128,short-term investments other,短期投資—其他,短期投资—其他
1129,allowance for reduction of short-term investment to market,備抵短期投資跌價損失,备抵短期投资跌价损失
1131,notes receivable,應收票據,应收票据
1132,discounted notes receivable,應收票據貼現,应收票据贴现
1137,notes receivable related parties,應收票據—關係人,应收票据—关系人
1138,other notes receivable,其他應收票據,其他应收票据
1139,allowance for uncollectible accounts notes receivable,備抵呆帳-應收票據,备抵呆帐-应收票据
1141,accounts receivable,應收帳款,应收帐款
1142,installment accounts receivable,應收分期帳款,应收分期帐款
1147,accounts receivable related parties,應收帳款—關係人,应收帐款—关系人
1149,allowance for uncollectible accounts accounts receivable,備抵呆帳-應收帳款,备抵呆帐-应收帐款
1181,forward exchange contract receivable,應收出售遠匯款,应收出售远汇款
1182,forward exchange contract receivable foreign currencies,應收遠匯款—外幣,应收远汇款—外币
1183,discount on forward ex-change contract,買賣遠匯折價,买卖远汇折价
1184,earned revenue receivable,應收收益,应收收益
1185,income tax refund receivable,應收退稅款,应收退税款
1187,other receivables related parties,其他應收款—關係人,其他应收款—关系人
1188,other receivables other,其他應收款—其他,其他应收款—其他
1189,allowance for uncollectible accounts other receivables,備抵呆帳—其他應收款,备抵呆帐—其他应收款
1211,merchandise inventory,商品存貨,商品存货
1212,consigned goods,寄銷商品,寄销商品
1213,goods in transit,在途商品,在途商品
1219,allowance for reduction of inventory to market,備抵存貨跌價損失,备抵存货跌价损失
1221,finished goods,製成品,制成品
1222,consigned finished goods,寄銷製成品,寄销制成品
1223,by-products,副產品,副产品
1224,work in process,在製品,在制品
1225,work in process outsourced,委外加工,委外加工
1226,raw materials,原料,原料
1227,supplies,物料,物料
1228,materials and supplies in transit,在途原物料,在途原物料
1229,allowance for reduction of inventory to market,備抵存貨跌價損失,备抵存货跌价损失
1251,prepaid payroll,預付薪資,预付薪资
1252,prepaid rents,預付租金,预付租金
1253,prepaid insurance,預付保險費,预付保险费
1254,office supplies,用品盤存,用品盘存
1255,prepaid income tax,預付所得稅,预付所得税
1258,other prepaid expenses,其他預付費用,其他预付费用
1261,prepayment for purchases,預付貨款,预付货款
1268,other prepayments,其他預付款項,其他预付款项
1281,VAT paid ( or input tax),進項稅額,进项税额
1282,excess VAT paid (or overpaid VAT),留抵稅額,留抵税额
1283,temporary payments,暫付款,暂付款
1284,payment on behalf of others,代付款,代付款
1285,advances to employees,員工借支,员工借支
1286,refundable deposits,存出保證金,存出保证金
1287,certificate of deposit-restricted,受限制存款,受限制存款
1291,deferred income tax assets,遞延所得稅資產,递延所得税资产
1292,deferred foreign exchange losses,遞延兌換損失,递延兑换损失
1293,owners (stockholders) current account,業主(股東)往來,业主(股东)往来
1294,current account with others,同業往來,同业往来
1298,other current assets other,其他流動資產—其他,其他流动资产—其他
1311,redemption fund (or sinking fund),償債基金,偿债基金
1312,fund for improvement and expansion,改良及擴充基金,改良及扩充基金
1313,contingency fund,意外損失準備基金,意外损失准备基金
1314,pension fund,退休基金,退休基金
1318,other funds,其他基金,其他基金
1321,long-term equity investments,長期股權投資,长期股权投资
1322,long-term bond investments,長期債券投資,长期债券投资
1323,long-term real estate in-vestments,長期不動產投資,长期不动产投资
1324,cash surrender value of life insurance,人壽保險現金解約價值,人寿保险现金解约价值
1328,other long-term investments,其他長期投資,其他长期投资
1329,allowance for excess of cost over market value of long-term investments,備抵長期投資跌價損失,备抵长期投资跌价损失
1411,land,土地,土地
1418,land revaluation increments,土地—重估增值,土地—重估增值
1421,land improvements,土地改良物,土地改良物
1428,land improvements revaluation increments,土地改良物—重估增值,土地改良物—重估增值
1429,accumulated depreciation land improvements,累積折舊—土地改良物,累积折旧—土地改良物
1431,buildings,房屋及建物,房屋及建物
1438,buildings revaluation increments,房屋及建物—重估增值,房屋及建物—重估增值
1439,accumulated depreciation buildings,累積折舊—房屋及建物,累积折旧—房屋及建物
1441,machinery,機(器)具,机(器)具
1448,machinery revaluation increments,機(器)具—重估增值,机(器)具—重估增值
1449,accumulated depreciation machinery,累積折舊—機(器)具,累积折旧—机(器)具
1511,leased assets,租賃資產,租赁资产
1519,accumulated depreciation leased assets,累積折舊—租賃資產,累积折旧—租赁资产
1521,leasehold improvements,租賃權益改良,租赁权益改良
1529,accumulated depreciation leasehold improvements,累積折舊—租賃權益改良,累积折旧—租赁权益改良
1561,construction in progress,未完工程,未完工程
1562,prepayment for equipment,預付購置設備款,预付购置设备款
1581,"miscellaneous property, plant, and equipment",雜項固定資產,杂项固定资产
1588,"miscellaneous property, plant, and equipment revaluation increments",雜項固定資產—重估增值,杂项固定资产—重估增值
1589,"accumulated depreciation miscellaneous property, plant, and equipment",累積折舊—雜項固定資產,累积折旧—杂项固定资产
1611,natural resources,天然資源,天然资源
1618,natural resources revaluation increments,天然資源—重估增值,天然资源—重估增值
1619,accumulated depletion natural resources,累積折耗—天然資源,累积折耗—天然资源
1711,trademarks,商標權,商标权
1721,patents,專利權,专利权
1731,franchise,特許權,特许权
1741,copyright,著作權,著作权
1751,computer software cost,電腦軟體,电脑软体
1761,goodwill,商譽,商誉
1771,organization costs,開辦費,开办费
1781,deferred pension costs,遞延退休金成本,递延退休金成本
1782,leasehold improvements,租賃權益改良,租赁权益改良
1788,other intangible assets other,其他無形資產—其他,其他无形资产—其他
1811,deferred bond issuance costs,債券發行成本,债券发行成本
1812,long-term prepaid rent,長期預付租金,长期预付租金
1813,long-term prepaid insurance,長期預付保險費,长期预付保险费
1814,deferred income tax assets,遞延所得稅資產,递延所得税资产
1815,prepaid pension cost,預付退休金,预付退休金
1818,other deferred assets,其他遞延資產,其他递延资产
1821,idle assets,閒置資產,闲置资产
1841,long-term notes receivable,長期應收票據,长期应收票据
1842,long-term accounts receivable,長期應收帳款,长期应收帐款
1843,overdue receivables,催收帳款,催收帐款
1847,"long-term notes, accounts and overdue receivables related parties",長期應收票據及款項與催收帳款—關係人,长期应收票据及款项与催收帐款—关系人
1848,other long-term receivables,其他長期應收款項,其他长期应收款项
1849,"allowance for uncollectible accounts long-term notes, accounts and overdue receivables",備抵呆帳—長期應收票據及款項與催收帳款,备抵呆帐—长期应收票据及款项与催收帐款
1851,assets leased to others,出租資產,出租资产
1858,assets leased to others incremental value from revaluation,出租資產—重估增值,出租资产—重估增值
1859,accumulated depreciation assets leased to others,累積折舊—出租資產,累积折旧—出租资产
1861,refundable deposits,存出保證金,存出保证金
1881,certificate of deposit restricted,受限制存款,受限制存款
1888,miscellaneous assets other,雜項資產—其他,杂项资产—其他
2111,bank overdraft,銀行透支,银行透支
2112,bank loan,銀行借款,银行借款
2114,short-term borrowings owners,短期借款—業主,短期借款—业主
2115,short-term borrowings employees,短期借款—員工,短期借款—员工
2117,short-term borrowings related parties,短期借款—關係人,短期借款—关系人
2118,short-term borrowings other,短期借款—其他,短期借款—其他
2121,commercial paper payable,應付商業本票,应付商业本票
2122,bank acceptance,銀行承兌匯票,银行承兑汇票
2128,other short-term notes and bills payable,其他應付短期票券,其他应付短期票券
2129,discount on short-term notes and bills payable,應付短期票券折價,应付短期票券折价
2131,notes payable,應付票據,应付票据
2137,notes payable related parties,應付票據—關係人,应付票据—关系人
2138,other notes payable,其他應付票據,其他应付票据
2141,accounts payable,應付帳款,应付帐款
2147,accounts payable related parties,應付帳款—關係人,应付帐款—关系人
2161,income tax payable,應付所得稅,应付所得税
2171,accrued payroll,應付薪工,应付薪工
2172,accrued rent payable,應付租金,应付租金
2173,accrued interest payable,應付利息,应付利息
2174,accrued VAT payable,應付營業稅,应付营业税
2175,accrued taxes payable other,應付稅捐—其他,应付税捐—其他
2178,other accrued expenses payable,其他應付費用,其他应付费用
2181,forward exchange contract payable,應付購入遠匯款,应付购入远汇款
2182,forward exchange contract payable foreign currencies,應付遠匯款—外幣,应付远汇款—外币
2183,premium on forward exchange contract,買賣遠匯溢價,买卖远汇溢价
2184,payables on land and building purchased,應付土地房屋款,应付土地房屋款
2185,Payables on equipment,應付設備款,应付设备款
2187,other payables related parties,其他應付款—關係人,其他应付款—关系人
2191,dividend payable,應付股利,应付股利
2192,bonus payable,應付紅利,应付红利
2193,compensation payable to directors and supervisors,應付董監事酬勞,应付董监事酬劳
2198,other payables other,其他應付款—其他,其他应付款—其他
2261,sales revenue received in advance,預收貨款,预收货款
2262,revenue received in advance,預收收入,预收收入
2268,other advance receipts,其他預收款,其他预收款
2271,corporate bonds payable current portion,一年或一營業週期內到期公司債,一年或一营业周期内到期公司债
2272,long-term loans payable current portion,一年或一營業週期內到期長期借款,一年或一营业周期内到期长期借款
2273,long-term notes and accounts payable due within one year or one operating cycle,一年或一營業週期內到期長期應付票據及款項,一年或一营业周期内到期长期应付票据及款项
2277,long-term notes and accounts payables to related parties current portion,一年或一營業週期內到期長期應付票據及款項—關係人,一年或一营业周期内到期长期应付票据及款项—关系人
2278,other long-term liabilities current portion,其他一年或一營業週期內到期長期負債,其他一年或一营业周期内到期长期负债
2281,VAT received (or output tax),銷項稅額,销项税额
2283,temporary receipts,暫收款,暂收款
2284,receipts under custody,代收款,代收款
2285,estimated warranty liabilities,估計售後服務/保固負債,估计售后服务/保固负债
2291,deferred income tax liabilities,遞延所得稅負債,递延所得税负债
2292,deferred foreign exchange gain,遞延兌換利益,递延兑换利益
2293,owners current account,業主(股東)往來,业主(股东)往来
2294,current account with others,同業往來,同业往来
2298,other current liabilities others,其他流動負債—其他,其他流动负债—其他
2311,corporate bonds payable,應付公司債,应付公司债
2319,premium (discount) on corporate bonds payable,應付公司債溢(折)價,应付公司债溢(折)价
2321,long-term loans payable bank,長期銀行借款,长期银行借款
2324,long-term loans payable owners,長期借款—業主,长期借款—业主
2325,long-term loans payable employees,長期借款—員工,长期借款—员工
2327,long-term loans payable related parties,長期借款—關係人,长期借款—关系人
2328,long-term loans payable other,長期借款—其他,长期借款—其他
2331,long-term notes payable,長期應付票據,长期应付票据
2332,long-term accounts pay-able,長期應付帳款,长期应付帐款
2333,long-term capital lease liabilities,長期應付租賃負債,长期应付租赁负债
2337,Long-term notes and accounts payable related parties,長期應付票據及款項—關係人,长期应付票据及款项—关系人
2338,other long-term payables,其他長期應付款項,其他长期应付款项
2341,estimated accrued land value incremental tax pay-able,估計應付土地增值稅,估计应付土地增值税
2351,accrued pension liabilities,應計退休金負債,应计退休金负债
2388,other long-term liabilities other,其他長期負債—其他,其他长期负债—其他
2811,deferred revenue,遞延收入,递延收入
2814,deferred income tax liabilities,遞延所得稅負債,递延所得税负债
2818,other deferred liabilities,其他遞延負債,其他递延负债
2861,guarantee deposit received,存入保證金,存入保证金
2888,miscellaneous liabilities other,雜項負債—其他,杂项负债—其他
3111,capital common stock,普通股股本,普通股股本
3112,capital preferred stock,特別股股本,特别股股本
3113,capital collected in advance,預收股本,预收股本
3114,stock dividends to be distributed,待分配股票股利,待分配股票股利
3115,capital,資本,资本
3211,paid-in capital in excess of par- common stock,普通股股票溢價,普通股股票溢价
3212,paid-in capital in excess of par- preferred stock,特別股股票溢價,特别股股票溢价
3231,capital surplus from assets revaluation,資產重估增值準備,资产重估增值准备
3241,capital surplus from gain on disposal of assets,處分資產溢價公積,处分资产溢价公积
3251,capital surplus from business combination,合併公積,合并公积
3261,donated surplus,受贈公積,受赠公积
3281,additional paid-in capital from investee under equity method,權益法長期股權投資資本公積,权益法长期股权投资资本公积
3282,additional paid-in capital treasury stock trans-actions,資本公積—庫藏股票交易,资本公积—库藏股票交易
3311,legal reserve,法定盈餘公積,法定盈余公积
3321,contingency reserve,意外損失準備,意外损失准备
3322,improvement and expansion reserve,改良擴充準備,改良扩充准备
3323,special reserve for redemption of liabilities,償債準備,偿债准备
3328,other special reserve,其他特別盈餘公積,其他特别盈余公积
3351,accumulated profit or loss,累積盈虧,累积盈亏
3352,prior period adjustments,前期損益調整,前期损益调整
3353,net income or loss for current period,本期損益,本期损益
3411,unrealized loss on market value decline of long-term equity investments,長期股權投資未實現跌價損失,长期股权投资未实现跌价损失
3421,cumulative translation adjustments,累積換算調整數,累积换算调整数
3431,net loss not recognized as pension costs,未認列為退休金成本之淨損失,未认列为退休金成本之净损失
3511,treasury stock,庫藏股,库藏股
3611,minority interest,少數股權,少数股权
4111,sales revenue,銷貨收入,销货收入
4112,installment sales revenue,分期付款銷貨收入,分期付款销货收入
4171,sales return,銷貨退回,销货退回
4191,sales discounts and allowances,銷貨折讓,销货折让
4611,service revenue,勞務收入,劳务收入
4711,agency revenue,業務收入,业务收入
4888,other operating revenue other,其他營業收入—其他,其他营业收入—其他
5111,cost of goods sold,銷貨成本,销货成本
5112,installment cost of goods sold,分期付款銷貨成本,分期付款销货成本
5121,purchases,進貨,进货
5122,purchase expenses,進貨費用,进货费用
5123,purchase returns,進貨退出,进货退出
5124,charges on purchased merchandise,進貨折讓,进货折让
5131,material purchased,進料,进料
5132,charges on purchased material,進料費用,进料费用
5133,material purchase returns,進料退出,进料退出
5134,material purchase allowances,進料折讓,进料折让
5141,direct labor,直接人工,直接人工
5151,indirect labor,間接人工,间接人工
5152,"rent expense, rent",租金支出,租金支出
5153,office supplies (expense),文具用品,文具用品
5154,"travelling expense, travel",旅費,旅费
5155,"shipping expenses, freight",運費,运费
5156,postage (expenses),郵電費,邮电费
5157,repair (s) and maintenance (expense ),修繕費,修缮费
5158,packing expenses,包裝費,包装费
5161,utilities (expense),水電瓦斯費,水电瓦斯费
5162,insurance (expense),保險費,保险费
5163,manufacturing overhead outsourced,加工費,加工费
5166,taxes,稅捐,税捐
5168,depreciation expense,折舊,折旧
5169,various amortization,各項耗竭及攤提,各项耗竭及摊提
5172,meal (expenses),伙食費,伙食费
5173,employee benefits/welfare,職工福利,职工福利
5176,training (expense),訓練費,训练费
5177,indirect materials,間接材料,间接材料
5188,other manufacturing expenses,其他製造費用,其他制造费用
5611,service costs,勞務成本,劳务成本
5711,agency costs,業務成本,业务成本
5888,other operating costs other,其他營業成本—其他,其他营业成本—其他
6151,payroll expense,薪資支出,薪资支出
6152,"rent expense, rent",租金支出,租金支出
6153,office supplies (expense),文具用品,文具用品
6154,"travelling expense, travel",旅費,旅费
6155,"shipping expenses, freight",運費,运费
6156,postage (expenses),郵電費,邮电费
6157,repair (s) and maintenance (expense),修繕費,修缮费
6159,"advertisement expense, advertisement",廣告費,广告费
6161,utilities (expense),水電瓦斯費,水电瓦斯费
6162,insurance (expense),保險費,保险费
6164,entertainment (expense),交際費,交际费
6165,donation (expense),捐贈,捐赠
6166,taxes,稅捐,税捐
6167,loss on uncollectible accounts,呆帳損失,呆帐损失
6168,depreciation expense,折舊,折旧
6169,various amortization,各項耗竭及攤提,各项耗竭及摊提
6172,meal (expenses),伙食費,伙食费
6173,employee benefits/welfare,職工福利,职工福利
6175,commission (expense),佣金支出,佣金支出
6176,training (expense),訓練費,训练费
6188,other selling expenses,其他推銷費用,其他推销费用
6251,payroll expense,薪資支出,薪资支出
6252,"rent expense, rent",租金支出,租金支出
6253,office supplies,文具用品,文具用品
6254,"travelling expense, travel",旅費,旅费
6255,"shipping expenses,freight",運費,运费
6256,postage (expenses),郵電費,邮电费
6257,repair (s) and maintenance (expense),修繕費,修缮费
6259,"advertisement expense, advertisement",廣告費,广告费
6261,utilities (expense),水電瓦斯費,水电瓦斯费
6262,insurance (expense),保險費,保险费
6264,entertainment (expense),交際費,交际费
6265,donation (expense),捐贈,捐赠
6266,taxes,稅捐,税捐
6267,loss on uncollectible accounts,呆帳損失,呆帐损失
6268,depreciation expense,折舊,折旧
6269,various amortization,各項耗竭及攤提,各项耗竭及摊提
6271,loss on export sales,外銷損失,外销损失
6272,meal (expenses),伙食費,伙食费
6273,employee benefits/welfare,職工福利,职工福利
6274,research and development expense,研究發展費用,研究发展费用
6275,commission (expense),佣金支出,佣金支出
6276,training (expense),訓練費,训练费
6278,professional service fees,勞務費,劳务费
6288,other general and administrative expenses,其他管理及總務費用,其他管理及总务费用
6351,payroll expense,薪資支出,薪资支出
6352,"rent expense, rent",租金支出,租金支出
6353,office supplies,文具用品,文具用品
6354,"travelling expense, travel",旅費,旅费
6355,"shipping expenses, freight",運費,运费
6356,postage (expenses),郵電費,邮电费
6357,repair (s) and maintenance (expense),修繕費,修缮费
6361,utilities (expense),水電瓦斯費,水电瓦斯费
6362,insurance (expense),保險費,保险费
6364,entertainment (expense),交際費,交际费
6366,taxes,稅捐,税捐
6368,depreciation expense,折舊,折旧
6369,various amortization,各項耗竭及攤提,各项耗竭及摊提
6372,meal (expenses),伙食費,伙食费
6373,employee benefits/welfare,職工福利,职工福利
6376,training (expense),訓練費,训练费
6378,other research and development expenses,其他研究發展費用,其他研究发展费用
7111,interest revenue/income,利息收入,利息收入
7121,investment income recognized under equity method,權益法認列之投資收益,权益法认列之投资收益
7122,dividends income,股利收入,股利收入
7123,gain on market price recovery of short-term investment,短期投資市價回升利益,短期投资市价回升利益
7131,foreign exchange gain,兌換利益,兑换利益
7141,gain on disposal of investments,處分投資收益,处分投资收益
7151,gain on disposal of assets,處分資產溢價收入,处分资产溢价收入
7481,donation income,捐贈收入,捐赠收入
7482,rent revenue/income,租金收入,租金收入
7483,commission revenue/income,佣金收入,佣金收入
7484,revenue from sale of scraps,出售下腳及廢料收入,出售下脚及废料收入
7485,gain on physical inventory,存貨盤盈,存货盘盈
7486,gain from price recovery of inventory,存貨跌價回升利益,存货跌价回升利益
7487,gain on reversal of bad debts,壞帳轉回利益,坏帐转回利益
7488,other non-operating revenue other items,其他營業外收入—其他,其他营业外收入—其他
7511,interest expense,利息費用,利息费用
7521,investment loss recognized under equity method,權益法認列之投資損失,权益法认列之投资损失
7523,unrealized loss on reduction of short-term investments to market,短期投資未實現跌價損失,短期投资未实现跌价损失
7531,foreign exchange loss,兌換損失,兑换损失
7541,loss on disposal of investments,處分投資損失,处分投资损失
7551,loss on disposal of assets,處分資產損失,处分资产损失
7881,loss on work stoppages,停工損失,停工损失
7882,casualty loss,災害損失,灾害损失
7885,loss on physical inventory,存貨盤損,存货盘损
7886,loss for market price decline and obsolete and slow-moving inventories,存貨跌價及呆滯損失,存货跌价及呆滞损失
7888,other non-operating expenses other,其他營業外費用—其他,其他营业外费用—其他
8111,income tax expense ( or benefit),所得稅費用(或利益),所得税费用(或利益)
9111,income (loss) from operations of discontinued segment,停業部門損益—停業前營業損益,停业部门损益—停业前营业损益
9121,gain (loss) from disposal of discontinued segment,停業部門損益—處分損益,停业部门损益—处分损益
9211,extraordinary gain or loss,非常損益,非常损益
9311,cumulative effect of changes in accounting principles,會計原則變動累積影響數,会计原则变动累积影响数
9411,minority interest income,少數股權淨利,少数股权净利
1 code title l10n-zh_Hant l10n-zh_Hans
2 1 assets 資產 资产
3 2 liabilities 負債 负债
4 3 owners’ equity 業主權益 业主权益
5 4 operating revenue 營業收入 营业收入
6 5 operating costs 營業成本 营业成本
7 6 operating expenses 營業費用 营业费用
8 7 non-operating revenue and expenses, other income (expense) 營業外收入及費用 营业外收入及费用
9 8 income tax expense (or benefit) 所得稅費用(或利益) 所得税费用(或利益)
10 9 nonrecurring gain or loss 非經常營業損益 非经常营业损益
11 11 current assets 流動資產 流动资产
12 12 current assets 流動資產 流动资产
13 13 funds and long-term investments 基金及長期投資 基金及长期投资
14 14 property , plant, and equipment 固定資產 固定资产
15 15 property , plant, and equipment 固定資產 固定资产
16 16 depletable assets 遞耗資產 递耗资产
17 17 intangible assets 無形資產 无形资产
18 18 other assets 其他資產 其他资产
19 21 current liabilities 流動負債 流动负债
20 22 current liabilities 流動負債 流动负债
21 23 long-term liabilities 長期負債 长期负债
22 28 other liabilities 其他負債 其他负债
23 31 capital 資本 资本
24 32 additional paid-in capital 資本公積 资本公积
25 33 retained earnings (accumulated deficit) 保留盈餘(或累積虧損) 保留盈余(或累积亏损)
26 34 equity adjustments 權益調整 权益调整
27 35 treasury stock 庫藏股 库藏股
28 36 minority interest 少數股權 少数股权
29 41 sales revenue 銷貨收入 销货收入
30 46 service revenue 勞務收入 劳务收入
31 47 agency revenue 業務收入 业务收入
32 48 other operating revenue 其他營業收入 其他营业收入
33 51 cost of goods sold 銷貨成本 销货成本
34 56 service costs 勞務成本 劳务成本
35 57 agency costs 業務成本 业务成本
36 58 other operating costs 其他營業成本 其他营业成本
37 61 selling expenses 推銷費用 推销费用
38 62 general & administrative expenses 管理及總務費用 管理及总务费用
39 63 research and development expenses 研究發展費用 研究发展费用
40 71 non-operating revenue 營業外收入 营业外收入
41 72 non-operating revenue 營業外收入 营业外收入
42 73 non-operating revenue 營業外收入 营业外收入
43 74 non-operating revenue 營業外收入 营业外收入
44 75 non-operating expenses 營業外費用 营业外费用
45 76 non-operating expenses 營業外費用 营业外费用
46 77 non-operating expenses 營業外費用 营业外费用
47 78 non-operating expenses 營業外費用 营业外费用
48 81 income tax expense (or benefit) 所得稅費用(或利益) 所得税费用(或利益)
49 91 gain (loss) from discontinued operations 停業部門損益 停业部门损益
50 92 extraordinary gain or loss 非常損益 非常损益
51 93 cumulative effect of changes in accounting principles 會計原則變動累積影響數 会计原则变动累积影响数
52 94 minority interest income 少數股權淨利 少数股权净利
53 111 cash and cash equivalents 現金及約當現金 现金及约当现金
54 112 short-term investments 短期投資 短期投资
55 113 notes receivable 應收票據 应收票据
56 114 accounts receivable 應收帳款 应收帐款
57 118 other receivables 其他應收款 其他应收款
58 121 inventories 存貨 存货
59 122 inventories 存貨 存货
60 125 prepaid expenses 預付費用 预付费用
61 126 prepayments 預付款項 预付款项
62 128 other current assets 其他流動資產 其他流动资产
63 129 other current assets 其他流動資產 其他流动资产
64 131 funds 基金 基金
65 132 long-term investments 長期投資 长期投资
66 141 land 土地 土地
67 142 land improvements 土地改良物 土地改良物
68 143 buildings 房屋及建物 房屋及建物
69 144 machinery and equipment 機(器)具及設備 机(器)具及设备
70 145 machinery and equipment 機(器)具及設備 机(器)具及设备
71 146 machinery and equipment 機(器)具及設備 机(器)具及设备
72 151 leased assets 租賃資產 租赁资产
73 152 leasehold improvements 租賃權益改良 租赁权益改良
74 156 construction in progress and prepayments for equipment 未完工程及預付購置設備款 未完工程及预付购置设备款
75 158 miscellaneous property, plant, and equipment 雜項固定資產 杂项固定资产
76 161 depletable assets 遞耗資產 递耗资产
77 171 trademarks 商標權 商标权
78 172 patents 專利權 专利权
79 173 franchise 特許權 特许权
80 174 copyright 著作權 著作权
81 175 computer software 電腦軟體 电脑软体
82 176 goodwill 商譽 商誉
83 177 organization costs 開辦費 开办费
84 178 other intangibles 其他無形資產 其他无形资产
85 181 deferred assets 遞延資產 递延资产
86 182 idle assets 閒置資產 闲置资产
87 184 long-term notes , accounts and overdue receivables 長期應收票據及款項與催收帳款 长期应收票据及款项与催收帐款
88 185 assets leased to others 出租資產 出租资产
89 186 refundable deposit 存出保證金 存出保证金
90 188 miscellaneous assets 雜項資產 杂项资产
91 211 short-term borrowings (debt) 短期借款 短期借款
92 212 short-term notes and bills payable 應付短期票券 应付短期票券
93 213 notes payable 應付票據 应付票据
94 214 accounts pay able 應付帳款 应付帐款
95 216 income taxes payable 應付所得稅 应付所得税
96 217 accrued expenses 應付費用 应付费用
97 218 other payables 其他應付款 其他应付款
98 219 other payables 其他應付款 其他应付款
99 226 advance receipts 預收款項 预收款项
100 227 long-term liabilities -current portion 一年或一營業週期內到期長期負債 一年或一营业周期内到期长期负债
101 228 other current liabilities 其他流動負債 其他流动负债
102 229 other current liabilities 其他流動負債 其他流动负债
103 231 corporate bonds payable 應付公司債 应付公司债
104 232 long-term loans payable 長期借款 长期借款
105 233 long-term notes and accounts payable 長期應付票據及款項 长期应付票据及款项
106 234 accrued liabilities for land value increment tax 估計應付土地增值稅 估计应付土地增值税
107 235 accrued pension liabilities 應計退休金負債 应计退休金负债
108 238 other long-term liabilities 其他長期負債 其他长期负债
109 281 deferred liabilities 遞延負債 递延负债
110 286 deposits received 存入保證金 存入保证金
111 288 miscellaneous liabilities 雜項負債 杂项负债
112 311 capital 資本(或股本) 资本(或股本)
113 321 paid-in capital in excess of par 股票溢價 股票溢价
114 323 capital surplus from assets revaluation 資產重估增值準備 资产重估增值准备
115 324 capital surplus from gain on disposal of assets 處分資產溢價公積 处分资产溢价公积
116 325 capital surplus from business combination 合併公積 合并公积
117 326 donated surplus 受贈公積 受赠公积
118 328 other additional paid-in capital 其他資本公積 其他资本公积
119 331 legal reserve 法定盈餘公積 法定盈余公积
120 332 special reserve 特別盈餘公積 特别盈余公积
121 335 retained earnings-unappropriated (or accumulated deficit) 未分配盈餘(或累積虧損) 未分配盈余(或累积亏损)
122 341 unrealized loss on market value decline of long-term equity investments 長期股權投資未實現跌價損失 长期股权投资未实现跌价损失
123 342 cumulative translation adjustment 累積換算調整數 累积换算调整数
124 343 net loss not recognized as pension cost 未認列為退休金成本之淨損失 未认列为退休金成本之净损失
125 351 treasury stock 庫藏股 库藏股
126 361 minority interest 少數股權 少数股权
127 411 sales revenue 銷貨收入 销货收入
128 417 sales return 銷貨退回 销货退回
129 419 sales allowances 銷貨折讓 销货折让
130 461 service revenue 勞務收入 劳务收入
131 471 agency revenue 業務收入 业务收入
132 488 other operating revenue 其他營業收入—其他 其他营业收入—其他
133 511 cost of goods sold 銷貨成本 销货成本
134 512 purchases 進貨 进货
135 513 materials purchased 進料 进料
136 514 direct labor 直接人工 直接人工
137 515 manufacturing overhead 製造費用 制造费用
138 516 manufacturing overhead 製造費用 制造费用
139 517 manufacturing overhead 製造費用 制造费用
140 518 manufacturing overhead 製造費用 制造费用
141 561 service costs 勞務成本 劳务成本
142 571 agency costs 業務成本 业务成本
143 588 other operating costs-other 其他營業成本—其他 其他营业成本—其他
144 615 selling expenses 推銷費用 推销费用
145 616 selling expenses 推銷費用 推销费用
146 617 selling expenses 推銷費用 推销费用
147 618 selling expenses 推銷費用 推销费用
148 625 general & administrative expenses 管理及總務費用 管理及总务费用
149 626 general & administrative expenses 管理及總務費用 管理及总务费用
150 627 general & administrative expenses 管理及總務費用 管理及总务费用
151 628 general & administrative expenses 管理及總務費用 管理及总务费用
152 635 research and development expenses 研究發展費用 研究发展费用
153 636 research and development expenses 研究發展費用 研究发展费用
154 637 research and development expenses 研究發展費用 研究发展费用
155 638 research and development expenses 研究發展費用 研究发展费用
156 711 interest revenue 利息收入 利息收入
157 712 investment income 投資收益 投资收益
158 713 foreign exchange gain 兌換利益 兑换利益
159 714 gain on disposal of investments 處分投資收益 处分投资收益
160 715 gain on disposal of assets 處分資產溢價收入 处分资产溢价收入
161 748 other non-operating revenue 其他營業外收入 其他营业外收入
162 751 interest expense 利息費用 利息费用
163 752 investment loss 投資損失 投资损失
164 753 foreign exchange loss 兌換損失 兑换损失
165 754 loss on disposal of investments 處分投資損失 处分投资损失
166 755 loss on disposal of assets 處分資產損失 处分资产损失
167 788 other non-operating expenses 其他營業外費用 其他营业外费用
168 811 income tax expense (or benefit) 所得稅費用(或利益) 所得税费用(或利益)
169 911 income (loss) from operations of discontinued segments 停業部門損益—停業前營業損益 停业部门损益—停业前营业损益
170 912 gain (loss) from disposal of discontinued segments 停業部門損益—處分損益 停业部门损益—处分损益
171 921 extraordinary gain or loss 非常損益 非常损益
172 931 cumulative effect of changes in accounting principles 會計原則變動累積影響數 会计原则变动累积影响数
173 941 minority interest income 少數股權淨利 少数股权净利
174 1111 cash on hand 庫存現金 库存现金
175 1112 petty cash/revolving funds 零用金/週轉金 零用金/周转金
176 1113 cash in banks 銀行存款 银行存款
177 1116 cash in transit 在途現金 在途现金
178 1117 cash equivalents 約當現金 约当现金
179 1118 other cash and cash equivalents 其他現金及約當現金 其他现金及约当现金
180 1121 short-term investments – stock 短期投資—股票 短期投资—股票
181 1122 short-term investments – short-term notes and bills 短期投資—短期票券 短期投资—短期票券
182 1123 short-term investments – government bonds 短期投資—政府債券 短期投资—政府债券
183 1124 short-term investments – beneficiary certificates 短期投資—受益憑證 短期投资—受益凭证
184 1125 short-term investments – corporate bonds 短期投資—公司債 短期投资—公司债
185 1128 short-term investments – other 短期投資—其他 短期投资—其他
186 1129 allowance for reduction of short-term investment to market 備抵短期投資跌價損失 备抵短期投资跌价损失
187 1131 notes receivable 應收票據 应收票据
188 1132 discounted notes receivable 應收票據貼現 应收票据贴现
189 1137 notes receivable – related parties 應收票據—關係人 应收票据—关系人
190 1138 other notes receivable 其他應收票據 其他应收票据
191 1139 allowance for uncollectible accounts – notes receivable 備抵呆帳-應收票據 备抵呆帐-应收票据
192 1141 accounts receivable 應收帳款 应收帐款
193 1142 installment accounts receivable 應收分期帳款 应收分期帐款
194 1147 accounts receivable – related parties 應收帳款—關係人 应收帐款—关系人
195 1149 allowance for uncollectible accounts – accounts receivable 備抵呆帳-應收帳款 备抵呆帐-应收帐款
196 1181 forward exchange contract receivable 應收出售遠匯款 应收出售远汇款
197 1182 forward exchange contract receivable – foreign currencies 應收遠匯款—外幣 应收远汇款—外币
198 1183 discount on forward ex-change contract 買賣遠匯折價 买卖远汇折价
199 1184 earned revenue receivable 應收收益 应收收益
200 1185 income tax refund receivable 應收退稅款 应收退税款
201 1187 other receivables – related parties 其他應收款—關係人 其他应收款—关系人
202 1188 other receivables – other 其他應收款—其他 其他应收款—其他
203 1189 allowance for uncollectible accounts – other receivables 備抵呆帳—其他應收款 备抵呆帐—其他应收款
204 1211 merchandise inventory 商品存貨 商品存货
205 1212 consigned goods 寄銷商品 寄销商品
206 1213 goods in transit 在途商品 在途商品
207 1219 allowance for reduction of inventory to market 備抵存貨跌價損失 备抵存货跌价损失
208 1221 finished goods 製成品 制成品
209 1222 consigned finished goods 寄銷製成品 寄销制成品
210 1223 by-products 副產品 副产品
211 1224 work in process 在製品 在制品
212 1225 work in process – outsourced 委外加工 委外加工
213 1226 raw materials 原料 原料
214 1227 supplies 物料 物料
215 1228 materials and supplies in transit 在途原物料 在途原物料
216 1229 allowance for reduction of inventory to market 備抵存貨跌價損失 备抵存货跌价损失
217 1251 prepaid payroll 預付薪資 预付薪资
218 1252 prepaid rents 預付租金 预付租金
219 1253 prepaid insurance 預付保險費 预付保险费
220 1254 office supplies 用品盤存 用品盘存
221 1255 prepaid income tax 預付所得稅 预付所得税
222 1258 other prepaid expenses 其他預付費用 其他预付费用
223 1261 prepayment for purchases 預付貨款 预付货款
224 1268 other prepayments 其他預付款項 其他预付款项
225 1281 VAT paid ( or input tax) 進項稅額 进项税额
226 1282 excess VAT paid (or overpaid VAT) 留抵稅額 留抵税额
227 1283 temporary payments 暫付款 暂付款
228 1284 payment on behalf of others 代付款 代付款
229 1285 advances to employees 員工借支 员工借支
230 1286 refundable deposits 存出保證金 存出保证金
231 1287 certificate of deposit-restricted 受限制存款 受限制存款
232 1291 deferred income tax assets 遞延所得稅資產 递延所得税资产
233 1292 deferred foreign exchange losses 遞延兌換損失 递延兑换损失
234 1293 owners’ (stockholders’) current account 業主(股東)往來 业主(股东)往来
235 1294 current account with others 同業往來 同业往来
236 1298 other current assets – other 其他流動資產—其他 其他流动资产—其他
237 1311 redemption fund (or sinking fund) 償債基金 偿债基金
238 1312 fund for improvement and expansion 改良及擴充基金 改良及扩充基金
239 1313 contingency fund 意外損失準備基金 意外损失准备基金
240 1314 pension fund 退休基金 退休基金
241 1318 other funds 其他基金 其他基金
242 1321 long-term equity investments 長期股權投資 长期股权投资
243 1322 long-term bond investments 長期債券投資 长期债券投资
244 1323 long-term real estate in-vestments 長期不動產投資 长期不动产投资
245 1324 cash surrender value of life insurance 人壽保險現金解約價值 人寿保险现金解约价值
246 1328 other long-term investments 其他長期投資 其他长期投资
247 1329 allowance for excess of cost over market value of long-term investments 備抵長期投資跌價損失 备抵长期投资跌价损失
248 1411 land 土地 土地
249 1418 land – revaluation increments 土地—重估增值 土地—重估增值
250 1421 land improvements 土地改良物 土地改良物
251 1428 land improvements – revaluation increments 土地改良物—重估增值 土地改良物—重估增值
252 1429 accumulated depreciation – land improvements 累積折舊—土地改良物 累积折旧—土地改良物
253 1431 buildings 房屋及建物 房屋及建物
254 1438 buildings –revaluation increments 房屋及建物—重估增值 房屋及建物—重估增值
255 1439 accumulated depreciation – buildings 累積折舊—房屋及建物 累积折旧—房屋及建物
256 1441 machinery 機(器)具 机(器)具
257 1448 machinery – revaluation increments 機(器)具—重估增值 机(器)具—重估增值
258 1449 accumulated depreciation – machinery 累積折舊—機(器)具 累积折旧—机(器)具
259 1511 leased assets 租賃資產 租赁资产
260 1519 accumulated depreciation – leased assets 累積折舊—租賃資產 累积折旧—租赁资产
261 1521 leasehold improvements 租賃權益改良 租赁权益改良
262 1529 accumulated depreciation – leasehold improvements 累積折舊—租賃權益改良 累积折旧—租赁权益改良
263 1561 construction in progress 未完工程 未完工程
264 1562 prepayment for equipment 預付購置設備款 预付购置设备款
265 1581 miscellaneous property, plant, and equipment 雜項固定資產 杂项固定资产
266 1588 miscellaneous property, plant, and equipment – revaluation increments 雜項固定資產—重估增值 杂项固定资产—重估增值
267 1589 accumulated depreciation – miscellaneous property, plant, and equipment 累積折舊—雜項固定資產 累积折旧—杂项固定资产
268 1611 natural resources 天然資源 天然资源
269 1618 natural resources –revaluation increments 天然資源—重估增值 天然资源—重估增值
270 1619 accumulated depletion – natural resources 累積折耗—天然資源 累积折耗—天然资源
271 1711 trademarks 商標權 商标权
272 1721 patents 專利權 专利权
273 1731 franchise 特許權 特许权
274 1741 copyright 著作權 著作权
275 1751 computer software cost 電腦軟體 电脑软体
276 1761 goodwill 商譽 商誉
277 1771 organization costs 開辦費 开办费
278 1781 deferred pension costs 遞延退休金成本 递延退休金成本
279 1782 leasehold improvements 租賃權益改良 租赁权益改良
280 1788 other intangible assets – other 其他無形資產—其他 其他无形资产—其他
281 1811 deferred bond issuance costs 債券發行成本 债券发行成本
282 1812 long-term prepaid rent 長期預付租金 长期预付租金
283 1813 long-term prepaid insurance 長期預付保險費 长期预付保险费
284 1814 deferred income tax assets 遞延所得稅資產 递延所得税资产
285 1815 prepaid pension cost 預付退休金 预付退休金
286 1818 other deferred assets 其他遞延資產 其他递延资产
287 1821 idle assets 閒置資產 闲置资产
288 1841 long-term notes receivable 長期應收票據 长期应收票据
289 1842 long-term accounts receivable 長期應收帳款 长期应收帐款
290 1843 overdue receivables 催收帳款 催收帐款
291 1847 long-term notes, accounts and overdue receivables – related parties 長期應收票據及款項與催收帳款—關係人 长期应收票据及款项与催收帐款—关系人
292 1848 other long-term receivables 其他長期應收款項 其他长期应收款项
293 1849 allowance for uncollectible accounts – long-term notes, accounts and overdue receivables 備抵呆帳—長期應收票據及款項與催收帳款 备抵呆帐—长期应收票据及款项与催收帐款
294 1851 assets leased to others 出租資產 出租资产
295 1858 assets leased to others – incremental value from revaluation 出租資產—重估增值 出租资产—重估增值
296 1859 accumulated depreciation – assets leased to others 累積折舊—出租資產 累积折旧—出租资产
297 1861 refundable deposits 存出保證金 存出保证金
298 1881 certificate of deposit – restricted 受限制存款 受限制存款
299 1888 miscellaneous assets – other 雜項資產—其他 杂项资产—其他
300 2111 bank overdraft 銀行透支 银行透支
301 2112 bank loan 銀行借款 银行借款
302 2114 short-term borrowings – owners 短期借款—業主 短期借款—业主
303 2115 short-term borrowings – employees 短期借款—員工 短期借款—员工
304 2117 short-term borrowings – related parties 短期借款—關係人 短期借款—关系人
305 2118 short-term borrowings – other 短期借款—其他 短期借款—其他
306 2121 commercial paper payable 應付商業本票 应付商业本票
307 2122 bank acceptance 銀行承兌匯票 银行承兑汇票
308 2128 other short-term notes and bills payable 其他應付短期票券 其他应付短期票券
309 2129 discount on short-term notes and bills payable 應付短期票券折價 应付短期票券折价
310 2131 notes payable 應付票據 应付票据
311 2137 notes payable – related parties 應付票據—關係人 应付票据—关系人
312 2138 other notes payable 其他應付票據 其他应付票据
313 2141 accounts payable 應付帳款 应付帐款
314 2147 accounts payable – related parties 應付帳款—關係人 应付帐款—关系人
315 2161 income tax payable 應付所得稅 应付所得税
316 2171 accrued payroll 應付薪工 应付薪工
317 2172 accrued rent payable 應付租金 应付租金
318 2173 accrued interest payable 應付利息 应付利息
319 2174 accrued VAT payable 應付營業稅 应付营业税
320 2175 accrued taxes payable – other 應付稅捐—其他 应付税捐—其他
321 2178 other accrued expenses payable 其他應付費用 其他应付费用
322 2181 forward exchange contract payable 應付購入遠匯款 应付购入远汇款
323 2182 forward exchange contract payable – foreign currencies 應付遠匯款—外幣 应付远汇款—外币
324 2183 premium on forward exchange contract 買賣遠匯溢價 买卖远汇溢价
325 2184 payables on land and building purchased 應付土地房屋款 应付土地房屋款
326 2185 Payables on equipment 應付設備款 应付设备款
327 2187 other payables – related parties 其他應付款—關係人 其他应付款—关系人
328 2191 dividend payable 應付股利 应付股利
329 2192 bonus payable 應付紅利 应付红利
330 2193 compensation payable to directors and supervisors 應付董監事酬勞 应付董监事酬劳
331 2198 other payables – other 其他應付款—其他 其他应付款—其他
332 2261 sales revenue received in advance 預收貨款 预收货款
333 2262 revenue received in advance 預收收入 预收收入
334 2268 other advance receipts 其他預收款 其他预收款
335 2271 corporate bonds payable – current portion 一年或一營業週期內到期公司債 一年或一营业周期内到期公司债
336 2272 long-term loans payable – current portion 一年或一營業週期內到期長期借款 一年或一营业周期内到期长期借款
337 2273 long-term notes and accounts payable due within one year or one operating cycle 一年或一營業週期內到期長期應付票據及款項 一年或一营业周期内到期长期应付票据及款项
338 2277 long-term notes and accounts payables to related parties – current portion 一年或一營業週期內到期長期應付票據及款項—關係人 一年或一营业周期内到期长期应付票据及款项—关系人
339 2278 other long-term liabilities – current portion 其他一年或一營業週期內到期長期負債 其他一年或一营业周期内到期长期负债
340 2281 VAT received (or output tax) 銷項稅額 销项税额
341 2283 temporary receipts 暫收款 暂收款
342 2284 receipts under custody 代收款 代收款
343 2285 estimated warranty liabilities 估計售後服務/保固負債 估计售后服务/保固负债
344 2291 deferred income tax liabilities 遞延所得稅負債 递延所得税负债
345 2292 deferred foreign exchange gain 遞延兌換利益 递延兑换利益
346 2293 owners’ current account 業主(股東)往來 业主(股东)往来
347 2294 current account with others 同業往來 同业往来
348 2298 other current liabilities – others 其他流動負債—其他 其他流动负债—其他
349 2311 corporate bonds payable 應付公司債 应付公司债
350 2319 premium (discount) on corporate bonds payable 應付公司債溢(折)價 应付公司债溢(折)价
351 2321 long-term loans payable – bank 長期銀行借款 长期银行借款
352 2324 long-term loans payable – owners 長期借款—業主 长期借款—业主
353 2325 long-term loans payable – employees 長期借款—員工 长期借款—员工
354 2327 long-term loans payable – related parties 長期借款—關係人 长期借款—关系人
355 2328 long-term loans payable – other 長期借款—其他 长期借款—其他
356 2331 long-term notes payable 長期應付票據 长期应付票据
357 2332 long-term accounts pay-able 長期應付帳款 长期应付帐款
358 2333 long-term capital lease liabilities 長期應付租賃負債 长期应付租赁负债
359 2337 Long-term notes and accounts payable – related parties 長期應付票據及款項—關係人 长期应付票据及款项—关系人
360 2338 other long-term payables 其他長期應付款項 其他长期应付款项
361 2341 estimated accrued land value incremental tax pay-able 估計應付土地增值稅 估计应付土地增值税
362 2351 accrued pension liabilities 應計退休金負債 应计退休金负债
363 2388 other long-term liabilities – other 其他長期負債—其他 其他长期负债—其他
364 2811 deferred revenue 遞延收入 递延收入
365 2814 deferred income tax liabilities 遞延所得稅負債 递延所得税负债
366 2818 other deferred liabilities 其他遞延負債 其他递延负债
367 2861 guarantee deposit received 存入保證金 存入保证金
368 2888 miscellaneous liabilities – other 雜項負債—其他 杂项负债—其他
369 3111 capital – common stock 普通股股本 普通股股本
370 3112 capital – preferred stock 特別股股本 特别股股本
371 3113 capital collected in advance 預收股本 预收股本
372 3114 stock dividends to be distributed 待分配股票股利 待分配股票股利
373 3115 capital 資本 资本
374 3211 paid-in capital in excess of par- common stock 普通股股票溢價 普通股股票溢价
375 3212 paid-in capital in excess of par- preferred stock 特別股股票溢價 特别股股票溢价
376 3231 capital surplus from assets revaluation 資產重估增值準備 资产重估增值准备
377 3241 capital surplus from gain on disposal of assets 處分資產溢價公積 处分资产溢价公积
378 3251 capital surplus from business combination 合併公積 合并公积
379 3261 donated surplus 受贈公積 受赠公积
380 3281 additional paid-in capital from investee under equity method 權益法長期股權投資資本公積 权益法长期股权投资资本公积
381 3282 additional paid-in capital – treasury stock trans-actions 資本公積—庫藏股票交易 资本公积—库藏股票交易
382 3311 legal reserve 法定盈餘公積 法定盈余公积
383 3321 contingency reserve 意外損失準備 意外损失准备
384 3322 improvement and expansion reserve 改良擴充準備 改良扩充准备
385 3323 special reserve for redemption of liabilities 償債準備 偿债准备
386 3328 other special reserve 其他特別盈餘公積 其他特别盈余公积
387 3351 accumulated profit or loss 累積盈虧 累积盈亏
388 3352 prior period adjustments 前期損益調整 前期损益调整
389 3353 net income or loss for current period 本期損益 本期损益
390 3411 unrealized loss on market value decline of long-term equity investments 長期股權投資未實現跌價損失 长期股权投资未实现跌价损失
391 3421 cumulative translation adjustments 累積換算調整數 累积换算调整数
392 3431 net loss not recognized as pension costs 未認列為退休金成本之淨損失 未认列为退休金成本之净损失
393 3511 treasury stock 庫藏股 库藏股
394 3611 minority interest 少數股權 少数股权
395 4111 sales revenue 銷貨收入 销货收入
396 4112 installment sales revenue 分期付款銷貨收入 分期付款销货收入
397 4171 sales return 銷貨退回 销货退回
398 4191 sales discounts and allowances 銷貨折讓 销货折让
399 4611 service revenue 勞務收入 劳务收入
400 4711 agency revenue 業務收入 业务收入
401 4888 other operating revenue – other 其他營業收入—其他 其他营业收入—其他
402 5111 cost of goods sold 銷貨成本 销货成本
403 5112 installment cost of goods sold 分期付款銷貨成本 分期付款销货成本
404 5121 purchases 進貨 进货
405 5122 purchase expenses 進貨費用 进货费用
406 5123 purchase returns 進貨退出 进货退出
407 5124 charges on purchased merchandise 進貨折讓 进货折让
408 5131 material purchased 進料 进料
409 5132 charges on purchased material 進料費用 进料费用
410 5133 material purchase returns 進料退出 进料退出
411 5134 material purchase allowances 進料折讓 进料折让
412 5141 direct labor 直接人工 直接人工
413 5151 indirect labor 間接人工 间接人工
414 5152 rent expense, rent 租金支出 租金支出
415 5153 office supplies (expense) 文具用品 文具用品
416 5154 travelling expense, travel 旅費 旅费
417 5155 shipping expenses, freight 運費 运费
418 5156 postage (expenses) 郵電費 邮电费
419 5157 repair (s) and maintenance (expense ) 修繕費 修缮费
420 5158 packing expenses 包裝費 包装费
421 5161 utilities (expense) 水電瓦斯費 水电瓦斯费
422 5162 insurance (expense) 保險費 保险费
423 5163 manufacturing overhead – outsourced 加工費 加工费
424 5166 taxes 稅捐 税捐
425 5168 depreciation expense 折舊 折旧
426 5169 various amortization 各項耗竭及攤提 各项耗竭及摊提
427 5172 meal (expenses) 伙食費 伙食费
428 5173 employee benefits/welfare 職工福利 职工福利
429 5176 training (expense) 訓練費 训练费
430 5177 indirect materials 間接材料 间接材料
431 5188 other manufacturing expenses 其他製造費用 其他制造费用
432 5611 service costs 勞務成本 劳务成本
433 5711 agency costs 業務成本 业务成本
434 5888 other operating costs – other 其他營業成本—其他 其他营业成本—其他
435 6151 payroll expense 薪資支出 薪资支出
436 6152 rent expense, rent 租金支出 租金支出
437 6153 office supplies (expense) 文具用品 文具用品
438 6154 travelling expense, travel 旅費 旅费
439 6155 shipping expenses, freight 運費 运费
440 6156 postage (expenses) 郵電費 邮电费
441 6157 repair (s) and maintenance (expense) 修繕費 修缮费
442 6159 advertisement expense, advertisement 廣告費 广告费
443 6161 utilities (expense) 水電瓦斯費 水电瓦斯费
444 6162 insurance (expense) 保險費 保险费
445 6164 entertainment (expense) 交際費 交际费
446 6165 donation (expense) 捐贈 捐赠
447 6166 taxes 稅捐 税捐
448 6167 loss on uncollectible accounts 呆帳損失 呆帐损失
449 6168 depreciation expense 折舊 折旧
450 6169 various amortization 各項耗竭及攤提 各项耗竭及摊提
451 6172 meal (expenses) 伙食費 伙食费
452 6173 employee benefits/welfare 職工福利 职工福利
453 6175 commission (expense) 佣金支出 佣金支出
454 6176 training (expense) 訓練費 训练费
455 6188 other selling expenses 其他推銷費用 其他推销费用
456 6251 payroll expense 薪資支出 薪资支出
457 6252 rent expense, rent 租金支出 租金支出
458 6253 office supplies 文具用品 文具用品
459 6254 travelling expense, travel 旅費 旅费
460 6255 shipping expenses,freight 運費 运费
461 6256 postage (expenses) 郵電費 邮电费
462 6257 repair (s) and maintenance (expense) 修繕費 修缮费
463 6259 advertisement expense, advertisement 廣告費 广告费
464 6261 utilities (expense) 水電瓦斯費 水电瓦斯费
465 6262 insurance (expense) 保險費 保险费
466 6264 entertainment (expense) 交際費 交际费
467 6265 donation (expense) 捐贈 捐赠
468 6266 taxes 稅捐 税捐
469 6267 loss on uncollectible accounts 呆帳損失 呆帐损失
470 6268 depreciation expense 折舊 折旧
471 6269 various amortization 各項耗竭及攤提 各项耗竭及摊提
472 6271 loss on export sales 外銷損失 外销损失
473 6272 meal (expenses) 伙食費 伙食费
474 6273 employee benefits/welfare 職工福利 职工福利
475 6274 research and development expense 研究發展費用 研究发展费用
476 6275 commission (expense) 佣金支出 佣金支出
477 6276 training (expense) 訓練費 训练费
478 6278 professional service fees 勞務費 劳务费
479 6288 other general and administrative expenses 其他管理及總務費用 其他管理及总务费用
480 6351 payroll expense 薪資支出 薪资支出
481 6352 rent expense, rent 租金支出 租金支出
482 6353 office supplies 文具用品 文具用品
483 6354 travelling expense, travel 旅費 旅费
484 6355 shipping expenses, freight 運費 运费
485 6356 postage (expenses) 郵電費 邮电费
486 6357 repair (s) and maintenance (expense) 修繕費 修缮费
487 6361 utilities (expense) 水電瓦斯費 水电瓦斯费
488 6362 insurance (expense) 保險費 保险费
489 6364 entertainment (expense) 交際費 交际费
490 6366 taxes 稅捐 税捐
491 6368 depreciation expense 折舊 折旧
492 6369 various amortization 各項耗竭及攤提 各项耗竭及摊提
493 6372 meal (expenses) 伙食費 伙食费
494 6373 employee benefits/welfare 職工福利 职工福利
495 6376 training (expense) 訓練費 训练费
496 6378 other research and development expenses 其他研究發展費用 其他研究发展费用
497 7111 interest revenue/income 利息收入 利息收入
498 7121 investment income recognized under equity method 權益法認列之投資收益 权益法认列之投资收益
499 7122 dividends income 股利收入 股利收入
500 7123 gain on market price recovery of short-term investment 短期投資市價回升利益 短期投资市价回升利益
501 7131 foreign exchange gain 兌換利益 兑换利益
502 7141 gain on disposal of investments 處分投資收益 处分投资收益
503 7151 gain on disposal of assets 處分資產溢價收入 处分资产溢价收入
504 7481 donation income 捐贈收入 捐赠收入
505 7482 rent revenue/income 租金收入 租金收入
506 7483 commission revenue/income 佣金收入 佣金收入
507 7484 revenue from sale of scraps 出售下腳及廢料收入 出售下脚及废料收入
508 7485 gain on physical inventory 存貨盤盈 存货盘盈
509 7486 gain from price recovery of inventory 存貨跌價回升利益 存货跌价回升利益
510 7487 gain on reversal of bad debts 壞帳轉回利益 坏帐转回利益
511 7488 other non-operating revenue – other items 其他營業外收入—其他 其他营业外收入—其他
512 7511 interest expense 利息費用 利息费用
513 7521 investment loss recognized under equity method 權益法認列之投資損失 权益法认列之投资损失
514 7523 unrealized loss on reduction of short-term investments to market 短期投資未實現跌價損失 短期投资未实现跌价损失
515 7531 foreign exchange loss 兌換損失 兑换损失
516 7541 loss on disposal of investments 處分投資損失 处分投资损失
517 7551 loss on disposal of assets 處分資產損失 处分资产损失
518 7881 loss on work stoppages 停工損失 停工损失
519 7882 casualty loss 災害損失 灾害损失
520 7885 loss on physical inventory 存貨盤損 存货盘损
521 7886 loss for market price decline and obsolete and slow-moving inventories 存貨跌價及呆滯損失 存货跌价及呆滞损失
522 7888 other non-operating expenses – other 其他營業外費用—其他 其他营业外费用—其他
523 8111 income tax expense ( or benefit) 所得稅費用(或利益) 所得税费用(或利益)
524 9111 income (loss) from operations of discontinued segment 停業部門損益—停業前營業損益 停业部门损益—停业前营业损益
525 9121 gain (loss) from disposal of discontinued segment 停業部門損益—處分損益 停业部门损益—处分损益
526 9211 extraordinary gain or loss 非常損益 非常损益
527 9311 cumulative effect of changes in accounting principles 會計原則變動累積影響數 会计原则变动累积影响数
528 9411 minority interest income 少數股權淨利 少数股权净利

View File

@ -0,0 +1,10 @@
code,name,l10n-zh_Hant,l10n-zh_Hans
TWD,New Taiwan dollar,新臺幣,新台币
USD,United States dollar,美元,美元
JPY,Japanese yen,日圓,日圆
CNY,Renminbi,人民幣,人民币
HKD,Hong Kong dollar,港元,港元
EUR,Euro,歐元,欧元
MOP,Macanese pataca,澳門元,澳门元
AUD,Australian dollar,澳洲元,澳大利亚元
ETH,Ethereum,以太坊,以太坊
1 code name l10n-zh_Hant l10n-zh_Hans
2 TWD New Taiwan dollar 新臺幣 新台币
3 USD United States dollar 美元 美元
4 JPY Japanese yen 日圓 日圆
5 CNY Renminbi 人民幣 人民币
6 HKD Hong Kong dollar 港元 港元
7 EUR Euro 歐元 欧元
8 MOP Macanese pataca 澳門元 澳门元
9 AUD Australian dollar 澳洲元 澳大利亚元
10 ETH Ethereum 以太坊 以太坊

View File

@ -1,45 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# 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 database instance factory for the base account management.
This is to overcome the problem that the database instance needs to be
initialized at compile time, but as a submodule it is only available at run
time.
"""
from flask_sqlalchemy import SQLAlchemy
from accounting import AbstractUserUtils
db: SQLAlchemy
"""The database instance."""
user_utils: AbstractUserUtils
"""The user utilities."""
def set_db(new_db: SQLAlchemy, new_user_utils: AbstractUserUtils) -> None:
"""Sets the database instance.
:param new_db: The database instance.
:param new_user_utils: The user utilities.
:return: None.
"""
global db, user_utils
db = new_db
user_utils = new_user_utils

View File

@ -39,6 +39,17 @@ def gettext(string, **variables) -> str:
return domain.gettext(string, **variables)
def pgettext(context, string, **variables) -> str:
"""A replacement of the Babel gettext() function..
:param context: The context.
:param string: The message to translate.
:param variables: The variable substitution.
:return: The translated message.
"""
return domain.pgettext(context, string, **variables)
def lazy_gettext(string, **variables) -> LazyString:
"""A replacement of the Babel lazy_gettext() function..
@ -105,8 +116,8 @@ def __babel_js_catalog_view() -> Response:
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initializes the application.
:param bp: The blueprint of the accounting application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
bp.add_url_rule("/_jstrans.js", "babel_catalog",

View File

@ -17,18 +17,20 @@
"""The data models.
"""
from __future__ import annotations
import re
import typing as t
from decimal import Decimal
import sqlalchemy as sa
from flask import current_app
from flask_babel import get_locale
from sqlalchemy import text
from accounting.database import db, user_utils
user_cls: db.Model = user_utils.cls
user_pk_column: db.Column = user_utils.pk_column
from accounting import db
from accounting.locale import gettext
from accounting.utils.user import user_cls, user_pk_column
class BaseAccount(db.Model):
@ -66,13 +68,23 @@ class BaseAccount(db.Model):
return l10n.title
return self.title_l10n
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
class BaseAccountL10n(db.Model):
"""A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n"
"""The table name."""
account_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
ondelete="CASCADE"),
account_code = db.Column(db.String,
db.ForeignKey(BaseAccount.code,
onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False, primary_key=True)
"""The code of the account."""
account = db.relationship(BaseAccount, back_populates="l10n")
@ -87,10 +99,12 @@ class Account(db.Model):
"""An account."""
__tablename__ = "accounting_accounts"
"""The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True)
id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The account ID."""
base_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
ondelete="CASCADE"),
base_code = db.Column(db.String,
db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False)
"""The code of the base account."""
base = db.relationship(BaseAccount, back_populates="accounts")
@ -99,12 +113,14 @@ class Account(db.Model):
"""The account number under the base account."""
title_l10n = db.Column("title", db.String, nullable=False)
"""The title."""
is_offset_needed = db.Column(db.Boolean, nullable=False, default=False)
"""Whether the entries of this account need offsets."""
is_pay_off_needed = db.Column(db.Boolean, nullable=False, default=False)
"""Whether the entries of this account need pay-off."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
created_by_id = db.Column(db.Integer, db.ForeignKey(user_pk_column),
created_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
@ -112,7 +128,9 @@ class Account(db.Model):
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of last update."""
updated_by_id = db.Column(db.Integer, db.ForeignKey(user_pk_column),
updated_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
@ -120,7 +138,8 @@ class Account(db.Model):
l10n = db.relationship("AccountL10n", back_populates="account",
lazy=False)
"""The localized titles."""
db.UniqueConstraint(base_code, no)
entries = db.relationship("JournalEntry", back_populates="account")
"""The journal entries."""
__CASH = "1111-001"
"""The code of the cash account,"""
@ -182,18 +201,35 @@ class Account(db.Model):
if l10n.locale == current_locale:
l10n.title = value
return
self.l10n.append(AccountL10n(
locale=current_locale, title=value))
self.l10n.append(AccountL10n(locale=current_locale, title=value))
@property
def is_in_use(self) -> bool:
"""Returns whether the account is in use.
:return: True if the account is in use, or False otherwise.
"""
if not hasattr(self, "__is_in_use"):
setattr(self, "__is_in_use", len(self.entries) > 0)
return getattr(self, "__is_in_use")
@is_in_use.setter
def is_in_use(self, is_in_use: bool) -> None:
"""Sets whether the account is in use.
:param is_in_use: True if the account is in use, or False otherwise.
:return: None.
"""
setattr(self, "__is_in_use", is_in_use)
@classmethod
def find_by_code(cls, code: str) -> t.Self | None:
"""Finds an accounting account by its code.
"""Finds an account by its code.
:param code: The code.
:return: The accounting account, or None if this account does not
exist.
:return: The account, or None if this account does not exist.
"""
m = re.match("^([1-9]{4})-([0-9]{3})$", code)
m = 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),
@ -240,6 +276,14 @@ class Account(db.Model):
cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all()
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
@classmethod
def cash(cls) -> t.Self:
"""Returns the cash account.
@ -288,8 +332,21 @@ class Account(db.Model):
"""
return cls.find_by_code(cls.__NET_CHANGE)
@property
def is_modified(self) -> bool:
"""Returns whether a product account was modified.
:return: True if modified, or False otherwise.
"""
if db.session.is_modified(self):
return True
for l10n in self.l10n:
if db.session.is_modified(l10n):
return True
return False
def delete(self) -> None:
"""Deletes this accounting account.
"""Deletes this account.
:return: None.
"""
@ -301,10 +358,342 @@ class Account(db.Model):
class AccountL10n(db.Model):
"""A localized account title."""
__tablename__ = "accounting_accounts_l10n"
account_id = db.Column(db.Integer, db.ForeignKey(Account.id,
ondelete="CASCADE"),
"""The table name."""
account_id = db.Column(db.Integer,
db.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False, primary_key=True)
"""The account ID."""
account = db.relationship(Account, back_populates="l10n")
"""The account."""
locale = db.Column(db.String, nullable=False, primary_key=True)
"""The locale."""
title = db.Column(db.String, nullable=False)
db.UniqueConstraint(account_id, locale)
"""The localized title."""
class Currency(db.Model):
"""A currency."""
__tablename__ = "accounting_currencies"
"""The table name."""
code = db.Column(db.String, nullable=False, primary_key=True)
"""The code."""
name_l10n = db.Column("name", db.String, nullable=False)
"""The name."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
created_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
"""The creator."""
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of last update."""
updated_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator."""
l10n = db.relationship("CurrencyL10n", back_populates="currency",
lazy=False)
"""The localized names."""
entries = db.relationship("JournalEntry", back_populates="currency")
"""The journal entries."""
def __str__(self) -> str:
"""Returns the string representation of the currency.
:return: The string representation of the currency.
"""
return F"{self.name} ({self.code})"
@property
def name(self) -> str:
"""Returns the name in the current locale.
:return: The name in the current locale.
"""
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
return self.name_l10n
for l10n in self.l10n:
if l10n.locale == current_locale:
return l10n.name
return self.name_l10n
@name.setter
def name(self, value: str) -> None:
"""Sets the name in the current locale.
:param value: The new name.
:return: None.
"""
if self.name_l10n is None:
self.name_l10n = value
return
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
self.name_l10n = value
return
for l10n in self.l10n:
if l10n.locale == current_locale:
l10n.name = value
return
self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
@property
def is_modified(self) -> bool:
"""Returns whether a product account was modified.
:return: True if modified, or False otherwise.
"""
if db.session.is_modified(self):
return True
for l10n in self.l10n:
if db.session.is_modified(l10n):
return True
return False
def delete(self) -> None:
"""Deletes the currency.
:return: None.
"""
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
cls: t.Type[t.Self] = self.__class__
cls.query.filter(cls.code == self.code).delete()
class CurrencyL10n(db.Model):
"""A localized currency name."""
__tablename__ = "accounting_currencies_l10n"
"""The table name."""
currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False, primary_key=True)
"""The currency code."""
currency = db.relationship(Currency, back_populates="l10n")
"""The currency."""
locale = db.Column(db.String, nullable=False, primary_key=True)
"""The locale."""
name = db.Column(db.String, nullable=False)
"""The localized name."""
class TransactionCurrency:
"""A currency in a transaction."""
def __init__(self, code: str, debit: list[JournalEntry],
credit: list[JournalEntry]):
"""Constructs the currency in the transaction.
:param code: The currency code.
:param debit: The debit entries.
:param credit: The credit entries.
"""
self.code: str = code
"""The currency code."""
self.debit: list[JournalEntry] = debit
"""The debit entries."""
self.credit: list[JournalEntry] = credit
"""The credit entries."""
@property
def name(self) -> str:
"""Returns the currency name.
:return: The currency name.
"""
return db.session.get(Currency, self.code).name
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries.
:return: The total amount of the debit journal entries.
"""
return sum([x.amount for x in self.debit])
@property
def credit_total(self) -> str:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount for x in self.credit])
class Transaction(db.Model):
"""A transaction."""
__tablename__ = "accounting_transactions"
"""The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The transaction ID."""
date = db.Column(db.Date, nullable=False)
"""The date."""
no = db.Column(db.Integer, nullable=False, default=text("1"))
"""The account number under the date."""
note = db.Column(db.String)
"""The note."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
created_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
"""The creator."""
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of last update."""
updated_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator."""
entries = db.relationship("JournalEntry", back_populates="transaction")
"""The journal entries."""
def __str__(self) -> str:
"""Returns the string representation of this transaction.
:return: The string representation of this transaction.
"""
if self.is_cash_expense:
return gettext("Cash Expense Transaction#%(id)s", id=self.id)
if self.is_cash_income:
return gettext("Cash Income Transaction#%(id)s", id=self.id)
return gettext("Transfer Transaction#%(id)s", id=self.id)
@property
def currencies(self) -> list[TransactionCurrency]:
"""Returns the journal entries categorized by their currencies.
:return: The currency categories.
"""
entries: list[JournalEntry] = sorted(self.entries, key=lambda x: x.no)
codes: list[str] = []
by_currency: dict[str, list[JournalEntry]] = {}
for entry in entries:
if entry.currency_code not in by_currency:
codes.append(entry.currency_code)
by_currency[entry.currency_code] = []
by_currency[entry.currency_code].append(entry)
return [TransactionCurrency(code=x,
debit=[y for y in by_currency[x]
if y.is_debit],
credit=[y for y in by_currency[x]
if not y.is_debit])
for x in codes]
@property
def is_cash_income(self) -> bool:
"""Returns whether this is a cash income transaction.
:return: True if this is a cash income transaction, or False otherwise.
"""
for currency in self.currencies:
if len(currency.debit) > 1:
return False
if currency.debit[0].account.code != "1111-001":
return False
return True
@property
def is_cash_expense(self) -> bool:
"""Returns whether this is a cash expense transaction.
:return: True if this is a cash expense transaction, or False
otherwise.
"""
for currency in self.currencies:
if len(currency.credit) > 1:
return False
if currency.credit[0].account.code != "1111-001":
return False
return True
def delete(self) -> None:
"""Deletes the transaction.
:return: None.
"""
JournalEntry.query\
.filter(JournalEntry.transaction_id == self.id).delete()
db.session.delete(self)
class JournalEntry(db.Model):
"""An accounting journal entry."""
__tablename__ = "accounting_journal_entries"
"""The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The entry ID."""
transaction_id = db.Column(db.Integer,
db.ForeignKey(Transaction.id,
onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False)
"""The transaction ID."""
transaction = db.relationship(Transaction, back_populates="entries")
"""The transaction."""
is_debit = db.Column(db.Boolean, nullable=False)
"""True for a debit entry, or False for a credit entry."""
no = db.Column(db.Integer, nullable=False)
"""The entry number under the transaction and debit or credit."""
pay_off_target_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"),
nullable=True)
"""The ID of the pay-off target entry."""
pay_off_target = db.relationship("JournalEntry", back_populates="pay_off",
remote_side=id, passive_deletes=True)
"""The pay-off target entry."""
pay_off = db.relationship("JournalEntry", back_populates="pay_off_target")
"""The pay-off entries."""
currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"),
nullable=False)
"""The currency code."""
currency = db.relationship(Currency, back_populates="entries")
"""The currency."""
account_id = db.Column(db.Integer,
db.ForeignKey(Account.id,
onupdate="CASCADE"),
nullable=False)
"""The account ID."""
account = db.relationship(Account, back_populates="entries", lazy=False)
"""The account."""
summary = db.Column(db.String, nullable=True)
"""The summary."""
amount = db.Column(db.Numeric(14, 2), nullable=False)
"""The amount."""
@property
def eid(self) -> int | None:
"""Returns the journal entry ID. This is the alternative name of the
ID field, to work with WTForms.
:return: The journal entry ID.
"""
return self.id
@property
def account_code(self) -> str:
"""Returns the account code.
:return: The account code.
"""
return self.account.code

View File

@ -21,48 +21,101 @@
* First written: 2023/2/1
*/
.clickable {
.accounting-clickable {
cursor: pointer;
}
.btn-group .btn .search-input {
.btn-group .btn .accounting-search-input {
min-height: calc(1em + .5rem + 2px);
padding: 0 0.5rem;
}
.btn-group .btn .search-label button {
.btn-group .btn .accounting-search-label button {
border: none;
background-color: transparent;
color: inherit;
padding-right: 0;
}
.form-floating > textarea.form-control {
height: 6rem;
}
.accounting-dragged {
color: #141619;
background-color: #D3D3D4;
}
/** The account management */
.account {
/** The card layout */
.accounting-card {
padding: 2em 1.5em;
margin: 1em;
background-color: #E9ECEF;
border-radius: 0.3em;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
}
.account .account-title {
.accounting-card-title {
font-size: 1.8rem;
font-weight: bolder;
}
.account .account-code {
.accounting-card-code {
font-size: 1.4rem;
color: #373b3e;
}
.list-base-selector {
/** The option selector */
.accounting-selector-list {
height: 20rem;
overflow-y: scroll;
}
/** The transaction management */
.accounting-currency-control {
background-color: transparent;
}
.accounting-currency-content {
width: calc(100% - 3rem);
}
.accounting-entry-content {
width: calc(100% - 3rem);
background-color: transparent;
}
.accounting-entry-control {
border-color: transparent;
}
.accounting-transaction-card {
padding: 2em 1.5em;
margin: 1em;
background-color: #F8F9FA;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
}
.accounting-transaction-card h2 {
border-bottom: thick double slategray;
}
.accounting-list-group-stripped .list-group-item:nth-child(2n+1) {
background-color: #f2f2f2;
}
.accounting-list-group-stripped .list-group-item-success:nth-child(2n+1) {
background-color: #c7dbd2;
}
.accounting-list-group-hover .list-group-item:hover {
background-color: #ececec;
}
.accounting-transaction-entry {
border: none;
}
.accounting-transaction-entry-header {
font-weight: bolder;
border-bottom: thick double slategray;
}
.list-group-item.accounting-transaction-entry-total {
font-weight: bolder;
border-top: thick double slategray;
}
/* The Material Design text field (floating form control in Bootstrap) */
.material-text-field {
.accounting-material-text-field {
position: relative;
min-height: calc(3.5rem + 2px);
padding-top: 1.625rem;
}
.material-text-field > .form-label {
.accounting-material-text-field > .form-label {
position: absolute;
top: 0;
left: 0;
@ -71,27 +124,57 @@
transform-origin: 0 0;
transition: opacity .1s ease-in-out,transform .1s ease-in-out;
}
.material-text-field.not-empty > .form-label {
.accounting-material-text-field.accounting-not-empty > .form-label {
opacity: 0.65;
transform: scale(.85) translateY(-.5rem) translateX(.15rem);
}
/* The Material Design floating action buttons */
.material-fab {
.accounting-material-fab {
position: fixed;
right: 2rem;
bottom: 1rem;
z-index: 10;
flex-direction: column-reverse;
}
.material-fab .btn {
.accounting-material-fab .btn {
border-radius: 50%;
transform: scale(1.5);
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
display: block;
margin-top: 2.5rem;
}
.material-fab .btn:hover, .material-fab .btn:focus {
.accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus {
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
}
.accounting-btn-material-fab {
transition: transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out;
}
.show .accounting-btn-material-fab {
transform: scale(1.5) rotate(-45deg);
}
.accounting-material-fab-speed-dial-group {
position: absolute;
right: -2rem;
bottom: -7rem;
text-align: right;
opacity: 0;
transform: scale(0.1);
line-height: 5.5rem;
transition: opacity .1s ease-in-out, transform .1s ease-in-out, right .1s ease-in-out, bottom .1s ease-in-out;
}
.show .accounting-material-fab-speed-dial-group {
opacity: 1;
transform: scale(0.6);
right: -0.5rem;
bottom: 0.7rem;
}
.accounting-material-fab-speed-dial-group .btn {
background-color: white;
white-space: nowrap;
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
}
.accounting-material-fab-speed-dial-group .btn:hover, .accounting-material-fab-speed-dial-group .btn:focus {
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
}

View File

@ -23,12 +23,12 @@
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
initializeBaseAccountSelector()
document.getElementById("account-base-code")
initializeBaseAccountSelector();
document.getElementById("accounting-base-code")
.onchange = validateBase;
document.getElementById("account-title")
document.getElementById("accounting-title")
.onchange = validateTitle;
document.getElementById("account-form")
document.getElementById("accounting-form")
.onsubmit = validateForm;
});
@ -38,25 +38,25 @@ document.addEventListener("DOMContentLoaded", function () {
* @private
*/
function initializeBaseAccountSelector() {
const selector = document.getElementById("select-base-modal");
const base = document.getElementById("account-base");
const baseCode = document.getElementById("account-base-code");
const baseContent = document.getElementById("account-base-content");
const options = Array.from(document.getElementsByClassName("list-group-item-base"));
const btnClear = document.getElementById("btn-clear-base");
const selector = document.getElementById("accounting-base-selector-modal");
const base = document.getElementById("accounting-base");
const baseCode = document.getElementById("accounting-base-code");
const baseContent = document.getElementById("accounting-base-content");
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const btnClear = document.getElementById("accounting-btn-clear-base");
selector.addEventListener("show.bs.modal", function () {
base.classList.add("not-empty");
base.classList.add("accounting-not-empty");
options.forEach(function (item) {
item.classList.remove("active");
});
const selected = document.getElementById("list-group-item-base-" + baseCode.value);
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
if (selected !== null) {
selected.classList.add("active");
}
});
selector.addEventListener("hidden.bs.modal", function () {
if (baseCode.value === "") {
base.classList.remove("not-empty");
base.classList.remove("accounting-not-empty");
}
});
options.forEach(function (option) {
@ -79,6 +79,53 @@ function initializeBaseAccountSelector() {
validateBase();
bootstrap.Modal.getInstance(selector).hide();
}
initializeBaseAccountQuery();
}
/**
* Initializes the query on the base account options.
*
* @private
*/
function initializeBaseAccountQuery() {
const query = document.getElementById("accounting-base-selector-query");
const optionList = document.getElementById("accounting-base-option-list");
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const queryNoResult = document.getElementById("accounting-base-option-no-result");
query.addEventListener("input", function () {
if (query.value === "") {
options.forEach(function (option) {
option.classList.remove("d-none");
});
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
return
}
let hasAnyMatched = false;
options.forEach(function (option) {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
isMatched = true;
break;
}
}
if (isMatched) {
option.classList.remove("d-none");
hasAnyMatched = true;
} else {
option.classList.add("d-none");
}
});
if (!hasAnyMatched) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
}
});
}
/**
@ -101,9 +148,9 @@ function validateForm() {
* @private
*/
function validateBase() {
const field = document.getElementById("account-base-code");
const error = document.getElementById("account-base-code-error");
const displayField = document.getElementById("account-base");
const field = document.getElementById("accounting-base-code");
const error = document.getElementById("accounting-base-code-error");
const displayField = document.getElementById("accounting-base");
field.value = field.value.trim();
if (field.value === "") {
displayField.classList.add("is-invalid");
@ -122,8 +169,8 @@ function validateBase() {
* @private
*/
function validateTitle() {
const field = document.getElementById("account-title");
const error = document.getElementById("account-title-error");
const field = document.getElementById("accounting-title");
const error = document.getElementById("accounting-title-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");

View File

@ -0,0 +1,39 @@
/* The Mia! Accounting Flask Project
* account-order.js: The JavaScript for the account order
*/
/* 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/2
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
const list = document.getElementById("accounting-order-list");
if (list !== null) {
const onReorder = function () {
const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code");
no.value = String(i + 1);
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3);
}
};
initializeDragAndDropReordering(list, onReorder);
}
});

View File

@ -0,0 +1,174 @@
/* The Mia! Accounting Flask Project
* currency-form.js: The JavaScript for the currency form
*/
/* 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/6
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.getElementById("accounting-code")
.onchange = validateCode;
document.getElementById("accounting-name")
.onchange = validateName;
document.getElementById("accounting-form")
.onsubmit = validateForm;
});
/**
* The asynchronous validation result
* @type {object}
* @private
*/
let isAsyncValid = {};
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateForm() {
isAsyncValid = {
"code": false,
"_sync": false,
};
let isValid = true;
isValid = validateCode() && isValid;
isValid = validateName() && isValid;
isAsyncValid["_sync"] = isValid;
submitFormIfAllAsyncValid();
return false;
}
/**
* Submits the form if the whole form passed the asynchronous
* validations.
*
* @private
*/
function submitFormIfAllAsyncValid() {
let isValid = true;
Object.keys(isAsyncValid).forEach(function (key) {
isValid = isAsyncValid[key] && isValid;
});
if (isValid) {
document.getElementById("accounting-form").submit()
}
}
/**
* Validates the code.
*
* @param changeEvent {Event} the change event, if invoked from onchange
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateCode(changeEvent = null) {
const key = "code";
const isSubmission = changeEvent === null;
let hasAsyncValidation = false;
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the code.");
return false;
}
const blocklist = JSON.parse(field.dataset.blocklist);
if (blocklist.includes(field.value)) {
field.classList.add("is-invalid");
error.innerText = A_("This code is not available.");
return false;
}
if (!field.value.match(/^[A-Z]{3}$/)) {
field.classList.add("is-invalid");
error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
return false;
}
const original = field.dataset.original;
if (original === "" || field.value !== original) {
hasAsyncValidation = true;
validateAsyncCodeIsDuplicated(isSubmission, key);
}
if (!hasAsyncValidation) {
isAsyncValid[key] = true;
field.classList.remove("is-invalid");
error.innerText = "";
}
return true;
}
/**
* Validates asynchronously whether the code is duplicated.
* The boolean validation result is stored in isAsyncValid[key].
*
* @param isSubmission {boolean} whether this is invoked from a form submission
* @param key {string} the key to store the result in isAsyncValid
* @private
*/
function validateAsyncCodeIsDuplicated(isSubmission, key) {
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
const url = field.dataset.existsUrl;
const onLoad = function () {
if (this.status === 200) {
const result = JSON.parse(this.responseText);
if (result["exists"]) {
field.classList.add("is-invalid");
error.innerText = A_("Code conflicts with another currency.");
if (isSubmission) {
isAsyncValid[key] = false;
}
return;
}
field.classList.remove("is-invalid");
error.innerText = "";
if (isSubmission) {
isAsyncValid[key] = true;
submitFormIfAllAsyncValid();
}
}
};
const request = new XMLHttpRequest();
request.onload = onLoad;
request.open("GET", url + "?q=" + encodeURIComponent(field.value));
request.send();
}
/**
* Validates the name.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateName() {
const field = document.getElementById("accounting-name");
const error = document.getElementById("accounting-name-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the name.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}

View File

@ -0,0 +1,108 @@
/* The Mia! Accounting Flask Project
* drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
*/
/* 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/3
*/
/**
* Initializes the drag-and-drop reordering on a list.
*
* @param list {HTMLElement} the list to be reordered
* @param onReorder {(function())|*} The callback to reorder the items
*/
function initializeDragAndDropReordering(list, onReorder) {
initializeMouseDragAndDropReordering(list, onReorder);
initializeTouchDragAndDropReordering(list, onReorder);
}
/**
* Initializes the drag-and-drop reordering with mouse.
*
* @param list {HTMLElement} the list to be reordered
* @param onReorder {(function())|*} The callback to reorder the items
* @private
*/
function initializeMouseDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children);
let dragged = null;
items.forEach(function (item) {
item.draggable = true;
item.addEventListener("dragstart", function () {
dragged = item;
dragged.classList.add("accounting-dragged");
});
item.addEventListener("dragover", function () {
onDragOver(dragged, item);
onReorder();
});
item.addEventListener("dragend", function () {
dragged.classList.remove("accounting-dragged");
dragged = null;
});
});
}
/**
* Initializes the drag-and-drop reordering with touch devices.
*
* @param list {HTMLElement} the list to be reordered
* @param onReorder {(function())|*} The callback to reorder the items
* @private
*/
function initializeTouchDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children);
items.forEach(function (item) {
item.addEventListener("touchstart", function () {
item.classList.add("accounting-dragged");
});
item.addEventListener("touchmove", function (event) {
const touch = event.targetTouches[0];
const target = document.elementFromPoint(touch.pageX, touch.pageY);
onDragOver(item, target);
onReorder();
});
item.addEventListener("touchend", function () {
item.classList.remove("accounting-dragged");
});
});
}
/**
* Handles when an item is dragged over the other item.
*
* @param dragged {Element} the item that was dragged
* @param target {Element} the other item that was dragged over
*/
function onDragOver(dragged, target) {
if (dragged === null || target.parentElement !== dragged.parentElement || target === dragged) {
return;
}
let isBefore = false;
for (let p = target; p !== null; p = p.previousSibling) {
if (p === dragged) {
isBefore = true;
}
}
if (isBefore) {
target.parentElement.insertBefore(dragged, target.nextSibling);
} else {
target.parentElement.insertBefore(dragged, target);
}
}

View File

@ -0,0 +1,44 @@
/* The Mia! Accounting Flask Project
* material-fab-speed-dial.js: The JavaScript for the speed dial for the material floating buttons
*/
/* 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/25
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
initializeMaterialFabSpeedDial();
});
/**
* Initializes the speed dial of the material floating buttons.
*
* @private
*/
function initializeMaterialFabSpeedDial() {
const btnFab = document.getElementById("accounting-btn-material-fab-speed-dial");
const fab = document.getElementById(btnFab.dataset.target);
btnFab.onclick = function () {
if (fab.classList.contains("show")) {
fab.classList.remove("show");
} else {
fab.classList.add("show");
}
}
}

View File

@ -0,0 +1,832 @@
/* The Mia! Accounting Flask Project
* transaction-transfer-form.js: The JavaScript for the transfer transaction form
*/
/* 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/25
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
initializeCurrencyForms();
initializeJournalEntries();
initializeAccountSelectors();
initializeFormValidation();
});
/**
* Escapes the HTML special characters and returns.
*
* @param s {string} the original string
* @returns {string} the string with HTML special character escaped
* @private
*/
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
/**
* Formats a Decimal number.
*
* @param number {Decimal} the Decimal number
* @returns {string} the formatted Decimal number
*/
function formatDecimal(number) {
if (number.equals(new Decimal("0"))) {
return "-";
}
const frac = number.modulo(1);
const whole = Number(number.minus(frac)).toLocaleString();
return whole + String(frac).substring(1);
}
/**
* Initializes the currency forms.
*
* @private
*/
function initializeCurrencyForms() {
const form = document.getElementById("accounting-form");
const btnNew = document.getElementById("accounting-btn-new-currency");
const currencyList = document.getElementById("accounting-currency-list");
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
const onReorder = function () {
const currencies = Array.from(currencyList.children);
for (let i = 0; i < currencies.length; i++) {
const no = document.getElementById(currencies[i].dataset.prefix + "-no");
no.value = String(i + 1);
}
};
btnNew.onclick = function () {
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
let maxIndex = 0;
currencies.forEach(function (currency) {
const index = parseInt(currency.dataset.index);
if (maxIndex < index) {
maxIndex = index;
}
});
const newIndex = String(maxIndex + 1);
const html = form.dataset.currencyTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(newIndex));
currencyList.insertAdjacentHTML("beforeend", html);
const newEntryButtons = Array.from(document.getElementsByClassName("accounting-currency-" + newIndex + "-btn-new-entry"));
const btnDelete = document.getElementById("accounting-btn-delete-currency-" + newIndex);
newEntryButtons.forEach(initializeNewEntryButton);
initializeBtnDeleteCurrency(btnDelete);
resetDeleteCurrencyButtons();
initializeDragAndDropReordering(currencyList, onReorder);
};
deleteButtons.forEach(initializeBtnDeleteCurrency);
initializeDragAndDropReordering(currencyList, onReorder);
}
/**
* Initializes the button to delete a currency.
*
* @param button {HTMLButtonElement} the button to delete a currency.
* @private
*/
function initializeBtnDeleteCurrency(button) {
const target = document.getElementById(button.dataset.target);
button.onclick = function () {
target.parentElement.removeChild(target);
resetDeleteCurrencyButtons();
};
}
/**
* Resets the status of the delete currency buttons.
*
* @private
*/
function resetDeleteCurrencyButtons() {
const buttons = Array.from(document.getElementsByClassName("accounting-btn-delete-currency"));
if (buttons.length > 1) {
buttons.forEach(function (button) {
button.classList.remove("d-none");
});
} else {
buttons[0].classList.add("d-none");
}
}
/**
* Initializes the journal entry forms.
*
* @private
*/
function initializeJournalEntries() {
const newButtons = Array.from(document.getElementsByClassName("accounting-btn-new-entry"));
const entryLists = Array.from(document.getElementsByClassName("accounting-entry-list"));
const entries = Array.from(document.getElementsByClassName("accounting-entry"))
const deleteButtons = Array.from(document.getElementsByClassName("accounting-btn-delete-entry"));
newButtons.forEach(initializeNewEntryButton);
entryLists.forEach(initializeJournalEntryListReorder);
entries.forEach(initializeJournalEntry);
deleteButtons.forEach(initializeDeleteJournalEntryButton);
initializeJournalEntryFormModal();
}
/**
* Initializes the button to add a new journal entry.
*
* @param button {HTMLButtonElement} the button to add a new journal entry
*/
function initializeNewEntryButton(button) {
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 formAccountError = document.getElementById("accounting-entry-form-account-error")
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");
const formAmountError = document.getElementById("accounting-entry-form-amount-error");
button.onclick = function () {
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("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");
formSummaryError.innerText = ""
formAmount.value = "";
formAmount.classList.remove("is-invalid");
formAmountError.innerText = "";
};
}
/**
* Initializes the reordering of a journal entry list.
*
* @param entryList {HTMLUListElement} the journal entry list.
*/
function initializeJournalEntryListReorder(entryList) {
initializeDragAndDropReordering(entryList, function () {
const entries = Array.from(entryList.children);
for (let i = 0; i < entries.length; i++) {
const no = document.getElementById(entries[i].dataset.prefix + "-no");
no.value = String(i + 1);
}
});
}
/**
* Initializes the journal entry.
*
* @param entry {HTMLLIElement} the journal entry.
*/
function initializeJournalEntry(entry) {
const entryForm = document.getElementById("accounting-entry-form");
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
const summary = document.getElementById(entry.dataset.prefix + "-summary");
const amount = document.getElementById(entry.dataset.prefix + "-amount");
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 formSummary = document.getElementById("accounting-entry-form-summary");
const formAmount = document.getElementById("accounting-entry-form-amount");
control.onclick = function () {
entryForm.dataset.currencyIndex = entry.dataset.currencyIndex;
entryForm.dataset.entryType = entry.dataset.entryType;
entryForm.dataset.entryIndex = entry.dataset.entryIndex;
if (accountCode.value === "") {
formAccountControl.classList.remove("accounting-not-empty");
} else {
formAccountControl.classList.add("accounting-not-empty");
}
formAccountControl.dataset.bsTarget = entry.dataset.accountModal;
formAccount.innerText = accountCode.dataset.text;
formAccount.dataset.code = accountCode.value;
formAccount.dataset.text = accountCode.dataset.text;
formSummary.value = summary.value;
formAmount.value = amount.value;
validateJournalEntryForm();
};
}
/**
* Initializes the journal entry form modal.
*
* @private
*/
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 () {
const prefix = "accounting-" + entryForm.dataset.entryType + "-account";
const query = document.getElementById(prefix + "-selector-query")
const more = document.getElementById(prefix + "-more");
const options = Array.from(document.getElementsByClassName(prefix + "-option"));
const btnClear = document.getElementById(prefix + "-btn-clear");
query.value = "";
more.classList.remove("d-none");
filterAccountOptions(prefix);
options.forEach(function (option) {
if (option.dataset.code === formAccount.dataset.code) {
option.classList.add("active");
} else {
option.classList.remove("active");
}
});
if (formAccount.dataset.code === "") {
btnClear.classList.add("btn-secondary");
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
} else {
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary");
btnClear.disabled = false;
}
};
formSummary.onchange = validateJournalEntrySummary;
formAmount.onchange = validateJournalEntryAmount;
entryForm.onsubmit = function () {
if (validateJournalEntryForm()) {
saveJournalEntryForm();
bootstrap.Modal.getInstance(modal).hide();
}
return false;
}
}
/**
* Validates the journal entry form modal.
*
* @return {boolean} true if the form is valid, or false otherwise.
* @private
*/
function validateJournalEntryForm() {
let isValid = true;
isValid = validateJournalEntryAccount() && isValid;
isValid = validateJournalEntrySummary() && isValid;
isValid = validateJournalEntryAmount() && isValid
return isValid;
}
/**
* Validates the account in the journal entry form modal.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntryAccount() {
const field = document.getElementById("accounting-entry-form-account");
const error = document.getElementById("accounting-entry-form-account-error");
const control = document.getElementById("accounting-entry-form-account-control");
if (field.dataset.code === "") {
control.classList.add("is-invalid");
error.innerText = A_("Please select the account.");
return false;
}
control.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the summary in the journal entry form modal.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntrySummary() {
const field = document.getElementById("accounting-entry-form-summary");
const error = document.getElementById("accounting-entry-form-summary-error");
field.value = field.value.trim();
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the amount in the journal entry form modal.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntryAmount() {
const field = document.getElementById("accounting-entry-form-amount");
const error = document.getElementById("accounting-entry-form-amount-error");
field.value = field.value.trim();
field.classList.remove("is-invalid");
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the amount.");
return false;
}
error.innerText = "";
return true;
}
/**
* Saves the journal entry form modal to the form.
*
* @private
*/
function saveJournalEntryForm() {
const form = document.getElementById("accounting-form");
const entryForm = document.getElementById("accounting-entry-form");
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 currencyIndex = entryForm.dataset.currencyIndex;
const entryType = entryForm.dataset.entryType;
let entryIndex;
if (entryForm.dataset.entryIndex === "new") {
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
const entryList = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-list")
let maxIndex = 0;
entries.forEach(function (entry) {
const index = parseInt(entry.dataset.entryIndex);
if (maxIndex < index) {
maxIndex = index;
}
});
entryIndex = String(maxIndex + 1);
const html = form.dataset.entryTemplate
.replaceAll("CURRENCY_INDEX", escapeHtml(currencyIndex))
.replaceAll("ENTRY_TYPE", escapeHtml(entryType))
.replaceAll("ENTRY_INDEX", escapeHtml(entryIndex));
entryList.insertAdjacentHTML("beforeend", html);
initializeJournalEntryListReorder(entryList);
} else {
entryIndex = entryForm.dataset.entryIndex;
}
const currency = document.getElementById("accounting-currency-" + currencyIndex);
const entry = document.getElementById("accounting-currency-" + currencyIndex + "-" + entryType + "-" + entryIndex);
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
const accountText = document.getElementById(entry.dataset.prefix + "-account-text");
const summary = document.getElementById(entry.dataset.prefix + "-summary");
const summaryText = document.getElementById(entry.dataset.prefix + "-summary-text");
const amount = document.getElementById(entry.dataset.prefix + "-amount");
const amountText = document.getElementById(entry.dataset.prefix + "-amount-text");
accountCode.value = formAccount.dataset.code;
accountCode.dataset.text = formAccount.dataset.text;
accountText.innerText = formAccount.dataset.text;
summary.value = formSummary.value;
summaryText.innerText = formSummary.value;
amount.value = formAmount.value;
amountText.innerText = formatDecimal(new Decimal(formAmount.value));
if (entryForm.dataset.entryIndex === "new") {
const btnDelete = document.getElementById(entry.dataset.prefix + "-btn-delete");
initializeJournalEntry(entry);
initializeDeleteJournalEntryButton(btnDelete);
resetDeleteJournalEntryButtons(btnDelete.dataset.sameClass);
}
updateBalance(currencyIndex, entryType);
validateJournalEntriesReal(currencyIndex, entryType);
validateBalance(currency);
}
/**
* Initializes the button to delete a journal entry.
*
* @param button {HTMLButtonElement} the button to delete a journal entry
*/
function initializeDeleteJournalEntryButton(button) {
const target = document.getElementById(button.dataset.target);
const currencyIndex = target.dataset.currencyIndex;
const entryType = target.dataset.entryType;
const currency = document.getElementById("accounting-currency-" + currencyIndex);
button.onclick = function () {
target.parentElement.removeChild(target);
resetDeleteJournalEntryButtons(button.dataset.sameClass);
updateBalance(currencyIndex, entryType);
validateJournalEntriesReal(currencyIndex, entryType);
validateBalance(currency);
};
}
/**
* Resets the status of the delete journal entry buttons.
*
* @param sameClass {string} the class of the buttons
* @private
*/
function resetDeleteJournalEntryButtons(sameClass) {
const buttons = Array.from(document.getElementsByClassName(sameClass));
if (buttons.length > 1) {
buttons.forEach(function (button) {
button.classList.remove("d-none");
});
} else {
buttons[0].classList.add("d-none");
}
}
/**
* Updates the balance.
*
* @param currencyIndex {string} the currency index.
* @param entryType {string} the journal entry type, either "debit" or "credit"
* @private
*/
function updateBalance(currencyIndex, entryType) {
const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
const amounts = Array.from(document.getElementsByClassName(prefix + "-amount"));
const totalText = document.getElementById(prefix + "-total");
let total = new Decimal("0");
amounts.forEach(function (amount) {
if (amount.value !== "") {
total = total.plus(new Decimal(amount.value));
}
});
totalText.innerText = formatDecimal(total);
}
/**
* Initializes the account selectors.
*
* @private
*/
function initializeAccountSelectors() {
const selectors = Array.from(document.getElementsByClassName("accounting-selector-modal"));
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
const formAccount = document.getElementById("accounting-entry-form-account");
selectors.forEach(function (selector) {
const more = document.getElementById(selector.dataset.prefix + "-more");
const btnClear = document.getElementById(selector.dataset.prefix + "-btn-clear");
const options = Array.from(document.getElementsByClassName(selector.dataset.prefix + "-option"));
more.onclick = function () {
more.classList.add("d-none");
filterAccountOptions(selector.dataset.prefix);
};
initializeAccountQuery(selector);
btnClear.onclick = function () {
formAccountControl.classList.remove("accounting-not-empty");
formAccount.innerText = "";
formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount();
};
options.forEach(function (option) {
option.onclick = function () {
formAccountControl.classList.add("accounting-not-empty");
formAccount.innerText = option.dataset.content;
formAccount.dataset.code = option.dataset.code;
formAccount.dataset.text = option.dataset.content;
validateJournalEntryAccount();
};
});
});
}
/**
* Initializes the query on the account options.
*
* @param selector {HTMLDivElement} the selector modal
* @private
*/
function initializeAccountQuery(selector) {
const query = document.getElementById(selector.dataset.prefix + "-selector-query");
query.addEventListener("input", function () {
filterAccountOptions(selector.dataset.prefix);
});
}
/**
* Filters the account options.
*
* @param prefix {string} the HTML ID and class prefix
* @private
*/
function filterAccountOptions(prefix) {
const query = document.getElementById(prefix + "-selector-query");
const optionList = document.getElementById(prefix + "-option-list");
if (optionList === null) {
console.log(prefix + "-option-list");
}
const options = Array.from(document.getElementsByClassName(prefix + "-option"));
const more = document.getElementById(prefix + "-more");
const queryNoResult = document.getElementById(prefix + "-option-no-result");
const codesInUse = getAccountCodeUsedInForm();
let hasAnyMatched = false;
options.forEach(function (option) {
const isMatched = shouldAccountOptionShow(option, more, codesInUse, query);
if (isMatched) {
option.classList.remove("d-none");
hasAnyMatched = true;
} else {
option.classList.add("d-none");
}
});
if (!hasAnyMatched) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
}
}
/**
* Returns whether an account option should show.
*
* @param option {HTMLLIElement} the account option
* @param more {HTMLLIElement} the more account element
* @param inUse {string[]} the account codes that are used in the form
* @param query {HTMLInputElement} the query element, if any
* @return {boolean} true if the account option should show, or false otherwise
* @private
*/
function shouldAccountOptionShow(option, more, inUse, query) {
const isQueryMatched = function () {
if (query.value === "") {
return true;
}
const queryValues = JSON.parse(option.dataset.queryValues);
for (const queryValue of queryValues) {
if (queryValue.includes(query.value)) {
return true;
}
}
return false;
};
const isMoreMatched = function () {
if (more.classList.contains("d-none")) {
return true;
}
return option.classList.contains("accounting-account-in-use") || inUse.includes(option.dataset.code);
};
return isMoreMatched() && isQueryMatched();
}
/**
* Returns the account codes that are used in the form.
*
* @return {string[]} the account codes that are used in the form
* @private
*/
function getAccountCodeUsedInForm() {
const accountCodes = Array.from(document.getElementsByClassName("accounting-account-code"));
const formAccount = document.getElementById("accounting-entry-form-account");
const inUse = [formAccount.dataset.code];
accountCodes.forEach(function (accountCode) {
inUse.push(accountCode.value);
});
return inUse
}
/**
* Initializes the form validation.
*
* @private
*/
function initializeFormValidation() {
const date = document.getElementById("accounting-date");
const note = document.getElementById("accounting-note");
const form = document.getElementById("accounting-form");
date.onchange = validateDate;
note.onchange = validateNote;
form.onsubmit = validateForm;
}
/**
* Validates the form.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateForm() {
let isValid = true;
isValid = validateDate() && isValid;
isValid = validateCurrencies() && isValid;
isValid = validateNote() && isValid;
return isValid;
}
/**
* Validates the date.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateDate() {
const field = document.getElementById("accounting-date");
const error = document.getElementById("accounting-date-error");
field.value = field.value.trim();
field.classList.remove("is-invalid");
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the date.");
return false;
}
error.innerText = "";
return true;
}
/**
* Validates the currency sub-forms.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateCurrencies() {
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
let isValid = true;
isValid = validateCurrenciesReal() && isValid;
currencies.forEach(function (currency) {
isValid = validateCurrency(currency) && isValid;
});
return isValid;
}
/**
* Validates the currency sub-forms, the validator itself.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateCurrenciesReal() {
const field = document.getElementById("accounting-currencies");
const error = document.getElementById("accounting-currencies-error");
const currencies = Array.from(document.getElementsByClassName("accounting-currency"));
if (currencies.length === 0) {
field.classList.add("is-invalid");
error.innerText = A_("Please add some currencies.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates a currency sub-form.
*
* @param currency {HTMLDivElement} the currency sub-form
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateCurrency(currency) {
const prefix = "accounting-currency-" + currency.dataset.index;
const debit = document.getElementById(prefix + "-debit");
const credit = document.getElementById(prefix + "-credit");
let isValid = true;
if (debit !== null) {
isValid = validateJournalEntries(currency, "debit") && isValid;
}
if (credit !== null) {
isValid = validateJournalEntries(currency, "credit") && isValid;
}
if (debit !== null && credit !== null) {
isValid = validateBalance(currency) && isValid;
}
return isValid;
}
/**
* Validates the journal entries in a currency sub-form.
*
* @param currency {HTMLDivElement} the currency
* @param entryType {string} the journal entry type, either "debit" or "credit"
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntries(currency, entryType) {
const currencyIndex = currency.dataset.index;
const entries = Array.from(document.getElementsByClassName("accounting-currency-" + currencyIndex + "-" + entryType));
let isValid = true;
isValid = validateJournalEntriesReal(currencyIndex, entryType) && isValid;
entries.forEach(function (entry) {
isValid = validateJournalEntry(entry) && isValid;
})
return isValid;
}
/**
* Validates the journal entries, the validator itself.
*
* @param currencyIndex {string} the currency index
* @param entryType {string} the journal entry type, either "debit" or "credit"
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntriesReal(currencyIndex, entryType) {
const prefix = "accounting-currency-" + currencyIndex + "-" + entryType;
const field = document.getElementById(prefix);
const error = document.getElementById(prefix + "-error");
const entries = Array.from(document.getElementsByClassName(prefix));
if (entries.length === 0) {
field.classList.add("is-invalid");
error.innerText = A_("Please add some journal entries.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates a journal entry sub-form in a currency sub-form.
*
* @param entry {HTMLLIElement} the journal entry
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateJournalEntry(entry) {
const control = document.getElementById(entry.dataset.prefix + "-control");
const error = document.getElementById(entry.dataset.prefix + "-error");
const accountCode = document.getElementById(entry.dataset.prefix + "-account-code");
const amount = document.getElementById(entry.dataset.prefix + "-amount");
if (accountCode.value === "") {
control.classList.add("is-invalid");
error.innerText = A_("Please select the account.");
return false;
}
if (amount.value === "") {
control.classList.add("is-invalid");
error.innerText = A_("Please fill in the amount.");
return false;
}
control.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the balance of a currency sub-form.
*
* @param currency {HTMLDivElement} the currency sub-form
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateBalance(currency) {
const prefix = "accounting-currency-" + currency.dataset.index;
const control = document.getElementById(prefix + "-control");
const error = document.getElementById(prefix + "-error");
const debit = document.getElementById(prefix + "-debit");
const debitAmounts = Array.from(document.getElementsByClassName(prefix + "-debit-amount"));
const credit = document.getElementById(prefix + "-credit");
const creditAmounts = Array.from(document.getElementsByClassName(prefix + "-credit-amount"));
if (debit !== null && credit !== null) {
let debitTotal = new Decimal("0");
debitAmounts.forEach(function (amount) {
if (amount.value !== "") {
debitTotal = debitTotal.plus(new Decimal(amount.value));
}
});
let creditTotal = new Decimal("0");
creditAmounts.forEach(function (amount) {
if (amount.value !== "") {
creditTotal = creditTotal.plus(new Decimal(amount.value));
}
});
if (!debitTotal.equals(creditTotal)) {
control.classList.add("is-invalid");
error.innerText = A_("The totals of the debit and credit amounts do not match.");
return false;
}
}
control.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the note.
*
* @return {boolean} true if valid, or false otherwise
* @private
*/
function validateNote() {
const field = document.getElementById("accounting-note");
const error = document.getElementById("accounting-note-error");
field.value = field.value
.replace(/^\s*\n/, "")
.trimEnd();
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}

View File

@ -0,0 +1,37 @@
/* The Mia! Accounting Flask Project
* transaction-order.js: The JavaScript for the transaction order
*/
/* 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/26
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
const list = document.getElementById("accounting-order-list");
if (list !== null) {
const onReorder = function () {
const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
no.value = String(i + 1);
}
};
initializeDragAndDropReordering(list, onReorder);
}
});

View File

@ -26,42 +26,48 @@ First written: 2023/1/31
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if can_edit_accounting() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}">
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
</a>
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#delete-modal">
{% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("Order") }}
</a>
{% if accounting_can_edit() %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% endif %}
</div>
{% if can_edit_accounting() %}
<div class="d-md-none material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}">
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>
{% endif %}
{% if can_edit_accounting() %}
<form id="delete-form" action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
<input id="csrf_token" type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if accounting_can_edit() %}
<form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="modal fade" id="delete-modal" tabindex="-1" aria-labelledby="delete-model-label" aria-hidden="true">
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="delete-model-label">{{ A_("Delete Account Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Account Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
{{ A_("Do you really want to delete this account?") }}
@ -76,12 +82,12 @@ First written: 2023/1/31
</form>
{% endif %}
<div class="account col-sm-6">
<div class="account-title">{{ obj.title }}</div>
<div class="account-code">{{ obj.code }}</div>
{% if obj.is_offset_needed %}
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.is_pay_off_needed %}
<div>
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
</div>
{% endif %}
<div class="small text-secondary fst-italic">

View File

@ -23,6 +23,6 @@ First written: 2023/2/1
{% block header %}{% block title %}{{ A_("%(account)s Settings", account=account) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.account.detail", account=account)|inherit_next }}{% endblock %}
{% block back_url %}{{ url_for("accounting.account.detail", account=account)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.account.update", account=account) }}{% endblock %}

View File

@ -34,16 +34,16 @@ First written: 2023/2/1
</a>
</div>
<form id="account-form" action="{% block action_url %}{% endblock %}" method="post">
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
{{ form.csrf_token }}
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="form-floating mb-3">
<input id="account-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
<div id="account-base" class="form-control clickable material-text-field {% if form.base_code.data %} not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#select-base-modal">
<label id="account-base-label" class="form-label" for="account-base">{{ A_("Base account") }}</label>
<div id="account-base-content">
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
<div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-modal">
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
<div id="accounting-base-content">
{% if form.base_code.data %}
{% if form.base_code.errors %}
{{ A_("(Unknown)") }}
@ -53,19 +53,19 @@ First written: 2023/2/1
{% endif %}
</div>
</div>
<div id="account-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
<div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<input id="account-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
<label class="form-label" for="account-title">{{ A_("Title") }}</label>
<div id="account-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
<input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
<label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
</div>
<div class="form-check form-switch mb-3">
<input id="account-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="account-is-offset-needed">
{{ A_("The entries in the account need offsets.") }}
<input id="accounting-is-pay-off-needed" class="form-check-input" type="checkbox" name="is_pay_off_needed" value="1" {% if form.is_pay_off_needed.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="accounting-is-pay-off-needed">
{{ A_("The entries in the account need pay-off.") }}
</label>
</div>
@ -76,43 +76,44 @@ First written: 2023/2/1
</button>
</div>
<div class="d-md-none material-fab">
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
<div class="modal fade" id="select-base-modal" tabindex="-1" aria-labelledby="select-base-model-label" aria-hidden="true">
<div class="modal fade" id="accounting-base-selector-modal" tabindex="-1" aria-labelledby="accounting-base-selector-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="base-selector-model-label">{{ A_("Select Base Account") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<h1 class="modal-title fs-5" id="accounting-base-selector-modal-label">{{ A_("Select Base Account") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="select-base-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search">
<label class="input-group-text" for="select-base-query">
<input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-base-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul class="list-group list-base-selector">
<ul id="accounting-base-option-list" class="list-group accounting-selector-list">
{% for base in form.base_options %}
<li id="list-group-item-base-{{ base.code }}" class="list-group-item list-group-item-base clickable" data-code="{{ base.code }}" data-content="{{ base }}">
<li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
{{ base }}
</li>
{% endfor %}
</ul>
<p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
{% if form.base_code.data %}
<button id="btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
<button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
{% else %}
<button id="btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
<button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
{% endif %}
</div>
</div>

View File

@ -21,20 +21,20 @@ First written: 2023/1/30
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Account Management") }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2">
{% if can_edit_accounting() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|append_next }}">
<i class="fa-solid fa-user-plus"></i>
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</a>
{% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search">
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
<label for="search-input" class="search-label">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search-desktop" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
@ -43,9 +43,21 @@ First written: 2023/1/30
</form>
</div>
{% if can_edit_accounting() %}
<div class="d-md-none material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.create")|append_next }}">
<div class="btn-group mb-2 d-md-none">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search-mobile" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
</a>
</div>
@ -56,10 +68,10 @@ First written: 2023/1/30
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|append_next }}">
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
{{ item }}
{% if item.is_offset_needed %}
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
{% if item.is_pay_off_needed %}
<span class="badge rounded-pill bg-info">{{ A_("Pay-off needed") }}</span>
{% endif %}
</a>
{% endfor %}

View File

@ -0,0 +1,84 @@
{#
The Mia! Accounting Flask Project
order.html: The order of the accounts under a same base account
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/2
#}
{% extends "accounting/account/include/form.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-order.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("The Accounts of %(base)s", base=base) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
{% if base.accounts|length > 1 and accounting_can_edit() %}
<form action="{{ url_for("accounting.account.sort", base=base) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
{% for account in base.accounts|sort(attribute="no") %}
<li class="list-group-item d-flex justify-content-between" data-id="{{ account.id }}">
<input id="accounting-order-{{ account.id }}-no" type="hidden" name="{{ account.id }}-no" value="{{ loop.index }}">
<div>
<span id="accounting-order-{{ account.id }}-code">{{ account.code }}</span>
{{ account.title }}
</div>
<i class="fa-solid fa-bars"></i>
</li>
{% endfor %}
</ul>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% elif base.accounts %}
<ul class="list-group mb-3">
{% for account in base.accounts|sort(attribute="no") %}
<li class="list-group-item">
{{ account }}
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -26,19 +26,19 @@ First written: 2023/2/1
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
<div class="account col-sm-6">
<div class="account-title">{{ obj.title }}</div>
<div class="account-code">{{ obj.code }}</div>
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.accounts %}
<div>
{% for account in obj.accounts %}
<a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|append_next }}">
<a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
{{ account }}
</a>
{% endfor %}

View File

@ -21,14 +21,14 @@ First written: 2023/1/26
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Base Account Managements") }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search">
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
<label for="search-input" class="search-label">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-label="{{ A_("Search") }}">
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
@ -42,7 +42,7 @@ First written: 2023/1/26
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|append_next }}">
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|accounting_append_next }}">
{{ item }}
</a>
{% endfor %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The currency creation form
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/6
#}
{% extends "accounting/currency/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}

View File

@ -0,0 +1,90 @@
{#
The Mia! Accounting Flask Project
detail.html: The currency detail
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/6
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
</a>
{% endif %}
{% if accounting_can_edit() %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% endif %}
</div>
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>
{% endif %}
{% if accounting_can_edit() %}
<form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Currency Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
{{ A_("Do you really want to delete this currency?") }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.name }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
<div class="small text-secondary fst-italic">
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
<div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,30 @@
{#
The Mia! Accounting Flask Project
edit.html: The currency edit form
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/6
#}
{% extends "accounting/currency/include/form.html" %}
{% block header %}{% block title %}{{ A_("%(currency)s Settings", currency=currency) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.currency.detail", currency=currency)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.currency.update", currency=currency) }}{% endblock %}
{% block original_code %}{{ currency.code }}{% endblock %}

View File

@ -0,0 +1,68 @@
{#
The Mia! Accounting Flask Project
form.html: The currency form
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/6
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/currency-form.js") }}"></script>
{% endblock %}
{% block content %}
<div class="btn-group btn-actions mb-3">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
{{ form.csrf_token }}
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="form-floating mb-3">
<input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ "" if form.code.data is none else form.code.data }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
<label class="form-label" for="accounting-code">{{ A_("Code") }}</label>
<div id="accounting-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ "" if form.name.data is none else form.name.data }}" placeholder=" " required="required">
<label class="form-label" for="accounting-name">{{ A_("Name") }}</label>
<div id="accounting-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
</div>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,80 @@
{#
The Mia! Accounting Flask Project
list.html: The currency list
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/6
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</a>
{% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search-desktop" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
<div class="btn-group mb-2 d-md-none">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search-mobile" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
</a>
</div>
{% endif %}
{% if list %}
{% include "accounting/include/pagination.html" %}
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.currency.detail", currency=item)|accounting_append_next }}">
{{ item }}
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -19,13 +19,20 @@ nav.html: The navigation menu for the accounting application.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/1/26
#}
{% if can_view_accounting() %}
{# <ul> For SonarQube not to complain about incorrect HTML #}
{% if accounting_can_view() %}
<li class="nav-item dropdown">
<span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
<i class="fa-solid fa-gear"></i>
<i class="fa-solid fa-file-invoice-dollar"></i>
{{ A_("Accounting") }}
</span>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.transaction.") %} active {% endif %}" href="{{ url_for("accounting.transaction.list") }}">
<i class="fa-solid fa-receipt"></i>
{{ A_("Transactions") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
<i class="fa-solid fa-list"></i>
@ -38,6 +45,13 @@ First written: 2023/1/26
{{ A_("Base Accounts") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
<i class="fa-solid fa-money-bill-wave"></i>
{{ A_("Currencies") }}
</a>
</li>
</ul>
</li>
{% endif %}
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -19,10 +19,10 @@ pagination.html: The pagination navigation bar.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/1/26
#}
{% if pagination.is_needed %}
<nav aria-label="Page navigation">
{% if pagination.is_paged %}
<nav aria-label="{{ A_("Page navigation") }}">
<ul class="pagination">
{% for link in pagination.page_links %}
{% for link in pagination.pages %}
{% if link.uri is none %}
<li class="page-item disabled {% if not link.is_for_mobile %} d-none d-md-inline {% endif %}">
<span class="page-link">
@ -42,7 +42,7 @@ First written: 2023/1/26
{{ pagination.page_size }}
</div>
<ul class="dropdown-menu">
{% for link in pagination.page_sizes %}
{% for link in pagination.page_size_options %}
<li>
<a class="dropdown-item {% if link.is_current %} active {% endif %}" href="{{ link.uri }}">
{{ link.text }}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The cash expense transaction creation form
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/25
#}
{% extends "accounting/transaction/expense/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Cash Expense Transaction") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}

View File

@ -0,0 +1,53 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% block transaction_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% for entry in currency.debit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
</div>
</li>
</ul>
</div>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
edit.html: The cash expense transaction edit form
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/25
#}
{% extends "accounting/transaction/expense/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}

View File

@ -0,0 +1,83 @@
{#
The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the cash expense transaction form
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/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="mb-3">
<div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Content") }}</label>
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list">
{% for entry_form in debit_forms %}
{% with currency_index = currency_index,
entry_type = "debit",
entry_index = loop.index,
entry_id = entry_form.eid.data,
only_one_entry_form = debit_forms|length == 1,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div>
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
</div>

View File

@ -0,0 +1,50 @@
{#
The Mia! Accounting Flask Project
form.html: The cash expense transaction form
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/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
{% for currency_form in form.currencies %}
{% with currency_index = loop.index,
only_one_currency_form = form.currencies|length == 1,
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_txn_format_amount %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
debit_total = "-" %}
{% include "accounting/transaction/expense/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/debit-account-modal.html" %}
{% endblock %}

View File

@ -0,0 +1,39 @@
{#
The Mia! Accounting Flask Project
add-new-material-fab.html: The material floating action buttons to add a new transaction
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/25
#}
{% if accounting_can_edit() %}
<div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
<div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.expense)|accounting_append_next }}">
{{ A_("Cash expense") }}
</a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.income)|accounting_append_next }}">
{{ A_("Cash income") }}
</a>
<a class="btn rounded-pill" href="{{ url_for("accounting.transaction.create", txn_type=types.transfer)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</div>
<button id="accounting-btn-material-fab-speed-dial" class="btn btn-primary rounded-circle accounting-btn-material-fab" type="button" data-target="accounting-material-fab-speed-dial">
<i class="fas fa-plus"></i>
</button>
</div>
{% endif %}

View File

@ -0,0 +1,54 @@
{#
The Mia! Accounting Flask Project
credit-modals.html: The modals for the credit journal entry sub-form
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/25
#}
<div id="accounting-credit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-credit-account" tabindex="-1" aria-labelledby="accounting-credit-account-selector-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-credit-account-selector-modal-label">{{ A_("Select Credit Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-credit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-credit-account-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-credit-account-option-list" class="list-group accounting-selector-list">
{% for account in form.credit_account_options %}
<li id="accounting-credit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-credit-account-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-credit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-credit-account-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-credit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,54 @@
{#
The Mia! Accounting Flask Project
credit-modals.html: The modals for the debit journal entry sub-form
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/25
#}
<div id="accounting-debit-account-selector-modal" class="modal fade accounting-selector-modal" data-prefix="accounting-debit-account" tabindex="-1" aria-labelledby="accounting-debit-account-selector-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-debit-account-selector-modal-label">{{ A_("Select Debit Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-debit-account-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-debit-account-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-debit-account-option-list" class="list-group accounting-selector-list">
{% for account in form.debit_account_options %}
<li id="accounting-debit-account-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-debit-account-option {% if account.is_in_use %} accounting-account-in-use {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
{{ account }}
</li>
{% endfor %}
<li id="accounting-debit-account-more" class="list-group-item accounting-clickable">{{ A_("More…") }}</li>
</ul>
<p id="accounting-debit-account-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Cancel") }}</button>
<button id="accounting-debit-account-btn-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,111 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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/26
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
</a>
{% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.order", txn_date=obj.date)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("Order") }}
</a>
{% if accounting_can_edit() %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% endif %}
</div>
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.edit", txn=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>
{% endif %}
{% if accounting_can_edit() %}
<form action="{{ url_for("accounting.transaction.delete", txn=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-modal-label">{{ A_("Delete Transaction Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
{{ A_("Do you really want to delete this transaction?") }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
<div class="accounting-transaction-card">
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">{{ obj }}</h2>
</div>
<div class="mb-3">
{{ obj.date|accounting_txn_format_date }}
</div>
{% block transaction_currencies %}{% endblock %}
{% if obj.note %}
<div class="card mb-3">
<div class="card-body">
<i class="far fa-comment-dots"></i>
{{ obj.note|accounting_txn_text2html|safe }}
</div>
</div>
{% endif %}
<div class="small text-secondary fst-italic">
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
<div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,58 @@
{#
The Mia! Accounting Flask Project
entry-form-modal.html: The modal of the journal entry sub-form
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/25
#}
<form id="accounting-entry-form" data-currency-index="" data-entry-type="" data-entry-index="">
<div id="accounting-entry-form-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-entry-form-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-entry-form-modal-label">{{ A_("Journal Entry Content") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div id="accounting-entry-form-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="">
<label class="form-label" for="accounting-entry-form-account">{{ A_("Account") }}</label>
<div id="accounting-entry-form-account" data-code="" data-text=""></div>
</div>
<div id="accounting-entry-form-account-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-entry-form-summary" class="form-control" type="text" value="" placeholder=" ">
<label for="accounting-entry-form-summary">{{ A_("Summary") }}</label>
<div id="accounting-entry-form-summary-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-entry-form-amount" class="form-control" type="number" value="" min="0.01" max="" step="0.01" placeholder=" " required="required">
<label for="accounting-entry-form-amount">{{ A_("Amount") }}</label>
<div id="accounting-entry-form-amount-error" class="invalid-feedback"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button id="accounting-entry-form-btn-save" type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,48 @@
{#
The Mia! Accounting Flask Project
entry-sub-form.html: The journal entry sub-form in the transaction form
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/25
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" class="list-group-item list-group-item-action d-flex justify-content-between accounting-entry accounting-currency-{{ currency_index }}-{{ entry_type }}" data-currency-index="{{ currency_index }}" data-entry-type="{{ entry_type }}" data-entry-index="{{ entry_index }}" data-account-modal="#accounting-{{ entry_type }}-account-selector-modal" data-prefix="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}">
{% if entry_id %}
<input type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-eid" value="{{ entry_id }}">
{% endif %}
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-no" value="{{ entry_index }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-code" class="accounting-account-code" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account_code" value="{{ account_code_data }}" data-text="{{ account_text }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary" value="{{ summary_data }}">
<input id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" class="accounting-currency-{{ currency_index }}-{{ entry_type }}-amount" type="hidden" name="currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount" value="{{ amount_data }}">
<div class="accounting-entry-content">
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-control" class="form-control clickable d-flex justify-content-between accounting-entry-control {% if entry_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-account-text" class="small">{{ account_text }}</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-summary-text">{{ "" if summary_data is none else summary_data }}</div>
</div>
<div><span id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-amount-text" class="badge rounded-pill bg-primary">{{ amount_data }}</span></div>
</div>
<div id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-error" class="invalid-feedback">{% if entry_errors %}{{ entry_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}-btn-delete" class="btn btn-danger rounded-circle accounting-btn-delete-entry accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry {% if only_one_entry_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}-{{ entry_type }}-{{ entry_index }}" data-same-class="accounting-currency-{{ currency_index }}-{{ entry_type }}-btn-delete-entry">
<i class="fas fa-minus"></i>
</button>
</div>
</li>
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -0,0 +1,90 @@
{#
The Mia! Accounting Flask Project
form.html: The transfer transaction form
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/26
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-form.js") }}"></script>
{% endblock %}
{% block content %}
<div class="btn-group btn-actions mb-3">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post" data-currency-template="{{ currency_template }}" data-entry-template="{{ entry_template }}">
{{ form.csrf_token }}
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="form-floating mb-3">
<input id="accounting-date" class="form-control {% if form.date.errors %} is-invalid {% endif %}" type="date" name="date" value="{{ "" if form.date.data is none else form.date.data }}" placeholder=" " required="required">
<label class="form-label" for="accounting-date">{{ A_("Date") }}</label>
<div id="accounting-date-error" class="invalid-feedback">{% if form.date.errors %}{{ form.date.errors[0] }}{% endif %}</div>
</div>
<div class="mb-3">
<div id="accounting-currencies" class="form-control accounting-material-text-field accounting-not-empty {% if form.currencies_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currencies">{{ A_("Content") }}</label>
<div id="accounting-currency-list" class="mt-2">
{% block currency_sub_forms %}{% endblock %}
</div>
<div>
<button id="accounting-btn-new-currency" class="btn btn-primary" type="button">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currencies-error" class="invalid-feedback">{% if form.currencies_errors %}{{ form.currencies_errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<textarea id="accounting-note" class="form-control form-control-lg {% if form.note.errors %} is-invalid {% endif %}" name="note" rows="5" placeholder=" ">{{ "" if form.note.data is none else form.note.data }}</textarea>
<label class="form-label" for="accounting-note">{{ A_("Note") }}</label>
<div id="accounting-note-error" class="invalid-feedback">{% if form.note.errors %}{{ form.note.errors[0] }}{% endif %}</div>
</div>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% include "accounting/transaction/include/entry-form-modal.html" %}
{% block account_selector_modals %}{% endblock %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The cash income transaction creation form
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/25
#}
{% extends "accounting/transaction/income/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Cash Income Transaction") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}

View File

@ -0,0 +1,53 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% block transaction_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Content") }}</li>
{% for entry in currency.credit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
</div>
</li>
</ul>
</div>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
edit.html: The cash income transaction edit form
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/25
#}
{% extends "accounting/transaction/income/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}

View File

@ -0,0 +1,83 @@
{#
The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the cash income transaction form
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/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="mb-3">
<div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Content") }}</label>
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
{% for entry_form in credit_forms %}
{% with currency_index = currency_index,
entry_type = "credit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div>
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
</div>

View File

@ -0,0 +1,50 @@
{#
The Mia! Accounting Flask Project
form.html: The cash income transaction form
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/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
{% for currency_form in form.currencies %}
{% with currency_index = loop.index,
only_one_currency_form = form.currencies|length == 1,
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_txn_format_amount %}
{% include "accounting/transaction/income/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
credit_total = "-" %}
{% include "accounting/transaction/income/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/credit-account-modal.html" %}
{% endblock %}

View File

@ -0,0 +1,96 @@
{#
The Mia! Accounting Flask Project
list.html: The transaction list
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/18
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Transaction Management") }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2 d-none d-md-inline-flex">
{% if accounting_can_edit() %}
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.expense)|accounting_append_next }}">
{{ A_("Cash Expense") }}</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.income)|accounting_append_next }}">
{{ A_("Cash Income") }}
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for("accounting.transaction.create", txn_type=types.transfer)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</li>
</ul>
</div>
{% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.transaction.list") }}" method="get" role="search" aria-label="{{ A_("Search for Desktop") }}">
<input id="accounting-search-desktop" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search-desktop" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
<div class="btn-group mb-2 d-md-none">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.transaction.list") }}" method="get" role="search" aria-label="{{ A_("Search for Mobile") }}">
<input id="accounting-search-mobile" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required">
<label for="accounting-search-mobile" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
{% include "accounting/transaction/include/add-new-material-fab.html" %}
{% if list %}
{% include "accounting/include/pagination.html" %}
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.transaction.detail", txn=item)|accounting_append_next }}">
{{ item.date|accounting_txn_format_date }} {{ item }}
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,83 @@
{#
The Mia! Accounting Flask Project
order.html: The order of the transactions in a same day
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/26
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/transaction-order.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Transactions on %(date)s", date=date) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.transaction.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
{% if list|length > 1 and accounting_can_edit() %}
<form action="{{ url_for("accounting.transaction.sort", txn_date=date) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<ul id="accounting-order-list" class="list-group mb-3">
{% for item in list %}
<li class="list-group-item d-flex justify-content-between" data-id="{{ item.id }}">
<input id="accounting-order-{{ item.id }}-no" type="hidden" name="{{ item.id }}-no" value="{{ loop.index }}">
<div>
{{ item }}
</div>
<i class="fa-solid fa-bars"></i>
</li>
{% endfor %}
</ul>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% elif list %}
<ul class="list-group mb-3">
{% for item in list %}
<li class="list-group-item">
{{ item }}
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The transfer transaction creation form
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/25
#}
{% extends "accounting/transaction/transfer/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Transfer Transaction") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.transaction.list") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.store", txn_type=txn_type) }}{% endblock %}

View File

@ -0,0 +1,84 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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/26
#}
{% extends "accounting/transaction/include/detail.html" %}
{% block transaction_currencies %}
{% for currency in obj.currencies %}
<div class="mb-3">
<div class="mb-2 fw-bolder">{{ currency.name }}</div>
<div class="row">
{# The debit entries #}
<div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Debit") }}</li>
{% for entry in currency.debit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
</div>
</li>
</ul>
</div>
{# The credit entries #}
<div class="col-sm-6 mb-2">
<ul class="list-group accounting-list-group-stripped accounting-list-group-hover">
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-header">{{ A_("Credit") }}</li>
{% for entry in currency.credit %}
<li class="list-group-item accounting-transaction-entry">
<div class="d-flex justify-content-between">
<div>
<div class="small">{{ entry.account }}</div>
{% if entry.summary is not none %}
<div>{{ entry.summary }}</div>
{% endif %}
</div>
<div>{{ entry.amount|accounting_txn_format_amount }}</div>
</div>
</li>
{% endfor %}
<li class="list-group-item accounting-transaction-entry accounting-transaction-entry-total">
<div class="d-flex justify-content-between">
<div>{{ _("Total") }}</div>
<div>{{ currency.debit_total|accounting_txn_format_amount }}</div>
</div>
</li>
</ul>
</div>
</div>
</div>
{% endfor %}
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
edit.html: The transfer transaction edit form
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/25
#}
{% extends "accounting/transaction/transfer/include/form.html" %}
{% block header %}{% block title %}{{ A_("Editing %(txn)s", txn=txn) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.transaction.detail", txn=txn)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.transaction.update", txn=txn)|accounting_txn_with_type }}{% endblock %}

View File

@ -0,0 +1,126 @@
{#
The Mia! Accounting Flask Project
currency-sub-form.html: The currency sub-form in the transfer transaction form
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/25
#}
<div id="accounting-currency-{{ currency_index }}" class="mb-3 accounting-currency" data-index="{{ currency_index }}" data-prefix="accounting-currency-{{ currency_index }}">
<input id="accounting-currency-{{ currency_index }}-no" type="hidden" name="currency-{{ currency_index }}-no" value="{{ currency_index }}">
<div id="accounting-currency-{{ currency_index }}-control" class="form-control accounting-currency-control {% if currency_errors %} is-invalid {% endif %}">
<div class="d-flex justify-content-between mt-2 mb-3">
<div class="form-floating accounting-currency-content">
<select id="accounting-currency-{{ currency_index }}-code" class="form-select" name="currency-{{ currency_index }}-code">
{% for currency in accounting_txn_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == currency_code_data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-currency-{{ currency_index }}-code">{{ A_("Currency") }}</label>
<div id="accounting-currency-{{ currency_index }}-code-error" class="invalid-feedback">{% if currency_code_errors %}{{ currency_code_errors[0] }}{% endif %}</div>
</div>
<div>
<button id="accounting-btn-delete-currency-{{ currency_index }}" class="btn btn-danger rounded-circle accounting-btn-delete-currency {% if only_one_currency_form %} d-none {% endif %}" type="button" data-target="accounting-currency-{{ currency_index }}">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="row">
{# The debit entries #}
<div class="col-sm-6 mb-3">
<div id="accounting-currency-{{ currency_index }}-debit" class="form-control accounting-material-text-field accounting-not-empty {% if debit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-debit">{{ A_("Debit") }}</label>
<ul id="accounting-currency-{{ currency_index }}-debit-list" class="list-group accounting-entry-list accounting-currency-{{ currency_index }}-entry-list">
{% for entry_form in debit_forms %}
{% with currency_index = currency_index,
entry_type = "debit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-debit-total" class="badge rounded-pill bg-primary">{{ debit_total }}</span></div>
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="debit" data-entry-index="new" data-account-modal="#accounting-debit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-debit-error" class="invalid-feedback">{% if debit_errors %}{{ debit_errors[0] }}{% endif %}</div>
</div>
{# The credit entries #}
<div class="col-sm-6 mb-3">
<div id="accounting-currency-{{ currency_index }}-credit" class="form-control accounting-material-text-field accounting-not-empty {% if credit_errors %} is-invalid {% endif %}">
<label class="form-label" for="accounting-currency-{{ currency_index }}-credit">{{ A_("Credit") }}</label>
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
{% for entry_form in credit_forms %}
{% with currency_index = currency_index,
entry_id = entry_form.eid.data,
entry_type = "credit",
entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1,
account_code_data = "" if entry_form.account_code.data is none else entry_form.account_code.data,
account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text,
summary_data = "" if entry_form.summary.data is none else entry_form.summary.data,
summary_errors = entry_form.summary.errors,
amount_data = "" if entry_form.amount.data is none else entry_form.amount.data,
amount_errors = entry_form.amount.errors,
amount_text = entry_form.amount.data|accounting_txn_format_amount,
entry_errors = entry_form.all_errors %}
{% include "accounting/transaction/include/form-entry-item.html" %}
{% endwith %}
{% endfor %}
</ul>
<div class="d-flex justify-content-between mb-2">
<div>{{ A_("Total") }}</div>
<div><span id="accounting-currency-{{ currency_index }}-credit-total" class="badge rounded-pill bg-primary">{{ credit_total }}</span></div>
</div>
<div>
<button class="btn btn-primary accounting-btn-new-entry accounting-currency-{{ currency_index }}-btn-new-entry" type="button" data-currency-index="{{ currency_index }}" data-entry-type="credit" data-entry-index="new" data-account-modal="#accounting-credit-account-selector-modal" data-bs-toggle="modal" data-bs-target="#accounting-entry-form-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-credit-error" class="invalid-feedback">{% if credit_errors %}{{ credit_errors[0] }}{% endif %}</div>
</div>
</div>
</div>
<div id="accounting-currency-{{ currency_index }}-error" class="invalid-feedback">{% if currency_errors %}{{ currency_errors[0] }}{% endif %}</div>
</div>

View File

@ -0,0 +1,55 @@
{#
The Mia! Accounting Flask Project
form.html: The transfer transaction form
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/25
#}
{% extends "accounting/transaction/include/form.html" %}
{% block currency_sub_forms %}
{% if form.currencies %}
{% for currency_form in form.currencies %}
{% with currency_index = loop.index,
only_one_currency_form = form.currencies|length == 1,
currency_errors = currency_form.whole_form.errors,
currency_code_data = currency_form.code.data,
currency_code_errors = currency_form.code.errors,
debit_forms = currency_form.debit,
debit_errors = currency_form.debit_errors,
debit_total = currency_form.form.debit_total|accounting_txn_format_amount,
credit_forms = currency_form.credit,
credit_errors = currency_form.credit_errors,
credit_total = currency_form.form.credit_total|accounting_txn_format_amount %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
{% endwith %}
{% endfor %}
{% else %}
{% with currency_index = 1,
only_one_currency_form = True,
currency_code_data = accounting_txn_default_currency_code(),
debit_total = "-",
credit_total = "-" %}
{% include "accounting/transaction/transfer/include/form-currency-item.html" %}
{% endwith %}
{% endif %}
{% endblock %}
{% block account_selector_modals %}
{% include "accounting/transaction/include/debit-account-modal.html" %}
{% include "accounting/transaction/include/credit-account-modal.html" %}
{% endblock %}

View File

@ -0,0 +1,37 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# 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 transaction management.
"""
from flask import Flask, Blueprint
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .converters import TransactionConverter, TransactionTypeConverter, \
DateConverter
app.url_map.converters["transaction"] = TransactionConverter
app.url_map.converters["transactionType"] = TransactionTypeConverter
app.url_map.converters["date"] = DateConverter
from .views import bp as transaction_bp
bp.register_blueprint(transaction_bp, url_prefix="/transactions")

View File

@ -0,0 +1,100 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The path converters for the transaction management.
"""
from datetime import date
from flask import abort
from werkzeug.routing import BaseConverter
from accounting import db
from accounting.models import Transaction
from accounting.transaction.dispatcher import TransactionType, \
TXN_TYPE_DICT
class TransactionConverter(BaseConverter):
"""The transaction converter to convert the transaction ID from and to the
corresponding transaction in the routes."""
def to_python(self, value: str) -> Transaction:
"""Converts a transaction ID to a transaction.
:param value: The transaction ID.
:return: The corresponding transaction.
"""
transaction: Transaction | None = db.session.get(Transaction, value)
if transaction is None:
abort(404)
return transaction
def to_url(self, value: Transaction) -> str:
"""Converts a transaction to its ID.
:param value: The transaction.
:return: The ID.
"""
return str(value.id)
class TransactionTypeConverter(BaseConverter):
"""The transaction converter to convert the transaction type ID from and to
the corresponding transaction type in the routes."""
def to_python(self, value: str) -> TransactionType:
"""Converts a transaction ID to a transaction.
:param value: The transaction ID.
:return: The corresponding transaction.
"""
txn_type: TransactionType | None = TXN_TYPE_DICT.get(value)
if txn_type is None:
abort(404)
return txn_type
def to_url(self, value: TransactionType) -> str:
"""Converts a transaction type to its ID.
:param value: The transaction type.
:return: The ID.
"""
return str(value.ID)
class DateConverter(BaseConverter):
"""The date converter to convert the ISO date from and to the
corresponding date in the routes."""
def to_python(self, value: str) -> date:
"""Converts an ISO date to a date.
:param value: The ISO date.
:return: The corresponding date.
"""
try:
return date.fromisoformat(value)
except ValueError:
abort(404)
def to_url(self, value: date) -> str:
"""Converts a date to its ISO date.
:param value: The date.
:return: The ISO date.
"""
return value.isoformat()

View File

@ -0,0 +1,344 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# 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 transaction type dispatcher.
"""
import typing as t
from abc import ABC, abstractmethod
from flask import render_template, request, abort
from flask_wtf import FlaskForm
from accounting.models import Transaction
from .forms import TransactionForm, IncomeTransactionForm, \
ExpenseTransactionForm, TransferTransactionForm
from .template import default_currency_code
class TransactionType(ABC):
"""An abstract transaction type."""
ID: str = ""
"""The transaction type ID."""
CHECK_ORDER: int = -1
"""The order when checking the transaction type."""
@property
@abstractmethod
def form(self) -> t.Type[TransactionForm]:
"""Returns the form class.
:return: The form class.
"""
@abstractmethod
def render_create_template(self, form: FlaskForm) -> str:
"""Renders the template for the form to create a transaction.
:param form: The transaction form.
:return: the form to create a transaction.
"""
@abstractmethod
def render_detail_template(self, txn: Transaction) -> str:
"""Renders the template for the detail page.
:param txn: The transaction.
:return: the detail page.
"""
@abstractmethod
def render_edit_template(self, txn: Transaction, form: FlaskForm) -> str:
"""Renders the template for the form to edit a transaction.
:param txn: The transaction.
:param form: The form.
:return: the form to edit a transaction.
"""
@abstractmethod
def is_my_type(self, txn: Transaction) -> bool:
"""Checks and returns whether the transaction belongs to the type.
:param txn: The transaction.
:return: True if the transaction belongs to the type, or False
otherwise.
"""
@property
def _entry_template(self) -> str:
"""Renders and returns the template for the journal entry sub-form.
:return: The template for the journal entry sub-form.
"""
return render_template(
"accounting/transaction/include/form-entry-item.html",
currency_index="CURRENCY_INDEX",
entry_type="ENTRY_TYPE",
entry_index="ENTRY_INDEX")
class IncomeTransaction(TransactionType):
"""An income transaction."""
ID: str = "income"
"""The transaction type ID."""
CHECK_ORDER: int = 2
"""The order when checking the transaction type."""
@property
def form(self) -> t.Type[TransactionForm]:
"""Returns the form class.
:return: The form class.
"""
return IncomeTransactionForm
def render_create_template(self, form: IncomeTransactionForm) -> str:
"""Renders the template for the form to create a transaction.
:param form: The transaction form.
:return: the form to create a transaction.
"""
return render_template("accounting/transaction/income/create.html",
form=form, txn_type=self,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def render_detail_template(self, txn: Transaction) -> str:
"""Renders the template for the detail page.
:param txn: The transaction.
:return: the detail page.
"""
return render_template("accounting/transaction/income/detail.html",
obj=txn)
def render_edit_template(self, txn: Transaction,
form: IncomeTransactionForm) -> str:
"""Renders the template for the form to edit a transaction.
:param txn: The transaction.
:param form: The form.
:return: the form to edit a transaction.
"""
return render_template("accounting/transaction/income/edit.html",
txn=txn, form=form,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def is_my_type(self, txn: Transaction) -> bool:
"""Checks and returns whether the transaction belongs to the type.
:param txn: The transaction.
:return: True if the transaction belongs to the type, or False
otherwise.
"""
return txn.is_cash_income
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/transaction/income/include/form-currency-item.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
credit_total="-")
class ExpenseTransaction(TransactionType):
"""An expense transaction."""
ID: str = "expense"
"""The transaction type ID."""
CHECK_ORDER: int = 1
"""The order when checking the transaction type."""
@property
def form(self) -> t.Type[TransactionForm]:
"""Returns the form class.
:return: The form class.
"""
return ExpenseTransactionForm
def render_create_template(self, form: ExpenseTransactionForm) -> str:
"""Renders the template for the form to create a transaction.
:param form: The transaction form.
:return: the form to create a transaction.
"""
return render_template("accounting/transaction/expense/create.html",
form=form, txn_type=self,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def render_detail_template(self, txn: Transaction) -> str:
"""Renders the template for the detail page.
:param txn: The transaction.
:return: the detail page.
"""
return render_template("accounting/transaction/expense/detail.html",
obj=txn)
def render_edit_template(self, txn: Transaction,
form: ExpenseTransactionForm) -> str:
"""Renders the template for the form to edit a transaction.
:param txn: The transaction.
:param form: The form.
:return: the form to edit a transaction.
"""
return render_template("accounting/transaction/expense/edit.html",
txn=txn, form=form,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def is_my_type(self, txn: Transaction) -> bool:
"""Checks and returns whether the transaction belongs to the type.
:param txn: The transaction.
:return: True if the transaction belongs to the type, or False
otherwise.
"""
return txn.is_cash_expense
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/transaction/expense/include/form-currency-item.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
debit_total="-")
class TransferTransaction(TransactionType):
"""A transfer transaction."""
ID: str = "transfer"
"""The transaction type ID."""
CHECK_ORDER: int = 3
"""The order when checking the transaction type."""
@property
def form(self) -> t.Type[TransactionForm]:
"""Returns the form class.
:return: The form class.
"""
return TransferTransactionForm
def render_create_template(self, form: TransferTransactionForm) -> str:
"""Renders the template for the form to create a transaction.
:param form: The transaction form.
:return: the form to create a transaction.
"""
return render_template("accounting/transaction/transfer/create.html",
form=form, txn_type=self,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def render_detail_template(self, txn: Transaction) -> str:
"""Renders the template for the detail page.
:param txn: The transaction.
:return: the detail page.
"""
return render_template("accounting/transaction/transfer/detail.html",
obj=txn)
def render_edit_template(self, txn: Transaction,
form: TransferTransactionForm) -> str:
"""Renders the template for the form to edit a transaction.
:param txn: The transaction.
:param form: The form.
:return: the form to edit a transaction.
"""
return render_template("accounting/transaction/transfer/edit.html",
txn=txn, form=form,
currency_template=self.__currency_template,
entry_template=self._entry_template)
def is_my_type(self, txn: Transaction) -> bool:
"""Checks and returns whether the transaction belongs to the type.
:param txn: The transaction.
:return: True if the transaction belongs to the type, or False
otherwise.
"""
return True
@property
def __currency_template(self) -> str:
"""Renders and returns the template for the currency sub-form.
:return: The template for the currency sub-form.
"""
return render_template(
"accounting/transaction/transfer/include/form-currency-item.html",
currency_index="CURRENCY_INDEX",
currency_code_data=default_currency_code(),
debit_total="-", credit_total="-")
class TransactionTypes:
"""The transaction types, as object properties."""
def __init__(self, income: IncomeTransaction, expense: ExpenseTransaction,
transfer: TransferTransaction):
"""Constructs the transaction types as object properties.
:param income: The income transaction type.
:param expense: The expense transaction type.
:param transfer: The transfer transaction type.
"""
self.income: IncomeTransaction = income
self.expense: ExpenseTransaction = expense
self.transfer: TransferTransaction = transfer
TXN_TYPE_DICT: dict[str, TransactionType] \
= {x.ID: x() for x in {IncomeTransaction,
ExpenseTransaction,
TransferTransaction}}
"""The transaction types, as a dictionary."""
TXN_TYPE_OBJ: TransactionTypes = TransactionTypes(**TXN_TYPE_DICT)
"""The transaction types, as an object."""
def get_txn_type(txn: Transaction) -> TransactionType:
"""Returns the transaction type that may be specified in the "as" query
parameter. If it is not specified, check the transaction type from the
transaction.
:param txn: The transaction.
:return: None.
"""
if "as" in request.args:
if request.args["as"] not in TXN_TYPE_DICT:
abort(404)
return TXN_TYPE_DICT[request.args["as"]]
for txn_type in sorted(TXN_TYPE_DICT.values(),
key=lambda x: x.CHECK_ORDER):
if txn_type.is_my_type(txn):
return txn_type

View File

@ -0,0 +1,832 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# 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 forms for the transaction management.
"""
from __future__ import annotations
import re
import typing as t
from abc import ABC, abstractmethod
from datetime import date
from decimal import Decimal
import sqlalchemy as sa
from flask import request
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import DateField, StringField, FieldList, FormField, \
IntegerField, TextAreaField, DecimalField, BooleanField
from wtforms.validators import DataRequired, ValidationError
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Transaction, Account, JournalEntry, \
TransactionCurrency, Currency
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text, strip_multiline_text
from accounting.utils.user import get_current_user_pk
MISSING_CURRENCY: LazyString = lazy_gettext("Please select the currency.")
"""The error message when the currency code is empty."""
MISSING_ACCOUNT: LazyString = lazy_gettext("Please select the account.")
"""The error message when the account code is empty."""
class NeedSomeCurrencies:
"""The validator to check if there is any currency sub-form."""
def __call__(self, form: CurrencyForm, field: FieldList) \
-> None:
if len(field) == 0:
raise ValidationError(lazy_gettext(
"Please add some currencies."))
class CurrencyExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if db.session.get(Currency, field.data) is None:
raise ValidationError(lazy_gettext(
"The currency does not exist."))
class NeedSomeJournalEntries:
"""The validator to check if there is any journal entry sub-form."""
def __call__(self, form: TransferCurrencyForm, field: FieldList) \
-> None:
if len(field) == 0:
raise ValidationError(lazy_gettext(
"Please add some journal entries."))
class AccountExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if Account.find_by_code(field.data) is None:
raise ValidationError(lazy_gettext(
"The account does not exist."))
class PositiveAmount:
"""The validator to check if the amount is positive."""
def __call__(self, form: FlaskForm, field: DecimalField) -> None:
if field.data is None:
return
if field.data <= 0:
raise ValidationError(lazy_gettext(
"Please fill in a positive amount."))
class IsDebitAccount:
"""The validator to check if the account is for debit journal entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(lazy_gettext(
"This account is not for debit entries."))
class JournalEntryForm(FlaskForm):
"""The base form to create or edit a journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField()
"""The account code."""
amount = DecimalField()
"""The amount."""
@property
def account_text(self) -> str:
"""Returns the text representation of the account.
:return: The text representation of the account.
"""
if self.account_code.data is None:
return ""
account: Account | None = Account.find_by_code(self.account_code.data)
if account is None:
return ""
return str(account)
@property
def all_errors(self) -> list[str | LazyString]:
"""Returns all the errors of the form.
:return: All the errors of the form.
"""
all_errors: list[str | LazyString] = []
for key in self.errors:
if key != "csrf_token":
all_errors.extend(self.errors[key])
return all_errors
class DebitEntryForm(JournalEntryForm):
"""The form to create or edit a debit journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_ACCOUNT),
AccountExists(),
IsDebitAccount()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
:param obj: The journal entry object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = True
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
class IsCreditAccount:
"""The validator to check if the account is for credit journal entries."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if re.match(r"^(?:[123489]|7[1234])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"):
return
raise ValidationError(lazy_gettext(
"This account is not for credit entries."))
class CreditEntryForm(JournalEntryForm):
"""The form to create or edit a credit journal entry."""
eid = IntegerField()
"""The existing journal entry ID."""
no = IntegerField()
"""The order in the currency."""
account_code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_ACCOUNT),
AccountExists(),
IsCreditAccount()])
"""The account code."""
summary = StringField(filters=[strip_text])
"""The summary."""
amount = DecimalField(validators=[PositiveAmount()])
"""The amount."""
def populate_obj(self, obj: JournalEntry) -> None:
"""Populates the form data into a journal entry object.
:param obj: The journal entry object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
obj.account_id = Account.find_by_code(self.account_code.data).id
obj.summary = self.summary.data
obj.is_debit = False
obj.amount = self.amount.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency in a transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField()
"""The currency code."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
class TransactionForm(FlaskForm):
"""The base form to create or edit a transaction."""
date = DateField()
"""The date."""
currencies = FieldList(FormField(CurrencyForm))
"""The journal entries categorized by their currencies."""
note = TextAreaField()
"""The note."""
def __init__(self, *args, **kwargs):
"""Constructs a base transaction form.
:param args: The arguments.
:param kwargs: The keyword arguments.
"""
super().__init__(*args, **kwargs)
self.is_modified: bool = False
"""Whether the transaction is modified during populate_obj()."""
self.collector: t.Type[JournalEntryCollector] = JournalEntryCollector
"""The journal entry collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should
provide their own collectors."""
self.__in_use_account_id: set[int] | None = None
"""The ID of the accounts that are in use."""
def populate_obj(self, obj: Transaction) -> None:
"""Populates the form data into a transaction object.
:param obj: The transaction object.
:return: None.
"""
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(Transaction)
self.__set_date(obj, self.date.data)
obj.note = self.note.data
entries: list[JournalEntry] = obj.entries
collector_cls: t.Type[JournalEntryCollector] = self.collector
collector: collector_cls = collector_cls(self, obj.id, entries,
obj.currencies)
collector.collect()
to_delete: set[int] = {x.id for x in entries
if x.id not in collector.to_keep}
if len(to_delete) > 0:
JournalEntry.query.filter(JournalEntry.id.in_(to_delete)).delete()
self.is_modified = True
if is_new or db.session.is_modified(obj):
self.is_modified = True
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
@staticmethod
def __set_date(obj: Transaction, new_date: date) -> None:
"""Sets the transaction date and number.
:param obj: The transaction object.
:param new_date: The new date.
:return: None.
"""
if obj.date is None or obj.date != new_date:
if obj.date is not None:
sort_transactions_in(obj.date, obj.id)
sort_transactions_in(new_date, obj.id)
count: int = Transaction.query\
.filter(Transaction.date == new_date).count()
obj.date = new_date
obj.no = count + 1
@property
def debit_account_options(self) -> list[Account]:
"""The selectable debit accounts.
:return: The selectable debit accounts.
"""
accounts: list[Account] = Account.debit()
in_use: set[int] = self.__get_in_use_account_id()
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
@property
def credit_account_options(self) -> list[Account]:
"""The selectable credit accounts.
:return: The selectable credit accounts.
"""
accounts: list[Account] = Account.credit()
in_use: set[int] = self.__get_in_use_account_id()
for account in accounts:
account.is_in_use = account.id in in_use
return accounts
def __get_in_use_account_id(self) -> set[int]:
"""Returns the ID of the accounts that are in use.
:return: The ID of the accounts that are in use.
"""
if self.__in_use_account_id is None:
self.__in_use_account_id = set(db.session.scalars(
sa.select(JournalEntry.account_id)
.group_by(JournalEntry.account_id)).all())
return self.__in_use_account_id
@property
def currencies_errors(self) -> list[str | LazyString]:
"""Returns the currency errors, without the errors in their sub-forms.
:return:
"""
return [x for x in self.currencies.errors
if isinstance(x, str) or isinstance(x, LazyString)]
T = t.TypeVar("T", bound=TransactionForm)
"""A transaction form variant."""
class JournalEntryCollector(t.Generic[T], ABC):
"""The journal entry collector."""
def __init__(self, form: T, txn_id: int, entries: list[JournalEntry],
currencies: list[TransactionCurrency]):
"""Constructs the journal entry collector.
:param form: The transaction form.
:param txn_id: The transaction ID.
:param entries: The existing journal entries.
:param currencies: The currencies in the transaction.
"""
self.form: T = form
"""The transaction form."""
self.entries: list[JournalEntry] = entries
"""The existing journal entries."""
self.txn_id: int = txn_id
"""The transaction ID."""
self.__entries_by_id: dict[int, JournalEntry] \
= {x.id: x for x in entries}
"""A dictionary from the entry ID to their entries."""
self.__no_by_id: dict[int, int] = {x.id: x.no for x in entries}
"""A dictionary from the entry number to their entries."""
self.__currencies: list[TransactionCurrency] = currencies
"""The currencies in the transaction."""
self._debit_no: int = 1
"""The number index for the debit entries."""
self._credit_no: int = 1
"""The number index for the credit entries."""
self.to_keep: set[int] = set()
"""The ID of the existing journal entries to keep."""
@abstractmethod
def collect(self) -> set[int]:
"""Collects the journal entries.
:return: The ID of the journal entries to keep.
"""
def _add_entry(self, form: JournalEntryForm, currency_code: str, no: int) \
-> None:
"""Composes a journal entry from the form.
:param form: The journal entry form.
:param currency_code: The code of the currency.
:param no: The number of the entry.
:return: None.
"""
entry: JournalEntry | None = self.__entries_by_id.get(form.eid.data)
if entry is not None:
self.to_keep.add(entry.id)
entry.currency_code = currency_code
form.populate_obj(entry)
entry.no = no
if db.session.is_modified(entry):
self.form.is_modified = True
else:
entry = JournalEntry()
entry.transaction_id = self.txn_id
entry.currency_code = currency_code
form.populate_obj(entry)
entry.no = no
db.session.add(entry)
self.form.is_modified = True
def _make_cash_entry(self, forms: list[JournalEntryForm], is_debit: bool,
currency_code: str, no: int) -> None:
"""Composes the cash journal entry at the other side of the cash
transaction.
:param forms: The journal entry forms in the same currency.
:param is_debit: True for a cash income transaction, or False for a
cash expense transaction.
:param currency_code: The code of the currency.
:param no: The number of the entry.
:return: None.
"""
candidates: list[JournalEntry] = [x for x in self.entries
if x.is_debit == is_debit
and x.currency_code == currency_code]
entry: JournalEntry
if len(candidates) > 0:
candidates.sort(key=lambda x: x.no)
entry = candidates[0]
self.to_keep.add(entry.id)
entry.account_id = Account.cash().id
entry.summary = None
entry.amount = sum([x.amount.data for x in forms])
entry.no = no
if db.session.is_modified(entry):
self.form.is_modified = True
else:
entry = JournalEntry()
entry.id = new_id(JournalEntry)
entry.transaction_id = self.txn_id
entry.is_debit = is_debit
entry.currency_code = currency_code
entry.account_id = Account.cash().id
entry.summary = None
entry.amount = sum([x.amount.data for x in forms])
entry.no = no
db.session.add(entry)
self.form.is_modified = True
def _sort_entry_forms(self, forms: list[JournalEntryForm]) -> None:
"""Sorts the journal entry forms.
:param forms: The journal entry forms.
:return: None.
"""
missing_no: int = 100 if len(self.__no_by_id) == 0 \
else max(self.__no_by_id.values()) + 100
ord_by_form: dict[JournalEntryForm, int] \
= {forms[i]: i for i in range(len(forms))}
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
missing_no if x.eid.data is None else
self.__no_by_id.get(x.eid.data, missing_no),
ord_by_form.get(x)))
def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None:
"""Sorts the currency forms.
:param forms: The currency forms.
:return: None.
"""
missing_no: int = len(self.__currencies) + 100
no_by_code: dict[str, int] = {self.__currencies[i].code: i
for i in range(len(self.__currencies))}
ord_by_form: dict[CurrencyForm, int] \
= {forms[i]: i for i in range(len(forms))}
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
no_by_code.get(x.code.data, missing_no),
ord_by_form.get(x)))
class IncomeCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash income transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_CURRENCY),
CurrencyExists()])
"""The currency code."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
"""The credit entries."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount.data for x in self.credit
if x.amount.data is not None])
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.credit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class IncomeTransactionForm(TransactionForm):
"""The form to create or edit a cash income transaction."""
date = DateField(default=date.today())
"""The date."""
currencies = FieldList(FormField(IncomeCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class Collector(JournalEntryCollector[IncomeTransactionForm]):
"""The journal entry collector for the cash income transactions."""
def collect(self) -> None:
currencies: list[IncomeCurrencyForm] \
= [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies)
for currency in currencies:
# The debit cash entry
self._make_cash_entry(list(currency.credit), True,
currency.code.data, self._debit_no)
self._debit_no = self._debit_no + 1
# The credit forms
credit_forms: list[CreditEntryForm] \
= [x.form for x in currency.credit]
self._sort_entry_forms(credit_forms)
for credit_form in credit_forms:
self._add_entry(credit_form, currency.code.data,
self._credit_no)
self._credit_no = self._credit_no + 1
self.collector = Collector
class ExpenseCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a cash expense transaction."""
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_CURRENCY),
CurrencyExists()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
"""The debit entries."""
whole_form = BooleanField()
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries.
:return: The total amount of the debit journal entries.
"""
return sum([x.amount.data for x in self.debit
if x.amount.data is not None])
@property
def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.debit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class ExpenseTransactionForm(TransactionForm):
"""The form to create or edit a cash expense transaction."""
date = DateField(default=date.today())
"""The date."""
currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class Collector(JournalEntryCollector[ExpenseTransactionForm]):
"""The journal entry collector for the cash expense
transactions."""
def collect(self) -> None:
currencies: list[ExpenseCurrencyForm] \
= [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies)
for currency in currencies:
# The debit forms
debit_forms: list[DebitEntryForm] \
= [x.form for x in currency.debit]
self._sort_entry_forms(debit_forms)
for debit_form in debit_forms:
self._add_entry(debit_form, currency.code.data,
self._debit_no)
self._debit_no = self._debit_no + 1
# The credit forms
self._make_cash_entry(list(currency.debit), False,
currency.code.data, self._credit_no)
self._credit_no = self._credit_no + 1
self.collector = Collector
class TransferCurrencyForm(CurrencyForm):
"""The form to create or edit a currency in a transfer transaction."""
class IsBalanced:
"""The validator to check that the total amount of the debit and credit
entries are equal."""
def __call__(self, form: TransferCurrencyForm, field: BooleanField)\
-> None:
if len(form.debit) == 0 or len(form.credit) == 0:
return
if form.debit_total != form.credit_total:
raise ValidationError(lazy_gettext(
"The totals of the debit and credit amounts do not"
" match."))
no = IntegerField()
"""The order in the transaction."""
code = StringField(
filters=[strip_text],
validators=[DataRequired(MISSING_CURRENCY),
CurrencyExists()])
"""The currency code."""
debit = FieldList(FormField(DebitEntryForm),
validators=[NeedSomeJournalEntries()])
"""The debit entries."""
credit = FieldList(FormField(CreditEntryForm),
validators=[NeedSomeJournalEntries()])
"""The credit entries."""
whole_form = BooleanField(validators=[IsBalanced()])
"""The pseudo field for the whole form validators."""
@property
def debit_total(self) -> Decimal:
"""Returns the total amount of the debit journal entries.
:return: The total amount of the debit journal entries.
"""
return sum([x.amount.data for x in self.debit
if x.amount.data is not None])
@property
def credit_total(self) -> Decimal:
"""Returns the total amount of the credit journal entries.
:return: The total amount of the credit journal entries.
"""
return sum([x.amount.data for x in self.credit
if x.amount.data is not None])
@property
def debit_errors(self) -> list[str | LazyString]:
"""Returns the debit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.debit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
@property
def credit_errors(self) -> list[str | LazyString]:
"""Returns the credit journal entry errors, without the errors in their
sub-forms.
:return:
"""
return [x for x in self.credit.errors
if isinstance(x, str) or isinstance(x, LazyString)]
class TransferTransactionForm(TransactionForm):
"""The form to create or edit a transfer transaction."""
date = DateField(default=date.today())
"""The date."""
currencies = FieldList(FormField(TransferCurrencyForm), name="currency",
validators=[NeedSomeCurrencies()])
"""The journal entries categorized by their currencies."""
note = TextAreaField(filters=[strip_multiline_text])
"""The note."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class Collector(JournalEntryCollector[TransferTransactionForm]):
"""The journal entry collector for the transfer transactions."""
def collect(self) -> None:
currencies: list[TransferCurrencyForm] \
= [x.form for x in self.form.currencies]
self._sort_currency_forms(currencies)
for currency in currencies:
# The debit forms
debit_forms: list[DebitEntryForm] \
= [x.form for x in currency.debit]
self._sort_entry_forms(debit_forms)
for debit_form in debit_forms:
self._add_entry(debit_form, currency.code.data,
self._debit_no)
self._debit_no = self._debit_no + 1
# The credit forms
credit_forms: list[CreditEntryForm] \
= [x.form for x in currency.credit]
self._sort_entry_forms(credit_forms)
for credit_form in credit_forms:
self._add_entry(credit_form, currency.code.data,
self._credit_no)
self._credit_no = self._credit_no + 1
self.collector = Collector
def sort_transactions_in(txn_date: date, exclude: int) -> None:
"""Sorts the transactions under a date after changing the date or deleting
a transaction.
:param txn_date: The date of the transaction.
:param exclude: The transaction ID to exclude.
:return: None.
"""
transactions: list[Transaction] = Transaction.query\
.filter(Transaction.date == txn_date,
Transaction.id != exclude)\
.order_by(Transaction.no).all()
for i in range(len(transactions)):
if transactions[i].no != i + 1:
transactions[i].no = i + 1
class TransactionReorderForm:
"""The form to reorder the transactions."""
def __init__(self, txn_date: date):
"""Constructs the form to reorder the transactions in a day.
:param txn_date: The date.
"""
self.date: date = txn_date
self.is_modified: bool = False
def save_order(self) -> None:
"""Saves the order of the account.
:return:
"""
transactions: list[Transaction] = Transaction.query\
.filter(Transaction.date == self.date).all()
# Collects the specified order.
orders: dict[Transaction, int] = {}
for txn in transactions:
if f"{txn.id}-no" in request.form:
try:
orders[txn] = int(request.form[f"{txn.id}-no"])
except ValueError:
pass
# Missing and invalid orders are appended to the end.
missing: list[Transaction] \
= [x for x in transactions if x not in orders]
if len(missing) > 0:
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
for txn in missing:
orders[txn] = next_no
# Sort by the specified order first, and their original order.
transactions.sort(key=lambda x: (orders[x], x.no))
# Update the orders.
with db.session.no_autoflush:
for i in range(len(transactions)):
if transactions[i].no != i + 1:
transactions[i].no = i + 1
self.is_modified = True

View File

@ -0,0 +1,65 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# 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 transaction query.
"""
from datetime import datetime
import sqlalchemy as sa
from flask import request
from accounting.models import Transaction
from accounting.utils.query import parse_query_keywords
def get_transaction_query() -> list[Transaction]:
"""Returns the transactions, optionally filtered by the query.
:return: The transactions.
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return Transaction.query\
.order_by(Transaction.date, Transaction.no).all()
conditions: list[sa.BinaryExpression] = []
for k in keywords:
sub_conditions: list[sa.BinaryExpression] \
= [Transaction.note.contains(k)]
date: datetime
try:
date = datetime.strptime(k, "%Y")
sub_conditions.append(
sa.extract("year", Transaction.date) == date.year)
except ValueError:
pass
try:
date = datetime.strptime(k, "%Y/%m")
sub_conditions.append(sa.and_(
sa.extract("year", Transaction.date) == date.year,
sa.extract("month", Transaction.date) == date.month))
except ValueError:
pass
try:
date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
sub_conditions.append(sa.and_(
sa.extract("month", Transaction.date) == date.month,
sa.extract("day", Transaction.date) == date.day))
except ValueError:
pass
conditions.append(sa.or_(*sub_conditions))
return Transaction.query.filter(*conditions)\
.order_by(Transaction.date, Transaction.no).all()

View File

@ -0,0 +1,119 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
# 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 template filters and globals for the transaction management.
"""
from datetime import date, timedelta
from decimal import Decimal
from html import escape
from urllib.parse import ParseResult, urlparse, parse_qsl, urlencode, \
urlunparse
from flask import request, current_app
from flask_babel import get_locale
from accounting.locale import gettext
from accounting.models import Currency
def with_type(uri: str) -> str:
"""Adds the transaction type to the URI, if it is specified.
:param uri: The URI.
:return: The result URL, optionally with the transaction type added.
"""
if "as" not in request.args:
return uri
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] != "next"]
params.append(("as", request.args["as"]))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)
def format_amount(value: Decimal | None) -> str:
"""Formats an amount for readability.
:param value: The amount.
:return: The formatted amount text.
"""
if value is None or value == 0:
return "-"
whole: int = int(value)
frac: Decimal = (value - whole).normalize()
return "{:,}".format(whole) + str(frac)[1:]
def format_date(value: date) -> str:
"""Formats a date to be human-friendly.
:param value: The date.
:return: The human-friendly date text.
"""
today: date = date.today()
if value == today:
return gettext("Today")
if value == today - timedelta(days=1):
return gettext("Yesterday")
if value == today + timedelta(days=1):
return gettext("Tomorrow")
locale = str(get_locale())
if locale == "zh" or locale.startswith("zh_"):
if value == today - timedelta(days=2):
return gettext("The day before yesterday")
if value == today + timedelta(days=2):
return gettext("The day after tomorrow")
if locale == "zh" or locale.startswith("zh_"):
weekdays = ["", "", "", "", "", "", ""]
weekday = weekdays[value.weekday()]
else:
weekday = value.strftime("%a")
if value.year != today.year:
return "{}/{}/{}({})".format(
value.year, value.month, value.day, weekday)
return "{}/{}({})".format(value.month, value.day, weekday)
def text2html(value: str) -> str:
"""Converts plain text into HTML.
:param value: The plain text.
:return: The HTML.
"""
s: str = escape(value)
s = s.replace("\n", "<br>")
s = s.replace(" ", " &nbsp;")
return s
def currency_options() -> str:
"""Returns the currency options.
:return: The currency options.
"""
return Currency.query.order_by(Currency.code).all()
def default_currency_code() -> str:
"""Returns the default currency code.
:return: The default currency code.
"""
with current_app.app_context():
return current_app.config.get("DEFAULT_CURRENCY", "USD")

View File

@ -0,0 +1,223 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The views for the transaction management.
"""
from datetime import date
from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, request, \
flash, url_for
from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Transaction
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 .dispatcher import TransactionType, get_txn_type, TXN_TYPE_OBJ
from .template import with_type, format_amount, format_date, text2html, \
currency_options, default_currency_code
from .forms import sort_transactions_in, TransactionReorderForm
from .query import get_transaction_query
bp: Blueprint = Blueprint("transaction", __name__)
"""The view blueprint for the transaction management."""
bp.add_app_template_filter(with_type, "accounting_txn_with_type")
bp.add_app_template_filter(format_amount, "accounting_txn_format_amount")
bp.add_app_template_filter(format_date, "accounting_txn_format_date")
bp.add_app_template_filter(text2html, "accounting_txn_text2html")
bp.add_app_template_global(currency_options, "accounting_txn_currency_options")
bp.add_app_template_global(default_currency_code,
"accounting_txn_default_currency_code")
@bp.get("", endpoint="list")
@has_permission(can_view)
def list_transactions() -> str:
"""Lists the transactions.
:return: The transaction list.
"""
transactions: list[Transaction] = get_transaction_query()
pagination: Pagination = Pagination[Transaction](transactions)
return render_template("accounting/transaction/list.html",
list=pagination.list, pagination=pagination,
types=TXN_TYPE_OBJ)
@bp.get("/create/<transactionType:txn_type>", endpoint="create")
@has_permission(can_edit)
def show_add_transaction_form(txn_type: TransactionType) -> str:
"""Shows the form to add a transaction.
:param txn_type: The transaction type.
:return: The form to add a transaction.
"""
form: txn_type.form
if "form" in session:
form = txn_type.form(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = txn_type.form()
return txn_type.render_create_template(form)
@bp.post("/store/<transactionType:txn_type>", endpoint="store")
@has_permission(can_edit)
def add_transaction(txn_type: TransactionType) -> redirect:
"""Adds a transaction.
:param txn_type: The transaction type.
:return: The redirection to the transaction detail on success, or the
transaction creation form on error.
"""
form: txn_type.form = txn_type.form(request.form)
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(with_type(
url_for("accounting.transaction.create", txn_type=txn_type))))
txn: Transaction = Transaction()
form.populate_obj(txn)
db.session.add(txn)
db.session.commit()
flash(lazy_gettext("The transaction is added successfully"), "success")
return redirect(inherit_next(__get_detail_uri(txn)))
@bp.get("/<transaction:txn>", endpoint="detail")
@has_permission(can_view)
def show_transaction_detail(txn: Transaction) -> str:
"""Shows the transaction detail.
:param txn: The transaction.
:return: The detail.
"""
txn_type: TransactionType = get_txn_type(txn)
return txn_type.render_detail_template(txn)
@bp.get("/<transaction:txn>/edit", endpoint="edit")
@has_permission(can_edit)
def show_transaction_edit_form(txn: Transaction) -> str:
"""Shows the form to edit a transaction.
:param txn: The transaction.
:return: The form to edit the transaction.
"""
txn_type: TransactionType = get_txn_type(txn)
form: txn_type.form
if "form" in session:
form = txn_type.form(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = txn_type.form(obj=txn)
return txn_type.render_edit_template(txn, form)
@bp.post("/<transaction:txn>/update", endpoint="update")
@has_permission(can_edit)
def update_transaction(txn: Transaction) -> redirect:
"""Updates a transaction.
:param txn: The transaction.
:return: The redirection to the transaction detail on success, or the
transaction edit form on error.
"""
txn_type: TransactionType = get_txn_type(txn)
form: txn_type.form = txn_type.form(request.form)
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(with_type(
url_for("accounting.transaction.edit", txn=txn))))
with db.session.no_autoflush:
form.populate_obj(txn)
if not form.is_modified:
flash(lazy_gettext("The transaction was not modified."), "success")
return redirect(inherit_next(with_type(__get_detail_uri(txn))))
txn.updated_by_id = get_current_user_pk()
txn.updated_at = sa.func.now()
db.session.commit()
flash(lazy_gettext("The transaction is updated successfully."), "success")
return redirect(inherit_next(with_type(__get_detail_uri(txn))))
@bp.post("/<transaction:txn>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_transaction(txn: Transaction) -> redirect:
"""Deletes a transaction.
:param txn: The transaction.
:return: The redirection to the transaction list on success, or the
transaction detail on error.
"""
txn.delete()
sort_transactions_in(txn.date, txn.id)
db.session.commit()
flash(lazy_gettext("The transaction is deleted successfully."), "success")
return redirect(or_next(with_type(url_for("accounting.transaction.list"))))
@bp.get("/dates/<date:txn_date>", endpoint="order")
@has_permission(can_view)
def show_transaction_order(txn_date: date) -> str:
"""Shows the order of the transactions in a same date.
:param txn_date: The date.
:return: The order of the transactions in the date.
"""
transactions: list[Transaction] = Transaction.query\
.filter(Transaction.date == txn_date)\
.order_by(Transaction.no).all()
return render_template("accounting/transaction/order.html",
date=txn_date, list=transactions)
@bp.post("/dates/<date:txn_date>", endpoint="sort")
@has_permission(can_edit)
def sort_accounts(txn_date: date) -> redirect:
"""Reorders the transactions in a date.
:param txn_date: The date.
:return: The redirection to the incoming account or the account list. The
reordering operation does not fail.
"""
form: TransactionReorderForm = TransactionReorderForm(txn_date)
form.save_order()
if not form.is_modified:
flash(lazy_gettext("The order was not modified."), "success")
return redirect(or_next(url_for("accounting.account.list")))
db.session.commit()
flash(lazy_gettext("The order is updated successfully."), "success")
return redirect(or_next(url_for("accounting.account.list")))
def __get_detail_uri(txn: Transaction) -> str:
"""Returns the detail URI of a transaction.
:param txn: The transaction.
:return: The detail URI of the transaction.
"""
return url_for("accounting.transaction.detail", txn=txn)

View File

@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-02-01 19:51+0800\n"
"PO-Revision-Date: 2023-02-01 19:52+0800\n"
"POT-Creation-Date: 2023-02-27 15:28+0800\n"
"PO-Revision-Date: 2023-02-27 15:29+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -19,84 +19,219 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n"
#: src/accounting/account/forms.py:39
#: src/accounting/models.py:575
#, python-format
msgid "Cash Expense Transaction#%(id)s"
msgstr "現金支出傳票#%(id)s"
#: src/accounting/models.py:577
#, python-format
msgid "Cash Income Transaction#%(id)s"
msgstr "現金收入傳票#%(id)s"
#: src/accounting/models.py:578
#, python-format
msgid "Transfer Transaction#%(id)s"
msgstr "轉帳傳票#%(id)s"
#: src/accounting/account/forms.py:41
msgid "The base account does not exist."
msgstr "沒有這個基本科目。"
#: src/accounting/account/forms.py:48
#: src/accounting/static/js/account-form.js:110
#: src/accounting/account/forms.py:52
msgid "The base account is not available."
msgstr "不能選這個基本科目。"
#: src/accounting/account/forms.py:61
#: src/accounting/static/js/account-form.js:157
msgid "Please select the base account."
msgstr "請選擇基本科目。"
#: src/accounting/account/forms.py:53
#: src/accounting/account/forms.py:67
msgid "Please fill in the title"
msgstr "請填上標題。"
#: src/accounting/account/views.py:88
#: src/accounting/account/query.py:50
#: src/accounting/templates/accounting/account/detail.html:90
#: src/accounting/templates/accounting/account/list.html:74
msgid "Pay-off needed"
msgstr "逐筆核銷"
#: src/accounting/account/views.py:89
msgid "The account is added successfully"
msgstr "科目加好了。"
#: src/accounting/account/views.py:140
#: src/accounting/account/views.py:142
msgid "The account was not modified."
msgstr "科目未異動。"
#: src/accounting/account/views.py:145
#: src/accounting/account/views.py:148
msgid "The account is updated successfully."
msgstr "科目存好了。"
#: src/accounting/account/views.py:163
#: src/accounting/account/views.py:165
msgid "The account is deleted successfully."
msgstr "科目刪掉了"
#: src/accounting/static/js/account-form.js:130
#: src/accounting/account/views.py:192 src/accounting/transaction/views.py:210
msgid "The order was not modified."
msgstr "順序未異動。"
#: src/accounting/account/views.py:195 src/accounting/transaction/views.py:213
msgid "The order is updated successfully."
msgstr "順序存好了。"
#: src/accounting/currency/forms.py:46
#: src/accounting/static/js/currency-form.js:136
msgid "Code conflicts with another currency."
msgstr "代碼與其它貨幣重複。"
#: src/accounting/currency/forms.py:51
#: src/accounting/static/js/currency-form.js:92
msgid "Please fill in the code."
msgstr "請填上代碼。"
#: src/accounting/currency/forms.py:53
#: src/accounting/static/js/currency-form.js:103
msgid "Code can only be composed of 3 upper-cased letters."
msgstr "代碼限為三個大寫英文字母。"
#: src/accounting/currency/forms.py:56
#: src/accounting/static/js/currency-form.js:98
msgid "This code is not available."
msgstr "不能用這個代碼。"
#: src/accounting/currency/forms.py:62
#: src/accounting/static/js/currency-form.js:168
msgid "Please fill in the name."
msgstr "請填上名稱。"
#: src/accounting/currency/views.py:91
msgid "The currency is added successfully"
msgstr "貨幣加好了。"
#: src/accounting/currency/views.py:145
msgid "The currency was not modified."
msgstr "貨幣未異動。"
#: src/accounting/currency/views.py:151
msgid "The currency is updated successfully."
msgstr "貨幣存好了。"
#: src/accounting/currency/views.py:167
msgid "The currency is deleted successfully."
msgstr "貨幣刪掉了"
#: src/accounting/static/js/account-form.js:177
msgid "Please fill in the title."
msgstr "請填上標題。"
#: src/accounting/static/js/transaction-form.js:308
#: src/accounting/static/js/transaction-form.js:764
#: src/accounting/transaction/forms.py:46
msgid "Please select the account."
msgstr "請選擇科目。"
#: src/accounting/static/js/transaction-form.js:344
#: src/accounting/static/js/transaction-form.js:769
msgid "Please fill in the amount."
msgstr "請填上金額。"
#: src/accounting/static/js/transaction-form.js:641
msgid "Please fill in the date."
msgstr "請填上日期。"
#: src/accounting/static/js/transaction-form.js:676
#: src/accounting/transaction/forms.py:56
msgid "Please add some currencies."
msgstr "請加上貨幣。"
#: src/accounting/static/js/transaction-form.js:742
#: src/accounting/transaction/forms.py:77
msgid "Please add some journal entries."
msgstr "請加上分錄。"
#: src/accounting/static/js/transaction-form.js:807
#: src/accounting/transaction/forms.py:670
msgid "The totals of the debit and credit amounts do not match."
msgstr "借方貸方合計不符。 "
#: src/accounting/templates/accounting/account/create.html:24
msgid "Add a New Account"
msgstr "新增科目"
#: src/accounting/templates/accounting/account/detail.html:31
#: src/accounting/templates/accounting/account/include/form.html:33
#: src/accounting/templates/accounting/account/order.html:36
#: src/accounting/templates/accounting/base-account/detail.html:31
#: src/accounting/templates/accounting/currency/detail.html:31
#: src/accounting/templates/accounting/currency/include/form.html:33
#: src/accounting/templates/accounting/transaction/include/detail.html:31
#: src/accounting/templates/accounting/transaction/include/form.html:34
#: src/accounting/templates/accounting/transaction/order.html:36
msgid "Back"
msgstr "回上頁"
#: src/accounting/templates/accounting/account/detail.html:36
#: src/accounting/templates/accounting/currency/detail.html:36
#: src/accounting/templates/accounting/transaction/include/detail.html:36
msgid "Settings"
msgstr "設定"
#: src/accounting/templates/accounting/account/detail.html:40
#: src/accounting/templates/accounting/account/detail.html:41
#: src/accounting/templates/accounting/transaction/include/detail.html:41
msgid "Order"
msgstr "次序"
#: src/accounting/templates/accounting/account/detail.html:46
#: src/accounting/templates/accounting/currency/detail.html:42
#: src/accounting/templates/accounting/transaction/include/detail.html:46
msgid "Delete"
msgstr "刪除"
#: src/accounting/templates/accounting/account/detail.html:63
#: src/accounting/templates/accounting/account/detail.html:69
msgid "Delete Account Confirmation"
msgstr "科目刪除確認"
#: src/accounting/templates/accounting/account/detail.html:67
#: src/accounting/templates/accounting/account/detail.html:70
#: src/accounting/templates/accounting/account/include/form.html:91
#: src/accounting/templates/accounting/currency/detail.html:66
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:27
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:27
#: src/accounting/templates/accounting/transaction/include/detail.html:70
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:28
msgid "Close"
msgstr "關閉"
#: src/accounting/templates/accounting/account/detail.html:73
msgid "Do you really want to delete this account?"
msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/account/detail.html:70
#: src/accounting/templates/accounting/account/include/form.html:111
#: src/accounting/templates/accounting/account/detail.html:76
#: src/accounting/templates/accounting/account/include/form.html:112
#: src/accounting/templates/accounting/currency/detail.html:72
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:49
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:49
#: src/accounting/templates/accounting/transaction/include/detail.html:76
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:52
msgid "Cancel"
msgstr "取消"
#: src/accounting/templates/accounting/account/detail.html:71
#: src/accounting/templates/accounting/account/detail.html:77
#: src/accounting/templates/accounting/currency/detail.html:73
#: src/accounting/templates/accounting/transaction/include/detail.html:77
msgid "Confirm"
msgstr "確定"
#: src/accounting/templates/accounting/account/detail.html:84
#: src/accounting/templates/accounting/account/list.html:62
msgid "Offset needed"
msgstr "逐筆核銷"
#: src/accounting/templates/accounting/account/detail.html:88
#: src/accounting/templates/accounting/account/detail.html:94
#: src/accounting/templates/accounting/currency/detail.html:85
#: src/accounting/templates/accounting/transaction/include/detail.html:106
msgid "Created"
msgstr "建檔"
#: src/accounting/templates/accounting/account/detail.html:89
#: src/accounting/templates/accounting/account/detail.html:95
#: src/accounting/templates/accounting/currency/detail.html:86
#: src/accounting/templates/accounting/transaction/include/detail.html:107
msgid "Updated"
msgstr "更新"
@ -105,25 +240,81 @@ msgstr "更新"
msgid "%(account)s Settings"
msgstr "%(account)s設定"
#: src/accounting/templates/accounting/account/list.html:24
#: src/accounting/templates/accounting/base-account/list.html:24
#: src/accounting/templates/accounting/currency/list.html:24
#: src/accounting/templates/accounting/transaction/list.html:28
#, python-format
msgid "Search Result for \"%(query)s\""
msgstr "「%(query)s」搜尋結果"
#: src/accounting/templates/accounting/account/list.html:24
msgid "Account Management"
msgstr "科目管理"
#: src/accounting/templates/accounting/account/list.html:32
#: src/accounting/templates/accounting/currency/list.html:32
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:75
#: src/accounting/templates/accounting/transaction/include/form.html:60
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:75
#: src/accounting/templates/accounting/transaction/list.html:37
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:77
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:117
msgid "New"
msgstr "新增"
#: src/accounting/templates/accounting/account/list.html:35
#: src/accounting/templates/accounting/currency/list.html:35
#: src/accounting/templates/accounting/transaction/list.html:57
msgid "Search for Desktop"
msgstr "桌機版檢索"
#: src/accounting/templates/accounting/account/include/form.html:98
#: src/accounting/templates/accounting/account/list.html:40
#: src/accounting/templates/accounting/account/list.html:52
#: src/accounting/templates/accounting/base-account/list.html:29
#: src/accounting/templates/accounting/base-account/list.html:34
#: src/accounting/templates/accounting/currency/list.html:40
#: src/accounting/templates/accounting/currency/list.html:52
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:34
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:34
#: src/accounting/templates/accounting/transaction/list.html:62
#: src/accounting/templates/accounting/transaction/list.html:74
msgid "Search"
msgstr "搜尋"
#: src/accounting/templates/accounting/account/list.html:68
#: src/accounting/templates/accounting/account/list.html:47
#: src/accounting/templates/accounting/currency/list.html:47
#: src/accounting/templates/accounting/transaction/list.html:69
msgid "Search for Mobile"
msgstr "行動版檢索"
#: src/accounting/templates/accounting/account/include/form.html:109
#: src/accounting/templates/accounting/account/list.html:80
#: src/accounting/templates/accounting/account/order.html:81
#: src/accounting/templates/accounting/base-account/list.html:51
#: src/accounting/templates/accounting/currency/list.html:77
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:46
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:46
#: src/accounting/templates/accounting/transaction/list.html:93
#: src/accounting/templates/accounting/transaction/order.html:80
msgid "There is no data."
msgstr "沒有資料。"
#: src/accounting/templates/accounting/account/order.html:29
#, python-format
msgid "The Accounts of %(base)s"
msgstr "%(base)s下的科目"
#: src/accounting/templates/accounting/account/include/form.html:75
#: src/accounting/templates/accounting/account/order.html:62
#: src/accounting/templates/accounting/currency/include/form.html:57
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:53
#: src/accounting/templates/accounting/transaction/include/form.html:76
#: src/accounting/templates/accounting/transaction/order.html:61
msgid "Save"
msgstr "儲存"
#: src/accounting/templates/accounting/account/include/form.html:45
msgid "Base account"
msgstr "基本科目"
@ -137,19 +328,17 @@ msgid "Title"
msgstr "標題"
#: src/accounting/templates/accounting/account/include/form.html:68
msgid "The entries in the account need offsets."
msgid "The entries in the account need pay-off."
msgstr "帳目要逐筆核銷。"
#: src/accounting/templates/accounting/account/include/form.html:75
msgid "Save"
msgstr "儲存"
#: src/accounting/templates/accounting/account/include/form.html:90
msgid "Select Base Account"
msgstr "選擇基本科目"
#: src/accounting/templates/accounting/account/include/form.html:113
#: src/accounting/templates/accounting/account/include/form.html:115
#: src/accounting/templates/accounting/account/include/form.html:114
#: src/accounting/templates/accounting/account/include/form.html:116
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:50
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:50
msgid "Clear"
msgstr "清除"
@ -157,23 +346,255 @@ msgstr "清除"
msgid "Base Account Managements"
msgstr "基本科目管理"
#: src/accounting/templates/accounting/currency/create.html:24
msgid "Add a New Currency"
msgstr "新增貨幣"
#: src/accounting/templates/accounting/currency/detail.html:65
msgid "Delete Currency Confirmation"
msgstr "貨幣刪除確認"
#: src/accounting/templates/accounting/currency/detail.html:69
msgid "Do you really want to delete this currency?"
msgstr "你確定要刪掉這個貨幣嗎?"
#: src/accounting/templates/accounting/currency/edit.html:24
#, python-format
msgid "%(currency)s Settings"
msgstr "%(currency)s設定"
#: src/accounting/templates/accounting/currency/list.html:24
msgid "Currency Management"
msgstr "貨幣管理"
#: src/accounting/templates/accounting/currency/include/form.html:44
msgid "Code"
msgstr "代碼"
#: src/accounting/templates/accounting/currency/include/form.html:50
msgid "Name"
msgstr "名稱"
#: src/accounting/templates/accounting/include/nav.html:26
msgid "Accounting"
msgstr "記帳"
#: src/accounting/templates/accounting/include/nav.html:32
msgid "Transactions"
msgstr "傳票"
#: src/accounting/templates/accounting/include/nav.html:38
msgid "Accounts"
msgstr "科目"
#: src/accounting/templates/accounting/include/nav.html:38
#: src/accounting/templates/accounting/include/nav.html:44
msgid "Base Accounts"
msgstr "基本科目"
#: src/accounting/utils/pagination.py:146
msgid "Previous"
msgstr "前一頁"
#: src/accounting/templates/accounting/include/nav.html:50
msgid "Currencies"
msgstr "貨幣"
#: src/accounting/utils/pagination.py:194
#: src/accounting/templates/accounting/include/pagination.html:23
msgid "Page navigation"
msgstr "分頁瀏覽"
#: src/accounting/templates/accounting/transaction/list.html:28
msgid "Transaction Management"
msgstr "傳票管理"
#: src/accounting/templates/accounting/transaction/list.html:42
msgid "Cash Expense"
msgstr "現金支出"
#: src/accounting/templates/accounting/transaction/list.html:46
msgid "Cash Income"
msgstr "現金收入"
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:32
#: src/accounting/templates/accounting/transaction/list.html:51
msgid "Transfer"
msgstr "轉帳"
#: src/accounting/templates/accounting/transaction/order.html:29
#, python-format
msgid "Transactions on %(date)s"
msgstr "%(date)s的傳票"
#: src/accounting/templates/accounting/transaction/expense/create.html:24
msgid "Add a New Cash Expense Transaction"
msgstr "新增現金支出傳票"
#: src/accounting/templates/accounting/transaction/expense/detail.html:30
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:45
#: src/accounting/templates/accounting/transaction/include/form.html:52
#: src/accounting/templates/accounting/transaction/income/detail.html:30
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:45
msgid "Content"
msgstr "內容"
#: src/accounting/templates/accounting/transaction/expense/detail.html:46
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:68
#: src/accounting/templates/accounting/transaction/income/detail.html:46
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:68
#: src/accounting/templates/accounting/transaction/transfer/detail.html:49
#: src/accounting/templates/accounting/transaction/transfer/detail.html:75
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:70
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:110
msgid "Total"
msgstr "合計"
#: src/accounting/templates/accounting/transaction/expense/edit.html:24
#: src/accounting/templates/accounting/transaction/income/edit.html:24
#: src/accounting/templates/accounting/transaction/transfer/edit.html:24
#, python-format
msgid "Editing %(txn)s"
msgstr "編輯%(txn)s"
#: src/accounting/templates/accounting/transaction/expense/include/form-currency-item.html:32
#: src/accounting/templates/accounting/transaction/income/include/form-currency-item.html:32
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:32
msgid "Currency"
msgstr "貨幣"
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:26
msgid "Cash expense"
msgstr "現金支出"
#: src/accounting/templates/accounting/transaction/include/add-new-material-fab.html:29
msgid "Cash income"
msgstr "現金收入"
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:26
msgid "Select Credit Account"
msgstr "選擇貸方科目科目"
#: src/accounting/templates/accounting/transaction/include/credit-account-modal.html:44
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:44
msgid "More…"
msgstr "更多…"
#: src/accounting/templates/accounting/transaction/include/debit-account-modal.html:26
msgid "Select Debit Account"
msgstr "選擇借方科目"
#: src/accounting/templates/accounting/transaction/include/detail.html:69
msgid "Delete Transaction Confirmation"
msgstr "傳票刪除確認"
#: src/accounting/templates/accounting/transaction/include/detail.html:73
msgid "Do you really want to delete this transaction?"
msgstr "你確定要刪掉這張傳票嗎?"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:27
msgid "Journal Entry Content"
msgstr "分錄內容"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:33
msgid "Account"
msgstr "科目"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:41
msgid "Summary"
msgstr "摘要"
#: src/accounting/templates/accounting/transaction/include/entry-form-modal.html:47
msgid "Amount"
msgstr "金額"
#: src/accounting/templates/accounting/transaction/include/form.html:46
msgid "Date"
msgstr "日期"
#: src/accounting/templates/accounting/transaction/include/form.html:69
msgid "Note"
msgstr "備註"
#: src/accounting/templates/accounting/transaction/income/create.html:24
msgid "Add a New Cash Income Transaction"
msgstr "新增現金收入傳票"
#: src/accounting/templates/accounting/transaction/transfer/create.html:24
msgid "Add a New Transfer Transaction"
msgstr "新增轉帳傳票"
#: src/accounting/templates/accounting/transaction/transfer/detail.html:33
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:47
msgid "Debit"
msgstr "借方"
#: src/accounting/templates/accounting/transaction/transfer/detail.html:59
#: src/accounting/templates/accounting/transaction/transfer/include/form-currency-item.html:87
msgid "Credit"
msgstr "貸方"
#: src/accounting/transaction/forms.py:44
msgid "Please select the currency."
msgstr "請選擇貨幣。"
#: src/accounting/transaction/forms.py:67
msgid "The currency does not exist."
msgstr "沒有這個貨幣。"
#: src/accounting/transaction/forms.py:88
msgid "The account does not exist."
msgstr "沒有這個科目。"
#: src/accounting/transaction/forms.py:99
msgid "Please fill in a positive amount."
msgstr "金額請填正數。"
#: src/accounting/transaction/forms.py:113
msgid "This account is not for debit entries."
msgstr "科目不是借方科目。"
#: src/accounting/transaction/forms.py:200
msgid "This account is not for credit entries."
msgstr "科目不是貸方科目。"
#: src/accounting/transaction/template.py:71
msgid "Today"
msgstr "今天"
#: src/accounting/transaction/template.py:73
msgid "Yesterday"
msgstr "昨天"
#: src/accounting/transaction/template.py:75
msgid "Tomorrow"
msgstr "明天"
#: src/accounting/transaction/template.py:79
msgid "The day before yesterday"
msgstr "前天"
#: src/accounting/transaction/template.py:81
msgid "The day after tomorrow"
msgstr "後天"
#: src/accounting/transaction/views.py:104
msgid "The transaction is added successfully"
msgstr "傳票加好了。"
#: src/accounting/transaction/views.py:158
msgid "The transaction was not modified."
msgstr "傳票未異動。"
#: src/accounting/transaction/views.py:163
msgid "The transaction is updated successfully."
msgstr "傳票存好了。"
#: src/accounting/transaction/views.py:179
msgid "The transaction is deleted successfully."
msgstr "傳票刪掉了"
#: src/accounting/utils/pagination.py:206
msgctxt "Pagination|"
msgid "Previous"
msgstr "上一頁"
#: src/accounting/utils/pagination.py:255
msgctxt "Pagination|"
msgid "Next"
msgstr "下一頁"

View File

@ -0,0 +1,50 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# 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 utility to flash all errors from the forms.
This module should not import any other module from the application.
"""
import typing as t
from flask import flash
from flask_wtf import FlaskForm
def flash_form_errors(form: FlaskForm) -> None:
"""Flash all errors from a form recursively.
:param form: The form.
:return: None.
"""
__flash_errors(form.errors)
def __flash_errors(error: t.Any) -> None:
"""Flash all errors recursively.
:param error: The errors.
:return: None.
"""
if isinstance(error, dict):
for key in error:
__flash_errors(error[key])
elif isinstance(error, list):
for e in error:
__flash_errors(e)
else:
flash(error, "error")

View File

@ -14,7 +14,7 @@
# 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 to handle the next URL.
"""The utilities to handle the next URI.
This module should not import any other module from the application.
@ -22,7 +22,7 @@ This module should not import any other module from the application.
from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
urlunparse
from flask import request
from flask import request, Blueprint
def append_next(uri: str) -> str:
@ -68,8 +68,19 @@ def __set_next(uri: str, next_uri: str) -> str:
"""
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] == "next"]
params = [x for x in params if x[0] != "next"]
params.append(("next", next_uri))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)
def init_app(bp: Blueprint) -> None:
"""Initializes the application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
bp.add_app_template_filter(append_next, "accounting_append_next")
bp.add_app_template_filter(inherit_next, "accounting_inherit_next")
bp.add_app_template_filter(or_next, "accounting_or_next")

View File

@ -24,12 +24,13 @@ from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
ParseResult
from flask import request
from werkzeug.routing import RequestRedirect
from accounting.locale import gettext
from accounting.locale import gettext, pgettext
class PageLink:
"""A link in the pagination."""
class Link:
"""A link."""
def __init__(self, text: str, uri: str | None = None,
is_current: bool = False, is_for_mobile: bool = False):
@ -52,15 +53,20 @@ class PageLink:
"""Whether the link should be shown on mobile screens."""
class Redirection(RequestRedirect):
"""The redirection."""
code = 302
"""The HTTP code."""
DEFAULT_PAGE_SIZE: int = 10
"""The default page size."""
T = t.TypeVar("T")
class Pagination(t.Generic[T]):
"""The pagination utilities"""
AVAILABLE_PAGE_SIZES: list[int] = [10, 100, 200]
"""The available page sizes."""
DEFAULT_PAGE_SIZE: int = 10
"""The default page size."""
"""The pagination utility."""
def __init__(self, items: list[T], is_reversed: bool = False):
"""Constructs the pagination.
@ -68,130 +74,186 @@ class Pagination(t.Generic[T]):
:param items: The items.
:param is_reversed: True if the default page is the last page, or False
otherwise.
:raise Redirection: When the pagination parameters are malformed.
"""
self.__items: list[T] = items
"""All the items."""
self.__is_reversed: bool = is_reversed
"""Whether the default page is the last page."""
self.page_size: int = int(request.args.get("page-size",
self.DEFAULT_PAGE_SIZE))
"""The number of items in a page."""
self.__total_pages: int = 0 if len(items) == 0 \
else int((len(items) - 1) / self.page_size) + 1
"""The total number of pages."""
self.is_needed: bool = self.__total_pages > 1
pagination: AbstractPagination[T] = EmptyPagination[T]() \
if len(items) == 0 \
else NonEmptyPagination[T](items, is_reversed)
self.is_paged: bool = pagination.is_paged
"""Whether there should be pagination."""
self.list: list[T] = pagination.list
"""The items shown in the list"""
self.pages: list[Link] = pagination.pages
"""The pages."""
self.page_size: int = pagination.page_size
"""The number of items in a page."""
self.page_size_options: list[Link] = pagination.page_size_options
"""The options to the number of items in a page."""
class AbstractPagination(t.Generic[T]):
"""An abstract pagination."""
def __init__(self):
"""Constructs an empty pagination."""
self.page_size: int = DEFAULT_PAGE_SIZE
"""The number of items in a page."""
self.is_paged: bool = False
"""Whether there should be pagination."""
self.__default_page_no: int = 0
"""The default page number."""
self.page_no: int = 0
"""The current page number."""
self.list: list[T] = []
"""The items shown in the list"""
if self.__total_pages > 0:
self.__set_list()
self.pages: list[Link] = []
"""The pages."""
self.page_size_options: list[Link] = []
"""The options to the number of items in a page."""
class EmptyPagination(AbstractPagination[T]):
"""The pagination from empty data."""
pass
class NonEmptyPagination(AbstractPagination[T]):
"""The pagination with real data."""
PAGE_SIZE_OPTION_VALUES: list[int] = [10, 100, 200]
"""The page size options."""
def __init__(self, items: list[T], is_reversed: bool = False):
"""Constructs the pagination.
:param items: The items.
:param is_reversed: True if the default page is the last page, or False
otherwise.
:raise Redirection: When the pagination parameters are malformed.
"""
super().__init__()
self.__current_uri: str = request.full_path if request.query_string \
else request.path
"""The current URI."""
self.__base_uri_params: tuple[list[str], list[tuple[str, str]]] \
= self.__get_base_uri_params()
"""The base URI parameters."""
self.page_links: list[PageLink] = self.__get_page_links()
"""The pagination links."""
self.page_sizes: list[PageLink] = self.__get_page_sizes()
"""The links to switch the number of items in a page."""
def __set_list(self) -> None:
"""Sets the items to show in the list.
:return: None.
"""
self.__default_page_no = self.__total_pages if self.__is_reversed \
else 1
self.page_no = int(request.args.get("page-no",
self.__default_page_no))
if self.page_no < 1:
self.page_no = 1
if self.page_no > self.__total_pages:
self.page_no = self.__total_pages
lower_bound: int = (self.page_no - 1) * self.page_size
self.__is_reversed: bool = is_reversed
"""Whether the default page is the last page."""
self.page_size = self.__get_page_size()
self.__total_pages: int = int((len(items) - 1) / self.page_size) + 1
"""The total number of pages."""
self.is_paged = self.__total_pages > 1
self.__default_page_no: int = self.__total_pages \
if self.__is_reversed else 1
"""The default page number."""
self.__page_no: int = self.__get_page_no()
"""The current page number."""
lower_bound: int = (self.__page_no - 1) * self.page_size
upper_bound: int = lower_bound + self.page_size
if upper_bound > len(self.__items):
upper_bound = len(self.__items)
self.list = self.__items[lower_bound:upper_bound]
if upper_bound > len(items):
upper_bound = len(items)
self.list = items[lower_bound:upper_bound]
self.pages = self.__get_pages()
self.page_size_options = self.__get_page_size_options()
def __get_base_uri_params(self) -> tuple[list[str], list[tuple[str, str]]]:
"""Returns the base URI and its parameters, with the "page-no" and
"page-size" parameters removed.
def __get_page_size(self) -> int:
"""Returns the page size.
:return: The URI parts and the cleaned-up query parameters.
:return: The page size.
:raise Redirection: When the page size is malformed.
"""
uri_p: ParseResult = urlparse(self.__current_uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] not in ["page-no", "page-size"]]
parts: list[str] = list(uri_p)
return parts, params
if "page-size" not in request.args:
return DEFAULT_PAGE_SIZE
try:
page_size: int = int(request.args["page-size"])
except ValueError:
raise Redirection(self.__uri_set("page-size", None))
if page_size == DEFAULT_PAGE_SIZE or page_size < 1:
raise Redirection(self.__uri_set("page-size", None))
return page_size
def __get_page_links(self) -> list[PageLink]:
def __get_page_no(self) -> int:
"""Returns the page number.
:return: The page number.
:raise Redirection: When the page number is malformed.
"""
if "page-no" not in request.args:
return self.__default_page_no
try:
page_no: int = int(request.args["page-no"])
except ValueError:
raise Redirection(self.__uri_set("page-no", None))
if page_no == self.__default_page_no:
raise Redirection(self.__uri_set("page-no", None))
if page_no < 1:
if not self.__is_reversed:
raise Redirection(self.__uri_set("page-no", None))
raise Redirection(self.__uri_set("page-no", "1"))
if page_no > self.__total_pages:
if self.__is_reversed:
raise Redirection(self.__uri_set("page-no", None))
raise Redirection(self.__uri_set("page-no",
str(self.__total_pages)))
return page_no
def __get_pages(self) -> list[Link]:
"""Returns the page links in the pagination navigation.
:return: The page links in the pagination navigation.
"""
if self.__total_pages < 2:
if not self.is_paged:
return []
uri: str | None
links: list[PageLink] = []
links: list[Link] = []
# The previous page.
uri = None if self.page_no == 1 else self.__uri_page(self.page_no - 1)
links.append(PageLink(gettext("Previous"), uri, is_for_mobile=True))
uri = None if self.__page_no == 1 \
else self.__uri_page(self.__page_no - 1)
links.append(Link(pgettext("Pagination|", "Previous"), uri,
is_for_mobile=True))
# The first page.
if self.page_no > 1:
links.append(PageLink("1", self.__uri_page(1)))
if self.__page_no > 1:
links.append(Link("1", self.__uri_page(1)))
# The eclipse of the previous pages.
if self.page_no - 3 == 2:
links.append(PageLink(str(self.page_no - 3),
self.__uri_page(self.page_no - 3)))
elif self.page_no - 3 > 2:
links.append(PageLink(""))
if self.__page_no - 3 == 2:
links.append(Link(str(self.__page_no - 3),
self.__uri_page(self.__page_no - 3)))
elif self.__page_no - 3 > 2:
links.append(Link(""))
# The previous two pages.
if self.page_no - 2 > 1:
links.append(PageLink(str(self.page_no - 2),
self.__uri_page(self.page_no - 2)))
if self.page_no - 1 > 1:
links.append(PageLink(str(self.page_no - 1),
self.__uri_page(self.page_no - 1)))
if self.__page_no - 2 > 1:
links.append(Link(str(self.__page_no - 2),
self.__uri_page(self.__page_no - 2)))
if self.__page_no - 1 > 1:
links.append(Link(str(self.__page_no - 1),
self.__uri_page(self.__page_no - 1)))
# The current page.
links.append(PageLink(str(self.page_no), self.__uri_page(self.page_no),
is_current=True))
links.append(Link(str(self.__page_no), self.__uri_page(self.__page_no),
is_current=True))
# The next two pages.
if self.page_no + 1 < self.__total_pages:
links.append(PageLink(str(self.page_no + 1),
self.__uri_page(self.page_no + 1)))
if self.page_no + 2 < self.__total_pages:
links.append(PageLink(str(self.page_no + 2),
self.__uri_page(self.page_no + 2)))
if self.__page_no + 1 < self.__total_pages:
links.append(Link(str(self.__page_no + 1),
self.__uri_page(self.__page_no + 1)))
if self.__page_no + 2 < self.__total_pages:
links.append(Link(str(self.__page_no + 2),
self.__uri_page(self.__page_no + 2)))
# The eclipse of the next pages.
if self.page_no + 3 == self.__total_pages - 1:
links.append(PageLink(str(self.page_no + 3),
self.__uri_page(self.page_no + 3)))
elif self.page_no + 3 < self.__total_pages - 1:
links.append(PageLink(""))
if self.__page_no + 3 == self.__total_pages - 1:
links.append(Link(str(self.__page_no + 3),
self.__uri_page(self.__page_no + 3)))
elif self.__page_no + 3 < self.__total_pages - 1:
links.append(Link(""))
# The last page.
if self.page_no < self.__total_pages:
links.append(PageLink(str(self.__total_pages),
self.__uri_page(self.__total_pages)))
if self.__page_no < self.__total_pages:
links.append(Link(str(self.__total_pages),
self.__uri_page(self.__total_pages)))
# The next page.
uri = None if self.page_no == self.__total_pages \
else self.__uri_page(self.page_no + 1)
links.append(PageLink(gettext("Next"), uri, is_for_mobile=True))
uri = None if self.__page_no == self.__total_pages \
else self.__uri_page(self.__page_no + 1)
links.append(Link(pgettext("Pagination|", "Next"), uri,
is_for_mobile=True))
return links
@ -201,21 +263,22 @@ class Pagination(t.Generic[T]):
:param page_no: The page number.
:return: The URI of the page.
"""
params: list[tuple[str, str]] = []
if page_no != self.__default_page_no:
params.append(("page-no", str(page_no)))
if self.page_size != self.DEFAULT_PAGE_SIZE:
params.append(("page-size", str(self.page_size)))
return self.__uri_set_params(params)
if page_no == self.__page_no:
return self.__current_uri
if page_no == self.__default_page_no:
return self.__uri_set("page-no", None)
return self.__uri_set("page-no", str(page_no))
def __get_page_sizes(self) -> list[PageLink]:
"""Returns the available page sizes.
def __get_page_size_options(self) -> list[Link]:
"""Returns the page size options.
:return: The available page sizes.
:return: The page size options.
"""
return [PageLink(str(x), self.__uri_size(x),
is_current=x == self.page_size)
for x in self.AVAILABLE_PAGE_SIZES]
if not self.is_paged:
return []
return [Link(str(x), self.__uri_size(x),
is_current=x == self.page_size)
for x in self.PAGE_SIZE_OPTION_VALUES]
def __uri_size(self, page_size: int) -> str:
"""Returns the URI of a page size.
@ -225,16 +288,34 @@ class Pagination(t.Generic[T]):
"""
if page_size == self.page_size:
return self.__current_uri
return self.__uri_set_params([("page-size", str(page_size))])
if page_size == DEFAULT_PAGE_SIZE:
return self.__uri_set("page-size", None)
return self.__uri_set("page-size", str(page_size))
def __uri_set_params(self, params: list[tuple[str, str]]) -> str:
"""Returns the URI with the query parameters set.
def __uri_set(self, name: str, value: str | None) -> str:
"""Raises current URI with a parameter set.
:param params: The query parameters.
:return: The URI with the query parameters set.
:param name: The name of the parameter.
:param value: The value, or None to remove the parameter.
:return: The URI with the parameter set.
"""
cur_params: list[tuple[str, str]] = self.__base_uri_params[1].copy()
cur_params.extend(params)
parts: list[str] = self.__base_uri_params[0].copy()
parts[4] = urlencode(cur_params)
uri_p: ParseResult = urlparse(self.__current_uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
# Try to keep the position of the parameter.
i: int = 0
is_found: bool = False
while i < len(params):
if params[i][0] == name:
if is_found or value is None:
params = params[:i] + params[i + 1:]
continue
params[i] = (name, value)
is_found = True
i = i + 1
if not is_found and value is not None:
params.append((name, value))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)

View File

@ -21,7 +21,9 @@ This module should not import any other module from the application.
"""
import typing as t
from flask import Flask, abort
from flask import abort, Blueprint
from accounting.utils.user import get_current_user
def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
@ -75,17 +77,22 @@ def can_view() -> bool:
def can_edit() -> bool:
"""Returns whether the current user can edit the account data.
The user has to log in.
:return: True if the current user can edit the accounting data, or False
otherwise.
"""
if get_current_user() is None:
return False
return __can_edit_func()
def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None,
def init_app(bp: Blueprint,
can_view_func: t.Callable[[], bool] | None = None,
can_edit_func: t.Callable[[], bool] | None = None) -> None:
"""Initializes the application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:param can_view_func: A callback that returns whether the current user can
view the accounting data.
:param can_edit_func: A callback that returns whether the current user can
@ -97,5 +104,5 @@ def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None,
__can_view_func = can_view_func
if can_edit_func is not None:
__can_edit_func = can_edit_func
app.jinja_env.globals["can_view_accounting"] = __can_view_func
app.jinja_env.globals["can_edit_accounting"] = __can_edit_func
bp.add_app_template_global(can_view, "accounting_can_view")
bp.add_app_template_global(can_edit, "accounting_can_edit")

View File

@ -34,11 +34,22 @@ def parse_query_keywords(q: str | None) -> list[str]:
if q == "":
return []
keywords: list[str] = []
while q is not None:
m: re.Match = re.match(r"(?:\"([^\"]+)\"|(\S+))(?:\s+(.+)|)$", q)
if m.group(1) is not None:
while True:
m: re.Match
m = re.match(r"\"([^\"]+)\"\s+(.+)$", q)
if m is not None:
keywords.append(m.group(1))
else:
keywords.append(m.group(2))
q = m.group(3)
q = m.group(2)
continue
m = re.match(r"\"([^\"]+)\"?$", q)
if m is not None:
keywords.append(m.group(1))
break
m = re.match(r"(\S+)\s+(.+)$", q)
if m is not None:
keywords.append(m.group(1))
q = m.group(2)
continue
keywords.append(q)
break
return keywords

View File

@ -22,7 +22,7 @@ This module should not import any other module from the application.
import typing as t
from secrets import randbelow
from accounting.database import db
from accounting import db
def new_id(cls: t.Type):
@ -32,6 +32,6 @@ def new_id(cls: t.Type):
:return: The generated new random ID.
"""
while True:
new: int = 100000000 + randbelow(900000000)
if db.session.get(cls, new) is None:
return new
obj_id: int = 100000000 + randbelow(900000000)
if db.session.get(cls, obj_id) is None:
return obj_id

View File

@ -19,6 +19,7 @@
This module should not import any other module from the application.
"""
import re
def strip_text(s: str | None) -> str | None:
@ -29,4 +30,17 @@ def strip_text(s: str | None) -> str | None:
"""
if s is None:
return None
return s.strip()
s = s.strip()
return s if s != "" else None
def strip_multiline_text(s: str | None) -> str | None:
"""The filter to strip a piece of multi-line text.
:param s: The text input string.
:return: The filtered string.
"""
if s is None:
return None
s = re.sub(r"^\s*\n", "", s.rstrip())
return s if s != "" else None

View File

@ -0,0 +1,129 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# 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 user utilities.
This module should not import any other module from the application.
"""
import typing as t
from abc import ABC, abstractmethod
import sqlalchemy as sa
from flask import g
from flask_sqlalchemy.model import Model
T = t.TypeVar("T", bound=Model)
class AbstractUserUtils(t.Generic[T], ABC):
"""The abstract user utilities."""
@property
@abstractmethod
def cls(self) -> t.Type[T]:
"""Returns the user class.
:return: The user class.
"""
@property
@abstractmethod
def pk_column(self) -> sa.Column:
"""Returns the primary key column.
:return: The primary key column.
"""
@property
@abstractmethod
def current_user(self) -> T | None:
"""Returns the currently logged-in user.
:return: The currently logged-in user, or None if the user has not
logged in
"""
@abstractmethod
def get_by_username(self, username: str) -> T | None:
"""Returns the user by her username.
:return: The user by her username, or None if the user was not found.
"""
@abstractmethod
def get_pk(self, user: T) -> int:
"""Returns the primary key of the user.
:return: The primary key of the user.
"""
__user_utils: AbstractUserUtils
"""The user utilities."""
user_cls: t.Type[Model] = Model
"""The user class."""
user_pk_column: sa.Column = sa.Column(sa.Integer)
"""The primary key column of the user class."""
def init_user_utils(utils: AbstractUserUtils) -> None:
"""Initializes the user utilities.
:param utils: The user utilities.
:return: None.
"""
global __user_utils, user_cls, user_pk_column
__user_utils = utils
user_cls = utils.cls
user_pk_column = utils.pk_column
def get_current_user_pk() -> int:
"""Returns the primary key value of the currently logged-in user.
:return: The primary key value of the currently logged-in user.
"""
return __user_utils.get_pk(get_current_user())
def has_user(username: str) -> bool:
"""Returns whether a user by the username exists.
:param username: The username.
:return: True if the user by the username exists, or False otherwise.
"""
return __user_utils.get_by_username(username) is not None
def get_user_pk(username: str) -> int:
"""Returns the primary key value of the user by the username.
:param username: The username.
:return: The primary key value of the user by the username.
"""
return __user_utils.get_pk(__user_utils.get_by_username(username))
def get_current_user() -> user_cls | None:
"""Returns the currently logged-in user. The result is cached in the
current request.
:return: The currently logged-in user.
"""
if not hasattr(g, "_accounting_user"):
setattr(g, "_accounting_user", __user_utils.current_user)
return getattr(g, "_accounting_user")

View File

@ -28,7 +28,7 @@ from babel.messages.frontend import CommandLineInterface
from opencc import OpenCC
root_dir: Path = Path(__file__).parent.parent
translation_dir: Path = root_dir / "tests" / "testsite" / "translations"
translation_dir: Path = root_dir / "tests" / "test_site" / "translations"
domain: str = "messages"
@ -49,7 +49,7 @@ def babel_extract() -> None:
/ f"{domain}.po"
CommandLineInterface().run([
"pybabel", "extract", "-F", str(cfg), "-k", "lazy_gettext", "-k", "A_",
"-o", str(pot), str(Path("tests") / "testsite")])
"-o", str(pot), str(Path("tests") / "test_site")])
if not zh_hant.exists():
zh_hant.touch()
if not zh_hans.exists():

766
tests/test_account.py Normal file
View File

@ -0,0 +1,766 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# 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 account management.
"""
import unittest
from datetime import timedelta
import httpx
import sqlalchemy as sa
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app, db
from testlib import get_client, set_locale
NEXT_URI: str = "/_next"
"""The next URI."""
class AccountData:
"""The account data."""
def __init__(self, base_code: str, no: int, title: str):
"""Constructs the account data.
:param base_code: The base code.
:param no: The number.
:param title: The title.
"""
self.base_code: str = base_code
"""The base code."""
self.no: int = no
"""The number."""
self.title: str = title
"""The title."""
self.code: str = f"{self.base_code}-{self.no:03d}"
"""The code."""
cash: AccountData = AccountData("1111", 1, "Cash")
"""The cash account."""
bank: AccountData = AccountData("1113", 1, "Bank")
"""The bank account."""
stock: AccountData = AccountData("1121", 1, "Stock")
"""The stock account."""
loan: AccountData = AccountData("2112", 1, "Loan")
"""The loan account."""
PREFIX: str = "/accounting/accounts"
"""The URL prefix of the currency management."""
class AccountCommandTestCase(unittest.TestCase):
"""The account console command 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, Account, AccountL10n
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)
AccountL10n.query.delete()
Account.query.delete()
db.session.commit()
def test_init(self) -> None:
"""Tests the "accounting-init-account" console command.
:return: None.
"""
from accounting.models import BaseAccount, Account, AccountL10n
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
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()
self.assertEqual({x.code for x in bases},
{x.base_code for x in accounts})
self.assertEqual(len(accounts), len(bases))
self.assertEqual(len(l10n), len(bases) * 2)
base_dict: dict[str, BaseAccount] = {x.code: x for x in bases}
for account in accounts:
base: BaseAccount = base_dict[account.base_code]
self.assertEqual(account.no, 1)
self.assertEqual(account.title_l10n, base.title_l10n)
self.assertEqual({x.locale: x.title for x in account.l10n},
{x.locale: x.title for x in base.l10n})
class AccountTestCase(unittest.TestCase):
"""The account 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, Account, AccountL10n
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)
AccountL10n.query.delete()
Account.query.delete()
db.session.commit()
self.client, self.csrf_token = get_client(self.app, "editor")
response: httpx.Response
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": cash.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{cash.code}")
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": bank.base_code,
"title": bank.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{bank.code}")
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
from accounting.models import Account
client, csrf_token = get_client(self.app, "nobody")
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{cash.code}")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{cash.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/update",
data={"csrf_token": csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
self.assertEqual(response.status_code, 403)
with self.app.app_context():
bank_id: int = Account.find_by_code(bank.code).id
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
data={"csrf_token": csrf_token,
"next": NEXT_URI,
f"{bank_id}-no": "5"})
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
"""Test the permission as viewer.
:return: None.
"""
from accounting.models import Account
client, csrf_token = get_client(self.app, "viewer")
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/{cash.code}")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{cash.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/update",
data={"csrf_token": csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
self.assertEqual(response.status_code, 200)
with self.app.app_context():
bank_id: int = Account.find_by_code(bank.code).id
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
data={"csrf_token": csrf_token,
"next": NEXT_URI,
f"{bank_id}-no": "5"})
self.assertEqual(response.status_code, 403)
def test_editor(self) -> None:
"""Test the permission as editor.
:return: None.
"""
from accounting.models import Account
response: httpx.Response
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{cash.code}")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.code}")
response = self.client.get(f"{PREFIX}/{cash.code}/edit")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{cash.code}/update",
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}")
response = self.client.post(f"{PREFIX}/{cash.code}/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], PREFIX)
response = self.client.get(f"{PREFIX}/bases/{cash.base_code}")
self.assertEqual(response.status_code, 200)
with self.app.app_context():
bank_id: int = Account.find_by_code(bank.code).id
response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
data={"csrf_token": self.csrf_token,
"next": NEXT_URI,
f"{bank_id}-no": "5"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
def test_add(self) -> None:
"""Tests to add the currencies.
:return: None.
"""
from accounting.models import Account
create_uri: str = f"{PREFIX}/create"
store_uri: str = f"{PREFIX}/store"
detail_uri: str = f"{PREFIX}/{stock.code}"
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code})
# Missing CSRF token
response = self.client.post(store_uri,
data={"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 400)
# CSRF token mismatch
response = self.client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2",
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 400)
# Empty base account code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": " ",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Non-existing base account
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "9999",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Unavailable base account
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "1",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Empty name
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": " "})
self.assertEqual(response.status_code, 302)
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,
"base_code": f" {stock.base_code} ",
"title": f" {stock.title} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
# Success under the same base
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.base_code}-002")
# 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.no = 66
db.session.commit()
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.base_code}-003")
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code, stock.code,
f"{stock.base_code}-002",
f"{stock.base_code}-003"})
stock_account: Account = Account.find_by_code(stock.code)
self.assertEqual(stock_account.base_code, stock.base_code)
self.assertEqual(stock_account.title_l10n, stock.title)
def test_basic_update(self) -> None:
"""Tests the basic rules to update a user.
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
edit_uri: str = f"{PREFIX}/{cash.code}/edit"
update_uri: str = f"{PREFIX}/{cash.code}/update"
detail_c_uri: str = f"{PREFIX}/{stock.code}"
response: httpx.Response
# Success, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {cash.base_code} ",
"title": f" {cash.title}-1 "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.base_code, cash.base_code)
self.assertEqual(cash_account.title_l10n, f"{cash.title}-1")
# Empty base account code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": " ",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Non-existing base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "9999",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Unavailable base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "1",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Empty name
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Change the base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_c_uri)
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.get(detail_c_uri)
self.assertEqual(response.status_code, 200)
def test_update_not_modified(self) -> None:
"""Tests that the data is not modified.
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
cash_account: Account
response: httpx.Response
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {cash.base_code} ",
"title": f" {cash.title} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account = Account.find_by_code(cash.code)
self.assertIsNotNone(cash_account)
cash_account.created_at \
= cash_account.created_at - timedelta(seconds=5)
cash_account.updated_at = cash_account.created_at
db.session.commit()
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account = Account.find_by_code(cash.code)
self.assertIsNotNone(cash_account)
self.assertLess(cash_account.created_at,
cash_account.updated_at)
def test_created_updated_by(self) -> None:
"""Tests the created-by and updated-by record.
:return: None.
"""
from accounting.models import Account
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
response: httpx.Response
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.created_by.username, editor_username)
self.assertEqual(cash_account.updated_by.username, editor_username)
response = client.post(update_uri,
data={"csrf_token": csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.created_by.username,
editor_username)
self.assertEqual(cash_account.updated_by.username,
editor2_username)
def test_l10n(self) -> None:
"""Tests the localization.
:return: None
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
response: httpx.Response
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, cash.title)
self.assertEqual(cash_account.l10n, [])
set_locale(self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-zh_Hant"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, cash.title)
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant")})
set_locale(self.client, self.csrf_token, "en")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant")})
set_locale(self.client, self.csrf_token, "zh_Hant")
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)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant-2")})
def test_delete(self) -> None:
"""Tests to delete a currency.
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
delete_uri: str = f"{PREFIX}/{cash.code}/delete"
list_uri: str = PREFIX
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{bank.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 404)
def test_change_base_code(self) -> None:
"""Tests to change the base code of an account.
:return: None.
"""
from accounting.models import Account
response: httpx.Response
for i in range(2, 6):
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/1111-00{i}")
with self.app.app_context():
account_1: Account = Account.find_by_code("1111-001")
id_1: int = account_1.id
account_2: Account = Account.find_by_code("1111-002")
id_2: int = account_2.id
account_3: Account = Account.find_by_code("1111-003")
id_3: int = account_3.id
account_4: Account = Account.find_by_code("1111-004")
id_4: int = account_4.id
account_5: Account = Account.find_by_code("1111-005")
id_5: int = account_5.id
account_1.no = 3
account_2.no = 5
account_3.no = 8
account_4.base_code = "1112"
account_4.no = 2
account_5.base_code = "1112"
account_5.no = 6
db.session.commit()
response = self.client.post(f"{PREFIX}/1111-005/update",
data={"csrf_token": self.csrf_token,
"base_code": "1112",
"title": "Title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/1112-003")
with self.app.app_context():
self.assertEqual(db.session.get(Account, id_1).no, 1)
self.assertEqual(db.session.get(Account, id_2).no, 3)
self.assertEqual(db.session.get(Account, id_3).no, 2)
self.assertEqual(db.session.get(Account, id_4).no, 1)
self.assertEqual(db.session.get(Account, id_5).no, 2)
def test_reorder(self) -> None:
"""Tests to reorder the accounts under a same base account.
:return: None.
"""
from accounting.models import Account
response: httpx.Response
for i in range(2, 6):
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/1111-00{i}")
# Normal reorder
with self.app.app_context():
id_1: int = Account.find_by_code("1111-001").id
id_2: int = Account.find_by_code("1111-002").id
id_3: int = Account.find_by_code("1111-003").id
id_4: int = Account.find_by_code("1111-004").id
id_5: int = Account.find_by_code("1111-005").id
response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token,
"next": NEXT_URI,
f"{id_1}-no": "4",
f"{id_2}-no": "1",
f"{id_3}-no": "5",
f"{id_4}-no": "2",
f"{id_5}-no": "3"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context():
self.assertEqual(db.session.get(Account, id_1).code, "1111-004")
self.assertEqual(db.session.get(Account, id_2).code, "1111-001")
self.assertEqual(db.session.get(Account, id_3).code, "1111-005")
self.assertEqual(db.session.get(Account, id_4).code, "1111-002")
self.assertEqual(db.session.get(Account, id_5).code, "1111-003")
# Malformed orders
with self.app.app_context():
db.session.get(Account, id_1).no = 3
db.session.get(Account, id_2).no = 4
db.session.get(Account, id_3).no = 6
db.session.get(Account, id_4).no = 8
db.session.get(Account, id_5).no = 9
db.session.commit()
response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token,
"next": NEXT_URI,
f"{id_2}-no": "3a",
f"{id_3}-no": "5",
f"{id_4}-no": "2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context():
self.assertEqual(db.session.get(Account, id_1).code, "1111-003")
self.assertEqual(db.session.get(Account, id_2).code, "1111-004")
self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
self.assertEqual(db.session.get(Account, id_4).code, "1111-001")
self.assertEqual(db.session.get(Account, id_5).code, "1111-005")

View File

@ -14,10 +14,11 @@
# 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 base account management.
"""
import csv
import typing as t
import unittest
import httpx
@ -25,8 +26,66 @@ from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from testlib import get_csrf_token
from testsite import create_app
from test_site import create_app
from testlib import get_client
LIST_URI: str = "/accounting/base-accounts"
"""The list URI."""
DETAIL_URI: str = "/accounting/base-accounts/1111"
"""The detail URI."""
class BaseAccountCommandTestCase(unittest.TestCase):
"""The base account console command test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
from accounting.models import BaseAccount, BaseAccountL10n
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
BaseAccountL10n.query.delete()
BaseAccount.query.delete()
def test_init(self) -> None:
"""Tests the "accounting-init-base" console command.
:return: None.
"""
from accounting import data_dir
from accounting.models import BaseAccount
with open(data_dir / "base_accounts.csv") as fp:
data: dict[dict[str, t.Any]] \
= {x["code"]: {"code": x["code"],
"title": x["title"],
"l10n": {y[5:]: x[y]
for y in x if y.startswith("l10n-")}}
for x in csv.DictReader(fp)}
runner: FlaskCliRunner = self.app.test_cli_runner()
result: Result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
with self.app.app_context():
accounts: list[BaseAccount] = BaseAccount.query.all()
self.assertEqual(len(accounts), len(data))
for account in accounts:
self.assertIn(account.code, data)
self.assertEqual(account.title_l10n, data[account.code]["title"])
l10n: dict[str, str] = {x.locale: x.title for x in account.l10n}
self.assertEqual(len(l10n), len(data[account.code]["l10n"]))
for locale in l10n:
self.assertIn(locale, data[account.code]["l10n"])
self.assertEqual(l10n[locale],
data[account.code]["l10n"][locale])
class BaseAccountTestCase(unittest.TestCase):
@ -38,77 +97,55 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import BaseAccount
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
self.client: httpx.Client = httpx.Client(app=self.app,
base_url="https://testserver")
self.client.headers["Referer"] = "https://testserver"
self.csrf_token: str = get_csrf_token(self, self.client, "/login")
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
def test_init(self) -> None:
"""Tests the "accounting-init-base" console command.
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
from accounting.models import BaseAccountL10n
from accounting.models import BaseAccount
runner: FlaskCliRunner = self.app.test_cli_runner()
result: Result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
with self.app.app_context():
accounts: list[BaseAccount] = BaseAccount.query.all()
l10n: list[BaseAccountL10n] = BaseAccountL10n.query.all()
self.assertEqual(len(accounts), 527)
self.assertEqual(len(l10n), 527 * 2)
l10n_keys: set[str] = {f"{x.account_code}-{x.locale}" for x in l10n}
for account in accounts:
self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
list_uri: str = "/accounting/base-accounts"
client, csrf_token = get_client(self.app, "nobody")
response: httpx.Response
self.__logout()
response = self.client.get(list_uri)
response = client.get(LIST_URI)
self.assertEqual(response.status_code, 403)
self.__logout()
self.__login_as("viewer")
response = self.client.get(list_uri)
self.assertEqual(response.status_code, 200)
self.__logout()
self.__login_as("editor")
response = self.client.get(list_uri)
self.assertEqual(response.status_code, 200)
self.__logout()
self.__login_as("nobody")
response = self.client.get(list_uri)
response = client.get(DETAIL_URI)
self.assertEqual(response.status_code, 403)
def __logout(self) -> None:
"""Logs out the currently logged-in user.
def test_viewer(self) -> None:
"""Test the permission as viewer.
:return: None.
"""
response: httpx.Response = self.client.post(
"/logout", data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/")
client, csrf_token = get_client(self.app, "viewer")
response: httpx.Response
def __login_as(self, username: str) -> None:
"""Logs in as a specific user.
response = client.get(LIST_URI)
self.assertEqual(response.status_code, 200)
response = client.get(DETAIL_URI)
self.assertEqual(response.status_code, 200)
def test_editor(self) -> None:
"""Test the permission as editor.
:param username: The username.
:return: None.
"""
response: httpx.Response = self.client.post(
"/login", data={"csrf_token": self.csrf_token,
"username": username})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/")
client, csrf_token = get_client(self.app, "editor")
response: httpx.Response
response = client.get(LIST_URI)
self.assertEqual(response.status_code, 200)
response = client.get(DETAIL_URI)
self.assertEqual(response.status_code, 200)

607
tests/test_currency.py Normal file
View File

@ -0,0 +1,607 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# 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 currency management.
"""
import csv
import typing as t
import unittest
from datetime import timedelta
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app, db
from testlib import get_client, set_locale
class CurrencyData:
"""The currency data."""
def __init__(self, code: str, name: str):
"""Constructs the currency data.
:param code: The code.
:param name: The name.
"""
self.code: str = code
"""The code."""
self.name: str = name
"""The name."""
zza: CurrencyData = CurrencyData("ZZA", "Testing Dollar #A")
"""The first test currency."""
zzb: CurrencyData = CurrencyData("ZZB", "Testing Dollar #B")
"""The second test currency."""
zzc: CurrencyData = CurrencyData("ZZC", "Testing Dollar #C")
"""The third test currency."""
zzd: CurrencyData = CurrencyData("ZZD", "Testing Dollar #D")
"""The fourth test currency."""
PREFIX: str = "/accounting/currencies"
"""The URL prefix of the currency management."""
class CurrencyCommandTestCase(unittest.TestCase):
"""The account console command 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 Currency, CurrencyL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
CurrencyL10n.query.delete()
Currency.query.delete()
db.session.commit()
def test_init(self) -> None:
"""Tests the "accounting-init-currencies" console command.
:return: None.
"""
from accounting import data_dir
from accounting.models import Currency
with open(data_dir / "currencies.csv") as fp:
data: dict[dict[str, t.Any]] \
= {x["code"]: {"code": x["code"],
"name": x["name"],
"l10n": {y[5:]: x[y]
for y in x if y.startswith("l10n-")}}
for x in csv.DictReader(fp)}
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(
args=["accounting-init-currencies", "-u", "editor"])
self.assertEqual(result.exit_code, 0)
currencies: list[Currency] = Currency.query.all()
self.assertEqual(len(currencies), len(data))
for currency in currencies:
self.assertIn(currency.code, data)
self.assertEqual(currency.name_l10n, data[currency.code]["name"])
l10n: dict[str, str] = {x.locale: x.name for x in currency.l10n}
self.assertEqual(len(l10n), len(data[currency.code]["l10n"]))
for locale in l10n:
self.assertIn(locale, data[currency.code]["l10n"])
self.assertEqual(l10n[locale],
data[currency.code]["l10n"][locale])
class CurrencyTestCase(unittest.TestCase):
"""The currency 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 Currency, CurrencyL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
CurrencyL10n.query.delete()
Currency.query.delete()
db.session.commit()
self.client, self.csrf_token = get_client(self.app, "editor")
response: httpx.Response
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": zza.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zza.code}")
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": zzb.code,
"name": zzb.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzb.code}")
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{zza.code}")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{zza.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zza.code}/update",
data={"csrf_token": csrf_token,
"code": zzd.code,
"name": zzd.name})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zzb.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
"""Test the permission as viewer.
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/{zza.code}")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{zza.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zza.code}/update",
data={"csrf_token": csrf_token,
"code": zzd.code,
"name": zzd.name})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zzb.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
def test_editor(self) -> None:
"""Test the permission as editor.
:return: None.
"""
response: httpx.Response
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{zza.code}")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzc.code}")
response = self.client.get(f"{PREFIX}/{zza.code}/edit")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{zza.code}/update",
data={"csrf_token": self.csrf_token,
"code": zzd.code,
"name": zzd.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzd.code}")
response = self.client.post(f"{PREFIX}/{zzb.code}/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], PREFIX)
def test_add(self) -> None:
"""Tests to add the currencies.
:return: None.
"""
from accounting.models import Currency
create_uri: str = f"{PREFIX}/create"
store_uri: str = f"{PREFIX}/store"
detail_uri: str = f"{PREFIX}/{zzc.code}"
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code})
# Missing CSRF token
response = self.client.post(store_uri,
data={"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 400)
# CSRF token mismatch
response = self.client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2",
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 400)
# Empty code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Blocked code, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " create ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Bad code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " zzc ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Empty name
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": " "})
self.assertEqual(response.status_code, 302)
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,
"code": f" {zzc.code} ",
"name": f" {zzc.name} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
# Duplicated code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code, zzc.code})
zzc_currency: Currency = db.session.get(Currency, zzc.code)
self.assertEqual(zzc_currency.code, zzc.code)
self.assertEqual(zzc_currency.name_l10n, zzc.name)
def test_basic_update(self) -> None:
"""Tests the basic rules to update a user.
:return: None.
"""
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{zza.code}"
edit_uri: str = f"{PREFIX}/{zza.code}/edit"
update_uri: str = f"{PREFIX}/{zza.code}/update"
detail_c_uri: str = f"{PREFIX}/{zzc.code}"
response: httpx.Response
# Success, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": f" {zza.code} ",
"name": f" {zza.name}-1 "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.code, zza.code)
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-1")
# Empty code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": " ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Blocked code, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": " create ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Bad code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": "abc/def",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Empty name
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Duplicated code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zzb.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Change code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_c_uri)
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.get(detail_c_uri)
self.assertEqual(response.status_code, 200)
def test_update_not_modified(self) -> None:
"""Tests that the data is not modified.
:return: None.
"""
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
zza_currency: Currency
response: httpx.Response
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": f" {zza.code} ",
"name": f" {zza.name} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency = db.session.get(Currency, zza.code)
self.assertIsNotNone(zza_currency)
zza_currency.created_at \
= zza_currency.created_at - timedelta(seconds=5)
zza_currency.updated_at = zza_currency.created_at
db.session.commit()
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency = db.session.get(Currency, zza.code)
self.assertIsNotNone(zza_currency)
self.assertLess(zza_currency.created_at,
zza_currency.updated_at)
def test_created_updated_by(self) -> None:
"""Tests the created-by and updated-by record.
:return: None.
"""
from accounting.models import Currency
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
response: httpx.Response
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.created_by.username, editor_username)
self.assertEqual(zza_currency.updated_by.username, editor_username)
response = client.post(update_uri,
data={"csrf_token": csrf_token,
"code": zza.code,
"name": f"{zza.name}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.created_by.username, editor_username)
self.assertEqual(zza_currency.updated_by.username, editor2_username)
def test_api_exists(self) -> None:
"""Tests the API to check if a code exists.
:return: None.
"""
response: httpx.Response
response = self.client.get(
f"/accounting/api/currencies/exists-code?q={zza.code}")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(set(data.keys()), {"exists"})
self.assertTrue(data["exists"])
response = self.client.get(
f"/accounting/api/currencies/exists-code?q={zza.code}-1")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(set(data.keys()), {"exists"})
self.assertFalse(data["exists"])
def test_l10n(self) -> None:
"""Tests the localization.
:return: None
"""
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
response: httpx.Response
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, zza.name)
self.assertEqual(zza_currency.l10n, [])
set_locale(self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": f"{zza.name}-zh_Hant"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, zza.name)
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant")})
set_locale(self.client, self.csrf_token, "en")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": f"{zza.name}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant")})
set_locale(self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": f"{zza.name}-zh_Hant-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant-2")})
def test_delete(self) -> None:
"""Tests to delete a currency.
:return: None.
"""
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{zza.code}"
delete_uri: str = f"{PREFIX}/{zza.code}/delete"
list_uri: str = PREFIX
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zzb.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 404)

View File

@ -29,6 +29,8 @@ from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect
from sqlalchemy import Column
import accounting.utils.user
bp: Blueprint = Blueprint("home", __name__)
babel_js: BabelJS = BabelJS()
csrf: CSRFProtect = CSRFProtect()
@ -53,7 +55,6 @@ def create_app(is_testing: bool = False) -> Flask:
})
if is_testing:
app.config["TESTING"] = True
app.config["SQLALCHEMY_ECHO"] = True
babel_js.init_app(app)
csrf.init_app(app)
@ -68,7 +69,7 @@ def create_app(is_testing: bool = False) -> Flask:
from . import auth
auth.init_app(app)
class UserUtils(accounting.AbstractUserUtils[auth.User]):
class UserUtils(accounting.utils.user.AbstractUserUtils[auth.User]):
@property
def cls(self) -> t.Type[auth.User]:
@ -79,7 +80,7 @@ def create_app(is_testing: bool = False) -> Flask:
return auth.User.id
@property
def current_user(self) -> auth.User:
def current_user(self) -> auth.User | None:
return auth.current_user()
def get_by_username(self, username: str) -> auth.User | None:
@ -90,9 +91,9 @@ def create_app(is_testing: bool = False) -> Flask:
return user.id
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username in ["viewer", "editor"]
and auth.current_user().username in ["viewer", "editor", "editor2"]
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username == "editor"
and auth.current_user().username in ["editor", "editor2"]
accounting.init_app(app, user_utils=UserUtils(),
can_view_func=can_view, can_edit_func=can_edit)
@ -105,7 +106,7 @@ def init_db_command() -> None:
"""Initializes the database."""
db.create_all()
from .auth import User
for username in ["viewer", "editor", "nobody"]:
for username in ["viewer", "editor", "editor2", "nobody"]:
if User.query.filter(User.username == username).first() is None:
db.session.add(User(username=username))
db.session.commit()

View File

@ -58,7 +58,8 @@ def login() -> redirect:
:return: The redirection to the home page.
"""
if request.form.get("username") not in ["viewer", "editor", "nobody"]:
if request.form.get("username") not in ["viewer", "editor", "editor2",
"nobody"]:
return redirect(url_for("auth.login"))
session["user"] = request.form.get("username")
return redirect(url_for("home.home"))

View File

@ -29,8 +29,8 @@ First written: 2023/1/27
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css">
{% block styles %}{% endblock %}
<script src="{{ url_for("babel_catalog") }}"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/decimal.js/9.0.0/decimal.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/decimal.js-light@2.5.1/decimal.min.js" integrity="sha384-QdsxGEq4Y0erX8WUIsZJDtfoSSyBF6dmNCnzRNYCa2AOM/xzNsyhHu0RbdFBAm+l" crossorigin="anonymous"></script>
{% block scripts %}{% endblock %}
<title>{% block title %}{% endblock %}</title>
</head>

Some files were not shown because too many files have changed in this diff Show More