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:
@@ -250,64 +250,55 @@ class HrAttendance(models.Model):
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_auto_clock_out(self):
|
||||
"""Cron job: auto clock-out employees after shift + grace period."""
|
||||
"""Cron job: safety-net auto clock-out.
|
||||
|
||||
Overtime past the scheduled end is expected, so this NEVER closes a shift
|
||||
at the scheduled end. It only closes an attendance left open longer than
|
||||
the max-shift safety cap (someone forgot to clock out), and flags the
|
||||
employee to explain on their next clock-in.
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_auto_clockout', 'True') != 'True':
|
||||
return
|
||||
|
||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '12.0'))
|
||||
grace_min = float(ICP.get_param('fusion_clock.grace_period_minutes', '15'))
|
||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0'))
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
|
||||
now = fields.Datetime.now()
|
||||
|
||||
open_attendances = self.sudo().search([
|
||||
('check_out', '=', False),
|
||||
])
|
||||
|
||||
open_attendances = self.sudo().search([('check_out', '=', False)])
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
|
||||
for att in open_attendances:
|
||||
check_in = att.check_in
|
||||
if not check_in:
|
||||
continue
|
||||
effective_deadline = check_in + timedelta(hours=max_shift)
|
||||
if now <= effective_deadline:
|
||||
continue
|
||||
|
||||
employee = att.employee_id
|
||||
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
|
||||
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
|
||||
max_deadline = check_in + timedelta(hours=max_shift)
|
||||
day_plan = employee._get_fclk_day_plan(check_in_date)
|
||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||
effective_deadline = max_deadline
|
||||
else:
|
||||
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
|
||||
deadline = scheduled_out + timedelta(minutes=grace_min)
|
||||
effective_deadline = min(deadline, max_deadline)
|
||||
|
||||
if now > effective_deadline:
|
||||
clock_out_time = min(effective_deadline, now)
|
||||
try:
|
||||
clock_out_time = effective_deadline
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
att.sudo().write({
|
||||
'check_out': clock_out_time,
|
||||
'x_fclk_auto_clocked_out': True,
|
||||
'x_fclk_grace_used': True,
|
||||
'x_fclk_clock_source': 'auto',
|
||||
})
|
||||
|
||||
# Apply break deduction
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
if (att.worked_hours or 0) >= threshold:
|
||||
break_min = employee._get_fclk_break_minutes(check_in_date)
|
||||
att.sudo().write({'x_fclk_break_minutes': break_min})
|
||||
|
||||
att.sudo().write(
|
||||
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
|
||||
)
|
||||
att.sudo().message_post(
|
||||
body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} "
|
||||
f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||
f"(max-shift cap reached). Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
# Log to activity log
|
||||
ActivityLog.create({
|
||||
'employee_id': employee.id,
|
||||
'log_type': 'auto_clock_out',
|
||||
@@ -317,11 +308,7 @@ class HrAttendance(models.Model):
|
||||
'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False,
|
||||
'source': 'system',
|
||||
})
|
||||
|
||||
# Set pending reason
|
||||
employee.sudo().write({'x_fclk_pending_reason': True})
|
||||
|
||||
# Notify office user
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Auto Clock-Out: {employee.name}",
|
||||
@@ -330,16 +317,15 @@ class HrAttendance(models.Model):
|
||||
'hr.attendance',
|
||||
att.id,
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Fusion Clock: Auto clocked out %s (attendance %s)",
|
||||
employee.name, att.id,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
|
||||
att.id, str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
|
||||
att.id, str(e),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_wipe_old_photos(self):
|
||||
@@ -407,127 +393,144 @@ class HrAttendance(models.Model):
|
||||
LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
|
||||
|
||||
for emp in employees:
|
||||
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
|
||||
|
||||
if yesterday.weekday() >= 5:
|
||||
continue
|
||||
day_plan = emp._get_fclk_day_plan(yesterday)
|
||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||
continue
|
||||
# Only days the employee was actually scheduled to work
|
||||
# (posted shift or covering recurring shift) can count as an
|
||||
# absence. Off days and unscheduled days are never flagged.
|
||||
if not emp._get_fclk_day_plan(yesterday).get('scheduled'):
|
||||
continue
|
||||
|
||||
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
|
||||
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
|
||||
|
||||
holidays = self.env['resource.calendar.leaves'].sudo().search([
|
||||
('resource_id', '=', False),
|
||||
('date_from', '<=', day_end),
|
||||
('date_to', '>=', day_start),
|
||||
])
|
||||
if holidays:
|
||||
continue
|
||||
holidays = self.env['resource.calendar.leaves'].sudo().search([
|
||||
('resource_id', '=', False),
|
||||
('date_from', '<=', day_end),
|
||||
('date_to', '>=', day_start),
|
||||
])
|
||||
if holidays:
|
||||
continue
|
||||
|
||||
att_count = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', day_start),
|
||||
('check_in', '<', day_end),
|
||||
])
|
||||
if att_count > 0:
|
||||
continue
|
||||
att_count = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', day_start),
|
||||
('check_in', '<', day_end),
|
||||
])
|
||||
if att_count > 0:
|
||||
continue
|
||||
|
||||
leave = LeaveRequest.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('leave_date', '=', yesterday),
|
||||
], limit=1)
|
||||
if leave:
|
||||
continue
|
||||
leave = LeaveRequest.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('leave_date', '=', yesterday),
|
||||
], limit=1)
|
||||
if leave:
|
||||
continue
|
||||
|
||||
ActivityLog.create({
|
||||
'employee_id': emp.id,
|
||||
'log_type': 'absent',
|
||||
'log_date': day_start,
|
||||
'description': f"No attendance recorded for {yesterday}",
|
||||
'source': 'system',
|
||||
})
|
||||
ActivityLog.create({
|
||||
'employee_id': emp.id,
|
||||
'log_type': 'absent',
|
||||
'log_date': day_start,
|
||||
'description': f"No attendance recorded for {yesterday}",
|
||||
'source': 'system',
|
||||
})
|
||||
|
||||
emp.sudo().write({'x_fclk_pending_reason': True})
|
||||
emp.sudo().write({'x_fclk_pending_reason': True})
|
||||
|
||||
month_start = yesterday.replace(day=1)
|
||||
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
|
||||
absence_count = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', month_boundary_start),
|
||||
])
|
||||
month_start = yesterday.replace(day=1)
|
||||
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
|
||||
absence_count = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', month_boundary_start),
|
||||
])
|
||||
|
||||
if absence_count >= max_absences:
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Excessive Absences: {emp.name}",
|
||||
f"{emp.name} has {absence_count} absences this month "
|
||||
f"(threshold: {max_absences}). Please review.",
|
||||
'hr.employee',
|
||||
emp.id,
|
||||
)
|
||||
if absence_count >= max_absences:
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Excessive Absences: {emp.name}",
|
||||
f"{emp.name} has {absence_count} absences this month "
|
||||
f"(threshold: {max_absences}). Please review.",
|
||||
'hr.employee',
|
||||
emp.id,
|
||||
)
|
||||
|
||||
_logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday)
|
||||
_logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday)
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: absence check failed for %s: %s", emp.name, e)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_employee_reminders(self):
|
||||
"""Cron job: send clock-in/out reminders to employees."""
|
||||
"""Cron job: schedule-driven clock-in / clock-out reminders.
|
||||
|
||||
Reminders only go to employees actually SCHEDULED to work today (posted
|
||||
shift or covering recurring shift). Someone not scheduled — or whose
|
||||
shift simply hasn't started yet — is never pinged.
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_employee_notifications', 'True') != 'True':
|
||||
return
|
||||
|
||||
reminder_in_min = float(ICP.get_param('fusion_clock.reminder_before_shift_minutes', '30'))
|
||||
reminder_out_min = float(ICP.get_param('fusion_clock.reminder_before_end_minutes', '15'))
|
||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0'))
|
||||
|
||||
now = fields.Datetime.now()
|
||||
|
||||
employees = self.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
|
||||
for emp in employees:
|
||||
today = get_local_today(self.env, emp)
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
today = get_local_today(self.env, emp)
|
||||
if not emp._get_fclk_day_plan(today).get('scheduled'):
|
||||
continue
|
||||
if emp.x_fclk_last_reminder_date == today:
|
||||
continue
|
||||
|
||||
if today.weekday() >= 5:
|
||||
continue
|
||||
day_plan = emp._get_fclk_day_plan(today)
|
||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||
continue
|
||||
is_checked_in = emp.attendance_state == 'checked_in'
|
||||
|
||||
if emp.x_fclk_last_reminder_date == today:
|
||||
continue
|
||||
|
||||
scheduled_in, scheduled_out = emp._get_fclk_scheduled_times(today)
|
||||
is_checked_in = emp.attendance_state == 'checked_in'
|
||||
|
||||
# Missed clock-in reminder
|
||||
reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min)
|
||||
if not is_checked_in and now > reminder_deadline:
|
||||
today_start, _ = get_local_day_boundaries(self.env, today, emp)
|
||||
has_attendance = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
if has_attendance == 0:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-In Reminder",
|
||||
f"Hi {emp.name}, you haven't clocked in yet today. "
|
||||
f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
|
||||
# Clock-out reminder
|
||||
reminder_before_end = scheduled_out - timedelta(minutes=reminder_out_min)
|
||||
if is_checked_in and now > reminder_before_end and now < scheduled_out:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-Out Reminder",
|
||||
f"Hi {emp.name}, your shift ends at {_fclk_utc_to_local_str(scheduled_out, emp)}. "
|
||||
f"Don't forget to clock out.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
if not is_checked_in:
|
||||
# Missed clock-in — only after THIS employee's own shift
|
||||
# start (+ threshold), so a late shift is never pinged early.
|
||||
scheduled_in, _scheduled_out = emp._get_fclk_scheduled_times(today)
|
||||
if now <= scheduled_in + timedelta(minutes=reminder_in_min):
|
||||
continue
|
||||
today_start, _ = get_local_day_boundaries(self.env, today, emp)
|
||||
has_attendance = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
if has_attendance == 0:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-In Reminder",
|
||||
f"Hi {emp.name}, you haven't clocked in yet today. "
|
||||
f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
else:
|
||||
# Still-clocked-in nudge (OT-aware): only as the max-shift
|
||||
# safety cap approaches, never at the scheduled end.
|
||||
open_att = self.sudo().search([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_out', '=', False),
|
||||
], order='check_in desc', limit=1)
|
||||
if not open_att or not open_att.check_in:
|
||||
continue
|
||||
cap = open_att.check_in + timedelta(hours=max_shift)
|
||||
if cap - timedelta(minutes=reminder_out_min) < now < cap:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-Out Reminder",
|
||||
f"Hi {emp.name}, you're still clocked in. "
|
||||
f"Remember to clock out when you leave.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: reminder failed for %s: %s", emp.name, e)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_weekly_summary(self):
|
||||
|
||||
Reference in New Issue
Block a user