feat(fusion_clock): open shifts + self-assign + bulk apply [B4-B5]

Model: fclk_create_open_shifts/claim_open_shift/release_shift (days-before
cutoff + role eligibility)/bulk_apply. Planner: Open Shift… panel, open-shifts
strip with delete, Apply-to-dept; load includes open shifts. Portal: claim
open shifts + release own upcoming shifts with feedback banners. Tests for
claim/role-gate/release/bulk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-04 21:12:10 -04:00
parent 68aaa132ee
commit 2ad94070c7
9 changed files with 514 additions and 1 deletions

View File

@@ -470,6 +470,68 @@ class FusionClockSchedule(models.Model):
rule.unlink()
return True
# ----- Open shifts + bulk apply (native "Apply Also To" / self-assign) -----
@api.model
def fclk_create_open_shifts(self, company, date_obj, start, end,
role_id=False, count=1, break_minutes=0.0, note=None):
"""Create N open (unassigned) shifts for a day, available to claim."""
date_obj = fields.Date.to_date(date_obj)
if not date_obj:
raise ValidationError(_("Pick a date for the open shift."))
company_id = (company.id if company else False) or self.env.company.id
vals_list = [{
'is_open': True,
'schedule_date': date_obj,
'start_time': float(start or 0.0),
'end_time': float(end or 0.0),
'break_minutes': float(break_minutes or 0.0),
'role_id': int(role_id) if role_id else False,
'company_id': company_id,
'note': note or False,
'state': 'posted',
} for _i in range(max(1, int(count or 1)))]
return self.sudo().create(vals_list)
@api.model
def fclk_claim_open_shift(self, schedule, employee):
"""Assign an open shift to an employee (portal self-assign)."""
schedule = schedule.sudo()
employee = employee.sudo()
if not schedule or not schedule.is_open:
raise ValidationError(_("This shift is no longer available."))
if not employee:
raise ValidationError(_("No employee to assign this shift to."))
# If the shift carries a role and the employee has an explicit allowed
# list, enforce eligibility (no list = eligible for anything).
if schedule.role_id and employee.x_fclk_role_ids \
and schedule.role_id not in employee.x_fclk_role_ids:
raise ValidationError(_("You are not eligible for this shift's role."))
schedule.write({'employee_id': employee.id, 'is_open': False})
return schedule
@api.model
def fclk_release_shift(self, schedule, employee):
"""Release a claimed shift back to the open pool (portal self-unassign),
respecting the company's days-before cutoff."""
schedule = schedule.sudo()
if not schedule or schedule.employee_id != employee.sudo():
raise ValidationError(_("You can only release your own shift."))
cutoff = schedule.company_id.fclk_self_unassign_days_before or 0
if (schedule.schedule_date - fields.Date.today()).days < cutoff:
raise ValidationError(_("It is too late to release this shift."))
schedule.write({'employee_id': False, 'is_open': True})
return schedule
@api.model
def fclk_bulk_apply(self, employees, date_obj, payload, user=None):
"""Apply the same shift payload to several employees in one go
(native replacement for Planning's 'Apply Also To')."""
results = self.browse()
for employee in employees:
results |= self.fclk_apply_planner_cell(employee, date_obj, dict(payload or {}), user)
return results
@api.model
def fclk_email_posted_range(self, employee, start, end, message=None):
"""Email one employee a summary of their POSTED shifts between two