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:
@@ -237,6 +237,38 @@ class FusionClockShiftPlanner(http.Controller):
|
|||||||
'data': self._load_week_data(start),
|
'data': self._load_week_data(start),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@http.route('/fusion_clock/shift_planner/set_recurrence', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
|
def set_recurrence(self, employee_id=None, date=None, repeat=None, week_start=None, **kw):
|
||||||
|
"""Make the shift at (employee, date) recurring and generate it forward."""
|
||||||
|
if not self._check_manager():
|
||||||
|
return {'error': 'Access denied.'}
|
||||||
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
|
schedule = Schedule.search([
|
||||||
|
('employee_id', '=', int(employee_id or 0)),
|
||||||
|
('schedule_date', '=', date),
|
||||||
|
], limit=1)
|
||||||
|
if not schedule:
|
||||||
|
return {'success': False, 'message': 'Save this shift before repeating it.'}
|
||||||
|
try:
|
||||||
|
Schedule.fclk_attach_recurrence(schedule, repeat or {})
|
||||||
|
except ValidationError as exc:
|
||||||
|
return {'success': False, 'message': str(exc.args[0] if exc.args else exc)}
|
||||||
|
return {'success': True, 'data': self._load_week_data(week_start)}
|
||||||
|
|
||||||
|
@http.route('/fusion_clock/shift_planner/clear_recurrence', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
|
def clear_recurrence(self, employee_id=None, date=None, week_start=None, **kw):
|
||||||
|
"""Stop the recurrence seeded at (employee, date); keep posted rows."""
|
||||||
|
if not self._check_manager():
|
||||||
|
return {'error': 'Access denied.'}
|
||||||
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
|
schedule = Schedule.search([
|
||||||
|
('employee_id', '=', int(employee_id or 0)),
|
||||||
|
('schedule_date', '=', date),
|
||||||
|
], limit=1)
|
||||||
|
if schedule:
|
||||||
|
Schedule.fclk_clear_recurrence(schedule)
|
||||||
|
return {'success': True, 'data': self._load_week_data(week_start)}
|
||||||
|
|
||||||
@http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST'])
|
@http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
def export_xlsx(self, week_start=None, **kw):
|
def export_xlsx(self, week_start=None, **kw):
|
||||||
if not self._check_manager():
|
if not self._check_manager():
|
||||||
|
|||||||
17
fusion_clock/data/clock_recurrence_cron.xml
Normal file
17
fusion_clock/data/clock_recurrence_cron.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<!-- Recurring Shift Generation: rolls every recurrence's horizon forward.
|
||||||
|
Odoo 19 dropped numbercall; an active recurring cron runs forever. -->
|
||||||
|
<record id="cron_generate_recurring_shifts" model="ir.cron">
|
||||||
|
<field name="name">Fusion Clock: Generate Recurring Shifts</field>
|
||||||
|
<field name="model_id" ref="fusion_clock.model_fusion_clock_schedule_recurrence"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_generate()</field>
|
||||||
|
<field name="interval_number">1</field>
|
||||||
|
<field name="interval_type">days</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
<field name="priority">75</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -12,6 +12,7 @@ from . import clock_activity_log
|
|||||||
from . import clock_leave_request
|
from . import clock_leave_request
|
||||||
from . import clock_role
|
from . import clock_role
|
||||||
from . import clock_shift
|
from . import clock_shift
|
||||||
|
from . import clock_recurrence
|
||||||
from . import clock_schedule
|
from . import clock_schedule
|
||||||
from . import clock_correction
|
from . import clock_correction
|
||||||
from . import res_company
|
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_id': schedule.role_id.id or False,
|
||||||
'role_name': schedule.role_id.name or '',
|
'role_name': schedule.role_id.name or '',
|
||||||
'role_color': schedule.role_id._get_color_from_code() if schedule.role_id else '',
|
'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)
|
plan = employee._get_fclk_day_plan(date_obj)
|
||||||
@@ -399,6 +400,37 @@ class FusionClockSchedule(models.Model):
|
|||||||
'role_color': '',
|
'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
|
@api.model
|
||||||
def fclk_email_posted_week(self, employee, week_start, week_end):
|
def fclk_email_posted_week(self, employee, week_start, week_end):
|
||||||
"""Email one employee a summary of their POSTED shifts for the week."""
|
"""Email one employee a summary of their POSTED shifts for the week."""
|
||||||
|
|||||||
@@ -173,6 +173,19 @@ class HrEmployee(models.Model):
|
|||||||
('schedule_date', '=', date_obj),
|
('schedule_date', '=', date_obj),
|
||||||
], limit=1)
|
], 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):
|
def _get_fclk_day_plan(self, date):
|
||||||
"""Return the effective plan for a local date, with an explicit
|
"""Return the effective plan for a local date, with an explicit
|
||||||
``scheduled`` flag that ALL attendance automation keys off.
|
``scheduled`` flag that ALL attendance automation keys off.
|
||||||
|
|||||||
@@ -14,3 +14,14 @@ class ResCompany(models.Model):
|
|||||||
domain="[('company_id', '=', id)]",
|
domain="[('company_id', '=', id)]",
|
||||||
help="Clock location bound to the on-site kiosk (NFC and PIN) for this company.",
|
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.",
|
||||||
|
)
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export class FusionClockShiftPlanner extends Component {
|
|||||||
error: "",
|
error: "",
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
recurring: false,
|
||||||
|
showRepeat: false,
|
||||||
|
repeat: { interval: 1, unit: "week", type: "forever", until: "", number: 4 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -258,9 +261,72 @@ export class FusionClockShiftPlanner extends Component {
|
|||||||
this.state.editor.breakMinutes = breakMinutes;
|
this.state.editor.breakMinutes = breakMinutes;
|
||||||
this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours);
|
this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours);
|
||||||
this.state.editor.error = cell.error || "";
|
this.state.editor.error = cell.error || "";
|
||||||
|
this.state.editor.recurring = !!cell.recurring;
|
||||||
|
this.state.editor.showRepeat = false;
|
||||||
this._positionActiveEditor(anchor);
|
this._positionActiveEditor(anchor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleRepeatPanel() {
|
||||||
|
this.state.editor.showRepeat = !this.state.editor.showRepeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
onRepeatField(field, ev) {
|
||||||
|
const value = ev.target.value;
|
||||||
|
this.state.editor.repeat[field] =
|
||||||
|
field === "interval" || field === "number" ? Number(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRecurrence() {
|
||||||
|
const editor = this.state.editor;
|
||||||
|
this.state.saving = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("/fusion_clock/shift_planner/set_recurrence", {
|
||||||
|
employee_id: editor.employeeId,
|
||||||
|
date: editor.date,
|
||||||
|
week_start: this.state.weekStart,
|
||||||
|
repeat: {
|
||||||
|
repeat_interval: editor.repeat.interval,
|
||||||
|
repeat_unit: editor.repeat.unit,
|
||||||
|
repeat_type: editor.repeat.type,
|
||||||
|
repeat_until: editor.repeat.until || false,
|
||||||
|
repeat_number: editor.repeat.number,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.error || result.success === false) {
|
||||||
|
this.notification.add(result.error || result.message || "Could not repeat shift.", {
|
||||||
|
type: "danger",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this._applyData(result.data);
|
||||||
|
this.notification.add("Recurring shift created.", { type: "success" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.notification.add(error.message || "Could not repeat shift.", { type: "danger" });
|
||||||
|
}
|
||||||
|
this.state.saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearRecurrence() {
|
||||||
|
const editor = this.state.editor;
|
||||||
|
this.state.saving = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("/fusion_clock/shift_planner/clear_recurrence", {
|
||||||
|
employee_id: editor.employeeId,
|
||||||
|
date: editor.date,
|
||||||
|
week_start: this.state.weekStart,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
this.notification.add(result.error, { type: "danger" });
|
||||||
|
} else {
|
||||||
|
this._applyData(result.data);
|
||||||
|
this.notification.add("Recurrence stopped.", { type: "success" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.notification.add(error.message || "Could not stop recurrence.", { type: "danger" });
|
||||||
|
}
|
||||||
|
this.state.saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
closeCellEditor() {
|
closeCellEditor() {
|
||||||
this.state.editor.open = false;
|
this.state.editor.open = false;
|
||||||
this.activeCellAnchor = null;
|
this.activeCellAnchor = null;
|
||||||
|
|||||||
@@ -217,6 +217,52 @@
|
|||||||
padding: 4px;
|
padding: 4px;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
background: var(--fclk-planner-card, #ffffff);
|
background: var(--fclk-planner-card, #ffffff);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__cell-recur {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__cell-role {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 3px;
|
||||||
|
right: 4px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__repeat-panel {
|
||||||
|
border-top: 1px solid var(--fclk-planner-border, #d8dadd);
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.fclk-planner__repeat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
select,
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fclk-planner__repeat-int {
|
||||||
|
max-width: 64px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fclk-planner__shift-cell--fallback {
|
.fclk-planner__shift-cell--fallback {
|
||||||
|
|||||||
@@ -115,6 +115,13 @@
|
|||||||
<div class="fclk-planner__cell-error" t-if="cell.error">
|
<div class="fclk-planner__cell-error" t-if="cell.error">
|
||||||
<t t-esc="cell.error"/>
|
<t t-esc="cell.error"/>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="fclk-planner__cell-recur" t-if="cell.recurring"
|
||||||
|
title="Recurring shift">
|
||||||
|
<i class="fa fa-repeat"/>
|
||||||
|
</span>
|
||||||
|
<span class="fclk-planner__cell-role" t-if="cell.role_color"
|
||||||
|
t-att-style="'background-color: ' + cell.role_color + ';'"
|
||||||
|
t-att-title="cell.role_name"/>
|
||||||
</td>
|
</td>
|
||||||
<td class="fclk-planner__hours-cell">
|
<td class="fclk-planner__hours-cell">
|
||||||
<t t-esc="cell.hours_display || '0:00'"/>
|
<t t-esc="cell.hours_display || '0:00'"/>
|
||||||
@@ -182,12 +189,57 @@
|
|||||||
<t t-esc="state.editor.error"/>
|
<t t-esc="state.editor.error"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="fclk-planner__repeat-panel" t-if="state.editor.showRepeat">
|
||||||
|
<div class="fclk-planner__repeat-row">
|
||||||
|
<span>Every</span>
|
||||||
|
<input type="number" min="1" class="fclk-planner__repeat-int"
|
||||||
|
t-att-value="state.editor.repeat.interval"
|
||||||
|
t-on-change="(ev) => this.onRepeatField('interval', ev)"/>
|
||||||
|
<select t-on-change="(ev) => this.onRepeatField('unit', ev)">
|
||||||
|
<option value="day" t-att-selected="state.editor.repeat.unit === 'day'">day(s)</option>
|
||||||
|
<option value="week" t-att-selected="state.editor.repeat.unit === 'week'">week(s)</option>
|
||||||
|
<option value="month" t-att-selected="state.editor.repeat.unit === 'month'">month(s)</option>
|
||||||
|
<option value="year" t-att-selected="state.editor.repeat.unit === 'year'">year(s)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fclk-planner__repeat-row">
|
||||||
|
<select t-on-change="(ev) => this.onRepeatField('type', ev)">
|
||||||
|
<option value="forever" t-att-selected="state.editor.repeat.type === 'forever'">Forever</option>
|
||||||
|
<option value="until" t-att-selected="state.editor.repeat.type === 'until'">Until date</option>
|
||||||
|
<option value="x_times" t-att-selected="state.editor.repeat.type === 'x_times'"># of times</option>
|
||||||
|
</select>
|
||||||
|
<input type="date" t-if="state.editor.repeat.type === 'until'"
|
||||||
|
t-att-value="state.editor.repeat.until"
|
||||||
|
t-on-change="(ev) => this.onRepeatField('until', ev)"/>
|
||||||
|
<input type="number" min="1" t-if="state.editor.repeat.type === 'x_times'"
|
||||||
|
class="fclk-planner__repeat-int"
|
||||||
|
t-att-value="state.editor.repeat.number"
|
||||||
|
t-on-change="(ev) => this.onRepeatField('number', ev)"/>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary w-100"
|
||||||
|
t-on-click="() => this.setRecurrence()">
|
||||||
|
<i class="fa fa-check me-1"/> Apply recurrence
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="fclk-planner__editor-actions">
|
<div class="fclk-planner__editor-actions">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-sm btn-light"
|
class="btn btn-sm btn-light"
|
||||||
t-on-click="() => this.clearActiveCell()">
|
t-on-click="() => this.clearActiveCell()">
|
||||||
<i class="fa fa-eraser me-1"/> Clear
|
<i class="fa fa-eraser me-1"/> Clear
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
t-if="!state.editor.recurring"
|
||||||
|
class="btn btn-sm btn-light"
|
||||||
|
t-on-click="() => this.toggleRepeatPanel()">
|
||||||
|
<i class="fa fa-repeat me-1"/> Repeat…
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
t-if="state.editor.recurring"
|
||||||
|
class="btn btn-sm btn-warning"
|
||||||
|
t-on-click="() => this.clearRecurrence()">
|
||||||
|
<i class="fa fa-ban me-1"/> Stop repeat
|
||||||
|
</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-sm btn-primary"
|
class="btn btn-sm btn-primary"
|
||||||
t-on-click="() => this.applyEditorRange(true)">
|
t-on-click="() => this.applyEditorRange(true)">
|
||||||
|
|||||||
95
fusion_clock/tests/test_recurrence.py
Normal file
95
fusion_clock/tests/test_recurrence.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
class TestRecurrence(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.emp = self.env['hr.employee'].create({'name': 'Rita'})
|
||||||
|
self.Schedule = self.env['fusion.clock.schedule']
|
||||||
|
|
||||||
|
def _seed(self, day):
|
||||||
|
return self.Schedule.create({
|
||||||
|
'employee_id': self.emp.id,
|
||||||
|
'schedule_date': day,
|
||||||
|
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
|
||||||
|
'state': 'posted',
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_weekly_until_generates_inclusive_series(self):
|
||||||
|
seed = self._seed(date(2026, 6, 1))
|
||||||
|
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||||
|
'repeat_type': 'until', 'repeat_until': date(2026, 6, 29)})
|
||||||
|
rows = self.Schedule.search([('recurrence_id', '=', rule.id)], order='schedule_date')
|
||||||
|
self.assertEqual(
|
||||||
|
rows.mapped('schedule_date'),
|
||||||
|
[date(2026, 6, 1), date(2026, 6, 8), date(2026, 6, 15),
|
||||||
|
date(2026, 6, 22), date(2026, 6, 29)])
|
||||||
|
# Generated (non-seed) rows are draft until posted.
|
||||||
|
generated = rows.filtered(lambda r: r.schedule_date != date(2026, 6, 1))
|
||||||
|
self.assertTrue(all(r.state == 'draft' for r in generated))
|
||||||
|
|
||||||
|
def test_x_times_counts_seed(self):
|
||||||
|
seed = self._seed(date(2026, 6, 1))
|
||||||
|
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||||
|
'repeat_type': 'x_times', 'repeat_number': 3})
|
||||||
|
rows = self.Schedule.search([('recurrence_id', '=', rule.id)])
|
||||||
|
self.assertEqual(len(rows), 3, "3 repetitions = seed + 2 generated")
|
||||||
|
|
||||||
|
def test_interval_two_weeks(self):
|
||||||
|
seed = self._seed(date(2026, 6, 1))
|
||||||
|
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
'repeat_interval': 2, 'repeat_unit': 'week',
|
||||||
|
'repeat_type': 'until', 'repeat_until': date(2026, 7, 1)})
|
||||||
|
rows = self.Schedule.search([('recurrence_id', '=', rule.id)], order='schedule_date')
|
||||||
|
self.assertEqual(rows.mapped('schedule_date'),
|
||||||
|
[date(2026, 6, 1), date(2026, 6, 15), date(2026, 6, 29)])
|
||||||
|
|
||||||
|
def test_stop_deletes_future_drafts_keeps_posted(self):
|
||||||
|
seed = self._seed(date(2026, 6, 1))
|
||||||
|
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||||
|
'repeat_type': 'x_times', 'repeat_number': 4})
|
||||||
|
# Post one generated occurrence.
|
||||||
|
gen = self.Schedule.search([
|
||||||
|
('recurrence_id', '=', rule.id), ('schedule_date', '=', date(2026, 6, 8))])
|
||||||
|
gen.state = 'posted'
|
||||||
|
rule._stop(date(2026, 6, 2))
|
||||||
|
remaining = self.Schedule.search([('recurrence_id', '=', rule.id)]).mapped('schedule_date')
|
||||||
|
self.assertIn(date(2026, 6, 1), remaining) # seed, before cutoff
|
||||||
|
self.assertIn(date(2026, 6, 8), remaining) # posted, kept
|
||||||
|
self.assertNotIn(date(2026, 6, 15), remaining) # future draft, removed
|
||||||
|
self.assertNotIn(date(2026, 6, 22), remaining)
|
||||||
|
|
||||||
|
def test_leave_day_skipped(self):
|
||||||
|
self.env['fusion.clock.leave.request'].create({
|
||||||
|
'employee_id': self.emp.id,
|
||||||
|
'leave_date': date(2026, 6, 8), 'date_to': date(2026, 6, 8)})
|
||||||
|
seed = self._seed(date(2026, 6, 1))
|
||||||
|
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||||
|
'repeat_type': 'until', 'repeat_until': date(2026, 6, 15)})
|
||||||
|
dates = self.Schedule.search([('recurrence_id', '=', rule.id)]).mapped('schedule_date')
|
||||||
|
self.assertNotIn(date(2026, 6, 8), dates, "Leave day should be skipped")
|
||||||
|
self.assertIn(date(2026, 6, 15), dates)
|
||||||
|
|
||||||
|
def test_clear_recurrence_unlinks_rule_when_empty(self):
|
||||||
|
seed = self._seed(date(2026, 6, 1))
|
||||||
|
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||||
|
'repeat_type': 'x_times', 'repeat_number': 3})
|
||||||
|
rule_id = rule.id
|
||||||
|
self.Schedule.fclk_clear_recurrence(seed)
|
||||||
|
# Seed kept (it's posted), future drafts gone, seed detached.
|
||||||
|
self.assertFalse(seed.recurrence_id)
|
||||||
|
self.assertFalse(self.env['fusion.clock.schedule.recurrence'].browse(rule_id).exists())
|
||||||
72
fusion_clock/views/clock_recurrence_views.xml
Normal file
72
fusion_clock/views/clock_recurrence_views.xml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fusion_clock_recurrence_list" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.schedule.recurrence.list</field>
|
||||||
|
<field name="model">fusion.clock.schedule.recurrence</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="display_name"/>
|
||||||
|
<field name="repeat_interval"/>
|
||||||
|
<field name="repeat_unit"/>
|
||||||
|
<field name="repeat_type"/>
|
||||||
|
<field name="repeat_until"/>
|
||||||
|
<field name="repeat_number"/>
|
||||||
|
<field name="last_generated_date"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fusion_clock_recurrence_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.schedule.recurrence.form</field>
|
||||||
|
<field name="model">fusion.clock.schedule.recurrence</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<label for="repeat_interval" string="Repeat Every"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="repeat_interval" class="oe_inline"/>
|
||||||
|
<field name="repeat_unit" class="oe_inline"/>
|
||||||
|
</div>
|
||||||
|
<field name="repeat_type"/>
|
||||||
|
<field name="repeat_until"
|
||||||
|
invisible="repeat_type != 'until'"
|
||||||
|
required="repeat_type == 'until'"/>
|
||||||
|
<field name="repeat_number" invisible="repeat_type != 'x_times'"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="last_generated_date" readonly="1"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Generated Shifts">
|
||||||
|
<field name="schedule_ids" nolabel="1" colspan="2">
|
||||||
|
<list>
|
||||||
|
<field name="schedule_date"/>
|
||||||
|
<field name="employee_id"/>
|
||||||
|
<field name="start_time" widget="float_time"/>
|
||||||
|
<field name="end_time" widget="float_time"/>
|
||||||
|
<field name="state"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fusion_clock_recurrence" model="ir.actions.act_window">
|
||||||
|
<field name="name">Recurring Shifts</field>
|
||||||
|
<field name="res_model">fusion.clock.schedule.recurrence</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">No recurring shifts yet</p>
|
||||||
|
<p>Recurring shifts are created from the Shift Planner — click a cell,
|
||||||
|
then <b>Repeat…</b>. They appear here so you can review or stop them.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user