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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user