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

@@ -2,11 +2,15 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import re
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class FusionClockSchedule(models.Model):
_name = 'fusion.clock.schedule'
@@ -72,6 +76,15 @@ class FusionClockSchedule(models.Model):
compute='_compute_display_name',
store=True,
)
state = fields.Selection(
[('draft', 'Draft'), ('posted', 'Posted')],
string='Status',
default='draft',
index=True,
help="Only POSTED entries drive reminders, absence checks and penalties. "
"Draft entries are ignored by automation until the team lead posts them.",
)
posted_date = fields.Datetime(string='Posted On', readonly=True)
_employee_date_unique = models.Constraint(
'UNIQUE(employee_id, schedule_date)',
@@ -288,6 +301,10 @@ class FusionClockSchedule(models.Model):
'end_time': parsed.get('end_time') or 0.0,
'break_minutes': parsed.get('break_minutes') or 0.0,
'note': payload.get('note') or False,
# Any planner edit returns the cell to draft; it must be re-posted
# before automation acts on it.
'state': 'draft',
'posted_date': False,
}
if existing:
existing.write(vals)
@@ -321,6 +338,7 @@ class FusionClockSchedule(models.Model):
return {
'schedule_id': schedule.id,
'source': 'schedule',
'state': schedule.state,
'input': schedule.fclk_display_value(),
'label': schedule.fclk_display_value(),
'is_off': schedule.is_off,
@@ -336,7 +354,8 @@ class FusionClockSchedule(models.Model):
plan = employee._get_fclk_day_plan(date_obj)
return {
'schedule_id': False,
'source': plan.get('source') or 'fallback',
'source': plan.get('source') or 'none',
'state': False,
'input': plan.get('label') or '',
'label': plan.get('label') or '',
'is_off': plan.get('is_off', False),
@@ -349,6 +368,57 @@ class FusionClockSchedule(models.Model):
'note': '',
}
@api.model
def fclk_email_posted_week(self, employee, week_start, week_end):
"""Email one employee a summary of their POSTED shifts for the week."""
employee = employee.sudo()
if not employee.work_email:
return False
from .hr_attendance import _fclk_email_wrap
entries = self.sudo().search([
('employee_id', '=', employee.id),
('schedule_date', '>=', week_start),
('schedule_date', '<=', week_end),
('state', '=', 'posted'),
])
by_date = {entry.schedule_date: entry for entry in entries}
rows = []
day = week_start
while day <= week_end:
entry = by_date.get(day)
rows.append((
day.strftime('%a %b %d'),
entry.fclk_display_value() if entry else 'Not scheduled',
))
day += timedelta(days=1)
company = employee.company_id or self.env.company
body = _fclk_email_wrap(
company_name=company.name or '',
title='Your Posted Schedule',
summary=(
f'Hello <strong>{employee.name}</strong>, your shifts for '
f'<strong>{week_start.strftime("%b %d")} - {week_end.strftime("%b %d, %Y")}</strong> '
f'have been posted.'
),
sections=[('This Week', rows)],
note='Log in to <a href="/my/clock" style="color:#10B981;">your portal</a> for details.',
)
try:
mail = self.env['mail.mail'].sudo().create({
'subject': f'Your schedule: {week_start.strftime("%b %d")} - {week_end.strftime("%b %d")}',
'email_from': company.email or '',
'email_to': employee.work_email,
'body_html': body,
'auto_delete': True,
})
mail.send()
return True
except Exception as exc:
_logger.error(
"Fusion Clock: failed to email posted schedule to %s: %s", employee.name, exc
)
return False
class FusionClockScheduleAudit(models.Model):
_name = 'fusion.clock.schedule.audit'

View File

@@ -42,6 +42,17 @@ class FusionClockShift(models.Model):
)
active = fields.Boolean(default=True)
color = fields.Char(string='Color', default='#3B82F6')
# Weekday pattern — which days this recurring shift applies as the baseline
# when there is no posted planner entry for the day. Default Mon-Fri.
day_mon = fields.Boolean(string='Mon', default=True)
day_tue = fields.Boolean(string='Tue', default=True)
day_wed = fields.Boolean(string='Wed', default=True)
day_thu = fields.Boolean(string='Thu', default=True)
day_fri = fields.Boolean(string='Fri', default=True)
day_sat = fields.Boolean(string='Sat', default=False)
day_sun = fields.Boolean(string='Sun', default=False)
employee_ids = fields.One2many(
'hr.employee',
'x_fclk_shift_id',
@@ -56,6 +67,17 @@ class FusionClockShift(models.Model):
for rec in self:
rec.employee_count = len(rec.employee_ids)
def covers_weekday(self, date):
"""Return True if this recurring shift applies on the given date's
weekday (Mon=0 .. Sun=6)."""
self.ensure_one()
date_obj = fields.Date.to_date(date)
if not date_obj:
return False
days = (self.day_mon, self.day_tue, self.day_wed, self.day_thu,
self.day_fri, self.day_sat, self.day_sun)
return bool(days[date_obj.weekday()])
@property
def scheduled_hours(self):
"""Return the scheduled work hours for this shift (excluding break)."""

View File

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

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

View File

@@ -56,8 +56,11 @@ class ResConfigSettings(models.TransientModel):
fclk_max_shift_hours = fields.Float(
string='Max Shift Length (hours)',
config_parameter='fusion_clock.max_shift_hours',
default=12.0,
help="Maximum shift length before auto clock-out (safety net).",
default=16.0,
help="Safety-net cap: an attendance left open longer than this is "
"auto-clocked-out (assumed forgot-to-clock-out). Overtime up to this "
"cap is never cut off, so set it comfortably above your longest real "
"shift + overtime.",
)
fclk_enable_penalties = fields.Boolean(
string='Enable Penalty Tracking',