# 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 decimal import Decimal from collections import defaultdict from sql import Column, Literal, Null from sql.aggregate import Sum from sql.conditionals import Coalesce from trytond import backend from trytond.i18n import gettext from trytond.model import (ModelView, ModelSQL, DeactivableMixin, fields, Unique, tree) from trytond.wizard import Wizard, StateView, StateAction, Button from trytond.pyson import Eval, If, PYSONEncoder, PYSONDecoder from trytond.transaction import Transaction from trytond.tools import lstrip_wildcard from trytond.pool import Pool from .exceptions import AccountValidationError class Account( DeactivableMixin, tree('distribution_parents'), tree(), ModelSQL, ModelView): 'Analytic Account' __name__ = 'analytic_account.account' name = fields.Char('Name', required=True, translate=True, select=True) code = fields.Char('Code', select=True) company = fields.Many2One('company.company', 'Company', required=True) currency = fields.Function( fields.Many2One('currency.currency', 'Currency'), 'on_change_with_currency') currency_digits = fields.Function(fields.Integer('Currency Digits'), 'on_change_with_currency_digits') type = fields.Selection([ ('root', 'Root'), ('view', 'View'), ('normal', 'Normal'), ('distribution', 'Distribution'), ], 'Type', required=True) root = fields.Many2One('analytic_account.account', 'Root', select=True, domain=[ ('company', '=', Eval('company', -1)), ('parent', '=', None), ('type', '=', 'root'), ], states={ 'invisible': Eval('type') == 'root', 'required': Eval('type') != 'root', }, depends=['company', 'type']) parent = fields.Many2One('analytic_account.account', 'Parent', select=True, domain=['OR', ('root', '=', Eval('root', -1)), ('parent', '=', None), ], states={ 'invisible': Eval('type') == 'root', 'required': Eval('type') != 'root', }, depends=['root', 'type']) childs = fields.One2Many('analytic_account.account', 'parent', 'Children', states={ 'invisible': Eval('id', -1) < 0, }, domain=[ ('company', '=', Eval('company', -1)), ], depends=['company']) balance = fields.Function(fields.Numeric('Balance', digits=(16, Eval('currency_digits', 1)), depends=['currency_digits']), 'get_balance') credit = fields.Function(fields.Numeric('Credit', digits=(16, Eval('currency_digits', 2)), depends=['currency_digits']), 'get_credit_debit') debit = fields.Function(fields.Numeric('Debit', digits=(16, Eval('currency_digits', 2)), depends=['currency_digits']), 'get_credit_debit') state = fields.Selection([ ('draft', 'Draft'), ('opened', 'Opened'), ('closed', 'Closed'), ], 'State', required=True) note = fields.Text('Note') distributions = fields.One2Many( 'analytic_account.account.distribution', 'parent', "Distributions", states={ 'invisible': Eval('type') != 'distribution', 'required': Eval('type') == 'distribution', }, depends=['type']) distribution_parents = fields.Many2Many( 'analytic_account.account.distribution', 'account', 'parent', "Distribution Parents", readonly=True) @classmethod def __setup__(cls): super(Account, cls).__setup__() cls._order.insert(0, ('code', 'ASC')) cls._order.insert(1, ('name', 'ASC')) @classmethod def __register__(cls, module_name): super(Account, cls).__register__(module_name) table = cls.__table_handler__(module_name) # Migration from 4.0: remove currency table.not_null_action('currency', action='remove') # Migration from 5.0: remove display_balance table.drop_column('display_balance') @staticmethod def default_company(): return Transaction().context.get('company') @staticmethod def default_type(): return 'normal' @staticmethod def default_state(): return 'draft' @classmethod def validate(cls, accounts): super(Account, cls).validate(accounts) for account in accounts: account.check_distribution() def check_distribution(self): if self.type != 'distribution': return if sum((d.ratio for d in self.distributions)) != 1: raise AccountValidationError( gettext('analytic_account.msg_invalid_distribution', account=self.rec_name)) @fields.depends('company') def on_change_with_currency(self, name=None): if self.company: return self.company.currency.id @fields.depends('company') def on_change_with_currency_digits(self, name=None): if self.company: return self.company.currency.digits return 2 @fields.depends('parent', 'type', '_parent_parent.id', '_parent_parent.root', '_parent_parent.type') def on_change_parent(self): if (self.parent and self.parent.id is not None and self.parent.id > 0 and self.type != 'root'): if self.parent.type == 'root': self.root = self.parent else: self.root = self.parent.root else: self.root = None @classmethod def get_balance(cls, accounts, name): pool = Pool() Line = pool.get('analytic_account.line') MoveLine = pool.get('account.move.line') cursor = Transaction().connection.cursor() table = cls.__table__() line = Line.__table__() move_line = MoveLine.__table__() ids = [a.id for a in accounts] childs = cls.search([('parent', 'child_of', ids)]) all_ids = list({}.fromkeys(ids + [c.id for c in childs]).keys()) id2account = {} all_accounts = cls.browse(all_ids) for account in all_accounts: id2account[account.id] = account line_query = Line.query_get(line) cursor.execute(*table.join(line, 'LEFT', condition=table.id == line.account ).join(move_line, 'LEFT', condition=move_line.id == line.move_line ).select(table.id, Sum(Coalesce(line.credit, 0) - Coalesce(line.debit, 0)), where=(table.type != 'view') & table.id.in_(all_ids) & (table.active == Literal(True)) & line_query, group_by=table.id)) account_sum = defaultdict(Decimal) for account_id, value in cursor: account_sum.setdefault(account_id, Decimal('0.0')) # SQLite uses float for SUM if not isinstance(value, Decimal): value = Decimal(str(value)) account_sum[account_id] += value balances = {} for account in accounts: balance = Decimal() childs = cls.search([ ('parent', 'child_of', [account.id]), ]) for child in childs: balance += account_sum[child.id] exp = Decimal(str(10.0 ** -account.currency_digits)) balances[account.id] = balance.quantize(exp) return balances @classmethod def get_credit_debit(cls, accounts, names): pool = Pool() Line = pool.get('analytic_account.line') MoveLine = pool.get('account.move.line') cursor = Transaction().connection.cursor() table = cls.__table__() line = Line.__table__() move_line = MoveLine.__table__() result = {} ids = [a.id for a in accounts] for name in names: if name not in ('credit', 'debit'): raise Exception('Bad argument') result[name] = {}.fromkeys(ids, Decimal('0.0')) id2account = {} for account in accounts: id2account[account.id] = account line_query = Line.query_get(line) columns = [table.id] for name in names: columns.append(Sum(Coalesce(Column(line, name), 0))) cursor.execute(*table.join(line, 'LEFT', condition=table.id == line.account ).join(move_line, 'LEFT', condition=move_line.id == line.move_line ).select(*columns, where=(table.type != 'view') & table.id.in_(ids) & (table.active == Literal(True)) & line_query, group_by=table.id)) for row in cursor: account_id = row[0] for i, name in enumerate(names, 1): value = row[i] # SQLite uses float for SUM if not isinstance(value, Decimal): value = Decimal(str(value)) result[name][account_id] += value for account in accounts: for name in names: exp = Decimal(str(10.0 ** -account.currency_digits)) result[name][account.id] = ( result[name][account.id].quantize(exp)) return result def get_rec_name(self, name): if self.code: return self.code + ' - ' + str(self.name) else: return str(self.name) @classmethod def search_rec_name(cls, name, clause): if clause[1].startswith('!') or clause[1].startswith('not '): bool_op = 'AND' else: bool_op = 'OR' code_value = clause[2] if clause[1].endswith('like'): code_value = lstrip_wildcard(clause[2]) return [bool_op, ('code', clause[1], code_value) + tuple(clause[3:]), (cls._rec_name,) + tuple(clause[1:]), ] def distribute(self, amount): "Return a list of (account, amount) distribution" assert self.type in {'normal', 'distribution'} if self.type == 'normal': return [(self, amount)] else: result = [] remainder = amount for distribution in self.distributions: account = distribution.account ratio = distribution.ratio current_amount = self.currency.round(amount * ratio) remainder -= current_amount result.extend(account.distribute(current_amount)) if remainder: i = 0 while remainder: account, current_amount = result[i] rounding = self.currency.rounding.copy_sign(remainder) result[i] = (account, current_amount + rounding) remainder -= rounding i = (i + 1) % len(result) assert sum(a for _, a in result) == amount return result class OpenChartAccountStart(ModelView): 'Open Chart of Accounts' __name__ = 'analytic_account.open_chart.start' start_date = fields.Date('Start Date') end_date = fields.Date('End Date') class OpenChartAccount(Wizard): 'Open Chart of Accounts' __name__ = 'analytic_account.open_chart' start = StateView('analytic_account.open_chart.start', 'analytic_account.open_chart_start_view_form', [ Button('Cancel', 'end', 'tryton-cancel'), Button('Open', 'open_', 'tryton-ok', default=True), ]) open_ = StateAction('analytic_account.act_account_tree2') def do_open_(self, action): action['pyson_context'] = PYSONEncoder().encode({ 'start_date': self.start.start_date, 'end_date': self.start.end_date, }) return action, {} def transition_open_(self): return 'end' class AccountDistribution(ModelView, ModelSQL): "Analytic Account Distribution" __name__ = 'analytic_account.account.distribution' parent = fields.Many2One( 'analytic_account.account', "Parent", required=True, select=True) root = fields.Function( fields.Many2One('analytic_account.account', "Root"), 'on_change_with_root') account = fields.Many2One( 'analytic_account.account', "Account", required=True, domain=[ ('root', '=', Eval('root', -1)), ], depends=['root']) ratio = fields.Numeric("Ratio", required=True, domain=[ ('ratio', '>=', 0), ('ratio', '<=', 1), ]) @classmethod def __setup__(cls): super(AccountDistribution, cls).__setup__() cls._order.insert(0, ('ratio', 'DESC')) @fields.depends('parent', '_parent_parent.root') def on_change_with_root(self, name=None): if self.parent: return self.parent.root.id class AnalyticAccountEntry(ModelView, ModelSQL): 'Analytic Account Entry' __name__ = 'analytic.account.entry' origin = fields.Reference('Origin', selection='get_origin', select=True) root = fields.Many2One( 'analytic_account.account', "Root Analytic", required=True, domain=[ If(~Eval('company'), # No constraint if the origin is not set (), ('company', '=', Eval('company', -1))), ('type', '=', 'root'), ], depends=['company']) account = fields.Many2One('analytic_account.account', 'Account', ondelete='RESTRICT', domain=[ ('root', '=', Eval('root')), ('type', 'in', ['normal', 'distribution']), ], depends=['root', 'company']) company = fields.Function(fields.Many2One('company.company', 'Company'), 'on_change_with_company', searcher='search_company') @classmethod def __register__(cls, module_name): pool = Pool() Account = pool.get('analytic_account.account') transaction = Transaction() cursor = transaction.connection.cursor() update = transaction.connection.cursor() # Migration from 3.4: use origin as the key for One2Many migration_3_4 = False old_table = 'analytic_account_account_selection_rel' if backend.TableHandler.table_exist(old_table): backend.TableHandler.table_rename(old_table, cls._table) migration_3_4 = True # Don't create table before renaming table = cls.__table_handler__(module_name) super(AnalyticAccountEntry, cls).__register__(module_name) # Migration from 3.4: set root value and remove required if migration_3_4: account = Account.__table__() cursor.execute(*account.select(account.id, account.root, where=account.type != 'root')) entry = cls.__table__() for account_id, root_id in cursor: update.execute(*entry.update( columns=[entry.root], values=[root_id], where=entry.account == account_id)) table.not_null_action('selection', action='remove') table.not_null_action('account', action='remove') @classmethod def __setup__(cls): super(AnalyticAccountEntry, cls).__setup__() t = cls.__table__() cls._sql_constraints += [ ('root_origin_uniq', Unique(t, t.origin, t.root), 'analytic_account.msg_root_origin_unique'), ] @classmethod def _get_origin(cls): return ['analytic_account.rule'] @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] def on_change_with_company(self, name=None): return None @classmethod def search_company(cls, name, clause): return [] def get_analytic_lines(self, line, date): "Yield analytic lines for the accounting line and the date" pool = Pool() AnalyticLine = pool.get('analytic_account.line') if not self.account: return amount = line.debit or line.credit for account, amount in self.account.distribute(amount): analytic_line = AnalyticLine() analytic_line.debit = amount if line.debit else Decimal(0) analytic_line.credit = amount if line.credit else Decimal(0) analytic_line.account = account analytic_line.date = date yield analytic_line class AnalyticMixin(object): __slots__ = () analytic_accounts = fields.One2Many('analytic.account.entry', 'origin', 'Analytic Accounts', size=Eval('analytic_accounts_size', 0), depends=['analytic_accounts_size']) analytic_accounts_size = fields.Function(fields.Integer( 'Analytic Accounts Size'), 'get_analytic_accounts_size') @classmethod def __register__(cls, module_name): pool = Pool() AccountEntry = pool.get('analytic.account.entry') transaction = Transaction() cursor = transaction.connection.cursor() update = transaction.connection.cursor() super(AnalyticMixin, cls).__register__(module_name) handler = cls.__table_handler__(module_name) # Migration from 3.4: analytic accounting changed to reference field if handler.column_exist('analytic_accounts'): entry = AccountEntry.__table__() table = cls.__table__() cursor.execute(*table.select(table.id, table.analytic_accounts, where=table.analytic_accounts != Null)) for line_id, selection_id in cursor: update.execute(*entry.update( columns=[entry.origin], values=['%s,%s' % (cls.__name__, line_id)], where=entry.selection == selection_id)) handler.drop_column('analytic_accounts') @classmethod def analytic_accounts_domain(cls): context = Transaction().context.copy() context['context'] = context return PYSONDecoder(context).decode( PYSONEncoder().encode(cls.analytic_accounts.domain)) @classmethod def default_analytic_accounts(cls): pool = Pool() AnalyticAccount = pool.get('analytic_account.account') accounts = [] root_accounts = AnalyticAccount.search( cls.analytic_accounts_domain() + [ ('parent', '=', None), ]) for account in root_accounts: accounts.append({ 'root': account.id, }) return accounts @classmethod def default_analytic_accounts_size(cls): pool = Pool() AnalyticAccount = pool.get('analytic_account.account') return len(AnalyticAccount.search( cls.analytic_accounts_domain() + [ ('type', '=', 'root'), ])) @classmethod def get_analytic_accounts_size(cls, records, name): roots = cls.default_analytic_accounts_size() return {r.id: roots for r in records}