feat(fusion_clock): schedule parity — overnight, split shifts, open shifts [B1-B3]

is_open + crosses_midnight fields; employee_id optional (open shifts);
company_id computed w/ env.company fallback; drop hard one-per-day UNIQUE
(allow split + open). Overnight math in planned_hours/_check_schedule_times/
scheduled_times. _get_fclk_day_plan resolves multiple posted rows into ONE
work-window so penalties/overtime/absence stay correct. Migration drops the
old constraint defensively. Tests for overnight, window, open shifts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-04 21:04:58 -04:00
parent d35d5f4b34
commit 68aaa132ee
6 changed files with 252 additions and 23 deletions

View File

@@ -21,10 +21,16 @@ class FusionClockSchedule(models.Model):
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
required=False, # open (unassigned) shifts have no employee until claimed
index=True,
ondelete='cascade',
)
is_open = fields.Boolean(
string='Open Shift',
default=False,
index=True,
help="An unassigned shift any eligible employee can claim from the portal.",
)
schedule_date = fields.Date(
string='Date',
required=True,
@@ -57,6 +63,13 @@ class FusionClockSchedule(models.Model):
compute='_compute_planned_hours',
store=True,
)
crosses_midnight = fields.Boolean(
string='Overnight',
compute='_compute_planned_hours',
store=True,
help="Set automatically when the shift ends on the next day "
"(end time on or before start time).",
)
note = fields.Char(string='Note')
role_id = fields.Many2one(
'fusion.clock.role',
@@ -75,9 +88,10 @@ class FusionClockSchedule(models.Model):
company_id = fields.Many2one(
'res.company',
string='Company',
related='employee_id.company_id',
compute='_compute_fclk_company',
store=True,
readonly=True,
readonly=False,
index=True,
)
department_id = fields.Many2one(
'hr.department',
@@ -100,18 +114,41 @@ class FusionClockSchedule(models.Model):
)
posted_date = fields.Datetime(string='Posted On', readonly=True)
_employee_date_unique = models.Constraint(
'UNIQUE(employee_id, schedule_date)',
'Only one shift schedule is allowed per employee per day.',
)
# No hard UNIQUE(employee, date): the per-day model now allows split shifts
# and open (unassigned) shifts. The shift planner still manages one cell per
# day in place; the attendance contract (_get_fclk_day_plan) resolves
# multiple posted rows into a single work-window.
@api.depends('employee_id')
def _compute_fclk_company(self):
for rec in self:
if rec.employee_id:
rec.company_id = rec.employee_id.company_id
elif not rec.company_id:
rec.company_id = self.env.company
@api.constrains('employee_id', 'is_open')
def _check_employee_or_open(self):
for rec in self:
if not rec.employee_id and not rec.is_open:
raise ValidationError(
_("A shift must have an employee unless it is an open shift."))
@api.depends('is_off', 'start_time', 'end_time', 'break_minutes')
def _compute_planned_hours(self):
for rec in self:
rec.crosses_midnight = False
if rec.is_off:
rec.planned_hours = 0.0
continue
raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0)
start = rec.start_time or 0.0
end = rec.end_time or 0.0
if end <= start:
# Overnight: the shift ends on the following day.
rec.crosses_midnight = True
raw_hours = (24.0 - start) + end
else:
raw_hours = end - start
rec.planned_hours = round(max(raw_hours - ((rec.break_minutes or 0.0) / 60.0), 0.0), 2)
@api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time')
@@ -130,11 +167,13 @@ class FusionClockSchedule(models.Model):
continue
if rec.start_time < 0 or rec.start_time >= 24:
raise ValidationError(_("Start time must be between 00:00 and 23:59."))
if rec.end_time <= 0 or rec.end_time > 24:
raise ValidationError(_("End time must be between 00:01 and 24:00."))
if rec.end_time < 0 or rec.end_time > 24:
raise ValidationError(_("End time must be between 00:00 and 24:00."))
# Overnight shifts (end on/before start) are allowed and span midnight.
if rec.end_time <= rec.start_time:
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
shift_minutes = (rec.end_time - rec.start_time) * 60.0
shift_minutes = ((24.0 - rec.start_time) + rec.end_time) * 60.0
else:
shift_minutes = (rec.end_time - rec.start_time) * 60.0
if rec.break_minutes >= shift_minutes:
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))

