# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) # # Native recurring-shift engine. The field design and repeat semantics are # adapted from Odoo Enterprise ``planning.recurrency`` (repeat every N # days/weeks/months/years; forever / until / N-times), but the generation loop # targets Fusion Clock's per-day ``fusion.clock.schedule`` rows instead of # datetime ``planning.slot`` records — so there is no resource-calendar / DST # machinery to carry. Generated rows are born ``draft`` and must be posted # (published) before any attendance automation acts on them. import logging from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _ from odoo.exceptions import ValidationError _logger = logging.getLogger(__name__) # Hard safety cap on iterations when projecting a recurrence forward, so a # misconfigured rule can never loop unbounded (5 years of daily shifts). _MAX_OCCURRENCES = 365 * 5 class FusionClockScheduleRecurrence(models.Model): _name = 'fusion.clock.schedule.recurrence' _description = 'Clock Schedule Recurrence' _rec_name = 'display_name' schedule_ids = fields.One2many( 'fusion.clock.schedule', 'recurrence_id', string='Generated Shifts') repeat_interval = fields.Integer('Repeat Every', default=1, required=True) repeat_unit = fields.Selection( [('day', 'Days'), ('week', 'Weeks'), ('month', 'Months'), ('year', 'Years')], string='Repeat Unit', default='week', required=True) repeat_type = fields.Selection( [('forever', 'Forever'), ('until', 'Until'), ('x_times', 'Number of Repetitions')], string='Until', default='forever', required=True) repeat_until = fields.Date('Repeat Until') repeat_number = fields.Integer('Repetitions', default=1) last_generated_date = fields.Date(readonly=True) company_id = fields.Many2one( 'res.company', string='Company', required=True, default=lambda self: self.env.company) display_name = fields.Char(compute='_compute_display_name') _check_interval_positive = models.Constraint( 'CHECK(repeat_interval >= 1)', 'The repeat interval must be at least 1.') @api.constrains('repeat_type', 'repeat_until') def _check_until(self): for rec in self: if rec.repeat_type == 'until' and not rec.repeat_until: raise ValidationError(_('Set an end date for an "Until" recurrence.')) @api.constrains('repeat_type', 'repeat_number') def _check_number(self): for rec in self: if rec.repeat_type == 'x_times' and rec.repeat_number < 1: raise ValidationError(_('The number of repetitions must be at least 1.')) @api.depends('repeat_type', 'repeat_interval', 'repeat_unit', 'repeat_until', 'repeat_number') def _compute_display_name(self): units = dict(self._fields['repeat_unit'].selection) for rec in self: unit = units.get(rec.repeat_unit, rec.repeat_unit) if rec.repeat_type == 'forever': rec.display_name = _('Every %(n)s %(u)s, forever', n=rec.repeat_interval, u=unit) elif rec.repeat_type == 'until': rec.display_name = _('Every %(n)s %(u)s until %(d)s', n=rec.repeat_interval, u=unit, d=rec.repeat_until) else: rec.display_name = _('Every %(n)s %(u)s, %(c)s times', n=rec.repeat_interval, u=unit, c=rec.repeat_number) def _delta(self, n): """relativedelta for the n-th occurrence after the seed.""" self.ensure_one() key = {'day': 'days', 'week': 'weeks', 'month': 'months', 'year': 'years'}[self.repeat_unit] return relativedelta(**{key: self.repeat_interval * n}) def _horizon(self): """Furthest date we pre-generate to when the recurrence has no end.""" self.ensure_one() months = self.company_id.fclk_planning_generation_months or 6 return fields.Date.today() + relativedelta(months=months) def _generate(self, stop_date=False): """Materialise per-day schedule rows for each recurrence up to its horizon. Idempotent: dates already covered for the rule are skipped and ``last_generated_date`` advances.""" Schedule = self.env['fusion.clock.schedule'].sudo() for rec in self: seed = Schedule.search( [('recurrence_id', '=', rec.id)], order='schedule_date desc', limit=1) if not seed: # No anchor row -> nothing to repeat; drop the empty rule. rec.unlink() continue anchor = Schedule.search( [('recurrence_id', '=', rec.id)], order='schedule_date asc', limit=1) bounds = [stop_date or rec._horizon()] if rec.repeat_until: bounds.append(rec.repeat_until) limit = min(bounds) existing = Schedule.search_count([('recurrence_id', '=', rec.id)]) vals_list, last = [], rec.last_generated_date for i in range(1, _MAX_OCCURRENCES + 1): nxt = anchor.schedule_date + rec._delta(i) if nxt > limit: break if rec.repeat_type == 'x_times' and existing + len(vals_list) >= rec.repeat_number: break if Schedule.search_count( [('recurrence_id', '=', rec.id), ('schedule_date', '=', nxt)]): continue if anchor.employee_id and anchor.employee_id._fclk_on_leave(nxt): continue vals_list.append({ 'employee_id': anchor.employee_id.id or False, 'schedule_date': nxt, 'shift_id': anchor.shift_id.id or False, 'role_id': anchor.role_id.id or False, 'is_off': anchor.is_off, # is_open is added in the Phase B schedule extension; guard so # the engine works whether or not that field exists yet. 'is_open': bool(getattr(anchor, 'is_open', False)), 'start_time': anchor.start_time, 'end_time': anchor.end_time, 'break_minutes': anchor.break_minutes, 'note': anchor.note or False, 'recurrence_id': rec.id, 'state': 'draft', }) last = nxt if vals_list: Schedule.create(vals_list) rec.last_generated_date = last def _stop(self, from_date): """Delete future DRAFT rows of these rules (posted rows are kept).""" self.env['fusion.clock.schedule'].sudo().search([ ('recurrence_id', 'in', self.ids), ('schedule_date', '>=', from_date), ('state', '=', 'draft'), ]).unlink() @api.model def _cron_generate(self): """Roll every recurrence's horizon forward (called daily).""" self.search([])._generate()