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:
gsinghpal
2026-05-30 21:54:05 -04:00
parent b5d5a9acba
commit 2aaa1a57e7
18 changed files with 557 additions and 160 deletions

View File

@@ -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):