feat(fusion_clock): native recurring shifts engine [A4-A5]
fusion.clock.schedule.recurrence (repeat every N day/week/month/year; forever/until/N-times) re-fit from planning.recurrency onto per-day rows; daily generation cron; _fclk_on_leave skip; planner Repeat…/Stop-repeat UI + endpoints; recurrence + role indicators on cells. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ from . import clock_activity_log
|
||||
from . import clock_leave_request
|
||||
from . import clock_role
|
||||
from . import clock_shift
|
||||
from . import clock_recurrence
|
||||
from . import clock_schedule
|
||||
from . import clock_correction
|
||||
from . import res_company
|
||||
|
||||
154
fusion_clock/models/clock_recurrence.py
Normal file
154
fusion_clock/models/clock_recurrence.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# -*- 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()
|
||||
@@ -377,6 +377,7 @@ class FusionClockSchedule(models.Model):
|
||||
'role_id': schedule.role_id.id or False,
|
||||
'role_name': schedule.role_id.name or '',
|
||||
'role_color': schedule.role_id._get_color_from_code() if schedule.role_id else '',
|
||||
'recurring': bool(schedule.recurrence_id),
|
||||
}
|
||||
|
||||
plan = employee._get_fclk_day_plan(date_obj)
|
||||
@@ -399,6 +400,37 @@ class FusionClockSchedule(models.Model):
|
||||
'role_color': '',
|
||||
}
|
||||
|
||||
@api.model
|
||||
def fclk_attach_recurrence(self, schedule, repeat_vals):
|
||||
"""Attach a recurrence rule to a seed schedule cell and generate it
|
||||
forward. ``repeat_vals`` mirrors the recurrence fields."""
|
||||
schedule = schedule.sudo()
|
||||
if not schedule:
|
||||
raise ValidationError(_("Pick a shift to repeat first."))
|
||||
rule = self.env['fusion.clock.schedule.recurrence'].sudo().create({
|
||||
'repeat_interval': int(repeat_vals.get('repeat_interval') or 1),
|
||||
'repeat_unit': repeat_vals.get('repeat_unit') or 'week',
|
||||
'repeat_type': repeat_vals.get('repeat_type') or 'forever',
|
||||
'repeat_until': repeat_vals.get('repeat_until') or False,
|
||||
'repeat_number': int(repeat_vals.get('repeat_number') or 1),
|
||||
'company_id': schedule.company_id.id or self.env.company.id,
|
||||
})
|
||||
schedule.recurrence_id = rule.id
|
||||
rule._generate()
|
||||
return rule
|
||||
|
||||
@api.model
|
||||
def fclk_clear_recurrence(self, schedule):
|
||||
"""Detach + stop the recurrence on a seed cell (keeps posted rows)."""
|
||||
schedule = schedule.sudo()
|
||||
rule = schedule.recurrence_id
|
||||
if rule:
|
||||
rule._stop(fields.Date.today())
|
||||
schedule.recurrence_id = False
|
||||
if not rule.schedule_ids:
|
||||
rule.unlink()
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def fclk_email_posted_week(self, employee, week_start, week_end):
|
||||
"""Email one employee a summary of their POSTED shifts for the week."""
|
||||
|
||||
@@ -173,6 +173,19 @@ class HrEmployee(models.Model):
|
||||
('schedule_date', '=', date_obj),
|
||||
], limit=1)
|
||||
|
||||
def _fclk_on_leave(self, date):
|
||||
"""True if an approved leave request covers ``date`` for this employee.
|
||||
Used by the recurrence engine to skip generating shifts on days off."""
|
||||
self.ensure_one()
|
||||
date_obj = fields.Date.to_date(date)
|
||||
if not date_obj:
|
||||
return False
|
||||
return bool(self.env['fusion.clock.leave.request'].sudo().search_count([
|
||||
('employee_id', '=', self.id),
|
||||
('leave_date', '<=', date_obj),
|
||||
('date_to', '>=', date_obj),
|
||||
]))
|
||||
|
||||
def _get_fclk_day_plan(self, date):
|
||||
"""Return the effective plan for a local date, with an explicit
|
||||
``scheduled`` flag that ALL attendance automation keys off.
|
||||
|
||||
@@ -14,3 +14,14 @@ class ResCompany(models.Model):
|
||||
domain="[('company_id', '=', id)]",
|
||||
help="Clock location bound to the on-site kiosk (NFC and PIN) for this company.",
|
||||
)
|
||||
fclk_planning_generation_months = fields.Integer(
|
||||
string='Schedule Generation Horizon (months)',
|
||||
default=6,
|
||||
help="How many months ahead recurring shifts are pre-generated.",
|
||||
)
|
||||
fclk_self_unassign_days_before = fields.Integer(
|
||||
string='Self-Unassign Cutoff (days before shift)',
|
||||
default=1,
|
||||
help="Employees may release an open shift they claimed up to this many "
|
||||
"days before it starts.",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user