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