View File

@@ -200,23 +200,60 @@ class HrEmployee(models.Model):
"""
self.ensure_one()
Schedule = self.env['fusion.clock.schedule'].sudo()
schedule = self._get_fclk_schedule_for_date(date)
if schedule and schedule.state == 'posted':
date_obj = fields.Date.to_date(date)
# All POSTED, assigned (non-open) rows for the day. The model now allows
# split shifts, so resolve several rows into one work-window that the
# whole attendance pipeline keys off — earliest start to latest end.
posted = Schedule.search([
('employee_id', '=', self.id),
('schedule_date', '=', date_obj),
('state', '=', 'posted'),
('is_open', '=', False),
]) if date_obj else Schedule.browse()
working = posted.filtered(lambda s: not s.is_off)
if working:
start = min(working.mapped('start_time'))
def _eff_end(s):
return (s.end_time + 24.0) if s.crosses_midnight else s.end_time
win_end_eff = max(_eff_end(s) for s in working)
crosses = win_end_eff > 24.0
end = win_end_eff - 24.0 if crosses else win_end_eff
return {
'source': 'schedule',
'schedule_id': schedule.id,
'scheduled': not schedule.is_off,
'is_off': schedule.is_off,
'start_time': schedule.start_time,
'end_time': schedule.end_time,
'break_minutes': schedule.break_minutes,
'hours': schedule.planned_hours,
'label': schedule.fclk_display_value(),
'schedule_id': working[0].id,
'scheduled': True,
'is_off': False,
'start_time': start,
'end_time': end,
'break_minutes': sum(working.mapped('break_minutes')),
'hours': sum(working.mapped('planned_hours')),
'crosses_midnight': crosses,
'label': '%s - %s' % (
Schedule.fclk_float_to_display(start),
Schedule.fclk_float_to_display(end),
),
}
if posted: # every posted row for the day is OFF
return {
'source': 'schedule',
'schedule_id': posted[0].id,
'scheduled': False,
'is_off': True,
'start_time': 0.0,
'end_time': 0.0,
'break_minutes': 0.0,
'hours': 0.0,
'crosses_midnight': False,
'label': 'OFF',
}
shift = self.x_fclk_shift_id
if shift and shift.covers_weekday(date):
hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0)
crosses = shift.end_time <= shift.start_time
raw = ((24.0 - shift.start_time) + shift.end_time) if crosses else (shift.end_time - shift.start_time)
hours = max(raw - (shift.break_minutes / 60.0), 0.0)
return {
'source': 'shift',
'schedule_id': False,
@@ -226,6 +263,7 @@ class HrEmployee(models.Model):
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
'hours': hours,
'crosses_midnight': crosses,
'label': '%s - %s' % (
Schedule.fclk_float_to_display(shift.start_time),
Schedule.fclk_float_to_display(shift.end_time),
@@ -242,6 +280,7 @@ class HrEmployee(models.Model):
'schedule_id': False,
'scheduled': False,
'is_off': False,
'crosses_midnight': False,
'start_time': start_time,
'end_time': end_time,
'break_minutes': break_minutes,
@@ -320,6 +359,9 @@ class HrEmployee(models.Model):
local_out = local_tz.localize(
datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m))
)
# Overnight shift: scheduled clock-out lands on the following day.
if plan.get('crosses_midnight'):
local_out = local_out + timedelta(days=1)
scheduled_in = local_in.astimezone(utc).replace(tzinfo=None)
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)