diff --git a/book.py b/book.py index 3bdbfd6..a360f7c 100644 --- a/book.py +++ b/book.py @@ -9,6 +9,10 @@ from trytond.exceptions import UserError from trytond.i18n import gettext from trytond.transaction import Transaction from trytond.pool import Pool +from trytond.report import Report +from decimal import Decimal +from sql.aggregate import Sum +from sql.conditionals import Case STATES = { @@ -48,6 +52,15 @@ class Book(Workflow, ModelSQL, ModelView): account = fields.Many2One(string='Account', select=True, model_name='account.account', ondelete='RESTRICT', states=STATES, depends=DEPENDS) + start_balance = fields.Numeric(string='Initial Amount', required=True, + states={ + 'readonly': Or( + STATES['readonly'], + Bool(Eval('lines')), + ), + }, depends=DEPENDS+['lines']) + balance = fields.Function(fields.Numeric(string='Balance', readonly=True), + 'on_change_with_balance') currency = fields.Many2One(string='Currency', required=True, model_name='currency.currency', states={ @@ -64,6 +77,7 @@ class Book(Workflow, ModelSQL, ModelView): def __setup__(cls): super(Book, cls).__setup__() cls._order.insert(0, ('name', 'ASC')) + cls._order.insert(0, ('state', 'ASC')) t = cls.__table__() cls._sql_constraints.extend([ ('state_val', @@ -90,6 +104,12 @@ class Book(Workflow, ModelSQL, ModelView): }, }) + @classmethod + def default_start_balance(cls): + """ zero + """ + return Decimal('0.0') + @classmethod def default_currency(cls): """ currency of company @@ -116,6 +136,55 @@ class Book(Workflow, ModelSQL, ModelView): """ return Transaction().user + @staticmethod + def order_state(tables): + """ edit = 0, check/done = 1 + """ + Book2 = Pool().get('cashbook.book') + tab_book = Book2.__table__() + table, _ = tables[None] + + query = tab_book.select( + Case( + (tab_book.state == 'open', 0), + else_ = 1), + where=tab_book.id==table.id + ) + return [query] + + def get_rec_name(self, name): + """ name, balance, state + """ + return '%(name)s | %(balance)s %(symbol)s | %(state)s' % { + 'name': self.name or '-', + 'balance': Report.format_number(self.balance or 0.0, None), + 'symbol': getattr(self.currency, 'symbol', '-'), + 'state': self.state_string, + } + + @fields.depends('id', 'start_balance') + def on_change_with_balance(self, name=None): + """ compute balance + """ + Line = Pool().get('cashbook.line') + tab_line = Line.__table__() + cursor = Transaction().connection.cursor() + + query = tab_line.select( + Sum(tab_line.credit - tab_line.debit).as_('balance'), + group_by=[tab_line.cashbook], + where=tab_line.cashbook == self.id + ) + + if self.id: + if self.start_balance is not None: + balance = self.start_balance + cursor.execute(*query) + result = cursor.fetchone() + if result: + balance += result[0] + return balance + @classmethod @ModelView.button @Workflow.transition('open') @@ -147,6 +216,12 @@ class Book(Workflow, ModelSQL, ModelView): actions = iter(args) for books, values in zip(actions, actions): for book in books: + if 'start_balance' in values.keys(): + if len(book.lines) > 0: + raise UserError(gettext( + 'cashbook.msg_book_err_startamount_with_lines', + bookname = book.rec_name, + )) if book.state != 'open': # allow state-update, if its the only action if not (('state' in values.keys()) and (len(values.keys()) == 1)): diff --git a/line.py b/line.py index ee559c7..0484ab7 100644 --- a/line.py +++ b/line.py @@ -77,6 +77,13 @@ class Line(Workflow, ModelSQL, ModelView): required=True, readonly=True, depends=['currency_digits']) credit = fields.Numeric(string='Credit', digits=(16, Eval('currency_digits', 2)), required=True, readonly=True, depends=['currency_digits']) + + balance = fields.Function(fields.Numeric(string='Balance', + digits=(16, Eval('currency_digits', 2)), + help='Balance of the cash book up to the current line, if the default sorting applies.', + readonly=True, depends=['currency_digits']), + 'on_change_with_balance') + currency = fields.Function(fields.Many2One(model_name='currency.currency', string="Currency"), 'on_change_with_currency') currency_digits = fields.Function(fields.Integer(string='Currency Digits'), @@ -94,8 +101,8 @@ class Line(Workflow, ModelSQL, ModelView): @classmethod def __setup__(cls): super(Line, cls).__setup__() - cls._order.insert(0, ('state', 'ASC')) cls._order.insert(0, ('date', 'ASC')) + cls._order.insert(0, ('state', 'ASC')) t = cls.__table__() cls._sql_constraints.extend([ ('state_val', @@ -146,20 +153,6 @@ class Line(Workflow, ModelSQL, ModelView): """ pass - @fields.depends('bookingtype', 'category') - def on_change_bookingtype(self): - """ clear category if not valid type - """ - types = { - 'in': ['in', 'mvin'], - 'out': ['out', 'mvout'], - } - - if self.bookingtype: - if self.category: - if not self.bookingtype in types.get(self.category.cattype, ''): - self.category = None - @classmethod def default_state(cls): """ default: edit @@ -180,6 +173,12 @@ class Line(Workflow, ModelSQL, ModelView): context = Transaction().context return context.get('cashbook', None) + @classmethod + def search_rec_name(cls, name, clause): + """ search in description +... + """ + return [('description',) + tuple(clause[1:])] + def get_rec_name(self, name): """ short + name """ @@ -219,12 +218,6 @@ class Line(Workflow, ModelSQL, ModelView): return [tab2] - @classmethod - def search_category_view(cls, name, clause): - """ search in category - """ - return [('category.rec_name',) + tuple(clause[1:])] - @fields.depends('category') def on_change_with_category_view(self, name=None): """ show optimizef form of category for list-view @@ -239,10 +232,10 @@ class Line(Workflow, ModelSQL, ModelView): return self.category.get_long_recname(self.category.name) @classmethod - def search_rec_name(cls, name, clause): - """ search in description +... + def search_category_view(cls, name, clause): + """ search in category """ - return [('description',) + tuple(clause[1:])] + return [('category.rec_name',) + tuple(clause[1:])] @fields.depends('date') def on_change_with_month(self, name=None): @@ -286,6 +279,20 @@ class Line(Workflow, ModelSQL, ModelView): """ return [('cashbook.state',) + tuple(clause[1:])] + @fields.depends('bookingtype', 'category') + def on_change_bookingtype(self): + """ clear category if not valid type + """ + types = { + 'in': ['in', 'mvin'], + 'out': ['out', 'mvout'], + } + + if self.bookingtype: + if self.category: + if not self.bookingtype in types.get(self.category.cattype, ''): + self.category = None + @fields.depends('cashbook', '_parent_cashbook.currency') def on_change_with_currency(self, name=None): """ currency of cashbook @@ -302,6 +309,23 @@ class Line(Workflow, ModelSQL, ModelView): else: return 2 + @fields.depends('id', 'cashbook', '_parent_cashbook.start_balance', '_parent_cashbook.id') + def on_change_with_balance(self, name=None): + """ compute balance until current line, with current sort order + """ + Line = Pool().get('cashbook.line') + + if self.cashbook: + balance = self.cashbook.start_balance + lines = Line.search([ + ('cashbook.id', '=', self.cashbook.id), + ]) + for line in lines: + balance += line.credit - line.debit + if line.id == self.id: + break + return balance + @classmethod def get_debit_credit(cls, values): """ compute debit/credit from amount @@ -470,6 +494,7 @@ class LineContext(ModelView): """ get number of accessible cashbooks, depends on user-permissions """ + print('-- on_change_with_num_cashbook:', Transaction().context) LineContext = Pool().get('cashbook.line.context') return LineContext.default_num_cashbook() diff --git a/locale/de.po b/locale/de.po index 1114c50..6ab03d3 100644 --- a/locale/de.po +++ b/locale/de.po @@ -54,6 +54,10 @@ msgctxt "model:ir.message,text:msg_category_type_not_like_parent" msgid "The type of the current category '%(catname)s' must be equal to the type of the parent category '%(parentname)s'." msgstr "Der Typ der aktuellen Kategorie '%(catname)s' muß gleich dem Typ der übergeordneten Kategorie '%(parentname)s' sein." +msgctxt "model:ir.message,text:msg_book_err_startamount_with_lines" +msgid "The initial amount of the cash book '%(bookname)s' cannot be changed because it already contains bookings." +msgstr "Der Anfangsbetrag des Kassenbuchs '%(bookname)s' kann nicht geändert werden, da es bereits Buchungen enthält." + ############# # res.group # @@ -274,6 +278,10 @@ msgctxt "field:cashbook.book,currency:" msgid "Currency" msgstr "Währung" +msgctxt "field:cashbook.book,start_balance:" +msgid "Initial Amount" +msgstr "Anfangsbetrag" + ################# # cashbook.line # @@ -390,6 +398,14 @@ msgctxt "field:cashbook.line,currency_digits:" msgid "Currency Digits" msgstr "Nachkommastellen Währung" +msgctxt "field:cashbook.line,balance:" +msgid "Balance" +msgstr "Saldo" + +msgctxt "help:cashbook.line,balance:" +msgid "Balance of the cash book up to the current line, if the default sorting applies." +msgstr "Saldo des Kassenbuchs bis zur aktuellen Zeile, sofern die Standardsortierung gilt." + ################# # cashbook.type # diff --git a/message.xml b/message.xml index 9daa5b0..439317f 100644 --- a/message.xml +++ b/message.xml @@ -41,6 +41,9 @@ full copyright notices and license terms. --> The type of the current category '%(catname)s' must be equal to the type of the parent category '%(parentname)s'. + + The initial amount of the cash book '%(bookname)s' cannot be changed because it already contains bookings. + diff --git a/tests/test_book.py b/tests/test_book.py index 29a90f3..ed25879 100644 --- a/tests/test_book.py +++ b/tests/test_book.py @@ -62,7 +62,7 @@ class BookTestCase(ModuleTestCase): self.assertEqual(book.state, 'open') self.assertRaisesRegex(UserError, - "The cashbook 'Book 1' cannot be deleted because it contains 1 lines and is not in the status 'Archive'.", + "The cashbook 'Book 1 | 1.00 usd | Open' cannot be deleted because it contains 1 lines and is not in the status 'Archive'.", Book.delete, [book]) @@ -95,7 +95,7 @@ class BookTestCase(ModuleTestCase): self.assertEqual(book.state, 'closed') self.assertRaisesRegex(UserError, - "The cashbook 'Book 1' cannot be deleted because it contains 1 lines and is not in the status 'Archive'.", + "The cashbook 'Book 1 | 1.00 usd | Closed' cannot be deleted because it contains 1 lines and is not in the status 'Archive'.", Book.delete, [book]) @@ -160,7 +160,7 @@ class BookTestCase(ModuleTestCase): self.assertEqual(book.state, 'closed') self.assertRaisesRegex(UserError, - "The cash book 'Book 1a' is 'Closed' and cannot be changed.", + "The cash book 'Book 1a | 1.00 usd | Closed' is 'Closed' and cannot be changed.", Book.write, *[ [book], @@ -183,7 +183,7 @@ class BookTestCase(ModuleTestCase): Book.wfarchive([book]) self.assertRaisesRegex(UserError, - "The cash book 'Book 1c' is 'Archive' and cannot be changed.", + "The cash book 'Book 1c | 0.00 usd | Archive' is 'Archive' and cannot be changed.", Book.write, *[ [book], @@ -192,6 +192,59 @@ class BookTestCase(ModuleTestCase): }, ]) + @with_transaction() + def test_book_deny_update_start_amount(self): + """ create cashbook, add lines, update start-amount + """ + pool = Pool() + Book = pool.get('cashbook.book') + + types = self.prep_type() + company = self.prep_company() + category = self.prep_category(cattype='in') + book, = Book.create([{ + 'name': 'Book 1', + 'btype': types.id, + 'company': company.id, + 'currency': company.currency.id, + }]) + self.assertEqual(book.name, 'Book 1') + self.assertEqual(book.start_balance, Decimal('0.0')) + self.assertEqual(book.rec_name, 'Book 1 | 0.00 usd | Open') + + Book.write(*[ + [book], + { + 'start_balance': Decimal('1.0'), + }]) + self.assertEqual(book.start_balance, Decimal('1.0')) + self.assertEqual(book.balance, Decimal('1.0')) + + Book.write(*[ + [book], + { + 'lines': [('create', [{ + 'amount': Decimal('2.0'), + 'description': 'Test', + 'category': category.id, + 'bookingtype': 'in', + }])], + }]) + self.assertEqual(book.start_balance, Decimal('1.0')) + self.assertEqual(book.balance, Decimal('3.0')) + self.assertEqual(len(book.lines), 1) + self.assertEqual(book.lines[0].balance, Decimal('3.0')) + + self.assertRaisesRegex(UserError, + "The initial amount of the cash book 'Fridas book | 3.00 usd | Open' cannot be changed because it already contains bookings.", + Book.write, + *[ + [book], + { + 'start_balance': Decimal('1.5'), + }, + ]) + @with_transaction() def test_book_permission_owner(self): """ create book + 2x users, add users to group, check access @@ -228,7 +281,7 @@ class BookTestCase(ModuleTestCase): 'company': company.id, 'currency': company.currency.id, }]) - self.assertEqual(book.rec_name, 'Fridas book'), + self.assertEqual(book.rec_name, 'Fridas book | 0.00 usd | Open'), self.assertEqual(book.owner.rec_name, 'Frida'), with Transaction().set_context({ @@ -243,7 +296,7 @@ class BookTestCase(ModuleTestCase): with Transaction().set_user(usr_lst[0].id): books = Book.search([]) self.assertEqual(len(books), 1) - self.assertEqual(books[0].rec_name, 'Fridas book') + self.assertEqual(books[0].rec_name, 'Fridas book | 0.00 usd | Open') self.assertRaisesRegex(UserError, 'You are not allowed to access "Cashbook".', @@ -298,7 +351,7 @@ class BookTestCase(ModuleTestCase): 'currency': company.currency.id, 'btype': types.id, }]) - self.assertEqual(book.rec_name, 'Fridas book'), + self.assertEqual(book.rec_name, 'Fridas book | 0.00 usd | Open'), self.assertEqual(book.owner.rec_name, 'Frida'), with Transaction().set_context({ @@ -315,7 +368,7 @@ class BookTestCase(ModuleTestCase): with Transaction().set_user(usr_lst[0].id): books = Book.search([]) self.assertEqual(len(books), 1) - self.assertEqual(books[0].rec_name, 'Fridas book') + self.assertEqual(books[0].rec_name, 'Fridas book | 0.00 usd | Open') @with_transaction() def test_book_permission_observer(self): @@ -360,7 +413,7 @@ class BookTestCase(ModuleTestCase): 'currency': company.currency.id, 'btype': types.id, }]) - self.assertEqual(book.rec_name, 'Fridas book'), + self.assertEqual(book.rec_name, 'Fridas book | 0.00 usd | Open'), self.assertEqual(book.owner.rec_name, 'Frida'), with Transaction().set_context({ @@ -377,6 +430,6 @@ class BookTestCase(ModuleTestCase): with Transaction().set_user(usr_lst[0].id): books = Book.search([]) self.assertEqual(len(books), 1) - self.assertEqual(books[0].rec_name, 'Fridas book') + self.assertEqual(books[0].rec_name, 'Fridas book | 0.00 usd | Open') # end BookTestCase diff --git a/tests/test_line.py b/tests/test_line.py index 63c644f..3bcd986 100644 --- a/tests/test_line.py +++ b/tests/test_line.py @@ -131,7 +131,7 @@ class LineTestCase(ModuleTestCase): self.assertEqual(book.state, 'closed') self.assertRaisesRegex(UserError, - "The cash book 'Book 1' is 'Closed' and cannot be changed.", + "The cash book 'Book | 2.00 usd | Closed' is 'Closed' and cannot be changed.", Line.write, *[ [book.lines[0]], @@ -478,7 +478,7 @@ class LineTestCase(ModuleTestCase): self.assertEqual(book.state, 'closed') self.assertRaisesRegex(UserError, - "The cashbook line '05/01/2022 Text 1' cannot be deleted because the Cashbook 'Book 1' is in state 'Closed'.", + "The cashbook line '05/01/2022 Text 1' cannot be deleted because the Cashbook 'Book | 2.00 usd | Closed' is in state 'Closed'.", Lines.delete, [book.lines[0]]) @@ -570,7 +570,7 @@ class LineTestCase(ModuleTestCase): 'amount': Decimal('1.0'), }])], }]) - self.assertEqual(book.rec_name, 'Fridas book'), + self.assertEqual(book.rec_name, 'Fridas book | 1.00 usd | Open'), self.assertEqual(book.owner.rec_name, 'Frida'), with Transaction().set_context({ @@ -585,7 +585,7 @@ class LineTestCase(ModuleTestCase): with Transaction().set_user(usr_lst[0].id): lines = Line.search([]) self.assertEqual(len(lines), 1) - self.assertEqual(lines[0].cashbook.rec_name, 'Fridas book') + self.assertEqual(lines[0].cashbook.rec_name, 'Fridas book | 1.00 usd | Open') self.assertEqual(lines[0].rec_name, '05/01/2022 Test 1') Line.write(*[ @@ -647,7 +647,7 @@ class LineTestCase(ModuleTestCase): 'amount': Decimal('1.0'), }])], }]) - self.assertEqual(book.rec_name, 'Fridas book'), + self.assertEqual(book.rec_name, 'Fridas book | 1.00 usd | Open'), self.assertEqual(book.owner.rec_name, 'Frida'), with Transaction().set_context({ @@ -731,7 +731,7 @@ class LineTestCase(ModuleTestCase): 'amount': Decimal('1.0'), }])], }]) - self.assertEqual(book.rec_name, 'Fridas book'), + self.assertEqual(book.rec_name, 'Fridas book | 1.00 usd | Open'), self.assertEqual(book.owner.rec_name, 'Frida'), with Transaction().set_context({ diff --git a/view/book_form.xml b/view/book_form.xml index ec543c1..863bade 100644 --- a/view/book_form.xml +++ b/view/book_form.xml @@ -13,6 +13,10 @@ full copyright notices and license terms. -->