# -*- coding: utf-8 -*- # This file is part of the cashbook-module from m-ds for Tryton. # The COPYRIGHT file at the top level of this repository contains the # full copyright notices and license terms. from trytond.model import ModelView, ModelSQL, Workflow, fields, Check from trytond.pool import Pool from trytond.pyson import Eval, If, Or, Bool from trytond.transaction import Transaction from trytond.report import Report from trytond.exceptions import UserError from trytond.i18n import gettext from decimal import Decimal from sql import Cast, Literal from sql.functions import DatePart from sql.conditionals import Case from .book import sel_state_book sel_payee = [ ('cashbook.book', 'Cashbook'), ('party.party', 'Party') ] sel_linetype = [ ('edit', 'Edit'), ('check', 'Checked'), ('done', 'Done'), ] sel_bookingtype = [ ('in', 'Revenue'), ('out', 'Expense'), ('spin', 'Revenue Splitbooking'), ('spout', 'Expense Splitbooking'), ('mvin', 'Transfer from'), ('mvout', 'Transfer to'), ] STATES = { 'readonly': Or( Eval('state', '') != 'edit', Eval('state_cashbook', '') != 'open', ), } DEPENDS=['state', 'state_cashbook'] class Line(Workflow, ModelSQL, ModelView): 'Cashbook Line' __name__ = 'cashbook.line' cashbook = fields.Many2One(string='Cashbook', required=True, select=True, model_name='cashbook.book', ondelete='CASCADE', readonly=True) date = fields.Date(string='Date', required=True, select=True, states=STATES, depends=DEPENDS) month = fields.Function(fields.Integer(string='Month', readonly=True), 'on_change_with_month', searcher='search_month') number = fields.Char(string='Number', readonly=True) description = fields.Text(string='Description', states=STATES, depends=DEPENDS) category = fields.Many2One(string='Category', model_name='cashbook.category', ondelete='RESTRICT', states={ 'readonly': Or( STATES['readonly'], Bool(Eval('bookingtype')) == False, ), 'required': Eval('bookingtype', '').in_(['in', 'out']), 'invisible': ~Eval('bookingtype', '').in_(['in', 'out']), }, depends=DEPENDS+['bookingtype'], domain=[ If( Eval('bookingtype', '').in_(['in', 'mvin']), ('cattype', '=', 'in'), ('cattype', '=', 'out'), )]) category_view = fields.Function(fields.Char(string='Category', readonly=True), 'on_change_with_category_view', searcher='search_category_view') bookingtype = fields.Selection(string='Type', required=True, help='Type of Booking', selection=sel_bookingtype, states=STATES, depends=DEPENDS) bookingtype_string = bookingtype.translated('bookingtype') amount = fields.Numeric(string='Amount', digits=(16, Eval('currency_digits', 2)), required=True, states={ 'readonly': Or( STATES['readonly'], Eval('bookingtype', '').in_(['spin', 'spout']), ), }, depends=DEPENDS+['currency_digits', 'bookingtype']) debit = fields.Numeric(string='Debit', digits=(16, Eval('currency_digits', 2)), 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']) # party or cashbook as counterpart booktransf = fields.Many2One(string='Source/Dest', ondelete='RESTRICT', model_name='cashbook.book', domain=[ ('owner.id', '=', Eval('owner_cashbook', -1)), ('id', '!=', Eval('cashbook', -1)), ], states={ 'readonly': STATES['readonly'], 'invisible': ~Eval('bookingtype', '').in_(['mvin', 'mvout']), 'required': Eval('bookingtype', '').in_(['mvin', 'mvout']), }, depends=DEPENDS+['bookingtype', 'owner_cashbook', 'cashbook']) party = fields.Many2One(string='Party', model_name='party.party', ondelete='RESTRICT', states={ 'readonly': STATES['readonly'], 'invisible': ~Eval('bookingtype', '').in_(['in', 'out', 'spin', 'spout']), }, depends=DEPENDS+['bookingtype']) payee = fields.Function(fields.Reference(string='Payee', readonly=True, selection=sel_payee), 'on_change_with_payee', searcher='search_payee') # link to lines created by this record reference = fields.Many2One(string='Reference', readonly=True, select=True, states={ 'invisible': ~Bool(Eval('reference')), }, model_name='cashbook.line', ondelete='CASCADE', help='The current row was created by and is controlled by the reference row.') references = fields.One2Many(string='References', model_name='cashbook.line', help='The rows are created and managed by the current record.', states={ 'invisible': ~Bool(Eval('references')), }, field='reference', readonly=True) splitlines = fields.One2Many(string='Split booking lines', model_name='cashbook.split', help='Rows with different categories form the total sum of the booking', states={ 'invisible': ~Eval('bookingtype' '').in_(['spin', 'spout']), 'readonly': Or( ~Eval('bookingtype' '').in_(['spin', 'spout']), STATES['readonly'], ), 'required': Eval('bookingtype' '').in_(['spin', 'spout']), }, field='line', depends=DEPENDS+['bookingtype']) reconciliation = fields.Many2One(string='Reconciliation', readonly=True, model_name='cashbook.recon', ondelete='SET NULL', domain=[('cashbook.id', '=', Eval('cashbook'))], depends=['cashbook'], states={ 'invisible': ~Bool(Eval('reconciliation')), }) 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", readonly=True), 'on_change_with_currency') currency_digits = fields.Function(fields.Integer(string='Currency Digits', readonly=True), 'on_change_with_currency_digits') state = fields.Selection(string='State', required=True, readonly=True, select=True, selection=sel_linetype) state_string = state.translated('state') state_cashbook = fields.Function(fields.Selection(string='State of Cashbook', readonly=True, states={'invisible': True}, selection=sel_state_book), 'on_change_with_state_cashbook', searcher='search_state_cashbook') owner_cashbook = fields.Function(fields.Many2One(string='Owner', readonly=True, states={'invisible': True}, model_name='res.user'), 'on_change_with_owner_cashbook') #image = fields.Binary... @classmethod def __setup__(cls): super(Line, cls).__setup__() cls._order.insert(0, ('date', 'ASC')) cls._order.insert(0, ('state', 'ASC')) t = cls.__table__() cls._sql_constraints.extend([ ('state_val', Check(t, t.state.in_(['edit', 'check', 'done'])), 'cashbook.msg_line_wrong_state_value'), ]) cls._transitions |= set(( ('edit', 'check'), ('check', 'done'), ('check', 'edit'), )) cls._buttons.update({ 'wfedit': { 'invisible': Eval('state', '') != 'check', 'readonly': Bool(Eval('reference')), 'depends': ['state', 'reference'], }, 'wfcheck': { 'invisible': Eval('state') != 'edit', 'depends': ['state'], }, 'wfdone': { 'invisible': Eval('state') != 'check', 'depends': ['state'], }, }) @classmethod @ModelView.button @Workflow.transition('edit') def wfedit(cls, lines): """ edit line """ pool = Pool() Line2 = pool.get('cashbook.line') to_delete_line = [] for line in lines: if line.reference: if Transaction().context.get('line.allow.wfedit', False) == False: raise UserError(gettext( 'cashbook.msg_line_denywf_by_reference', recname = line.reference.rec_name, cbook = line.reference.cashbook.rec_name, )) # delete references to_delete_line.extend(list(line.references)) if len(to_delete_line) > 0: with Transaction().set_context({ 'line.allow.wfedit': True, }): Line2.wfedit(to_delete_line) Line2.delete(to_delete_line) @classmethod @ModelView.button @Workflow.transition('check') def wfcheck(cls, lines): """ line is checked """ pool = Pool() Recon = pool.get('cashbook.recon') Line2 = pool.get('cashbook.line') to_create_line = [] to_write_line = [] for line in lines: # deny if date is in range of existing reconciliation # allow cashbook-line at range-limits if Recon.search_count([ ('state', 'in', ['check', 'done']), ('cashbook.id', '=', line.cashbook.id), ('date_from', '<', line.date), ('date_to', '>', line.date), ]) > 0: raise UserError(gettext( 'cashbook.msg_line_err_write_to_reconciled', datetxt = Report.format_date(line.date), )) # deny if date is at reconciliation limits and two # reconciliations exist if Recon.search_count([ ('state', 'in', ['check', 'done']), ('cashbook.id', '=', line.cashbook.id), ['OR', ('date_from', '=', line.date), ('date_to', '=', line.date), ] ]) > 1: raise UserError(gettext( 'cashbook.msg_line_err_write_to_reconciled', datetxt = Report.format_date(line.date), )) # in case of 'mvin' or 'mvout' - create counterpart if (line.bookingtype in ['mvout', 'mvin']) and (line.reference is None): values = { 'cashbook': line.booktransf.id, 'bookingtype': 'mvin' if line.bookingtype == 'mvout' else 'mvout', 'date': line.date, 'description': line.description, 'booktransf': line.cashbook.id, 'reference': line.id, } values.update(line.get_amount_by_second_currency(line.booktransf.currency)) values.update(cls.get_debit_credit(values)) to_create_line.append(values) # add number to line if line.cashbook.number_atcheck == True: if len(line.number or '') == 0: to_write_line.extend([ [line], { 'number': line.cashbook.number_sequ.get() }]) if len(to_write_line) > 0: Line2.write(*to_write_line) if len(to_create_line) > 0: new_lines = Line2.create(to_create_line) Line2.wfcheck(new_lines) @classmethod @ModelView.button @Workflow.transition('done') def wfdone(cls, lines): """ line is done """ Line2 = Pool().get('cashbook.line') to_write_line = [] for line in lines: # add number to line if len(line.number or '') == 0: to_write_line.extend([ [line], { 'number': line.cashbook.number_sequ.get() }]) if len(to_write_line) > 0: Line2.write(*to_write_line) @classmethod def default_state(cls): """ default: edit """ return 'edit' @classmethod def default_date(cls): """ default: today """ IrDate = Pool().get('ir.date') return IrDate.today() @classmethod def default_cashbook(cls): """ get default from context """ 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 """ credit = self.credit if self.credit is not None else Decimal('0.0') debit = self.debit if self.debit is not None else Decimal('0.0') return '%(date)s|%(type)s|%(amount)s %(symbol)s|%(desc)s [%(category)s]' % { 'date': Report.format_date(self.date), 'desc': (self.description or '-')[:40], 'amount': Report.format_number(credit - debit, None), 'symbol': getattr(self.currency, 'symbol', '-'), 'category': self.category_view \ if self.bookingtype in ['in', 'out'] \ else getattr(self.booktransf, 'rec_name', '-'), 'type': gettext('cashbook.msg_line_bookingtype_%s' % self.bookingtype), } def get_amount_by_second_currency(self, to_currency, amount=None): """ get amount, calculate credit/debit from currency of current cashbook to 'to_currency' """ Currency = Pool().get('currency.currency') values = { 'amount': amount if amount is not None else self.amount, } if to_currency.id != self.cashbook.currency.id: with Transaction().set_context({ 'date': self.date, }): values['amount'] = Currency.compute( self.cashbook.currency, self.amount, to_currency) return values @staticmethod def order_state(tables): """ edit = 0, check/done = 1 """ Line = Pool().get('cashbook.line') tab_line = Line.__table__() table, _ = tables[None] query = tab_line.select( Case( (tab_line.state == 'edit', 1), (tab_line.state.in_(['check', 'done']), 0), else_ = 2), where=tab_line.id==table.id ) return [query] @staticmethod def order_category_view(tables): """ order: name """ table, _ = tables[None] Category = Pool().get('cashbook.category') tab_cat = Category.__table__() tab2 = tab_cat.select(tab_cat.name, where=tab_cat.id==table.category ) return [tab2] @classmethod def search_payee(cls, names, clause): """ search in payee for party or cashbook """ return ['OR', ('party.rec_name',) + tuple(clause[1:]), ('booktransf.rec_name',) + tuple(clause[1:]), ] @classmethod def search_category_view(cls, name, clause): """ search in category """ return [('category.rec_name',) + tuple(clause[1:])] @classmethod def search_month(cls, names, clause): """ search in month """ pool = Pool() Line = pool.get('cashbook.line') IrDate = pool.get('ir.date') tab_line = Line.__table__() Operator = fields.SQL_OPERATORS[clause[1]] dt1 = IrDate.today() query = tab_line.select(tab_line.id, where=Operator( Literal(12 * dt1.year + dt1.month) - \ (Literal(12) * DatePart('year', tab_line.date) + DatePart('month', tab_line.date)), clause[2]), ) return [('id', 'in', query)] @classmethod def search_state_cashbook(cls, names, clause): """ search in state of cashbook """ return [('cashbook.state',) + tuple(clause[1:])] @fields.depends('amount', 'splitlines') def on_change_splitlines(self): """ update amount if splitlines change """ self.amount = sum([x.amount for x in self.splitlines if x.amount is not None]) @fields.depends('bookingtype', 'category', 'splitlines') def on_change_bookingtype(self): """ clear category if not valid type """ types = { 'in': ['in', 'mvin', 'spin'], 'out': ['out', 'mvout', 'spout'], } if self.bookingtype: if self.category: if not self.bookingtype in types.get(self.category.cattype, ''): self.category = None if self.bookingtype in ['spin', 'spout']: for spline in self.splitlines: if not self.bookingtype in types.get(getattr(spline.category, 'cattype', '-'), ''): spline.category = None else : self.splitlines = [] @fields.depends('party', 'booktransf', 'bookingtype') def on_change_with_payee(self, name=None): """ get party or cashbook """ if self.bookingtype: if self.bookingtype in ['in', 'out', 'spin', 'spout']: if self.party: return 'party.party,%d' % self.party.id elif self.bookingtype in ['mvin', 'mvout']: if self.booktransf: return 'cashbook.book,%d' % self.booktransf.id @fields.depends('category') def on_change_with_category_view(self, name=None): """ show optimizef form of category for list-view """ Configuration = Pool().get('cashbook.configuration') if self.category: cfg1 = Configuration.get_singleton() if getattr(cfg1, 'catnamelong', True) == True: return self.category.rec_name else : return self.category.name @fields.depends('date') def on_change_with_month(self, name=None): """ get difference of month to current date """ IrDate = Pool().get('ir.date') if self.date is not None: dt1 = IrDate.today() return (12 * dt1.year + dt1.month) - \ (12 * self.date.year + self.date.month) @fields.depends('cashbook', '_parent_cashbook.owner') def on_change_with_owner_cashbook(self, name=None): """ get current owner """ if self.cashbook: return self.cashbook.owner.id @fields.depends('cashbook', '_parent_cashbook.state') def on_change_with_state_cashbook(self, name=None): """ get state of cashbook """ if self.cashbook: return self.cashbook.state @fields.depends('cashbook', '_parent_cashbook.currency') def on_change_with_currency(self, name=None): """ currency of cashbook """ if self.cashbook: return self.cashbook.currency.id @fields.depends('cashbook', '_parent_cashbook.currency') def on_change_with_currency_digits(self, name=None): """ currency of cashbook """ if self.cashbook: return self.cashbook.currency.digits else: return 2 @fields.depends('id', 'date', 'cashbook', \ '_parent_cashbook.start_balance', '_parent_cashbook.id',\ 'reconciliation', '_parent_reconciliation.start_amount') def on_change_with_balance(self, name=None): """ compute balance until current line, with current sort order, try to use a reconciliation as start to speed up calculation """ pool = Pool() Reconciliation = pool.get('cashbook.recon') Line = pool.get('cashbook.line') if self.cashbook: query = [ ('cashbook.id', '=', self.cashbook.id), ] balance = self.cashbook.start_balance # get existing reconciliation, starting before current line # this will speed up calculation of by-line-balance if self.date is not None: recons = Reconciliation.search([ ('cashbook.id', '=', self.cashbook.id), ('date_from', '<=', self.date), ('state', '=', 'done'), ], order=[('date_from', 'DESC')], limit=1) if len(recons) > 0: query.extend([ ['OR', ('date', '>', recons[0].date_from), [ ('date', '=', recons[0].date_from), ('reconciliation.id', '=',recons[0].id), ], ] ]) balance = recons[0].start_amount lines = Line.search(query) for line in lines: balance += line.credit - line.debit if line.id == self.id: break return balance @classmethod def clear_by_bookingtype(cls, values, line=None): """ clear some fields by value of bookingtype """ values2 = {} values2.update(values) bookingtype = values2.get('bookingtype', getattr(line, 'bookingtype', None)) if (bookingtype in ['in', 'out', 'mvin', 'mvout']) and \ ('splitlines' not in values2.keys()): if line: if len(line.splitlines) > 0: values2['splitlines'] = [('delete', [x.id for x in line.splitlines])] if bookingtype in ['in', 'out']: values2['booktransf'] = None if bookingtype in ['spin', 'spout']: values2['category'] = None values2['booktransf'] = None if bookingtype in ['mvin', 'mvout']: values2['category'] = None return values2 @classmethod def get_debit_credit(cls, values): """ compute debit/credit from amount """ if isinstance(values, dict): type_ = values.get('bookingtype', None) amount = values.get('amount', None) else : type_ = getattr(values, 'bookingtype', None) amount = getattr(values, 'amount', None) if type_: if amount is not None: if type_ in ['in', 'mvin', 'spin']: return { 'debit': Decimal('0.0'), 'credit': amount, } elif type_ in ['out', 'mvout', 'spout']: return { 'debit': amount, 'credit': Decimal('0.0'), } else : raise ValueError('invalid "bookingtype"') return {} @classmethod def update_amount_by_splitlines(cls, lines): """ update amounts from split-lines """ Line2 = Pool().get('cashbook.line') to_write = [] for line in lines: to_write.extend([ [line], { 'amount': sum([x.amount for x in line.splitlines]), }]) if len(to_write) > 0: Line2.write(*to_write) @classmethod def validate(cls, lines): """ deny date before 'start_date' of cashbook """ super(Line, cls).validate(lines) types = { 'in': ['in', 'mvin', 'spin'], 'out': ['out', 'mvout', 'spout'], } for line in lines: if line.date < line.cashbook.start_date: raise UserError(gettext( 'cashbook.msg_line_date_before_book', datebook = Report.format_date(line.cashbook.start_date), recname = line.rec_name, )) # line: category <--> bookingtype? if line.category: if not line.bookingtype in types[line.category.cattype]: raise UserError(gettext( 'cashbook.msg_line_invalid_category', recname = line.rec_name, booktype = line.bookingtype_string, )) # splitline: category <--> bookingtype? for spline in line.splitlines: if not line.bookingtype in types[spline.category.cattype]: raise UserError(gettext( 'cashbook.msg_line_split_invalid_category', recname = line.rec_name, splitrecname = spline.rec_name, booktype = line.bookingtype_string, )) @classmethod def check_permission_write(cls, lines, values={}): """ deny update if cashbook.line!='open', """ for line in lines: # deny write if cashbook is not open if line.cashbook.state != 'open': raise UserError(gettext( 'cashbook.msg_book_deny_write', bookname = line.cashbook.rec_name, state_txt = line.cashbook.state_string, )) # deny write if reconciliation is 'check' or 'done' if line.reconciliation: if line.reconciliation.state == 'done': raise UserError(gettext( 'cashbook.msg_line_deny_write_by_reconciliation', recname = line.rec_name, reconame = line.reconciliation.rec_name, )) # deny write if line is not 'Edit' if line.state != 'edit': # allow state-update, if its the only action if not ((len(set({'state', 'reconciliation', 'number'}).intersection(values.keys())) > 0) \ and (len(values.keys()) == 1)): raise UserError(gettext( 'cashbook.msg_line_deny_write', recname = line.rec_name, state_txt = line.state_string, )) @classmethod def check_permission_delete(cls, lines): """ deny delete if book is not 'open' or wf is not 'edit' """ for line in lines: if line.cashbook.state == 'closed': raise UserError(gettext( 'cashbook.msg_line_deny_delete1', linetxt = line.rec_name, bookname = line.cashbook.rec_name, bookstate = line.cashbook.state_string, )) if line.state != 'edit': raise UserError(gettext( 'cashbook.msg_line_deny_delete2', linetxt = line.rec_name, linestate = line.state_string, )) @classmethod def copy(cls, lines, default=None): """ reset values """ if default is None: default = {} else: default = default.copy() default.setdefault('number', None) default.setdefault('state', cls.default_state()) return super(Line, cls).copy(moves, default=default) @classmethod def create(cls, vlist): """ add debit/credit """ vlist = [x.copy() for x in vlist] for values in vlist: values.update(cls.get_debit_credit(values)) values.update(cls.clear_by_bookingtype(values)) # deny add to reconciliation if state is not 'check' or 'done' if values.get('reconciliation', None): if not values.get('state', '-') in ['check', 'done']: date_txt = '-' if values.get('date', None): date_txt = Report.format_date(values.get('date', None)) raise UserError(gettext( 'cashbook.msg_line_deny_recon_by_state', recname = '%(date)s|%(descr)s' % { 'date': date_txt, 'descr': values.get('description', '-'), }, )) return super(Line, cls).create(vlist) @classmethod def write(cls, *args): """ deny update if cashbook.line!='open', add or update debit/credit """ actions = iter(args) to_write = [] for lines, values in zip(actions, actions): cls.check_permission_write(lines, values) for line in lines: if line.reconciliation: # deny state-change to 'edit' if line is linked to reconciliation if values.get('state', '-') == 'edit': raise UserError(gettext( 'cashbook.msg_line_deny_stateedit_with_recon', recname = line.rec_name, )) # deny add to reconciliation if state is not 'check' or 'done' if values.get('reconciliation', None): for line in lines: if not line.state in ['check', 'done']: raise UserError(gettext( 'cashbook.msg_line_deny_recon_by_state', recname = line.rec_name )) # update debit / credit if len(set(values.keys()).intersection(set({'amount', 'bookingtype'}))) > 0: for line in lines: values2 = {} values2.update(values) values2.update(cls.clear_by_bookingtype(values, line)) values2.update(cls.get_debit_credit({ x:values.get(x, getattr(line, x)) for x in ['amount', 'bookingtype'] })) to_write.extend([lines, values2]) else : to_write.extend([lines, values]) super(Line, cls).write(*to_write) @classmethod def delete(cls, lines): """ deny delete if book is not 'open' or wf is not 'edit' """ cls.check_permission_delete(lines) return super(Line, cls).delete(lines) # end Line class LineContext(ModelView): 'Line Context' __name__ = 'cashbook.line.context' cashbook = fields.Many2One(string='Cashbook', required=True, model_name='cashbook.book', states={ 'readonly': Eval('num_cashbook', 0) < 2, }, depends=['num_cashbook']) date_from = fields.Date(string='Start Date', depends=['date_to'], domain=[ If(Eval('date_to') & Eval('date_from'), ('date_from', '<=', Eval('date_to')), ()), ]) date_to = fields.Date(string='End Date', depends=['date_from'], domain=[ If(Eval('date_to') & Eval('date_from'), ('date_from', '<=', Eval('date_to')), ()), ]) checked = fields.Boolean(string='Checked', help='Show account lines in Checked-state.') done = fields.Boolean(string='Done', help='Show account lines in Done-state.') num_cashbook = fields.Function(fields.Integer(string='Number of Cashbook', readonly=True, states={'invisible': True}), 'on_change_with_num_cashbook') @classmethod def default_cashbook(cls): """ get default from context """ context = Transaction().context return context.get('cashbook', None) @classmethod def default_date_from(cls): """ get default from context """ context = Transaction().context return context.get('date_from', None) @classmethod def default_date_to(cls): """ get default from context """ context = Transaction().context return context.get('date_to', None) @classmethod def default_checked(cls): """ get default from context """ context = Transaction().context return context.get('checked', False) @classmethod def default_num_cashbook(cls): """ get default from context """ CashBook = Pool().get('cashbook.book') with Transaction().set_context({ '_check_access': True, }): return CashBook.search_count([]) @classmethod def default_done(cls): """ get default from context """ context = Transaction().context return context.get('done', False) def on_change_with_num_cashbook(self, name=None): """ get number of accessible cashbooks, depends on user-permissions """ LineContext = Pool().get('cashbook.line.context') return LineContext.default_num_cashbook() # end LineContext