# This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. from collections import defaultdict from decimal import Decimal from itertools import chain, groupby, islice from dateutil.relativedelta import relativedelta from sql import Literal, Null, Window from sql.aggregate import Sum from sql.conditionals import Case, Coalesce from sql.functions import Abs, CharLength, Round from sql.operators import Exists from trytond import backend from trytond.i18n import gettext from trytond.model import ( Check, DeactivableMixin, Index, ModelSQL, ModelView, dualmethod, fields) from trytond.model.exceptions import AccessError from trytond.modules.currency.fields import Monetary from trytond.pool import Pool from trytond.pyson import Bool, Eval, If, PYSONEncoder from trytond.report import Report from trytond.rpc import RPC from trytond.tools import ( firstline, grouped_slice, reduce_ids, sqlite_apply_types) from trytond.transaction import Transaction, check_access from trytond.wizard import ( Button, StateAction, StateTransition, StateView, Wizard) from .exceptions import ( AccountMissing, CancelDelegatedWarning, CancelWarning, CopyWarning, DelegateLineError, GroupLineError, JournalMissing, PeriodNotFoundError, PostError, ReconciliationDeleteWarning, ReconciliationError, RescheduleLineError) _MOVE_STATES = { 'readonly': Eval('state') == 'posted', } _LINE_STATES = { 'readonly': Eval('state') == 'valid', } class DescriptionOriginMixin: __slots__ = () description_used = fields.Function( fields.Char("Description", states=_MOVE_STATES), 'on_change_with_description_used', setter='set_description_used', searcher='search_description_used') @fields.depends('description', 'origin') def on_change_with_description_used(self, name=None): description = self.description if not description and hasattr(self.origin, 'description'): description = firstline(self.origin.description or '') return description @classmethod def set_description_used(cls, moves, name, value): moves = [m for m in moves if m.description_used != value] if moves: cls.write(moves, {'description': value}) @classmethod def search_description_used(cls, name, clause): pool = Pool() operator = clause[1] if operator.startswith('!') or operator.startswith('not '): bool_op, bool_desc_op, desc_op = 'AND', 'OR', '=' else: bool_op, bool_desc_op, desc_op = 'OR', 'AND', '!=' domain = [bool_op, [bool_desc_op, ('description', desc_op, None), ('description', desc_op, ''), ('description', *clause[1:]), ], ] for origin, _ in cls.get_origin(): if not origin: continue Model = pool.get(origin) if 'description' in Model._fields: domain.append( ('origin.description', *clause[1:3], origin, *clause[3:])) return domain class Move(DescriptionOriginMixin, ModelSQL, ModelView): __name__ = 'account.move' _rec_name = 'number' number = fields.Char( "Number", readonly=True, help='Also known as Folio Number.') company = fields.Many2One('company.company', 'Company', required=True, states=_MOVE_STATES) period = fields.Many2One('account.period', 'Period', required=True, domain=[ ('company', '=', Eval('company', -1)), If(Eval('state') == 'draft', ('state', '=', 'open'), ()), If(Eval('date', None), [ ('start_date', '<=', Eval('date', None)), ('end_date', '>=', Eval('date', None)), ], []), ], states=_MOVE_STATES) journal = fields.Many2One( 'account.journal', "Journal", required=True, states=_MOVE_STATES, context={ 'company': Eval('company', -1), }, depends={'company'}) date = fields.Date('Effective Date', required=True, states=_MOVE_STATES) post_date = fields.Date('Post Date', readonly=True) description = fields.Char('Description', states=_MOVE_STATES) origin = fields.Reference('Origin', selection='get_origin', states=_MOVE_STATES) state = fields.Selection([ ('draft', 'Draft'), ('posted', 'Posted'), ], 'State', required=True, readonly=True, sort=False) lines = fields.One2Many('account.move.line', 'move', 'Lines', states=_MOVE_STATES, depends={'company'}, context={ 'journal': Eval('journal', -1), 'period': Eval('period', -1), 'date': Eval('date', None), }) @classmethod def __setup__(cls): cls.number.search_unaccented = False super().__setup__() t = cls.__table__() cls.create_date.select = True cls._check_modify_exclude = {'lines'} cls._order.insert(0, ('date', 'DESC')) cls._order.insert(1, ('number', 'DESC')) cls._buttons.update({ 'post': { 'invisible': Eval('state') == 'posted', 'depends': ['state'], }, }) cls.__rpc__.update({ 'post': RPC( readonly=False, instantiate=0, fresh_session=True), }) cls._sql_indexes.update({ Index(t, (t.period, Index.Range())), Index(t, (t.date, Index.Range()), (t.number, Index.Range())), Index( t, (t.journal, Index.Range()), (t.period, Index.Range())), }) @classmethod def __register__(cls, module): table_h = cls.__table_handler__(module) if (table_h.column_exist('number') and table_h.column_exist('post_number')): table_h.drop_column('number') table_h.column_rename('post_number', 'number') super().__register__(module) @classmethod def order_number(cls, tables): table, _ = tables[None] return [CharLength(table.number), table.number] @staticmethod def default_company(): return Transaction().context.get('company') @fields.depends('company', 'period', 'date') def on_change_company(self): pool = Pool() Period = pool.get('account.period') if self.company: if self.period and self.period.company != self.company: self.period = None if not self.period: try: self.period = Period.find(self.company, date=self.date) except PeriodNotFoundError: pass else: self.period = None @classmethod def default_period(cls): Period = Pool().get('account.period') company = cls.default_company() try: period = Period.find(company) except PeriodNotFoundError: return None return period.id @staticmethod def default_state(): return 'draft' @classmethod def default_date(cls): pool = Pool() Period = pool.get('account.period') Date = pool.get('ir.date') today = Date.today() period_id = cls.default_period() if period_id: period = Period(period_id) start_date = period.start_date end_date = period.end_date if start_date <= today <= end_date: return today elif start_date >= today: return start_date else: return end_date @fields.depends('date', 'period', 'company', 'lines') def on_change_date(self): pool = Pool() Period = pool.get('account.period') context = Transaction().context if self.date: company = self.company or context.get('company') if (not self.period or not ( self.period.start_date <= self.date <= self.period.end_date)): try: self.period = Period.find(company, date=self.date) except PeriodNotFoundError: self.period = None for line in (self.lines or []): line.date = self.date @fields.depends( 'period', 'date', 'company', 'journal', methods=['on_change_date']) def on_change_period(self): pool = Pool() Date = pool.get('ir.date') Line = pool.get('account.move.line') context = Transaction().context company_id = ( self.company.id if self.company else context.get('company')) with Transaction().set_context(company=company_id): today = Date.today() if self.period: start_date = self.period.start_date end_date = self.period.end_date if (not self.date or not (start_date <= self.date <= end_date)): if start_date <= today <= end_date: self.date = today else: lines = Line.search([ ('company', '=', company_id), ('journal', '=', self.journal), ('period', '=', self.period), ], order=[('date', 'DESC')], limit=1) if lines: line, = lines self.date = line.date elif start_date >= today: self.date = start_date else: self.date = end_date self.on_change_date() @classmethod def _get_origin(cls): 'Return list of Model names for origin Reference' return ['account.fiscalyear', 'account.move'] @classmethod def get_origin(cls): Model = Pool().get('ir.model') get_name = Model.get_name models = cls._get_origin() return [(None, '')] + [(m, get_name(m)) for m in models] @classmethod def check_modification(cls, mode, moves, values=None, external=False): super().check_modification( mode, moves, values=values, external=external) if (mode == 'delete' or (mode == 'write' and values.keys() - cls._check_modify_exclude)): for move in moves: if move.state == 'posted': raise AccessError(gettext( 'account.msg_modify_posted_moved', move=move.rec_name)) @classmethod def copy(cls, moves, default=None): pool = Pool() Date = pool.get('ir.date') Period = pool.get('account.period') Warning = pool.get('res.user.warning') if default is None: default = {} else: default = default.copy() def _check_period(origin): period = Period(origin['period']) if period.state == 'closed': move = cls(origin['id']) key = Warning.format('copy', [move]) if Warning.check(key): raise CopyWarning(key, gettext('account.msg_move_copy_closed_period', move=move)) return False else: return True def default_period(origin): if not _check_period(origin): with Transaction().set_context(company=origin['company']): today = Date.today() period = Period.find(origin['company'], date=today) return period.id else: return origin['period'] def default_date(origin): if not _check_period(origin): with Transaction().set_context(company=origin['company']): return Date.today() else: return origin['date'] default.setdefault('number', None) default.setdefault('state', cls.default_state()) default.setdefault('post_date', None) default.setdefault('period', default_period) default.setdefault('date', default_date) return super().copy(moves, default=default) @classmethod def validate_move(cls, moves): ''' Validate balanced move ''' pool = Pool() MoveLine = pool.get('account.move.line') line = MoveLine.__table__() transaction = Transaction() cursor = transaction.connection.cursor() for company, moves in groupby(moves, key=lambda m: m.company): currency = company.currency for sub_moves in grouped_slice(list(moves)): red_sql = reduce_ids(line.move, [m.id for m in sub_moves]) valid_move_query = line.select( line.move, where=red_sql, group_by=line.move, having=Abs(Round( Sum(line.debit - line.credit), currency.digits)) < abs(currency.rounding)) cursor.execute(*line.update( [line.state], ['valid'], where=line.move.in_(valid_move_query))) draft_move_query = line.select( line.move, where=red_sql, group_by=line.move, having=Abs(Round( Sum(line.debit - line.credit), currency.digits)) >= abs(currency.rounding)) cursor.execute(*line.update( [line.state], ['draft'], where=line.move.in_(draft_move_query))) Transaction().counter += 1 for cache in Transaction().cache.values(): if MoveLine.__name__ in cache: cache_cls = cache[MoveLine.__name__] cache_cls.clear() def _cancel_default(self, reversal=False): 'Return default dictionary to cancel move' pool = Pool() Date = pool.get('ir.date') Period = pool.get('account.period') Warning = pool.get('res.user.warning') default = { 'origin': str(self), } if self.period.state == 'closed': key = '%s.cancel' % self if Warning.check(key): raise CancelWarning(key, gettext('account.msg_move_cancel_closed_period', move=self.rec_name)) with Transaction().set_context(company=self.company.id): date = Date.today() period = Period.find(self.company, date=date) default.update({ 'date': date, 'period': period.id, }) if reversal: default['lines.debit'] = lambda data: data['credit'] default['lines.credit'] = lambda data: data['debit'] else: default['lines.debit'] = lambda data: data['debit'] * -1 default['lines.credit'] = lambda data: data['credit'] * -1 default['lines.amount_second_currency'] = ( lambda data: data['amount_second_currency'] * -1 if data['amount_second_currency'] else data['amount_second_currency']) default['lines.tax_lines.amount'] = lambda data: data['amount'] * -1 default['lines.origin'] = ( lambda data: 'account.move.line,%s' % data['id']) return default def cancel(self, default=None, reversal=False): 'Return a cancel move' if default is None: default = {} else: default = default.copy() default.update(self._cancel_default(reversal=reversal)) cancel_move, = self.copy([self], default=default) return cancel_move @dualmethod @ModelView.button def post(cls, moves): pool = Pool() Date = pool.get('ir.date') Line = pool.get('account.move.line') move = cls.__table__() line = Line.__table__() cursor = Transaction().connection.cursor() to_reconcile = [] for company, c_moves in groupby(moves, lambda m: m.company): with Transaction().set_context(company=company.id): today = Date.today() currency = company.currency c_moves = list(c_moves) for sub_moves in grouped_slice(c_moves): sub_moves_ids = [m.id for m in sub_moves] cursor.execute(*move.select( move.id, where=reduce_ids(move.id, sub_moves_ids) & ~Exists(line.select( line.move, where=line.move == move.id)))) try: move_id, = cursor.fetchone() except TypeError: pass else: raise PostError( gettext('account.msg_post_empty_move', move=cls(move_id).rec_name)) cursor.execute(*line.select( line.move, where=reduce_ids(line.move, sub_moves_ids), group_by=line.move, having=Abs(Round( Sum(line.debit - line.credit), currency.digits)) >= abs(currency.rounding))) try: move_id, = cursor.fetchone() except TypeError: pass else: raise PostError( gettext('account.msg_post_unbalanced_move', move=cls(move_id).rec_name)) cursor.execute(*line.select( line.id, where=reduce_ids(line.move, sub_moves_ids) & (line.debit == Decimal(0)) & (line.credit == Decimal(0)) & ((line.amount_second_currency == Null) | (line.amount_second_currency == Decimal(0))) )) to_reconcile.extend(l for l, in cursor) missing_number = defaultdict(list) for move in c_moves: move.state = 'posted' if not move.number: move.post_date = today missing_number[move.period.move_sequence_used].append( move) for sequence, m_moves in missing_number.items(): for move, number in zip( m_moves, sequence.get_many(len(m_moves))): move.number = number cls.save(moves) def keyfunc(line): # Set party last to avoid compare party instance and None # party will always be None for the same account return line.account, line.party to_reconcile = Line.browse(sorted( [l for l in Line.browse(to_reconcile) if l.account.reconcile], key=keyfunc)) to_reconcile = [list(l) for _, l in groupby(to_reconcile, keyfunc)] if to_reconcile: Line.reconcile(*to_reconcile) class MoveContext(ModelView): __name__ = 'account.move.context' company = fields.Many2One('company.company', "Company", required=True) @classmethod def default_company(cls): return Transaction().context.get('company') class Reconciliation(ModelSQL, ModelView): __name__ = 'account.move.reconciliation' _rec_name = 'number' number = fields.Char("Number", required=True) company = fields.Many2One('company.company', "Company", required=True) lines = fields.One2Many( 'account.move.line', 'reconciliation', 'Lines', domain=[ ('move.company', '=', Eval('company', -1)), ]) date = fields.Date( "Date", required=True, help='Highest date of the reconciled lines.') delegate_to = fields.Many2One( 'account.move.line', "Delegate To", ondelete="RESTRICT", domain=[ ('move.company', '=', Eval('company', -1)), ], help="The line to which the reconciliation status is delegated.") @classmethod def __setup__(cls): cls.number.search_unaccented = False super().__setup__() t = cls.__table__() cls._sql_indexes.add(Index(t, (t.date, Index.Range()))) @classmethod def __register__(cls, module_name): table = cls.__table_handler__(module_name) # Migration from 6.6: rename name to number if table.column_exist('name') and not table.column_exist('number'): table.column_rename('name', 'number') super().__register__(module_name) @classmethod def order_number(cls, tables): table, _ = tables[None] return [CharLength(table.number), table.number] @classmethod def default_company(cls): return Transaction().context.get('company') @classmethod def preprocess_values(cls, mode, values): pool = Pool() Configuration = pool.get('account.configuration') values = super().preprocess_values(mode, values) if mode == 'create' and not values.get('number'): company_id = values.get('company', cls.default_company()) if company_id is not None: configuration = Configuration(1) if sequence := configuration.get_multivalue( 'reconciliation_sequence', company=company_id): values['number'] = sequence.get() return values @classmethod def check_modification( cls, mode, reconciliations, values=None, external=False): pool = Pool() Warning = pool.get('res.user.warning') super().check_modification( mode, reconciliations, values=values, external=external) if mode == 'delete': for reconciliation in reconciliations: if reconciliation.delegate_to: key = Warning.format('delete.delegated', [reconciliation]) if Warning.check(key): raise ReconciliationDeleteWarning(key, gettext( 'account.msg_reconciliation_delete_delegated', reconciliation=reconciliation.rec_name, line=reconciliation.delegate_to.rec_name, move=reconciliation.delegate_to.move.rec_name)) for line in reconciliation.lines: if line.move.journal.type == 'write-off': key = Warning.format( 'delete.write-off', [reconciliation]) if Warning.check(key): raise ReconciliationDeleteWarning(key, gettext( 'account.' 'msg_reconciliation_delete_write_off', reconciliation=reconciliation.rec_name, line=line.rec_name, move=line.move.rec_name)) @classmethod def write(cls, moves, values, *args): raise AccessError(gettext('account.msg_write_reconciliation')) @classmethod def validate(cls, reconciliations): super().validate(reconciliations) cls.check_lines(reconciliations) @classmethod def check_lines(cls, reconciliations): Lang = Pool().get('ir.lang') for reconciliation in reconciliations: debit = Decimal(0) credit = Decimal(0) account = None if reconciliation.lines: party = reconciliation.lines[0].party for line in reconciliation.lines: if line.state != 'valid': raise ReconciliationError( gettext('account.msg_reconciliation_line_not_valid', line=line.rec_name)) debit += line.debit credit += line.credit if not account: account = line.account elif account.id != line.account.id: raise ReconciliationError( gettext('account' '.msg_reconciliation_different_accounts', line=line.rec_name, account1=line.account.rec_name, account2=account.rec_name)) if not account.reconcile: raise ReconciliationError( gettext('account' '.msg_reconciliation_account_not_reconcile', line=line.rec_name, account=line.account.rec_name)) if line.party != party: raise ReconciliationError( gettext('account' '.msg_reconciliation_different_parties', line=line.rec_name, party1=line.party.rec_name if line.party else '', party2=party.rec_name if party else '')) if not reconciliation.company.currency.is_zero(debit - credit): lang = Lang.get() debit = lang.currency(debit, reconciliation.company.currency) credit = lang.currency(credit, reconciliation.company.currency) raise ReconciliationError( gettext('account.msg_reconciliation_unbalanced', debit=debit, credit=credit)) class MoveLineMixin: __slots__ = () payable_receivable_date = fields.Function( fields.Date("Payable/Receivable Date"), 'on_change_with_payable_receivable_date') @classmethod def get_move_origin(cls): Move = Pool().get('account.move') return Move.get_origin() @classmethod def get_move_states(cls): pool = Pool() Move = pool.get('account.move') return Move.fields_get(['state'])['state']['selection'] def get_move_field(self, name): field = getattr(self.__class__, name) if name.startswith('move_'): name = name[5:] value = getattr(self.move, name) if isinstance(value, ModelSQL): if field._type == 'reference': return str(value) return value.id return value @classmethod def set_move_field(cls, lines, name, value): pool = Pool() Move = pool.get('account.move') if name.startswith('move_'): name = name[5:] if not value: return moves = {line.move for line in lines} moves = Move.browse(moves) Move.write(moves, { name: value, }) @classmethod def search_move_field(cls, name, clause): nested = clause[0][len(name):] if name.startswith('move_'): name = name[5:] return [('move.' + name + nested, *clause[1:])] @staticmethod def _order_move_field(name): def order_field(cls, tables): pool = Pool() Move = pool.get('account.move') field = Move._fields[name] table, _ = tables[None] move_tables = tables.get('move') if move_tables is None: move = Move.__table__() move_tables = { None: (move, move.id == table.move), } tables['move'] = move_tables return field.convert_order(name, move_tables, Move) return classmethod(order_field) def get_amount(self, name): sign = -1 if self.account.type.statement == 'income' else 1 if self.amount_second_currency is not None: return self.amount_second_currency * sign else: return (self.debit - self.credit) * sign def get_amount_currency(self, name): if self.second_currency: currency = self.second_currency else: currency = self.account.currency return currency.id @fields.depends('maturity_date', 'date') def on_change_with_payable_receivable_date(self, name=None): return self.maturity_date or self.date @classmethod def domain_payable_receivable_date(cls, domain, tables): pool = Pool() Move = pool.get('account.move') table, _ = tables[None] move = tables.get('move') if move is None: move = Move.__table__() tables['move'] = { None: (move, move.id == table.move), } else: move, _ = move[None] _, operator, operand = domain Operator = fields.SQL_OPERATORS[operator] date = Coalesce(table.maturity_date, move.date) return Operator(date, operand) @classmethod def order_payable_receivable_date(cls, tables): pool = Pool() Move = pool.get('account.move') table, _ = tables[None] move = tables.get('move') if move is None: move = Move.__table__() tables['move'] = { None: (move, move.id == table.move), } else: move, _ = move[None] return [Coalesce(table.maturity_date, move.date)] @classmethod def get_payable_receivable_balance(cls, lines, name): pool = Pool() Account = pool.get('account.account') AccountType = pool.get('account.account.type') Move = pool.get('account.move') transaction = Transaction() context = transaction.context cursor = transaction.connection.cursor() line = cls.__table__() account = Account.__table__() account_type = AccountType.__table__() move = Move.__table__() balances = dict.fromkeys(map(int, lines)) date = Coalesce(line.maturity_date, move.date) for company, lines in groupby(lines, lambda l: l.company): for sub_lines in grouped_slice(lines): sub_lines = list(sub_lines) id2currency = { l.id: l.second_currency or company.currency for l in sub_lines} where = account.company == company.id where_type = Literal(False) if context.get('receivable', True): where_type |= account_type.receivable if context.get('payable', True): where_type |= account_type.payable where &= where_type if not context.get('reconciled'): if hasattr(cls, 'reconciliation'): where &= line.reconciliation == Null elif hasattr(cls, 'reconciled'): where &= ~line.reconciled currency = Coalesce(line.second_currency, company.currency.id) sign = Case( (account_type.statement == 'income', -1), else_=1) balance = sign * Case( (line.amount_second_currency != Null, line.amount_second_currency), else_=line.debit - line.credit) balance = Sum(balance, window=Window( [line.party, currency], order_by=[date.asc.nulls_first, line.id.desc])) party_where = Literal(False) parties = {l.party for l in sub_lines} if None in parties: party_where |= line.party == parties.discard(None) party_where |= reduce_ids(line.party, map(int, parties)) where &= party_where query = (line .join(move, condition=line.move == move.id) .join(account, condition=line.account == account.id) .join( account_type, condition=account.type == account_type.id) .select( line.id.as_('id'), balance.as_('balance'), where=where)) query = query.select( query.id, query.balance.as_('balance'), where=reduce_ids(query.id, [l.id for l in sub_lines])) if backend.name == 'sqlite': sqlite_apply_types(query, [None, 'NUMERIC']) cursor.execute(*query) balances.update( (i, id2currency[i].round(a)) for (i, a) in cursor) return balances def get_rec_name(self, name): pool = Pool() Lang = pool.get('ir.lang') lang = Lang.get() amount = lang.currency(self.amount, self.amount_currency) return f'{amount} @ {self.account.code}' @classmethod def search_rec_name(cls, name, clause): return [('account.code', *clause[1:])] class Line(DescriptionOriginMixin, MoveLineMixin, ModelSQL, ModelView): __name__ = 'account.move.line' _states = { 'readonly': Eval('move_state') == 'posted', } debit = Monetary( "Debit", currency='currency', digits='currency', required=True, states=_states, domain=[ If(Eval('credit', 0), ('debit', '=', 0), ()), ], depends={'credit', 'tax_lines', 'journal'}) credit = Monetary( "Credit", currency='currency', digits='currency', required=True, states=_states, domain=[ If(Eval('debit', 0), ('credit', '=', 0), ()), ], depends={'debit', 'tax_lines', 'journal'}) account = fields.Many2One('account.account', 'Account', required=True, domain=[ ('company', '=', Eval('company', -1)), ('type', '!=', None), ('closed', '!=', True), ['OR', ('start_date', '=', None), ('start_date', '<=', Eval('date', None)), ], ['OR', ('end_date', '=', None), ('end_date', '>=', Eval('date', None)), ], ], context={ 'company': Eval('company', -1), 'period': Eval('period', -1), }, states=_states, depends={'company', 'period'}) move = fields.Many2One( 'account.move', "Move", required=True, ondelete='CASCADE', states={ 'required': False, 'readonly': (((Eval('state') == 'valid') | _states['readonly']) & Bool(Eval('move'))), }) journal = fields.Function(fields.Many2One( 'account.journal', 'Journal', states=_states, context={ 'company': Eval('company', -1), }, depends={'company'}), 'get_move_field', setter='set_move_field', searcher='search_move_field') period = fields.Function(fields.Many2One('account.period', 'Period', states=_states), 'get_move_field', setter='set_move_field', searcher='search_move_field') company = fields.Function(fields.Many2One( 'company.company', "Company", states=_states), 'get_move_field', setter='set_move_field', searcher='search_move_field') date = fields.Function(fields.Date('Effective Date', required=True, states=_states), 'on_change_with_date', setter='set_move_field', searcher='search_move_field') origin = fields.Reference( "Origin", selection='get_origin', states=_states) move_origin = fields.Function( fields.Reference("Move Origin", selection='get_move_origin'), 'get_move_field', searcher='search_move_field') description = fields.Char('Description', states=_states) move_description_used = fields.Function( fields.Char("Move Description", states=_states), 'get_move_field', setter='set_move_field', searcher='search_move_field') amount_second_currency = Monetary( "Amount Second Currency", currency='second_currency', digits='second_currency', domain=[ If(Eval('amount_second_currency', 0), If((Eval('debit', 0) > 0) | (Eval('credit', 0) < 0), ('amount_second_currency', '>=', 0), If((Eval('debit', 0) < 0) | (Eval('credit', 0) > 0), ('amount_second_currency', '<=', 0), ())), ()), ], states={ 'required': Bool(Eval('second_currency')), 'readonly': _states['readonly'], }, help='The amount expressed in a second currency.') second_currency = fields.Many2One('currency.currency', 'Second Currency', help='The second currency.', domain=[ If(~Eval('second_currency_required'), ('id', '!=', Eval('currency', -1)), ('id', '=', Eval('second_currency_required', -1))), ], states={ 'required': (Bool(Eval('amount_second_currency')) | Bool(Eval('second_currency_required'))), 'readonly': _states['readonly'] }) second_currency_required = fields.Function( fields.Many2One('currency.currency', "Second Currency Required"), 'on_change_with_second_currency_required') party = fields.Many2One( 'party.party', "Party", states={ 'required': Eval('party_required', False), 'invisible': ~Eval('party_required', False), 'readonly': _states['readonly'], }, context={ 'company': Eval('company', -1), }, depends={'company'}, ondelete='RESTRICT') party_required = fields.Function(fields.Boolean('Party Required'), 'on_change_with_party_required') maturity_date = fields.Date( "Maturity Date", states={ 'invisible': ~Eval('has_maturity_date'), }, depends=['has_maturity_date'], help="Set a date to make the line payable or receivable.") has_maturity_date = fields.Function( fields.Boolean("Has Maturity Date"), 'on_change_with_has_maturity_date') state = fields.Selection([ ('draft', 'Draft'), ('valid', 'Valid'), ], 'State', readonly=True, required=True, sort=False) reconciliation = fields.Many2One( 'account.move.reconciliation', 'Reconciliation', readonly=True, ondelete='SET NULL') reconciliations_delegated = fields.One2Many( 'account.move.reconciliation', 'delegate_to', "Reconciliations Delegated", readonly=True) tax_lines = fields.One2Many('account.tax.line', 'move_line', 'Tax Lines') move_state = fields.Function( fields.Selection('get_move_states', "Move State"), 'on_change_with_move_state', searcher='search_move_field') currency = fields.Function(fields.Many2One( 'currency.currency', "Currency"), 'on_change_with_currency', searcher='search_currency') amount = fields.Function(Monetary( "Amount", currency='amount_currency', digits='amount_currency'), 'get_amount') amount_currency = fields.Function(fields.Many2One('currency.currency', 'Amount Currency'), 'get_amount_currency') delegated_amount = fields.Function(Monetary( "Delegated Amount", currency='amount_currency', digits='amount_currency', states={ 'invisible': ( ~Eval('reconciliation', False) | ~Eval('delegated_amount', 0)), }), 'get_delegated_amount') payable_receivable_balance = fields.Function( Monetary( "Payable/Receivable Balance", currency='amount_currency', digits='amount_currency'), 'get_payable_receivable_balance') del _states @classmethod def __setup__(cls): super().__setup__() cls.__access__.add('move') cls._check_modify_exclude = { 'maturity_date', 'reconciliation', 'tax_lines'} cls._reconciliation_modify_disallow = { 'account', 'debit', 'credit', 'party', } table = cls.__table__() cls._sql_constraints += [ ('credit_debit', Check(table, table.credit * table.debit == 0), 'account.msg_line_debit_credit'), ('second_currency_sign', Check(table, Coalesce(table.amount_second_currency, 0) * (table.debit - table.credit) >= 0), 'account.msg_line_second_currency_sign'), ] cls.__rpc__.update({ 'on_written': RPC(instantiate=0), }) # Do not cache default_date nor default_move cls.__rpc__['default_get'].cache = None cls._order[0] = ('id', 'DESC') cls._sql_indexes.update({ Index( table, (table.account, Index.Range()), (table.party, Index.Range())), Index(table, (table.reconciliation, Index.Range())), # Index for General Ledger Index( table, (table.move, Index.Range()), (table.account, Index.Range())), # Index for account.account.party Index( table, (table.account, Index.Range()), (table.party, Index.Range()), (table.id, Index.Range(cardinality='high')), where=table.party != Null), # Index for receivable/payable balance Index( table, (table.account, Index.Range()), (table.party, Index.Range()), where=table.reconciliation == Null), }) @classmethod def default_date(cls): ''' Return the date of the last line for journal, period or the starting date of the period or today ''' pool = Pool() Period = pool.get('account.period') Date = pool.get('ir.date') context = Transaction().context date = Date.today() lines = cls.search([ ('company', '=', context.get('company')), ('journal', '=', context.get('journal')), ('period', '=', context.get('period')), ], order=[('date', 'DESC')], limit=1) if lines: line, = lines date = line.date elif context.get('period'): period = Period(context['period']) if period.start_date >= date: date = period.start_date else: date = period.end_date if context.get('date'): date = context['date'] return date @classmethod def default_move(cls): transaction = Transaction() context = transaction.context if context.get('journal') and context.get('period'): lines = cls.search([ ('company', '=', context.get('company')), ('move.journal', '=', context['journal']), ('move.period', '=', context['period']), ('create_uid', '=', transaction.user), ('state', '=', 'draft'), ], order=[('id', 'DESC')], limit=1) if lines: line, = lines return line.move.id @fields.depends( 'move', 'debit', 'credit', '_parent_move.lines', '_parent_move.company') def on_change_move(self): if self.move: if not self.debit and not self.credit: total = sum((l.debit or 0) - (l.credit or 0) for l in getattr(self.move, 'lines', [])) self.debit = -total if total < 0 else Decimal(0) self.credit = total if total > 0 else Decimal(0) self.company = self.move.company @classmethod def default_company(cls): return Transaction().context.get('company') @staticmethod def default_state(): return 'draft' @staticmethod def default_debit(): return Decimal(0) @staticmethod def default_credit(): return Decimal(0) @fields.depends('account') def on_change_with_currency(self, name=None): return self.account.currency if self.account else None @classmethod def search_currency(cls, name, clause): return [('account.company.' + clause[0], * clause[1:])] @classmethod def _get_origin(cls): 'Return list of Model names for origin Reference' return ['account.move.line'] @classmethod def get_origin(cls): Model = Pool().get('ir.model') get_name = Model.get_name models = cls._get_origin() return [(None, '')] + [(m, get_name(m)) for m in models] @property def origin_rec_name(self): if self.origin: return self.origin.rec_name elif self.move_origin: return self.move_origin.rec_name else: return '' @fields.depends('debit', 'credit', 'amount_second_currency') def on_change_debit(self): if self.debit: self.credit = Decimal(0) self._amount_second_currency_sign() @fields.depends('debit', 'credit', 'amount_second_currency') def on_change_credit(self): if self.credit: self.debit = Decimal(0) self._amount_second_currency_sign() @fields.depends('amount_second_currency', 'debit', 'credit') def on_change_amount_second_currency(self): self._amount_second_currency_sign() def _amount_second_currency_sign(self): 'Set correct sign to amount_second_currency' if self.amount_second_currency: self.amount_second_currency = \ self.amount_second_currency.copy_sign( (self.debit or 0) - (self.credit or 0)) @fields.depends('account') def on_change_account(self): if self.account: if self.account.second_currency: self.second_currency = self.account.second_currency if not self.account.party_required: self.party = None @fields.depends('account') def on_change_with_second_currency_required(self, name=None): if self.account and self.account.second_currency: return self.account.second_currency.id @fields.depends('account') def on_change_with_party_required(self, name=None): if self.account: return self.account.party_required return False @fields.depends('move', '_parent_move.date') def on_change_with_date(self, name=None): if self.move: return self.move.date @fields.depends('move', '_parent_move.state') def on_change_with_move_state(self, name=None): if self.move: return self.move.state @classmethod def order_maturity_date(self, tables): pool = Pool() Move = pool.get('account.move') table, _ = tables[None] if 'move' not in tables: move = Move.__table__() tables['move'] = { None: (move, table.move == move.id), } else: move, _ = tables['move'][None] return [Coalesce(table.maturity_date, move.date)] @fields.depends('account') def on_change_with_has_maturity_date(self, name=None): if self.account: type_ = self.account.type return type_.receivable or type_.payable order_journal = MoveLineMixin._order_move_field('journal') order_period = MoveLineMixin._order_move_field('period') order_company = MoveLineMixin._order_move_field('company') order_date = MoveLineMixin._order_move_field('date') order_move_origin = MoveLineMixin._order_move_field('origin') order_move_state = MoveLineMixin._order_move_field('state') def get_delegated_amount(self, name): def final_delegated_line(line): if not line.reconciliation or not line.reconciliation.delegate_to: return line return final_delegated_line(line.reconciliation.delegate_to) final_delegation = final_delegated_line(self) if final_delegation == self: return None elif final_delegation.reconciliation: return final_delegation.amount_currency.round(0) else: return final_delegation.amount @classmethod def query_get(cls, table): ''' Return SQL clause and fiscal years for account move line depending of the context. table is the SQL instance of account.move.line table ''' pool = Pool() FiscalYear = pool.get('account.fiscalyear') Move = pool.get('account.move') Period = pool.get('account.period') move = Move.__table__() period = Period.__table__() fiscalyear = FiscalYear.__table__() context = Transaction().context company = context.get('company') fiscalyear_ids = [] where = Literal(True) if context.get('posted'): where &= move.state == 'posted' if context.get('journal'): where &= move.journal == context['journal'] date = context.get('date') from_date, to_date = context.get('from_date'), context.get('to_date') fiscalyear_id = context.get('fiscalyear') period_ids = context.get('periods') if date: fiscalyears = FiscalYear.search([ ('start_date', '<=', date), ('end_date', '>=', date), ('company', '=', company), ], order=[('start_date', 'DESC')], limit=1) if fiscalyears: fiscalyear_id = fiscalyears[0].id else: fiscalyear_id = -1 fiscalyear_ids = list(map(int, fiscalyears)) where &= period.fiscalyear == fiscalyear_id where &= move.date <= date elif fiscalyear_id or period_ids is not None or from_date or to_date: if fiscalyear_id: fiscalyear_ids = [fiscalyear_id] where &= fiscalyear.id == fiscalyear_id if period_ids is not None: where &= move.period.in_(period_ids or [None]) if from_date: where &= move.date >= from_date if to_date: where &= move.date <= to_date else: where &= fiscalyear.state == 'open' where &= fiscalyear.company == company fiscalyears = FiscalYear.search([ ('state', '=', 'open'), ('company', '=', company), ]) fiscalyear_ids = list(map(int, fiscalyears)) # Use LEFT JOIN to allow database optimization # if no joined table is used in the where clause. return (table.move.in_(move .join(period, 'LEFT', condition=move.period == period.id) .join(fiscalyear, 'LEFT', condition=period.fiscalyear == fiscalyear.id) .select(move.id, where=where)), fiscalyear_ids) @classmethod def on_written(cls, lines): return list(set(l.id for line in lines for l in line.move.lines)) @classmethod def validate_fields(cls, lines, field_names): super().validate(lines) cls.check_account(lines, field_names) @classmethod def check_account(cls, lines, field_names=None): if field_names and not (field_names & {'account', 'party'}): return for line in lines: if not line.account.type or line.account.closed: raise AccessError( gettext('account.msg_line_closed_account', account=line.account.rec_name)) if bool(line.party) != bool(line.account.party_required): error = 'party_set' if line.party else 'party_required' raise AccessError( gettext('account.msg_line_%s' % error, account=line.account.rec_name, line=line.rec_name)) @classmethod def check_journal_period_modify(cls, period, journal): ''' Check if the lines can be modified or created for the journal - period and if there is no journal - period, create it ''' JournalPeriod = Pool().get('account.journal.period') journal_periods = JournalPeriod.search([ ('journal', '=', journal.id), ('period', '=', period.id), ], limit=1) if journal_periods: journal_period, = journal_periods if journal_period.state == 'closed': raise AccessError( gettext('account.msg_modify_line_closed_journal_period', journal_period=journal_period.rec_name)) else: JournalPeriod.lock() JournalPeriod.create([{ 'journal': journal.id, 'period': period.id, }]) @classmethod def check_modification(cls, mode, lines, values=None, external=False): pool = Pool() Move = pool.get('account.move') super().check_modification( mode, lines, values=values, external=external) if (mode in {'create', 'delete'} or (mode == 'write' and values.keys() - cls._check_modify_exclude)): journal_period_done = set() for line in lines: if line.move.state == 'posted': raise AccessError(gettext( 'account.msg_modify_line_posted_move', line=line.rec_name, move=line.move.rec_name)) journal_period = (line.journal.id, line.period.id) if journal_period not in journal_period_done: cls.check_journal_period_modify( line.period, line.journal) journal_period_done.add(journal_period) if mode == 'delete': for line in lines: if line.reconciliation: raise AccessError(gettext( 'account.msg_delete_line_reconciled', line=line.rec_name)) if (mode == 'write' and values.keys() & cls._reconciliation_modify_disallow): for line in lines: if line.reconciliation: raise AccessError(gettext( 'account.msg_modify_line_reconciled', line=line.rec_name)) if mode in {'create', 'write'}: moves = Move.browse({l.move for l in lines}) Move.validate_move(moves) @classmethod def on_delete(cls, lines): pool = Pool() Move = pool.get('account.move') callback = super().on_delete(lines) moves = Move.browse({l.move for l in lines}) callback.append(lambda: Move.validate_move(moves)) return callback @classmethod def view_attributes(cls): attributes = super().view_attributes() view_ids = cls._view_reconciliation_muted() if Transaction().context.get('view_id') in view_ids: attributes.append( ('/tree', 'visual', If(Bool(Eval('reconciliation')), 'muted', ''))) return attributes @classmethod def _view_reconciliation_muted(cls): pool = Pool() ModelData = pool.get('ir.model.data') return {ModelData.get_id( 'account', 'move_line_view_list_payable_receivable')} @classmethod def create(cls, vlist): pool = Pool() Move = pool.get('account.move') def move_fields(move_name): for fname, field in cls._fields.items(): if (isinstance(field, fields.Function) and field.setter == 'set_move_field'): if move_name and fname.startswith('move_'): fname = fname[5:] yield fname moves = {} context = Transaction().context vlist = [x.copy() for x in vlist] for vals in vlist: if not vals.get('move'): move_values = {} for fname in move_fields(move_name=True): move_values[fname] = vals.get(fname) or context.get(fname) key = tuple(sorted(move_values.items())) move = moves.get(key) if move is None: move = Move(**move_values) move.save() moves[key] = move vals['move'] = move.id else: # prevent default value for field with set_move_field for fname in move_fields(move_name=False): vals.setdefault(fname, None) return super().create(vlist) @classmethod def copy(cls, lines, default=None): if default is None: default = {} else: default = default.copy() default.setdefault('move', None) default.setdefault('reconciliation', None) default.setdefault('reconciliations_delegated', []) return super().copy(lines, default=default) @classmethod def view_toolbar_get(cls): pool = Pool() Template = pool.get('account.move.template') toolbar = super().view_toolbar_get() # Add a wizard entry for each templates context = Transaction().context company = context.get('company') journal = context.get('journal') period = context.get('period') if company and journal and period: templates = Template.search([ ('company', '=', company), ('journal', '=', journal), ]) for template in templates: action = toolbar['action'] # Use template id for action id to auto-select the template action.append({ 'name': template.name, 'type': 'ir.action.wizard', 'wiz_name': 'account.move.template.create', 'id': template.id, }) return toolbar @classmethod def reconcile( cls, *lines_list, date=None, writeoff=None, description=None, delegate_to=None): """ Reconcile each list of lines together. The writeoff keys are: date, method and description. """ pool = Pool() Reconciliation = pool.get('account.move.reconciliation') Move = pool.get('account.move') Lang = pool.get('ir.lang') lang = Lang.get() delegate_to = delegate_to.id if delegate_to else None reconciliations = [] to_post = [] for lines in lines_list: if not lines: continue for line in lines: if line.reconciliation: raise AccessError( gettext('account.msg_line_already_reconciled', line=line.rec_name)) lines = list(lines) reconcile_account = None reconcile_party = None amount = Decimal(0) amount_second_currency = Decimal(0) second_currencies = set() posted = True for line in lines: posted &= line.move.state == 'posted' amount += line.debit - line.credit if not reconcile_account: reconcile_account = line.account if not reconcile_party: reconcile_party = line.party if line.amount_second_currency is not None: amount_second_currency += line.amount_second_currency second_currencies.add(line.second_currency) company = reconcile_account.company try: second_currency, = second_currencies except ValueError: amount_second_currency = None second_currency = None if second_currency: writeoff_amount = amount_second_currency writeoff_currency = second_currency else: writeoff_amount = amount writeoff_currency = company.currency if writeoff_amount: if not writeoff: raise ReconciliationError(gettext( 'account.msg_reconciliation_write_off_missing', amount=lang.currency( writeoff_amount, writeoff_currency))) move = cls._get_writeoff_move( reconcile_account, reconcile_party, writeoff_amount, writeoff_currency, writeoff, date=date, description=description) move.save() if posted: to_post.append(move) for line in move.lines: if line.account == reconcile_account: lines.append(line) amount += line.debit - line.credit if second_currency and amount: move = cls._get_exchange_move( reconcile_account, reconcile_party, amount, date) move.save() if posted: to_post.append(move) for line in move.lines: if line.account == reconcile_account: lines.append(line) amount += line.debit - line.credit assert not amount, f"{amount} must be zero" reconciliations.append({ 'company': reconcile_account.company, 'lines': [('add', [x.id for x in lines])], 'date': max(l.date for l in lines), 'delegate_to': delegate_to, }) if to_post: Move.post(to_post) return Reconciliation.create(reconciliations) @classmethod def _get_writeoff_move( cls, reconcile_account, reconcile_party, amount, currency, writeoff, date=None, description=None): pool = Pool() Currency = pool.get('currency.currency') Date = pool.get('ir.date') Period = pool.get('account.period') Move = pool.get('account.move') company = reconcile_account.company if not date: with Transaction().set_context(company=company.id): date = Date.today() period = Period.find(reconcile_account.company, date=date) if amount >= 0: account = writeoff.debit_account else: account = writeoff.credit_account if account == reconcile_account: raise ReconciliationError(gettext( 'account.msg_reconciliation_write_off_same_account', write_off=writeoff.rec_name, account=account.rec_name)) journal = writeoff.journal if currency != company.currency: amount_second_currency = amount second_currency = currency with Transaction().set_context(date=date): amount = Currency.compute( currency, amount, company.currency) else: amount_second_currency = None second_currency = None move = Move() move.company = company move.journal = journal move.period = period move.date = date move.description = description lines = [] line = cls() lines.append(line) line.account = reconcile_account line.party = reconcile_party line.debit = -amount if amount < 0 else 0 line.credit = amount if amount > 0 else 0 if second_currency: line.amount_second_currency = ( amount_second_currency).copy_sign( line.debit - line.credit) line.second_currency = second_currency line = cls() lines.append(line) line.account = account line.party = ( reconcile_party if account.party_required else None) line.debit = amount if amount > 0 else 0 line.credit = -amount if amount < 0 else 0 if account.second_currency: with Transaction().set_context(date=date): line.amount_second_currency = Currency.compute( company.currency, amount, reconcile_account.second_currency).copy_sign( line.debit - line.credit) move.lines = lines return move @classmethod def _get_exchange_move(cls, account, party, amount, date=None): pool = Pool() Configuration = pool.get('account.configuration') Date = pool.get('ir.date') Move = pool.get('account.move') Period = pool.get('account.period') configuration = Configuration(1) company = account.company if not date: with Transaction().set_context(company=company.id): date = Date.today() period_id = Period.find(company.id, date=date) move = Move() move.company = company move.journal = configuration.get_multivalue( 'currency_exchange_journal', company=company.id) if not move.journal: raise JournalMissing(gettext( 'account.' 'msg_reconciliation_currency_exchange_journal_missing', company=company.rec_name)) move.period = period_id move.date = date lines = [] line = cls() lines.append(line) line.account = account line.party = party line.debit = -amount if amount < 0 else 0 line.credit = amount if amount > 0 else 0 line = cls() lines.append(line) line.debit = amount if amount > 0 else 0 line.credit = -amount if amount < 0 else 0 if line.credit: line.account = configuration.get_multivalue( 'currency_exchange_credit_account', company=company.id) if not line.account: raise AccountMissing(gettext( 'account.' 'msg_reconciliation_currency_exchange_' 'credit_account_missing', company=company.rec_name)) else: line.account = configuration.get_multivalue( 'currency_exchange_debit_account', company=company.id) if not line.account: raise AccountMissing(gettext( 'account.' 'msg_reconciliation_currency_exchange_' 'debit_account_missing', company=company.rec_name)) move.lines = lines return move @classmethod def find_best_reconciliation(cls, lines, currency, amount=0): """Return the list of lines to reconcile for the amount with the smallest remaining. For performance reason it is an approximation that searches for the smallest remaining by removing the last line that decrease it.""" assert len({l.account for l in lines}) <= 1 def get_balance(line): if line.second_currency == currency: return line.amount_second_currency elif line.currency == currency: return line.debit - line.credit else: return 0 with Transaction().set_context(_record_cache_size=max(len(lines), 1)): lines = cls.browse(sorted(lines, key=cls._reconciliation_sort_key)) debit, credit = [], [] remaining = -amount for line in list(lines): balance = get_balance(line) if balance > 0: debit.append(line) elif balance < 0: credit.append(line) else: lines.remove(line) remaining += balance best_lines, best_remaining = list(lines), remaining if remaining: while lines: try: line = (debit if remaining > 0 else credit).pop() except IndexError: break lines.remove(line) remaining -= get_balance(line) if lines and abs(remaining) < abs(best_remaining): best_lines, best_remaining = list(lines), remaining if not remaining: break return best_lines, best_remaining def _reconciliation_sort_key(self): return self.maturity_date or self.date class LineReceivablePayableContext(ModelView): __name__ = 'account.move.line.receivable_payable.context' company = fields.Many2One('company.company', "Company", required=True) reconciled = fields.Boolean("Reconciled") receivable = fields.Boolean("Receivable") payable = fields.Boolean("Payable") @classmethod def default_company(cls): return Transaction().context.get('company') @classmethod def default_reconciled(cls): return Transaction().context.get('reconciled', False) @classmethod def default_receivable(cls): return Transaction().context.get('receivable', True) @classmethod def default_payable(cls): return Transaction().context.get('payable', True) class WriteOff(DeactivableMixin, ModelSQL, ModelView): __name__ = 'account.move.reconcile.write_off' company = fields.Many2One('company.company', "Company", required=True) name = fields.Char("Name", required=True, translate=True) journal = fields.Many2One('account.journal', "Journal", required=True, domain=[('type', '=', 'write-off')], context={ 'company': Eval('company', -1), }, depends={'company'}) credit_account = fields.Many2One('account.account', "Credit Account", required=True, domain=[ ('type', '!=', None), ('closed', '!=', True), ('company', '=', Eval('company', -1)), ]) debit_account = fields.Many2One('account.account', "Debit Account", required=True, domain=[ ('type', '!=', None), ('closed', '!=', True), ('company', '=', Eval('company', -1)), ]) @classmethod def __setup__(cls): super().__setup__() cls._order.insert(0, ('name', 'ASC')) @classmethod def default_company(cls): return Transaction().context.get('company') class OpenJournalAsk(ModelView): __name__ = 'account.move.open_journal.ask' company = fields.Many2One('company.company', "Company", required=True) journal = fields.Many2One( 'account.journal', 'Journal', required=True, context={ 'company': Eval('company', None), }) period = fields.Many2One('account.period', 'Period', required=True, domain=[ ('company', '=', Eval('company', -1)), ('state', '!=', 'closed'), ]) @classmethod def default_company(cls): return Transaction().context.get('company') @classmethod def default_period(cls): pool = Pool() Period = pool.get('account.period') if company := cls.default_company(): try: period = Period.find(company) except PeriodNotFoundError: return None return period.id class OpenJournal(Wizard): __name__ = 'account.move.open_journal' _readonly = True start = StateTransition() ask = StateView('account.move.open_journal.ask', 'account.open_journal_ask_view_form', [ Button('Cancel', 'end', 'tryton-cancel'), Button('Open', 'open_', 'tryton-ok', default=True), ]) open_ = StateAction('account.act_move_line_form') def transition_start(self): if (self.model and self.model.__name__ == 'account.journal.period' and self.record): return 'open_' return 'ask' def default_ask(self, fields): if (self.model and self.model.__name__ == 'account.journal.period' and self.record): return { 'company': self.record.company.id, 'journal': self.record.journal.id, 'period': self.record.period.id, } return {} def do_open_(self, action): JournalPeriod = Pool().get('account.journal.period') if (self.model and self.model.__name__ == 'account.journal.period' and self.record): journal = self.record.journal period = self.record.period else: journal = self.ask.journal period = self.ask.period journal_periods = JournalPeriod.search([ ('journal', '=', journal.id), ('period', '=', period.id), ], limit=1) if not journal_periods: journal_period, = JournalPeriod.create([{ 'journal': journal.id, 'period': period.id, }]) else: journal_period, = journal_periods action['name'] += ' (%s)' % journal_period.rec_name action['pyson_domain'] = PYSONEncoder().encode([ ('journal', '=', journal.id), ('period', '=', period.id), ('company', '=', period.company.id), ]) action['pyson_context'] = PYSONEncoder().encode({ 'journal': journal.id, 'period': period.id, 'company': period.company.id, }) return action, {} def transition_open_(self): return 'end' class OpenAccount(Wizard): __name__ = 'account.move.open_account' start_state = 'open_' _readonly = True open_ = StateAction('account.act_move_line_form') def do_open_(self, action): pool = Pool() FiscalYear = pool.get('account.fiscalyear') context = Transaction().context company_id = self.record.company.id if self.record else -1 date = context.get('date') fiscalyear = context.get('fiscalyear') if date: fiscalyears = FiscalYear.search([ ('start_date', '<=', date), ('end_date', '>=', date), ('company', '=', company_id), ], order=[('start_date', 'DESC')], limit=1) elif fiscalyear: fiscalyears = [FiscalYear(fiscalyear)] else: fiscalyears = FiscalYear.search([ ('state', '=', 'open'), ('company', '=', company_id), ]) periods = [p for f in fiscalyears for p in f.periods] action['pyson_domain'] = [ ('period', 'in', [p.id for p in periods]), ('account', '=', self.record.id if self.record else None), ('state', '=', 'valid'), ] if Transaction().context.get('posted'): action['pyson_domain'].append(('move.state', '=', 'posted')) if Transaction().context.get('date'): action['pyson_domain'].append(('move.date', '<=', Transaction().context['date'])) if self.record: action['name'] += ' (%s)' % self.record.rec_name action['pyson_domain'] = PYSONEncoder().encode(action['pyson_domain']) action['pyson_context'] = PYSONEncoder().encode({ 'fiscalyear': Transaction().context.get('fiscalyear'), }) return action, {} class ReconcileLinesWriteOff(ModelView): __name__ = 'account.move.reconcile_lines.writeoff' company = fields.Many2One('company.company', "Company", readonly=True) writeoff = fields.Many2One('account.move.reconcile.write_off', "Write Off", required=True, domain=[ ('company', '=', Eval('company', -1)), ]) date = fields.Date('Date', required=True) amount = Monetary( "Amount", currency='currency', digits='currency', readonly=True) currency = fields.Many2One('currency.currency', "Currency", readonly=True) description = fields.Char('Description') @staticmethod def default_date(): Date = Pool().get('ir.date') return Date.today() class ReconcileLines(Wizard): __name__ = 'account.move.reconcile_lines' start = StateTransition() writeoff = StateView('account.move.reconcile_lines.writeoff', 'account.reconcile_lines_writeoff_view_form', [ Button('Cancel', 'end', 'tryton-cancel'), Button('Reconcile', 'reconcile', 'tryton-ok', default=True), ]) reconcile = StateTransition() @classmethod def check_access(cls, models=None): transaction = Transaction() if transaction.user: context = transaction.context models = set() if models is None else models.copy() models.update({'account.move.line', 'account.general_ledger.line'}) model = context.get('active_model') if model and model not in models: raise AccessError(gettext( 'ir.msg_access_wizard_model_error', wizard=cls.__name__, model=model)) super().check_access() def get_writeoff(self): "Return writeoff amount and company" company = currency = None amount = Decimal(0) amount_second_currency = Decimal(0) second_currencies = set() for line in self.records: amount += line.debit - line.credit if line.amount_second_currency is not None: amount_second_currency += line.amount_second_currency second_currencies.add(line.second_currency) if not company: company = line.account.company currency = company.currency try: second_currency, = second_currencies except ValueError: second_currency = None if second_currency: amount = amount_second_currency currency = second_currency return amount, currency, company def transition_start(self): amount, currency, company = self.get_writeoff() if not company: return 'end' if currency.is_zero(amount): return 'reconcile' return 'writeoff' def default_writeoff(self, fields): amount, currency, company = self.get_writeoff() return { 'amount': amount, 'currency': currency.id, 'company': company.id, } def transition_reconcile(self): self.model.reconcile( self.records, writeoff=self.writeoff.writeoff, date=self.writeoff.date, description=self.writeoff.description) return 'end' class UnreconcileLines(Wizard): __name__ = 'account.move.unreconcile_lines' start_state = 'unreconcile' unreconcile = StateTransition() @classmethod def check_access(cls, models=None): transaction = Transaction() if transaction.user: context = transaction.context models = set() if models is None else models.copy() models.update({'account.move.line', 'account.general_ledger.line'}) model = context.get('active_model') if model and model not in models: raise AccessError(gettext( 'ir.msg_access_wizard_model_error', wizard=cls.__name__, model=model)) super().check_access() def transition_unreconcile(self): self.make_unreconciliation(self.records) return 'end' @classmethod def make_unreconciliation(cls, lines): pool = Pool() Reconciliation = pool.get('account.move.reconciliation') reconciliations = [x.reconciliation for x in lines if x.reconciliation] if reconciliations: Reconciliation.delete(reconciliations) class Reconcile(Wizard): __name__ = 'account.reconcile' start = StateView( 'account.reconcile.start', 'account.reconcile_start_view_form', [ Button("Cancel", 'end', 'tryton-cancel'), Button("Reconcile", 'setup', 'tryton-ok', default=True), ]) setup = StateTransition() next_ = StateTransition() show = StateView('account.reconcile.show', 'account.reconcile_show_view_form', [ Button('Cancel', 'end', 'tryton-cancel'), Button('Skip', 'next_', 'tryton-forward', validate=False), Button('Reconcile', 'reconcile', 'tryton-ok', default=True), ]) reconcile = StateTransition() def get_accounts(self): 'Return a list of account id to reconcile' pool = Pool() Rule = pool.get('ir.rule') Line = pool.get('account.move.line') line = Line.__table__() Account = pool.get('account.account') AccountType = pool.get('account.account.type') account = Account.__table__() account_type = AccountType.__table__() cursor = Transaction().connection.cursor() account_rule = Rule.query_get(Account.__name__) if self.model and self.model.__name__ == 'account.move.line': lines = [l for l in self.records if not l.reconciliation] return list({l.account for l in lines if l.account.reconcile}) balance = line.debit - line.credit cursor.execute(*line.join(account, condition=line.account == account.id) .join(account_type, condition=account.type == account_type.id) .select( account.id, where=((line.reconciliation == Null) & (line.state == 'valid') & account.reconcile & account.id.in_(account_rule)), group_by=[account.id, account_type.receivable, account_type.payable], having=(( Sum(Case((balance > 0, 1), else_=0)) > 0) & (Sum(Case((balance < 0, 1), else_=0)) > 0) | Case((account_type.receivable & ~Coalesce(account_type.payable, False), Sum(balance) < 0), else_=False) | Case((account_type.payable & ~Coalesce(account_type.receivable, False), Sum(balance) > 0), else_=False) ))) return [a for a, in cursor] def get_parties(self, account, party=None): 'Return a list party to reconcile for the account' pool = Pool() Line = pool.get('account.move.line') line = Line.__table__() cursor = Transaction().connection.cursor() if self.model and self.model.__name__ == 'account.move.line': lines = [l for l in self.records if not l.reconciliation] return list({ l.party for l in lines if l.account == account and l.party}) where = ((line.reconciliation == Null) & (line.state == 'valid') & (line.account == account.id)) if party: where &= (line.party == party.id) else: where &= (line.party != Null) cursor.execute(*line.select(line.party, where=where, group_by=line.party)) return [p for p, in cursor] def get_currencies(self, account, party, currency=None, _balanced=False): "Return a list of currencies to reconcile for the account and party" pool = Pool() Line = pool.get('account.move.line') line = Line.__table__() cursor = Transaction().connection.cursor() if self.model and self.model.__name__ == 'account.move.line': lines = [l for l in self.records if not l.reconciliation] return list({ (l.second_currency or l.currency).id for l in lines if l.account == account and l.party == party}) balance = Case( (line.second_currency != Null, line.amount_second_currency), else_=line.debit - line.credit) if _balanced: having = Sum(balance) == 0 else: having = (( Sum(Case((balance > 0, 1), else_=0)) > 0) & (Sum(Case((balance < 0, 1), else_=0)) > 0) | Case((account.type.receivable, Sum(balance) < 0), else_=False) | Case((account.type.payable, Sum(balance) > 0), else_=False) ) where = ((line.reconciliation == Null) & (line.state == 'valid') & (line.account == account.id) & (line.party == (party.id if party else None))) currency_expr = Coalesce(line.second_currency, account.currency.id) if currency: where &= (currency_expr == currency.id) cursor.execute(*line.select( currency_expr, where=where, group_by=line.second_currency, having=having)) return [p for p, in cursor] def transition_setup(self): return self.transition_next_(first=True) @check_access def transition_next_(self, first=False): pool = Pool() Line = pool.get('account.move.line') def next_account(): accounts = list(self.show.accounts) if not accounts: return account = accounts.pop() self.show.account = account self.show.accounts = accounts self.show.parties = self.get_parties(account) return account def next_party(): parties = list(self.show.parties) if not parties: return party = parties.pop() self.show.party = party self.show.parties = parties self.show.currencies = self.get_currencies( self.show.account, party) return party, def next_currency(): currencies = list(self.show.currencies) if not currencies: return currency = currencies.pop() self.show.currency = currency self.show.currencies = currencies return currency if first: self.show.accounts = self.get_accounts() account = next_account() if not account or (not next_party() and account.party_required): return 'end' if self.show.account.party_required: self.show.parties = self.get_parties(self.show.account) if not next_party(): return 'end' else: self.show.parties = [] self.show.party = None self.show.currencies = self.get_currencies( self.show.account, self.show.party) while True: while not next_currency(): if self.show.account.party_required: while not next_party(): if not next_account(): return 'end' else: if not next_account(): return 'end' if self.start.automatic or self.start.only_balanced: lines = self._default_lines() if lines and self.start.automatic: while lines: Line.reconcile(lines) lines = self._default_lines() continue elif not lines and self.start.only_balanced: continue return 'show' def default_show(self, fields): pool = Pool() Date = pool.get('ir.date') defaults = {} defaults['accounts'] = [a.id for a in self.show.accounts] defaults['account'] = self.show.account.id defaults['company'] = self.show.account.company.id defaults['parties'] = [p.id for p in self.show.parties] defaults['party'] = self.show.party.id if self.show.party else None defaults['currencies'] = [c.id for c in self.show.currencies] defaults['currency'] = ( self.show.currency.id if self.show.currency else None) defaults['lines'] = list(map(int, self._default_lines())) defaults['write_off_amount'] = None with Transaction().set_context(company=self.show.account.company.id): defaults['date'] = Date.today() return defaults def _all_lines(self): 'Return all lines to reconcile for the current state' pool = Pool() Line = pool.get('account.move.line') return Line.search([ ('account', '=', self.show.account.id), ('party', '=', self.show.party.id if self.show.party else None), ('reconciliation', '=', None), ['OR', [ ('currency', '=', self.show.currency.id), ('second_currency', '=', None), ], ('second_currency', '=', self.show.currency.id), ], ('state', '=', 'valid'), ], order=[]) def _line_sort_key(self, line): return [line.maturity_date or line.date] def _default_lines(self): 'Return the larger list of lines which can be reconciled' pool = Pool() Line = pool.get('account.move.line') if self.model and self.model.__name__ == 'account.move.line': requested = { l for l in self.records if l.account == self.show.account and l.party == self.show.party and (l.second_currency or l.currency) == self.show.currency} else: requested = [] currency = self.show.currency lines, remaining = Line.find_best_reconciliation( self._all_lines(), currency) if remaining: return requested else: return lines def transition_reconcile(self): pool = Pool() Line = pool.get('account.move.line') if self.show.lines: Line.reconcile(self.show.lines, date=self.show.date, writeoff=self.show.write_off, description=self.show.description) if self.get_currencies( self.show.account, self.show.party, currency=self.show.currency): return 'show' return 'next_' class ReconcileStart(ModelView): __name__ = 'account.reconcile.start' automatic = fields.Boolean( "Automatic", help="Automatically reconcile suggestions.") only_balanced = fields.Boolean( "Only Balanced", help="Skip suggestion with write-off.") @classmethod def default_automatic(cls): return False @classmethod def default_only_balanced(cls): return False class ReconcileShow(ModelView): __name__ = 'account.reconcile.show' company = fields.Many2One('company.company', "Company", readonly=True) accounts = fields.Many2Many('account.account', None, None, 'Account', readonly=True) account = fields.Many2One('account.account', 'Account', readonly=True) parties = fields.Many2Many( 'party.party', None, None, 'Parties', readonly=True, context={ 'company': Eval('company', -1), }, depends={'company'}) party = fields.Many2One( 'party.party', 'Party', readonly=True, context={ 'company': Eval('company', -1), }, depends={'company'}) currencies = fields.Many2Many( 'currency.currency', None, None, "Currencies", readonly=True) currency = fields.Many2One('currency.currency', "Currency", readonly=True) lines = fields.Many2Many('account.move.line', None, None, 'Lines', domain=[ ('account', '=', Eval('account', -1)), ('party', '=', Eval('party', -1)), ('reconciliation', '=', None), ['OR', [ ('currency', '=', Eval('currency', -1)), ('second_currency', '=', None), ], ('second_currency', '=', Eval('currency', -1)), ], ]) _write_off_states = { 'required': Bool(Eval('write_off_amount', 0)), 'invisible': ~Eval('write_off_amount', 0), } write_off_amount = Monetary( "Amount", currency='currency', digits='currency', readonly=True, states=_write_off_states) write_off = fields.Many2One( 'account.move.reconcile.write_off', "Write Off", domain=[ ('company', '=', Eval('company', -1)), ], states=_write_off_states) date = fields.Date('Date', states=_write_off_states) description = fields.Char('Description', states={ 'invisible': _write_off_states['invisible'], }) @fields.depends('lines', 'currency', 'company') def on_change_lines(self): amount = Decimal(0) if self.currency: for line in self.lines: if line.second_currency == self.currency: amount += ( line.amount_second_currency or 0) elif line.currency == self.currency: amount += ( (line.debit or 0) - (line.credit or 0)) amount = self.currency.round(amount) self.write_off_amount = amount class CancelMoves(Wizard): __name__ = 'account.move.cancel' start_state = 'default' default = StateView('account.move.cancel.default', 'account.move_cancel_default_view_form', [ Button('Cancel', 'end', 'tryton-cancel'), Button('OK', 'cancel', 'tryton-ok', default=True), ]) cancel = StateTransition() def default_cancel(self, move): default = {} if self.default.description: default['description'] = self.default.description return default def transition_cancel(self): pool = Pool() Line = pool.get('account.move.line') Move = pool.get('account.move') Warning = pool.get('res.user.warning') Unreconcile = pool.get('account.move.unreconcile_lines', type='wizard') transaction = Transaction() moves = self.records moves_w_delegation = { m: [ml for ml in m.lines if ml.reconciliation and ml.reconciliation.delegate_to] for m in moves} if any(dml for dml in moves_w_delegation.values()): names = ', '.join(m.rec_name for m in islice(moves_w_delegation.keys(), None, 5)) if len(moves_w_delegation) > 5: names += '...' key = Warning.format('cancel_delegated', moves_w_delegation) if Warning.check(key): raise CancelDelegatedWarning( key, gettext( 'account.msg_cancel_line_delegated', moves=names)) to_post = [] for move in moves: if moves_w_delegation.get(move): with transaction.set_context(_skip_warnings=True): Unreconcile.make_unreconciliation(moves_w_delegation[move]) default = self.default_cancel(move) cancel_move = move.cancel( default=default, reversal=self.default.reversal) if move.state == 'posted': to_post.append(cancel_move) to_reconcile = defaultdict(list) for line in move.lines + cancel_move.lines: if line.account.reconcile: to_reconcile[(line.account, line.party)].append(line) for lines in to_reconcile.values(): Line.reconcile(lines) if to_post: Move.post(to_post) return 'end' class CancelMovesDefault(ModelView): __name__ = 'account.move.cancel.default' description = fields.Char('Description') reversal = fields.Boolean( "Reversal", help="Reverse debit and credit.") @classmethod def default_reversal(cls): return True class GroupLines(Wizard): __name__ = 'account.move.line.group' start = StateView('account.move.line.group.start', 'account.move_line_group_start_view_form', [ Button("Cancel", 'end', 'tryton-cancel'), Button("Group", 'group', 'tryton-ok', default=True), ]) group = StateAction('account.act_move_form_grouping') def do_group(self, action): move, balance_line = self._group_lines(self.records) if all(l.move.state == 'posted' for l in self.records): move.post() return action, {'res_id': move.id} def _group_lines(self, lines, date=None): move, balance_line = self.group_lines(lines, self.start.journal, date) move.description = self.start.description move.save() return move, balance_line @classmethod def group_lines(cls, lines, journal, date=None): pool = Pool() Line = pool.get('account.move.line') grouping = cls.grouping(lines) move, balance_line = cls.get_move(lines, grouping, journal, date) move.save() to_reconcile = defaultdict(list) for line in chain(lines, move.lines): if line.account.reconcile: to_reconcile[line.account].append(line) if balance_line: balance_line.move = move balance_line.save() for lines in to_reconcile.values(): Line.reconcile(lines, delegate_to=balance_line) return move, balance_line @classmethod def grouping(cls, lines): if len(lines) == 1: raise GroupLineError(gettext('account.msg_group_line_single')) companies = set() parties = set() accounts = set() second_currencies = set() for line in lines: if not cls.allow_grouping(line.account): raise GroupLineError(gettext('account.msg_group_line')) companies.add(line.move.company) parties.add(line.party) accounts.add(line.account) second_currencies.add(line.second_currency) try: company, = companies except ValueError: raise GroupLineError( gettext('account.msg_group_line_same_company')) try: party, = parties except ValueError: raise GroupLineError( gettext('account.msg_group_line_many_parties')) try: second_currency, = second_currencies except ValueError: raise GroupLineError( gettext('account.msg_group_line_same_second_currency')) if len(accounts) > 2: raise GroupLineError( gettext('account.msg_group_line_maximum_account')) return { 'company': company, 'party': party, 'second_currency': second_currency, 'accounts': accounts, } @classmethod def allow_grouping(cls, account): return account.type.payable or account.type.receivable @classmethod def get_move(cls, lines, grouping, journal, date=None): pool = Pool() Date = pool.get('ir.date') Move = pool.get('account.move') Period = pool.get('account.period') Line = pool.get('account.move.line') company = grouping['company'] if not date: with Transaction().set_context(company=company.id): date = Date.today() period = Period.find(company, date=date) move = Move() move.company = company move.date = date move.period = period move.journal = journal accounts = defaultdict(Decimal) amount_second_currency = 0 maturity_dates = { 'debit': defaultdict(lambda: None), 'credit': defaultdict(lambda: None), } counterpart_lines = [] for line in lines: if line.maturity_date: if line.debit: m_dates = maturity_dates['debit'] else: m_dates = maturity_dates['credit'] if m_dates[line.account]: m_dates[line.account] = min( m_dates[line.account], line.maturity_date) elif line.maturity_date: m_dates[line.account] = line.maturity_date cline = cls._counterpart_line(line) accounts[cline.account] += cline.debit - cline.credit if cline.amount_second_currency: amount_second_currency += cline.amount_second_currency counterpart_lines.append(cline) move.lines = counterpart_lines balance_line = None if len(accounts) == 1: account, = grouping['accounts'] amount = -accounts[account] if amount: balance_line = Line() balance_line.account = account else: first, second = grouping['accounts'] if accounts[first] != accounts[second]: balance_line = Line() amount = -(accounts[first] + accounts[second]) if abs(accounts[first]) > abs(accounts[second]): balance_line.account = first else: balance_line.account = second if balance_line: if balance_line.account.party_required: balance_line.party = grouping['party'] if amount > 0: balance_line.debit, balance_line.credit = amount, 0 maturity_date = maturity_dates['debit'][balance_line.account] else: balance_line.debit, balance_line.credit = 0, -amount maturity_date = maturity_dates['credit'][balance_line.account] balance_line.maturity_date = maturity_date if grouping['second_currency']: balance_line.second_currency = grouping['second_currency'] balance_line.amount_second_currency = ( -amount_second_currency) return move, balance_line @classmethod def _counterpart_line(cls, line): pool = Pool() Line = pool.get('account.move.line') counterpart = Line() counterpart.account = line.account counterpart.debit = line.credit counterpart.credit = line.debit counterpart.party = line.party counterpart.second_currency = line.second_currency if line.second_currency: counterpart.amount_second_currency = -line.amount_second_currency else: counterpart.amount_second_currency = None return counterpart class GroupLinesStart(ModelView): __name__ = 'account.move.line.group.start' journal = fields.Many2One('account.journal', "Journal", required=True) description = fields.Char("Description") class RescheduleLines(Wizard): __name__ = 'account.move.line.reschedule' start = StateView('account.move.line.reschedule.start', 'account.move_line_reschedule_start_view_form', [ Button("Cancel", 'end', 'tryton-cancel'), Button("Preview", 'preview', 'tryton-ok', validate=False, default=True), ]) preview = StateView('account.move.line.reschedule.preview', 'account.move_line_reschedule_preview_view_form', [ Button("Cancel", 'end', 'tryton-cancel'), Button("Reschedule", 'reschedule', 'tryton-ok', default=True), ]) reschedule = StateAction('account.act_move_form_rescheduling') def get_origin(self): try: origin, = {r.move.origin for r in self.records} except ValueError: raise RescheduleLineError( gettext('account.msg_reschedule_line_same_origins')) return origin @classmethod def get_currency(cls, lines): try: currency, = {l.amount_currency for l in lines} except ValueError: raise RescheduleLineError( gettext('account.msg_reschedule_line_same_currency')) return currency @classmethod def get_account(cls, lines): try: account, = {l.account for l in lines} except ValueError: raise RescheduleLineError( gettext('account.msg_reschedule_line_same_account')) return account @classmethod def get_party(cls, lines): try: party, = {l.party for l in lines} except ValueError: raise RescheduleLineError( gettext('account.msg_reschedule_line_same_party')) return party @classmethod def get_total_amount(cls, lines): return sum(l.amount for l in lines) @classmethod def get_balance(cls, lines): return sum(l.debit - l.credit for l in lines) def default_start(self, fields): values = {} self.get_origin() self.get_account(self.records) currency = self.get_currency(self.records) values['currency'] = currency.id values['total_amount'] = self.get_total_amount(self.records) return values def default_preview(self, fields): values = {} currency = self.get_currency(self.records) try: journal, = {r.move.journal for r in self.records} values['journal'] = journal.id except ValueError: pass values['currency'] = currency.id if (self.start.start_date and self.start.interval and self.start.currency): remaining = self.start.total_amount date = self.start.start_date values['terms'] = terms = [] if self.start.amount: interval = self.start.interval amount = self.start.amount.copy_sign(remaining) while remaining - amount > 0: terms.append({ 'date': date, 'amount': amount, 'currency': currency.id, }) date = ( self.start.start_date + relativedelta(months=interval)) interval += self.start.interval remaining -= amount if remaining: terms.append({ 'date': date, 'amount': remaining, 'currency': currency.id, }) elif self.start.number: amount = self.start.currency.round( self.start.total_amount / self.start.number) for i in range(self.start.number): terms.append({ 'date': date + relativedelta(months=i), 'amount': amount, 'currency': currency.id, }) remaining -= amount if remaining: terms[-1]['amount'] += remaining return values def do_reschedule(self, action): move, balance_line = self.reschedule_lines( self.records, self.preview.journal, self.preview.terms) move.origin = self.get_origin() move.description = self.preview.description move.save() if all(l.move.state == 'posted' for l in self.records): move.post() action['res_id'] = [move.id] return action, {} @classmethod def _line_values(cls, lines): account = cls.get_account(lines) currency = cls.get_currency(lines) if currency == account.currency: currency = None return { 'account': account, 'second_currency': currency, 'party': cls.get_party(lines), } @classmethod def reschedule_lines(cls, lines, journal, terms): pool = Pool() Lang = pool.get('ir.lang') Line = pool.get('account.move.line') total_amount = cls.get_total_amount(lines) amount = sum(t.amount for t in terms) if amount != total_amount: lang = Lang.get() currency = cls.get_currency(lines) raise RescheduleLineError( gettext('account.msg_reschedule_line_wrong_amount', total_amount=lang.currency(total_amount, currency), amount=lang.currency(amount, currency))) balance = cls.get_balance(lines) line_values = cls._line_values(lines) account = line_values['account'] move, balance_line = cls.get_reschedule_move( amount, balance, journal, terms, **line_values) move.save() balance_line.move = move balance_line.save() if account.reconcile: Line.reconcile(lines + [balance_line]) return move, balance_line @classmethod def get_reschedule_move( cls, amount, balance, journal, terms, account, date=None, **line_values): pool = Pool() Date = pool.get('ir.date') Line = pool.get('account.move.line') Move = pool.get('account.move') Period = pool.get('account.period') company = account.company if not date: with Transaction().set_context(company=company.id): date = Date.today() period = Period.find(company, date=date) move = Move() move.company = company move.date = date move.period = period move.journal = journal balance_line = Line(account=account, **line_values) if balance >= 0: balance_line.debit, balance_line.credit = 0, balance else: balance_line.debit, balance_line.credit = -balance, 0 if balance_line.second_currency: balance_line.amount_second_currency = -amount remaining_balance = balance remaining_amount = amount lines = [] for term in terms: line = Line(account=account, **line_values) line.maturity_date = term.date factor = term.amount / amount line_amount = account.currency.round(balance * factor) if balance >= 0: line.debit, line.credit = line_amount, 0 else: line.debit, line.credit = 0, -line_amount remaining_balance -= line_amount if line.second_currency: line_amount_second_currency = line.second_currency.round( amount * factor) line.amount_second_currency = line_amount_second_currency remaining_amount -= line_amount_second_currency lines.append(line) if remaining_balance: if line.debit: line.debit += remaining_balance else: line.credit += remaining_balance if remaining_amount and line.second_currency: line.amount_second_currency += remaining_amount move.lines = lines return move, balance_line class RescheduleLinesStart(ModelView): __name__ = 'account.move.line.reschedule.start' start_date = fields.Date("Start Date", required=True) frequency = fields.Selection([ ('monthly', "Monthly"), ('quarterly', "Quarterly"), ('other', "Other"), ], "Frequency", sort=False, required=True) interval = fields.Integer( "Interval", required=True, states={ 'invisible': Eval('frequency') != 'other', }, help="The length of each period, in months.") amount = Monetary( "Amount", currency='currency', digits='currency', states={ 'required': ~Eval('number'), 'invisible': Bool(Eval('number')), }, domain=[If(Eval('amount'), If(Eval('total_amount', 0) > 0, [ ('amount', '<=', Eval('total_amount', 0)), ('amount', '>', 0), ], [ ('amount', '>=', Eval('total_amount', 0)), ('amount', '<', 0), ]), [])]) number = fields.Integer( "Number", domain=[ ('number', '>', 0), ], states={ 'required': ~Eval('amount'), 'invisible': Bool(Eval('amount')), }) total_amount = fields.Numeric("Total Amount", readonly=True) currency = fields.Many2One('currency.currency', "Currency", readonly=True) @classmethod def default_frequency(cls): return 'monthly' @classmethod def frequency_intervals(cls): return { 'monthly': 1, 'quarterly': 3, 'other': None, } @fields.depends('frequency', 'interval') def on_change_frequency(self): if self.frequency: self.interval = self.frequency_intervals()[self.frequency] class RescheduleLinesPreview(ModelView): __name__ = 'account.move.line.reschedule.preview' journal = fields.Many2One('account.journal', "Journal", required=True) description = fields.Char("Description") terms = fields.One2Many( 'account.move.line.reschedule.term', None, "Terms", domain=[ ('currency', '=', Eval('currency', -1)), ]) currency = fields.Many2One('currency.currency', "Currency", readonly=True) class RescheduleLinesTerm(ModelView): __name__ = 'account.move.line.reschedule.term' date = fields.Date("Date", required=True) amount = Monetary( "Amount", currency='currency', digits='currency', required=True) currency = fields.Many2One('currency.currency', "Currency", required=True) class DelegateLines(Wizard): __name__ = 'account.move.line.delegate' start = StateView( 'account.move.line.delegate.start', 'account.move_line_delegate_start_view_form', [ Button("Cancel", 'end', 'tryton-cancel'), Button("Delegate", 'delegate', 'tryton-ok', default=True), ]) delegate = StateAction('account.act_move_form_delegate') def default_start(self, fields): values = {} if 'journal' in fields: journals = {l.journal for l in self.records} if len(journals) == 1: journal, = journals values['journal'] = journal.id return values def do_delegate(self, action): move = self._delegate_lines(self.records, self.start.party) if all(l.move.state == 'posted' for l in self.records): move.post() return action, {'res_id': move.id} def _delegate_lines(self, lines, party, date=None): move = self.delegate_lines(lines, party, self.start.journal, date) move.description = self.start.description move.save() return move @classmethod def delegate_lines(cls, lines, party, journal, date=None): pool = Pool() Line = pool.get('account.move.line') move, counterpart, delegated = cls.get_move( lines, party, journal, date=date) move.save() Line.save(counterpart + delegated) for line, cline, dline in zip(lines, counterpart, delegated): Line.reconcile([line, cline], delegate_to=dline) return move @classmethod def get_move(cls, lines, party, journal, date=None): pool = Pool() Date = pool.get('ir.date') Move = pool.get('account.move') Period = pool.get('account.period') try: company, = {l.company for l in lines} except ValueError: raise DelegateLineError( gettext('account.msg_delegate_line_same_company')) try: origin, = {l.move.origin for l in lines} except ValueError: raise DelegateLineError( gettext('account.msg_delegate_line_same_origins')) if not date: with Transaction().set_context(company=company.id): date = Date.today() period = Period.find(company, date=date) move = Move() move.company = company move.date = date move.period = period move.journal = journal move.origin = origin counterpart = [] delegated = [] for line in lines: cline = cls.get_move_line(line) cline.move = move cline.debit, cline.credit = line.credit, line.debit if cline.amount_second_currency: cline.amount_second_currency *= -1 counterpart.append(cline) dline = cls.get_move_line(line) dline.move = move dline.party = party delegated.append(dline) return move, counterpart, delegated @classmethod def get_move_line(cls, line): pool = Pool() Line = pool.get('account.move.line') new = Line() new.debit = line.debit new.credit = line.credit new.account = line.account new.origin = line new.description = line.description new.amount_second_currency = line.amount_second_currency new.second_currency = line.second_currency new.party = line.party new.maturity_date = line.maturity_date return new class DelegateLinesStart(ModelView): __name__ = 'account.move.line.delegate.start' journal = fields.Many2One('account.journal', "Journal", required=True) party = fields.Many2One('party.party', "Party", required=True) description = fields.Char("Description") class GeneralJournal(Report): __name__ = 'account.move.general_journal' @classmethod def get_context(cls, records, header, data): pool = Pool() Company = pool.get('company.company') context = Transaction().context report_context = super().get_context(records, header, data) report_context['company'] = Company(context['company']) return report_context