feat(fusion_clock): schedule-driven attendance automation
Reminders, absence detection, late/early penalties, and auto-clock-out are now driven by each employee's real schedule (posted planner entry -> recurring shift), never the global 9-5 default. Employees who aren't scheduled get no reminders/absence. Overtime past the scheduled end is never cut off — auto clock-out only fires at a max-shift safety cap (default raised 12 -> 16h). Team leads build the planner in draft and Post it (publishes + emails employees). - hr.employee._get_fclk_day_plan: explicit `scheduled` flag; posted-only planner entries (drafts ignored), else recurring shift covering that weekday, else not-scheduled; sources 'schedule'/'shift'/'none'. - fusion.clock.shift: day_mon..day_sun weekday pattern + covers_weekday(). - fusion.clock.schedule: draft/posted state + posted_date; planner edits reset to draft; fclk_email_posted_week notification. - Rewrote the reminder / absence / auto-clock-out crons: schedule-gated, per-employee savepoints, OT-aware cap, weekend hardcode removed. - Penalties + all three clock-in paths skip days the employee isn't scheduled. - shift_planner: Post Week route + planner Post button + draft count. - Migration backfills pre-existing schedule entries to 'posted' so they keep driving automation after upgrade. - Tests: resolver matrix, cron gating, OT cap; fixed the existing planner test for the new state/source semantics. Design: docs/superpowers/specs/2026-05-30-schedule-driven-attendance-design.md Frontend footprint kept at zero to avoid colliding with the concurrent employee-portal (payslips) work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -132,18 +132,25 @@ class HrEmployee(models.Model):
|
||||
], limit=1)
|
||||
|
||||
def _get_fclk_day_plan(self, date):
|
||||
"""Return the effective plan for a local date.
|
||||
"""Return the effective plan for a local date, with an explicit
|
||||
``scheduled`` flag that ALL attendance automation keys off.
|
||||
|
||||
Dated schedules are the source of truth. If none exists, the legacy
|
||||
employee shift/global settings remain the fallback.
|
||||
Resolution order:
|
||||
1. POSTED planner entry (``fusion.clock.schedule`` state='posted').
|
||||
Draft entries are ignored, so the recurring baseline still applies
|
||||
until the team lead posts the schedule.
|
||||
2. The employee's recurring shift, IF it covers this weekday.
|
||||
3. Otherwise: not scheduled. The global default times are returned
|
||||
only as a display hint; ``scheduled`` stays False so nothing fires.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Schedule = self.env['fusion.clock.schedule'].sudo()
|
||||
schedule = self._get_fclk_schedule_for_date(date)
|
||||
if schedule:
|
||||
if schedule and schedule.state == 'posted':
|
||||
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,
|
||||
@@ -151,12 +158,14 @@ class HrEmployee(models.Model):
|
||||
'hours': schedule.planned_hours,
|
||||
'label': schedule.fclk_display_value(),
|
||||
}
|
||||
if self.x_fclk_shift_id:
|
||||
shift = self.x_fclk_shift_id
|
||||
|
||||
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)
|
||||
return {
|
||||
'source': 'fallback',
|
||||
'source': 'shift',
|
||||
'schedule_id': False,
|
||||
'scheduled': True,
|
||||
'is_off': False,
|
||||
'start_time': shift.start_time,
|
||||
'end_time': shift.end_time,
|
||||
@@ -168,23 +177,21 @@ class HrEmployee(models.Model):
|
||||
),
|
||||
}
|
||||
|
||||
# Not scheduled — global default times are a display hint only.
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
start_time = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||
end_time = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
break_minutes = float(ICP.get_param('fusion_clock.default_break_minutes', '30'))
|
||||
hours = max((end_time - start_time) - (break_minutes / 60.0), 0.0)
|
||||
return {
|
||||
'source': 'fallback',
|
||||
'source': 'none',
|
||||
'schedule_id': False,
|
||||
'scheduled': False,
|
||||
'is_off': False,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'break_minutes': break_minutes,
|
||||
'hours': hours,
|
||||
'label': '%s - %s' % (
|
||||
Schedule.fclk_float_to_display(start_time),
|
||||
Schedule.fclk_float_to_display(end_time),
|
||||
),
|
||||
'hours': 0.0,
|
||||
'label': '',
|
||||
}
|
||||
|
||||
def _get_fclk_break_minutes(self, date=None):
|
||||
|
||||
Reference in New Issue
Block a user