# -*- coding: utf-8 -*- # This file is part of the cashbook-planner from m-ds for Tryton. # The COPYRIGHT file at the top level of this repository contains the # full copyright notices and license terms. from decimal import Decimal from datetime import date, timedelta from string import Template from dateutil.rrule import ( rrule, YEARLY, MONTHLY, WEEKLY, DAILY, MO, TU, WE, TH, FR, SA, SU) from trytond.model import ModelSQL, ModelView, fields, Index, DeactivableMixin from trytond.transaction import Transaction from trytond.pool import Pool from trytond.report import Report from trytond.i18n import gettext from trytond.pyson import Eval, Bool, If, And from trytond.bus import notify from trytond.modules.currency.fields import Monetary from trytond.modules.cashbook.book import sel_state_book from trytond.modules.cashbook.line import sel_bookingtype as sel_bookingtype_cb sel_bookingtype = [ x for x in sel_bookingtype_cb if x[0] in ['in', 'out', 'mvin', 'mvout']] DEF_NONE = None SEL_FREQU = [ ('year', 'Yearly'), ('month', 'Monthly'), ('week', 'Weekly'), ('day', 'Daily')] SEL_WEEKDAY = [ ('99', '-'), ('0', 'Monday'), ('1', 'Tuesday'), ('2', 'Wednesday'), ('3', 'Thursday'), ('4', 'Friday'), ('5', 'Saturday'), ('6', 'Sunday')] SEL_MOVE_EVENT = [ ('nop', 'unchanged'), ('before', 'Business day before original date'), ('after', 'Business day after original date') ] class ScheduledBooking(DeactivableMixin, ModelSQL, ModelView): 'Scheduled Booking' __name__ = 'cashbook.planner' company = fields.Many2One( string='Company', model_name='company.company', required=True, ondelete="RESTRICT") name = fields.Char(string='Name', required=True) description = fields.Text(string='Description') cashbook = fields.Many2One( string='Cashbook', required=True, help='Cash book for which the planned posting is to be executed.', model_name='cashbook.book', ondelete='CASCADE', domain=[('btype', '!=', None)]) start_date = fields.Date(string='Start Date', required=True) end_date = fields.Date( string='End Date', depends=['start_date'], states={'readonly': ~Bool(Eval('start_date'))}, domain=[ 'OR', ('end_date', '>', Eval('start_date')), ('end_date', '=', DEF_NONE)]) frequ = fields.Selection( string='Frequency', required=True, selection=SEL_FREQU, sort=False) weekday = fields.Selection( string='Weekday', required=True, selection=SEL_WEEKDAY, sort=False, help='Select a day of the week if you want the rule to ' + 'run on that day.', depends=['frequ'], states={'invisible': Eval('frequ') != 'month'}) COND_SETPOS = And(Eval('weekday', '') != '99', Eval('frequ') == 'month') setpos = fields.Integer( string='Occurrence', depends=['weekday', 'frequ'], domain=[ If(COND_SETPOS, [('setpos', '<=', 4), ('setpos', '>=', 1)], ('setpos', '=', None))], help='For example, if you want to run the rule on the second ' + 'Wednesday of the month, enter 2 here.', states={'required': COND_SETPOS, 'invisible': ~COND_SETPOS}) COND_MONTHDAY = And(Eval('weekday', '') == '99', Eval('frequ') == 'month') monthday = fields.Integer( string='Day of month', help='If you want the rule to run on a specific day of the month, ' + 'select the day here.', domain=[ If(COND_MONTHDAY, [('monthday', '>=', 1), ('monthday', '<=', 31)], ('monthday', '=', None))], depends=['weekday', 'frequ'], states={ 'required': And(COND_MONTHDAY, ~Eval('last_day_of_month', False)), 'invisible': ~And( COND_MONTHDAY, ~Eval('last_day_of_month', False))}) last_day_of_month = fields.Boolean( string='Last day of the month', depends=['weekday', 'frequ'], help='The booking is made on the last day of the month.', states={'invisible': ~COND_MONTHDAY}) interval = fields.Integer( string='Interval', required=True, help='Select an interval to run the rule on every n-th date.', domain=[('interval', '>=', 1), ('interval', '<=', 10)]) nextdates = fields.Function(fields.Char( string='Next Dates', readonly=True, help='the next 5 appointments based on the configured rule'), 'on_change_with_nextdates') nextrun = fields.One2Many( string='Next Execution Date', size=1, field='planner', model_name='cashbook.planner.nextrun') nextrun_date = fields.Function(fields.Date( string='Next Execution Date', readonly=True), 'on_change_with_nextrun_date', searcher='search_nextrun_date') move_event = fields.Selection( string='If no business day', required=True, selection=SEL_MOVE_EVENT, help='If the date of execution falls on a weekend or holiday, ' + 'it can be moved to a business day.') notify_bycron = fields.Boolean( string='Notify', help='A notification will appear in the web ' + 'browser when the booking has been created.') bookingtype = fields.Selection( string='Type', selection=sel_bookingtype, required=True, help='Type of Booking') currency_cashbook = fields.Function(fields.Many2One( string='Currency', help='Currency of Cashbook', model_name='currency.currency'), 'on_change_with_currency_cashbook') amount = Monetary( string='Amount', currency='currency_cashbook', digits='currency_cashbook', required=True) category = fields.Many2One( string='Category', model_name='cashbook.category', help='Category for the planned booking', depends=['bookingtype'], states={ 'required': Eval('bookingtype', '').in_(['in', 'out']), 'invisible': ~Eval('bookingtype', '').in_(['in', 'out'])}) party = fields.Many2One( string='Party', model_name='party.party', depends=['bookingtype'], states={ 'required': Eval('bookingtype', '').in_(['in', 'out']), 'invisible': ~Eval('bookingtype', '').in_(['in', 'out'])}) booktransf = fields.Many2One( string='Source/Dest', ondelete='RESTRICT', model_name='cashbook.book', domain=[ ('owner.id', '=', Eval('owner_cashbook', -1)), ('id', '!=', Eval('cashbook', -1)), ('btype', '!=', None)], states={ 'readonly': Eval('state_cashbook', '') != 'open', 'invisible': ~Eval('bookingtype', '').in_(['mvin', 'mvout']), 'required': Eval('bookingtype', '').in_(['mvin', 'mvout'])}, depends=[ 'state_cashbook', 'bookingtype', 'owner_cashbook', 'cashbook']) owner_cashbook = fields.Function(fields.Many2One( string='Owner', readonly=True, states={'invisible': True}, model_name='res.user'), 'on_change_with_owner_cashbook') state_cashbook = fields.Function(fields.Selection( string='State of Cashbook', readonly=True, states={'invisible': True}, selection=sel_state_book), 'on_change_with_state_cashbook') subject = fields.Text(string='Booking text', required=True) wfcheck = fields.Boolean( string="Set to 'Checked'", help="Switches the booking to the 'Verified' state.") booking_target = fields.Function(fields.Reference( string='Target', selection='get_booking_modelnames', readonly=True), 'on_change_with_booking_target') cashbook_lines = fields.Many2Many( string='Cashbook lines', relation_name='cashbook.planner_rel', help='This cash book lines was generated by the current ' + 'scheduled booking.', origin='planner', target='line') @classmethod def __setup__(cls): super(ScheduledBooking, cls).__setup__() cls._order.insert(0, ('name', 'ASC')) cls._order.insert(0, ('nextrun_date', 'ASC')) t = cls.__table__() cls._sql_indexes.update({ Index( t, (t.company, Index.Equality())), Index( t, (t.start_date, Index.Range(order='ASC'))), Index( t, (t.end_date, Index.Range(order='ASC')), where=t.end_date != DEF_NONE)}) cls._buttons.update({ 'book_now': {'readonly': ~Eval('active', False)}, }) def get_rec_name(self, name=None): """ get formatted name of record Args: name (str, optional): name of field. Defaults to None. Returns: str: formatted description of record """ return '|'.join([ self.name, self.cashbook.name, gettext('cashbook.msg_line_bookingtype_%s' % self.bookingtype), self.booktransf.name if self.booktransf else self.category.rec_name if self.category else '-', Report.format_date(self.nextrun_date, lang=None) if self.nextrun_date else '-', Report.format_currency( self.amount, lang=None, currency=self.cashbook.currency) ]) def _compute_dates_by_rrule(self, query_date=None, count=5, params={}): """ run rrule with values from record or from 'params' Args: query_date (date, optional): Start date as a filter for recurrences. Defaults to None. count (int, optional): number of recurrences in result. Defaults to 5. max value = 100 params (dict, optional): Values in the dictionary are used instead of the stored values, Defaults to {}, allowed: frequ, weekday, start_date, end_date (preferred over 'count'), monthday, interval, setpos, move_event Returns: list: date values, result of rrlue """ def get_moved_date(xdate, m_mode): """ re-arrange xdate to a working day Args: xdate (date): date to move to a working day move_mode (str): move mode: nop - no operation after/before - move date to after/before input date Returns: date: re-arranged date """ Config = Pool().get('cashbook.configuration') config = Config.get_singleton() assert m_mode in ['nop', 'after', 'before'], 'invalid move_mode' if (not config) or (m_mode == 'nop'): return xdate holidays = config.holiday_dates([xdate.year, xdate.year + 1]) day_cnt = ( 1 if m_mode == 'after' else -1 if m_mode == 'before' else 0) if day_cnt != 0: while (xdate in holidays) or (xdate.weekday() in [5, 6]): # re-arrange xdate = xdate + timedelta(days=day_cnt) return xdate pfrequ = { 'year': YEARLY, 'month': MONTHLY, 'week': WEEKLY, 'day': DAILY} pweekday = { '0': MO, '1': TU, '2': WE, '3': TH, '4': FR, '5': SA, '6': SU, '99': None}.get(params.get('weekday', self.weekday), None) if count is None: count = 5 count = 1 if count < 1 else 100 if count > 100 else count last_day_of_month = params.get( 'last_day_of_month', self.last_day_of_month) end_date = params.get('end_date', self.end_date) frequ = pfrequ[params.get('frequ', self.frequ)] move_event = params.get('move_event', self.move_event) if move_event not in ['nop', 'before', 'after']: move_event = 'nop' setpos = params.get('setpos', self.setpos) if setpos is not None: setpos = 1 if setpos < 1 else 4 if setpos > 4 else setpos monthday = params.get('monthday', self.monthday) if monthday is not None: monthday = 1 if monthday < 1 else 31 if monthday > 31 else monthday interval = params.get('interval', self.interval) if interval is None: interval = 1 interval = 1 if interval < 1 else 10 if interval > 10 else interval # last-day-of-month: set date short before end of month, # then compute move result to end of month updt_lastday = False if last_day_of_month and (frequ == MONTHLY) and not pweekday: monthday = 28 updt_lastday = True lastday_valid = last_day_of_month and ( frequ == MONTHLY) and (pweekday is None) assert (lastday_valid or not last_day_of_month), \ ('last-day-of-month can only be used with frequ=month ' + 'and weekday=99.') assert (monthday is None) or (pweekday is None), \ "weekday and monthday cannot be used together" dtrule = rrule( freq=frequ, byweekday=pweekday, dtstart=params.get('start_date', self.start_date), until=end_date, bysetpos=setpos if frequ == MONTHLY else None, bymonthday=monthday, interval=interval) result = [] for x in dtrule: if (query_date and (x.date() >= query_date)) or \ (query_date is None): x_date = x.date() if updt_lastday: x_date = ( (x_date + timedelta(days=5)).replace(day=1) - timedelta(days=1)) x_date = get_moved_date(x_date, move_event) # if date was re-arranged backwards and we are before # query_date - skip it if x_date >= query_date: result.append(x_date) if len(result) >= count: break return result @classmethod def get_booking_modelnames(cls): """ get list of model for field 'booking_target Returns: list: list of tuple: (model_name, Description) """ Model = Pool().get('ir.model') return [ (x.model, x.name) for x in Model.search([ ('model', 'in', ['cashbook.book', 'cashbook.category'])])] @fields.depends('bookingtype', 'category', 'booktransf') def on_change_with_booking_target(self, name=None): """ get category of target-cashbook Args: name (str, optional): name of field. Defaults to None. Returns: tuple: tuple with model-name and id of booking-target """ if self.bookingtype in ['in', 'out']: if self.category: return '%s,%d' % ( self.category.__name__, self.category.id) elif self.bookingtype in ['mvin', 'mvout']: if self.booktransf: return '%s,%d' % ( self.booktransf.__name__, self.booktransf.id) @fields.depends('cashbook', '_parent_cashbook.currency') def on_change_with_currency_cashbook(self, name=None): """ get currency of selected cashbook Args: name (str, optional): name of field. Defaults to None. Returns: int: id of cashbook currency """ if self.cashbook: return self.cashbook.currency.id @fields.depends('nextrun') def on_change_with_nextrun_date(self, name=None): """ get nextrun-record if exist Args: name (str, optional): field name. Defaults to None. Returns: date: date of nextrun or None """ if self.nextrun: return self.nextrun[0].date return None @fields.depends( 'start_date', 'end_date', 'frequ', 'weekday', 'monthday', 'interval', 'setpos', 'move_event', 'last_day_of_month') def on_change_with_nextdates(self, name=None): """ Calculates the next 5 appointments based on the configured rule, returns a formatted date list Args: name (string, optional): name of field. Defaults to None. context: nextrun_querydate (date, optional): start date for dates in result, defaults to today if not set or None Returns: string: formatted list of dates """ IrDate = Pool().get('ir.date') context = Transaction().context query_date = context.get('nextrun_querydate', None) if not isinstance(query_date, date): query_date = IrDate.today() return ' | '.join([ Report.format_date(x) for x in self._compute_dates_by_rrule( query_date=query_date, params={ 'start_date': self.start_date, 'end_date': self.end_date, 'frequ': self.frequ, 'weekday': self.weekday, 'monthday': self.monthday, 'interval': self.interval, 'setpos': self.setpos, 'last_day_of_month': self.last_day_of_month} )]) @fields.depends('cashbook', '_parent_cashbook.owner') def on_change_with_owner_cashbook(self, name=None): """ get current owner """ if self.cashbook: return self.cashbook.owner.id @fields.depends('cashbook', '_parent_cashbook.state') def on_change_with_state_cashbook(self, name=None): """ get state of cashbook """ if self.cashbook: return self.cashbook.state @fields.depends('bookingtype', 'category', 'booktransf') def on_change_bookingtype(self): """ reset category/booktransf on change of bookingtype """ if self.bookingtype: if self.bookingtype in ['in', 'out']: self.booktransf = None elif self.bookingtype in ['mvin', 'mvout']: self.category = None @fields.depends( 'frequ', 'setpos', 'weekday', 'monthday', 'last_day_of_month') def on_change_frequ(self): """ update fields """ if self.frequ and self.frequ == 'month': if self.weekday: if self.weekday == '99': if self.last_day_of_month: self.monthday = None else: if self.monthday is None: self.monthday = 1 self.setpos = None else: if self.setpos is None: self.setpos = 1 self.monthday = None self.last_day_of_month = False else: self.setpos = None self.monthday = None self.weekday = '99' self.last_day_of_month = False @fields.depends( 'frequ', 'setpos', 'weekday', 'monthday', 'last_day_of_month') def on_change_weekday(self): """ clear day-of-month if weekday is used """ self.on_change_frequ() @staticmethod def order_nextrun_date(tables): """ get query to sort by date of next execution Args: tables (list): tables Returns: list of query: sort-query """ pool = Pool() Nextrun = pool.get('cashbook.planner.nextrun') Planner2 = pool.get('cashbook.planner') tab_nxrun = Nextrun.__table__() tab_plan = Planner2.__table__() table, _ = tables[None] query = tab_plan.join( tab_nxrun, condition=tab_nxrun.planner == tab_plan.id ).select( tab_nxrun.date, where=tab_plan.id == table.id) return [query] @classmethod def search_nextrun_date(cls, name, clause): """ get query for search on 'nextrun_date' Args: name (str): name of field to search on clause (dict): search clause Returns: list of dict: search clause """ return [('nextrun.date',) + tuple(clause[1:])] @classmethod def default_move_event(cls): """ 'no operation' as default for business-day occurence Returns: str: nop """ return 'nop' @classmethod def default_wfcheck(cls): """ False as default for wf-state 'checked' Returns: bool: False """ return False @classmethod def default_amount(cls): """ default for amount Returns: Decimal: 0.00 """ return Decimal('0.0') @classmethod def default_interval(cls): """ get default for interval Returns: int: 1 = each occurence """ return 1 @classmethod def default_weekday(cls): """ get default for weekday-rule Returns: string: '99' = not set """ return '99' @classmethod def default_monthday(cls): """ get default for day-of-month Returns: int: 1 """ return 1 @classmethod def default_last_day_of_month(cls): """ get default for last-day-of-month Returns: boolean: False """ return False @classmethod def default_frequ(cls): """ get default for frequency Returns: string: 'month' """ return 'month' @staticmethod def default_company(): return Transaction().context.get('company') or None @classmethod def default_start_date(cls): """ get today as start-date Returns: date: date of today """ IrDate = Pool().get('ir.date') return IrDate.today() @classmethod def default_notify_bycron(cls): """ get False as default Returns: boolean: False """ return False @classmethod def fill_placeholder(cls, linedata): """ replace placeholder in description Args: description (str): booking text of planned booking allowed substitution strings: ${date}, ${month}, ${year}, ${amount}, ${quantity} Returns: str: booking text """ pool = Pool() IrDate = pool.get('ir.date') Cashbook = pool.get('cashbook.book') line_date = linedata.get('date', IrDate.today()) amount = linedata.get('amount', None) from_book = linedata.get('cashbook', None) if from_book: from_book = Cashbook(from_book) to_book = linedata.get('booktransf', None) if to_book: to_book = Cashbook(to_book) quantity_txt = '-' quantity = linedata.get('quantity', None) if quantity is not None: uom = ( to_book.quantity_uom if to_book and to_book.quantity_uom else from_book.quantity_uom if from_book and from_book.quantity_uom else None) uom_digits = ( to_book.quantity_digits if to_book and to_book.quantity_digits is not None else from_book.quantity_digits if from_book and from_book.quantity_digits is not None else 2) if uom: quantity_txt = Report.format_number_symbol( quantity, lang=None, symbol=uom, digits=uom_digits) else: quantity_txt = Report.format_number( quantity, lang=None, digits=uom_digits) asset_rate = '-' if quantity and amount is not None: asset_rate = '%(rate)s %(currency)s/%(uom)s' % { 'rate': Report.format_number( amount / quantity, lang=None, digits=to_book.currency.digits), 'currency': to_book.currency.symbol, 'uom': to_book.quantity_uom.symbol} return Template(linedata.get('description')).safe_substitute({ 'date': Report.format_date(line_date, lang=None), 'month': line_date.month, 'year': line_date.year, 'amount': Report.format_currency( amount, lang=None, currency=from_book.currency) if (amount is not None) and from_book else '-', 'quantity': quantity_txt, 'rate': asset_rate}) @classmethod def update_next_occurence(cls, records, query_date=None): """ compute date of next execution, create/update nextrun-record, delete nextrun-record if scheduled booking is disabled Args: records (list): scheduled-booking records query_date (date): set date to compute next run, defaults to 'today+1' """ pool = Pool() IrDate = pool.get('ir.date') NextRun = pool.get('cashbook.planner.nextrun') context = Transaction().context if not query_date: query_date = context.get( 'nextrun_querydate', IrDate.today() + timedelta(days=1)) to_create = [] to_write = [] to_delete = [] for record in records: if not record.active: # delete nextrun-record if disabled if record.nextrun: to_delete.extend(record.nextrun) elif record.active: # get next-run date next_date = record._compute_dates_by_rrule( query_date=query_date, count=1) if next_date: next_date = next_date[0] else: if record.nextrun: to_delete.extend(record.nextrun) continue if not record.nextrun: # add record if not exist to_create.append({'planner': record.id, 'date': next_date}) else: # update existing records for nxrun in record.nextrun: if nxrun.date != next_date: to_write.extend([[nxrun], {'date': next_date}]) if to_create: NextRun.create(to_create) if to_delete: NextRun.delete(to_delete) if to_write: NextRun.write(*to_write) @classmethod @ModelView.button def book_now(cls, records): """ run planned booking now """ to_work = [x for x in records if x.active and x.nextrun_date] cls.run_booking(to_work) for record in to_work: if record.active: cls.update_next_occurence( [record], query_date=record.nextrun_date + timedelta(days=1)) @classmethod def create(cls, vlist): """ update nextrun-records on create of planner-records Args: vlist (list of dict): values to create records Returns: list: created records """ records = super(ScheduledBooking, cls).create(vlist) cls.update_next_occurence(records) return records @classmethod def write(cls, *args): """ update nextrun-records on create of planner-records """ to_update = [] actions = iter(args) for records, values in zip(actions, actions): to_update.extend(records) super(ScheduledBooking, cls).write(*args) cls.update_next_occurence(records) @classmethod def run_booking(cls, records): """ create planned bookings Args: records (list): list of planned bokings """ pool = Pool() IrDate = pool.get('ir.date') Line = pool.get('cashbook.line') Currency = pool.get('currency.currency') def add_asset_values(aline, from_book, to_book): """ compute quantity from rate of asset and amount to invest Args: aline (dict): prepared dictionary to create cashbook-line-record from_book (record): cashbook record, to_book (record): cashbook record Returns: dict: dictionary to create cashbook-line record """ with Transaction().set_context({'date': aline['date']}): # convert amount to target-currency target_amount = Currency.compute( from_book.currency, aline['amount'], to_book.currency, round=False) # convert asset-rate of target-cashbook to target-currency asset_rate = (Currency.compute( to_book.asset.currency, to_book.asset.rate, to_book.currency, round=False) * Decimal( to_book.asset.uom.factor / to_book.quantity_uom.factor)) aline['quantity'] = Decimal('0.0') if asset_rate: aline['quantity'] = (target_amount / asset_rate).quantize( Decimal(Decimal(1) / 10 ** to_book.quantity_digits)) return aline to_create = [] to_create_check = [] for record in records: line = { 'cashbook': record.cashbook.id, 'bookingtype': record.bookingtype, 'date': IrDate.today(), 'amount': record.amount, 'description': record.subject} if record.bookingtype in ['in', 'out']: if record.category: line['category'] = record.category.id if record.party: line['party'] = record.party.id elif record.bookingtype in ['mvin', 'mvout']: if record.booktransf: line['booktransf'] = record.booktransf.id if record.booktransf.feature == 'asset': line.update(add_asset_values( line, record.cashbook, record.booktransf)) line['description'] = cls.fill_placeholder(line) line['planners'] = [('add', [record.id])] if record.wfcheck: to_create_check.append(line) else: to_create.append(line) to_notify = [] if to_create_check: lines = Line.create(to_create_check) Line.wfcheck(lines) to_notify.extend([ x for x in lines if x.planners[0].notify_bycron]) if to_create: lines = Line.create(to_create) to_notify.extend([ x for x in lines if x.planners[0].notify_bycron]) for line in to_notify: notify( title=gettext('cashbook_planner.msg_title_notify'), body=gettext( 'cashbook_planner.msg_text_notify', bname=line.rec_name), user=line.cashbook.owner.id) @classmethod def cronjob(cls): """ run planned booking for due jobs, re-schedule for next runs """ IrDate = Pool().get('ir.date') context = Transaction().context query_date = context.get('nextrun_crondate', IrDate.today()) records = cls.search([ ('active', '=', True), ('nextrun.date', '<=', query_date)]) if records: cls.run_booking(records) cls.update_next_occurence( records, query_date=query_date + timedelta(days=1)) # end ScheduledBooking class ScheduledBookingCashbookRel(ModelSQL): 'Scheduled Booking - Cashbook Line - Relation' __name__ = 'cashbook.planner_rel' planner = fields.Many2One( string='Scheduled Booking', required=True, model_name='cashbook.planner', ondelete='CASCADE') line = fields.Many2One( string='Cashbook Line', required=True, model_name='cashbook.line', ondelete='CASCADE') # end ScheduledBookingCashbookRel