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

@@ -110,7 +110,8 @@ class FusionClockAPI(http.Controller):
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
return
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
if not day_plan.get('scheduled'):
# No late/early penalties on days the employee isn't scheduled to work.
return
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
@@ -282,7 +283,8 @@ class FusionClockAPI(http.Controller):
now = fields.Datetime.now()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
# "Unscheduled" = a posted OFF day OR a day with no schedule at all.
is_scheduled_off = not day_plan.get('scheduled')
geo_info = {
'latitude': latitude,
@@ -325,7 +327,7 @@ class FusionClockAPI(http.Controller):
if is_scheduled_off:
self._log_activity(
employee, 'unscheduled_shift',
f"Clocked in on a scheduled OFF day at {location.name}.",
f"Clocked in on an unscheduled day at {location.name}.",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source=source,
@@ -335,7 +337,7 @@ class FusionClockAPI(http.Controller):
request.env['hr.attendance'].sudo()._fclk_notify_office(
office_user_id,
f"Unscheduled Shift: {employee.name}",
f"{employee.name} clocked in on a scheduled OFF day.",
f"{employee.name} clocked in on an unscheduled day.",
'hr.attendance',
attendance.id,
)

View File

@@ -103,7 +103,7 @@ class FusionClockKiosk(http.Controller):
now = fields.Datetime.now()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
is_scheduled_off = not day_plan.get('scheduled')
geo_info = {
'latitude': latitude,
@@ -133,7 +133,7 @@ class FusionClockKiosk(http.Controller):
if is_scheduled_off:
api._log_activity(
employee, 'unscheduled_shift',
f"Kiosk clock-in on a scheduled OFF day at {location.name}",
f"Kiosk clock-in on an unscheduled day at {location.name}",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source='kiosk',

View File

@@ -324,7 +324,7 @@ class FusionClockNfcKiosk(http.Controller):
now = fields.Datetime.now()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
is_scheduled_off = not day_plan.get('scheduled')
geo_info = {
'latitude': 0,
@@ -352,7 +352,7 @@ class FusionClockNfcKiosk(http.Controller):
if is_scheduled_off:
api._log_activity(
employee, 'unscheduled_shift',
f"NFC kiosk clock-in on a scheduled OFF day at {location.name}",
f"NFC kiosk clock-in on an unscheduled day at {location.name}",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0,
source='nfc_kiosk',

View File

@@ -155,6 +155,41 @@ class FusionClockShiftPlanner(http.Controller):
'data': self._load_week_data(week_start),
}
@http.route('/fusion_clock/shift_planner/post_week', type='jsonrpc', auth='user', methods=['POST'])
def post_week(self, week_start=None, **kw):
"""Publish (post) the viewed week's draft entries so automation acts on
them, and email each newly-affected employee their posted shifts."""
if not self._check_manager():
return {'error': 'Access denied.'}
start = self._week_start(week_start)
end = start + timedelta(days=6)
employees = self._manager_employees()
Schedule = request.env['fusion.clock.schedule'].sudo()
entries = Schedule.search([
('employee_id', 'in', employees.ids),
('schedule_date', '>=', start),
('schedule_date', '<=', end),
('state', '!=', 'posted'),
])
posted_count = len(entries)
affected = entries.mapped('employee_id')
if entries:
entries.write({'state': 'posted', 'posted_date': fields.Datetime.now()})
notified = 0
for employee in affected:
if Schedule.fclk_email_posted_week(employee, start, end):
notified += 1
return {
'success': True,
'posted': posted_count,
'notified': notified,
'data': self._load_week_data(start),
}
@http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST'])
def copy_previous_week(self, week_start=None, **kw):
if not self._check_manager():