# 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 dateutil.relativedelta import relativedelta from sql import Null from sql.conditionals import Case from sql.operators import Equal, NotEqual from trytond.cache import Cache from trytond.i18n import gettext from trytond.model import ( Exclude, ModelSQL, ModelView, Unique, Workflow, fields) from trytond.model.exceptions import AccessError from trytond.pool import Pool from trytond.pyson import Eval, Id from trytond.rpc import RPC from trytond.sql.functions import DateRange from trytond.sql.operators import RangeOverlap from trytond.transaction import Transaction from trytond.wizard import ( Button, StateAction, StateTransition, StateView, Wizard) from .exceptions import ( FiscalYearCloseError, FiscalYearNotFoundError, FiscalYearReOpenError) STATES = { 'readonly': Eval('state') != 'open', } class FiscalYear(Workflow, ModelSQL, ModelView): __name__ = 'account.fiscalyear' name = fields.Char('Name', size=None, required=True) start_date = fields.Date( "Start Date", required=True, states=STATES, domain=[('start_date', '<=', Eval('end_date', None))]) end_date = fields.Date( "End Date", required=True, states=STATES, domain=[('end_date', '>=', Eval('start_date', None))]) periods = fields.One2Many('account.period', 'fiscalyear', 'Periods', states=STATES, domain=[ ('company', '=', Eval('company', -1)), ], order=[('start_date', 'ASC'), ('id', 'ASC')]) state = fields.Selection([ ('open', 'Open'), ('closed', 'Closed'), ('locked', 'Locked'), ], 'State', readonly=True, required=True, sort=False) move_sequence = fields.Many2One( 'ir.sequence.strict', "Move Sequence", required=True, domain=[ ('sequence_type', '=', Id('account', 'sequence_type_account_move')), ('company', '=', Eval('company', -1)), ], help="Used to generate the move number when posting " "if the period has no sequence.") company = fields.Many2One( 'company.company', "Company", required=True) icon = fields.Function(fields.Char("Icon"), 'get_icon') _find_cache = Cache(__name__ + '.find', context=False) @classmethod def __setup__(cls): super().__setup__() t = cls.__table__() cls._sql_constraints += [ ('dates_overlap', Exclude(t, (t.company, Equal), (DateRange(t.start_date, t.end_date, '[]'), RangeOverlap), ), 'account.msg_fiscalyear_overlap'), ('open_earlier', Exclude(t, (t.company, Equal), (DateRange( Case( (t.state == 'open', t.start_date), else_=Null), t.end_date), RangeOverlap), (Case((t.state == 'open', t.id), else_=-1), NotEqual)), 'account.msg_open_fiscalyear_earlier'), ('move_sequence_unique', Unique(t, t.move_sequence), 'account.msg_fiscalyear_move_sequence_unique'), ] cls._order.insert(0, ('start_date', 'DESC')) cls._transitions |= set(( ('open', 'closed'), ('closed', 'locked'), ('closed', 'open'), )) cls._buttons.update({ 'create_periods': { 'invisible': ((Eval('state') != 'open') | Eval('periods', [0])), 'depends': ['state'], }, 'close': { 'invisible': Eval('state') != 'open', 'depends': ['state'], }, 'reopen': { 'invisible': Eval('state') != 'closed', 'depends': ['state'], }, 'lock_': { 'invisible': Eval('state') != 'closed', 'depends': ['state'], }, }) cls.__rpc__.update({ 'create_period': RPC(readonly=False, instantiate=0), }) @classmethod def __register__(cls, module): pool = Pool() Period = pool.get('account.period') Sequence = pool.get('ir.sequence') SequenceStrict = pool.get('ir.sequence.strict') table_h = cls.__table_handler__(module) cursor = Transaction().connection.cursor() t = cls.__table__() migrate_move_sequence = not table_h.column_exist('move_sequence') super().__register__(module) # Migration from 6.8: rename state close to closed cursor.execute( *t.update([t.state], ['closed'], where=t.state == 'close')) # Migrationn from 7.4: use strict sequence if (table_h.column_exist('post_move_sequence') and migrate_move_sequence): table_h.not_null_action('post_move_sequence', 'remove') period_h = Period.__table_handler__(module) period = Period.__table__() old2new = {} period_migrated = ( period_h.column_exist('post_move_sequence') and period_h.column_exist('move_sequence')) if period_migrated: cursor.execute(*period.select( period.post_move_sequence, period.move_sequence)) old2new.update(cursor) cursor.execute(*t.select(t.post_move_sequence, distinct=True)) for sequence_id, in cursor: if sequence_id not in old2new: sequence = Sequence(sequence_id) new_sequence = SequenceStrict( name=sequence.name, sequence_type=sequence.sequence_type, prefix=sequence.prefix, suffix=sequence.suffix, type=sequence.type, number_next=sequence.number_next, number_increment=sequence.number_increment, padding=sequence.padding, timestamp_rounding=sequence.timestamp_rounding, timestamp_offset=sequence.timestamp_offset, last_timestamp=sequence.last_timestamp, company=sequence.company) new_sequence.save() old2new[sequence_id] = new_sequence.id for old_id, new_id in old2new.items(): cursor.execute(*t.update( [t.move_sequence], [new_id], where=t.post_move_sequence == old_id)) if period_migrated: table_h.drop_column('post_move_sequence') period_h.drop_column('post_move_sequence') @staticmethod def default_state(): return 'open' @staticmethod def default_company(): return Transaction().context.get('company') def get_icon(self, name): return { 'open': 'tryton-account-open', 'closed': 'tryton-account-close', 'locked': 'tryton-account-block', }.get(self.state) @classmethod def validate_fields(cls, fiscalyears, field_names): super().validate_fields(fiscalyears, field_names) cls.check_period_dates(fiscalyears, field_names) @classmethod def check_period_dates(cls, fiscalyears, field_names=None): pool = Pool() Period = pool.get('account.period') if field_names and not (field_names & {'start_date', 'end_date'}): return periods = [p for f in fiscalyears for p in f.periods] Period.check_fiscalyear_dates(periods, field_names={'fiscalyear'}) @classmethod def check_modification( cls, mode, fiscalyears, values=None, external=False): pool = Pool() Move = pool.get('account.move') super().check_modification( mode, fiscalyears, values=values, external=external) if mode == 'write' and 'move_sequence' in values: for fiscalyear in fiscalyears: if sequence := fiscalyear.move_sequence: if sequence.id != values['move_sequence']: if Move.search([ ('period.fiscalyear', '=', fiscalyear.id), ('state', '=', 'posted'), ], limit=1): raise AccessError( gettext('account.' 'msg_change_fiscalyear_move_sequence', fiscalyear=fiscalyear.rec_name)) @classmethod def on_modification(cls, mode, records, field_names=None): super().on_modification(mode, records, field_names=field_names) cls._find_cache.clear() @classmethod def create_period(cls, fiscalyears, interval=1, end_day=31): ''' Create periods for the fiscal years with month interval ''' Period = Pool().get('account.period') to_create = [] for fiscalyear in fiscalyears: period_start_date = fiscalyear.start_date while period_start_date < fiscalyear.end_date: month_offset = 1 if period_start_date.day < end_day else 0 period_end_date = (period_start_date + relativedelta(months=interval - month_offset) + relativedelta(day=end_day)) if period_end_date > fiscalyear.end_date: period_end_date = fiscalyear.end_date name = period_start_date.strftime('%Y-%m') if name != period_end_date.strftime('%Y-%m'): name += ' - ' + period_end_date.strftime('%Y-%m') to_create.append({ 'name': name, 'start_date': period_start_date, 'end_date': period_end_date, 'fiscalyear': fiscalyear.id, 'type': 'standard', }) period_start_date = period_end_date + relativedelta(days=1) if to_create: Period.create(to_create) @classmethod @ModelView.button_action('account.act_create_periods') def create_periods(cls, fiscalyears): pass @classmethod def find(cls, company, date=None, test_state=True): ''' Return the fiscal year for the company at the date or the current date or raise FiscalYearNotFoundError. If test_state is true, it searches on non-closed fiscal years ''' pool = Pool() Lang = pool.get('ir.lang') Date = pool.get('ir.date') Company = pool.get('company.company') company_id = int(company) if company is not None else None if not date: with Transaction().set_context(company=company_id): date = Date.today() key = (company_id, date) fiscalyear = cls._find_cache.get(key, -1) if fiscalyear is not None and fiscalyear < 0: clause = [ ('start_date', '<=', date), ('end_date', '>=', date), ('company', '=', company_id), ] fiscalyears = cls.search( clause, order=[('start_date', 'DESC')], limit=1) if fiscalyears: fiscalyear, = fiscalyears else: fiscalyear = None cls._find_cache.set(key, int(fiscalyear) if fiscalyear else None) elif fiscalyear is not None: fiscalyear = cls(fiscalyear) found = fiscalyear and (not test_state or fiscalyear.state == 'open') if not found: lang = Lang.get() if company is not None and not isinstance(company, Company): company = Company(company) if not fiscalyear: raise FiscalYearNotFoundError( gettext('account.msg_no_fiscalyear_date', date=lang.strftime(date), company=company.rec_name if company else '')) else: raise FiscalYearNotFoundError( gettext('account.msg_no_open_fiscalyear_date', date=lang.strftime(date), fiscalyear=fiscalyear.rec_name, company=company.rec_name if company else '')) else: return fiscalyear def get_deferral(self, account): 'Computes deferrals for accounts' pool = Pool() Currency = pool.get('currency.currency') Deferral = pool.get('account.account.deferral') if not account.type: return if not account.deferral: if not Currency.is_zero(self.company.currency, account.balance): raise FiscalYearCloseError( gettext('account' '.msg_close_fiscalyear_account_balance_not_zero', account=account.rec_name)) else: deferral = Deferral() deferral.account = account deferral.fiscalyear = self deferral.debit = account.debit deferral.credit = account.credit deferral.line_count = account.line_count deferral.amount_second_currency = account.amount_second_currency return deferral @classmethod @ModelView.button @Workflow.transition('closed') def close(cls, fiscalyears): ''' Close a fiscal year ''' pool = Pool() Period = pool.get('account.period') Account = pool.get('account.account') Deferral = pool.get('account.account.deferral') # Prevent create new fiscal year or period cls.lock() Period.lock() deferrals = [] for fiscalyear in fiscalyears: if cls.search([ ('end_date', '<=', fiscalyear.start_date), ('state', '=', 'open'), ('company', '=', fiscalyear.company.id), ]): raise FiscalYearCloseError( gettext('account.msg_close_fiscalyear_earlier', fiscalyear=fiscalyear.rec_name)) periods = Period.search([ ('fiscalyear', '=', fiscalyear.id), ]) Period.close(periods) with Transaction().set_context(fiscalyear=fiscalyear.id, date=None, cumulate=True, journal=None): accounts = Account.search([ ('company', '=', fiscalyear.company.id), ]) for account in accounts: deferral = fiscalyear.get_deferral(account) if deferral: deferrals.append(deferral) Deferral.save(deferrals) @classmethod @ModelView.button @Workflow.transition('open') def reopen(cls, fiscalyears): ''' Reopen a fiscal year ''' Deferral = Pool().get('account.account.deferral') for fiscalyear in fiscalyears: if cls.search([ ('start_date', '>=', fiscalyear.end_date), ('state', '!=', 'open'), ('company', '=', fiscalyear.company.id), ]): raise FiscalYearReOpenError( gettext('account.msg_reopen_fiscalyear_later', fiscalyear=fiscalyear.rec_name)) deferrals = Deferral.search([ ('fiscalyear', '=', fiscalyear.id), ]) Deferral.delete(deferrals) @classmethod @ModelView.button @Workflow.transition('locked') def lock_(cls, fiscalyears): pool = Pool() Period = pool.get('account.period') periods = Period.search([ ('fiscalyear', 'in', [f.id for f in fiscalyears]), ]) Period.lock_(periods) class BalanceNonDeferralStart(ModelView): __name__ = 'account.fiscalyear.balance_non_deferral.start' fiscalyear = fields.Many2One('account.fiscalyear', 'Fiscal Year', required=True, domain=[('state', '=', 'open')]) company = fields.Function(fields.Many2One('company.company', 'Company'), 'on_change_with_company') journal = fields.Many2One('account.journal', 'Journal', required=True, domain=[ ('type', '=', 'situation'), ], context={ 'company': Eval('company', -1), }, depends={'company'}) period = fields.Many2One('account.period', 'Period', required=True, domain=[ ('fiscalyear', '=', Eval('fiscalyear', -1)), ('type', '=', 'adjustment'), ]) credit_account = fields.Many2One('account.account', 'Credit Account', required=True, domain=[ ('type', '!=', None), ('closed', '!=', True), ('company', '=', Eval('company', -1)), ('deferral', '=', True), ]) debit_account = fields.Many2One('account.account', 'Debit Account', required=True, domain=[ ('type', '!=', None), ('closed', '!=', True), ('company', '=', Eval('company', -1)), ('deferral', '=', True), ]) @fields.depends('fiscalyear') def on_change_with_company(self, name=None): return self.fiscalyear.company if self.fiscalyear else None class BalanceNonDeferral(Wizard): __name__ = 'account.fiscalyear.balance_non_deferral' start = StateView('account.fiscalyear.balance_non_deferral.start', 'account.fiscalyear_balance_non_deferral_start_view_form', [ Button('Cancel', 'end', 'tryton-cancel'), Button('OK', 'balance', 'tryton-ok', default=True), ]) balance = StateAction('account.act_move_form') def get_move_line(self, account): pool = Pool() Line = pool.get('account.move.line') # Don't use account.balance because we need the non-commulated balance balance = account.debit - account.credit if account.company.currency.is_zero(balance): return line = Line() line.account = account if balance >= 0: line.credit = abs(balance) line.debit = 0 else: line.credit = 0 line.debit = abs(balance) return line def get_counterpart_line(self, amount): pool = Pool() Line = pool.get('account.move.line') if self.start.fiscalyear.company.currency.is_zero(amount): return line = Line() if amount >= 0: line.credit = abs(amount) line.debit = 0 line.account = self.start.credit_account else: line.credit = 0 line.debit = abs(amount) line.account = self.start.debit_account return line def create_move(self): pool = Pool() Account = pool.get('account.account') Move = pool.get('account.move') with Transaction().set_context(fiscalyear=self.start.fiscalyear.id, date=None, cumulate=False): accounts = Account.search([ ('company', '=', self.start.fiscalyear.company.id), ('deferral', '=', False), ('type', '!=', None), ('closed', '!=', True), ]) lines = [] for account in accounts: line = self.get_move_line(account) if line: lines.append(line) if not lines: return amount = sum(l.debit - l.credit for l in lines) counter_part_line = self.get_counterpart_line(amount) if counter_part_line: lines.append(counter_part_line) move = Move() move.period = self.start.period move.journal = self.start.journal move.date = self.start.period.start_date move.origin = self.start.fiscalyear move.lines = lines move.save() return move def do_balance(self, action): move = self.create_move() if move: action['views'].reverse() return action, {'res_id': move.id if move else None} class CreatePeriodsStart(ModelView): __name__ = 'account.fiscalyear.create_periods.start' 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.") end_day = fields.Integer("End Day", required=True, help="The day of the month on which periods end.\n" "Months with fewer days will end on the last day.") @classmethod def default_frequency(cls): return 'monthly' @classmethod def default_end_day(cls): return 31 @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 CreatePeriods(Wizard): __name__ = 'account.fiscalyear.create_periods' start = StateView('account.fiscalyear.create_periods.start', 'account.fiscalyear_create_periods_start_view_form', [ Button("Cancel", 'end', 'tryton-cancel'), Button("Create", 'create_periods', 'tryton-ok', default=True), ]) create_periods = StateTransition() def transition_create_periods(self): self.model.create_period( [self.record], self.start.interval, self.start.end_day) return 'end' def month_delta(d1, d2): month_offset = 1 if d1.day < d2.day else 0 return (d1.year - d2.year) * 12 + d1.month - d2.month - month_offset class RenewFiscalYearStart(ModelView): __name__ = 'account.fiscalyear.renew.start' name = fields.Char("Name", required=True) company = fields.Many2One('company.company', "Company", required=True) previous_fiscalyear = fields.Many2One( 'account.fiscalyear', "Previous Fiscalyear", required=True, domain=[ ('company', '=', Eval('company', -1)), ], help="Used as reference for fiscalyear configuration.") start_date = fields.Date("Start Date", required=True) end_date = fields.Date("End Date", required=True) reset_sequences = fields.Boolean("Reset Sequences", help="If checked, new sequences will be created.") @classmethod def default_company(cls): return Transaction().context.get('company') @classmethod def default_previous_fiscalyear(cls): pool = Pool() FiscalYear = pool.get('account.fiscalyear') fiscalyears = FiscalYear.search([ ('company', '=', cls.default_company() or -1), ], order=[('end_date', 'DESC')], limit=1) if fiscalyears: fiscalyear, = fiscalyears return fiscalyear.id @classmethod def default_reset_sequences(cls): return True @fields.depends('previous_fiscalyear') def on_change_previous_fiscalyear(self): if self.previous_fiscalyear: fiscalyear = self.previous_fiscalyear months = month_delta( fiscalyear.end_date, fiscalyear.start_date) + 1 self.start_date = fiscalyear.start_date + relativedelta( months=months, day=fiscalyear.start_date.day) self.end_date = fiscalyear.end_date + relativedelta( months=months, day=fiscalyear.end_date.day) self.name = fiscalyear.name.replace( str(fiscalyear.end_date.year), str(self.end_date.year)).replace( str(fiscalyear.start_date.year), str(self.start_date.year)) class RenewFiscalYear(Wizard): __name__ = 'account.fiscalyear.renew' start = StateView('account.fiscalyear.renew.start', 'account.fiscalyear_renew_start_view_form', [ Button("Cancel", 'end', 'tryton-cancel'), Button("Create", 'create_', 'tryton-ok', default=True), ]) create_ = StateAction('account.act_fiscalyear_form') def fiscalyear_defaults(self): pool = Pool() Sequence = pool.get('ir.sequence.strict') defaults = { 'name': self.start.name, 'start_date': self.start.start_date, 'end_date': self.start.end_date, 'periods': [], } previous_sequence = self.start.previous_fiscalyear.move_sequence sequence, = Sequence.copy([previous_sequence], default={ 'name': lambda data: data['name'].replace( self.start.previous_fiscalyear.name, self.start.name) }) if self.start.reset_sequences: sequence.number_next = 1 else: sequence.number_next = previous_sequence.number_next sequence.save() defaults['move_sequence'] = sequence.id return defaults def create_fiscalyear(self): pool = Pool() FiscalYear = pool.get('account.fiscalyear') fiscalyear, = FiscalYear.copy( [self.start.previous_fiscalyear], default=self.fiscalyear_defaults()) periods = [ p for p in self.start.previous_fiscalyear.periods if p.type == 'standard'] if periods: months = month_delta(fiscalyear.end_date, fiscalyear.start_date) months += 1 interval = months / len(periods) end_day = max( p.end_date.day for p in self.start.previous_fiscalyear.periods if p.type == 'standard') if interval.is_integer(): FiscalYear.create_period([fiscalyear], interval, end_day) return fiscalyear def do_create_(self, action): fiscalyear = self.create_fiscalyear() fiscalyear.save() action['views'].reverse() return action, {'res_id': fiscalyear.id}