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

@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
'version': '19.0.3.12.0',
'version': '19.0.3.12.1',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """

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

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Backfill schedule state on upgrade to 19.0.3.12.0.
Before this version there was no draft/posted concept — every dated
``fusion.clock.schedule`` entry was authoritative and drove reminders, absence
checks and penalties. The new ``state`` field defaults to 'draft', and the
schedule resolver now only acts on POSTED entries. Without this backfill, every
pre-existing schedule entry would silently become draft on upgrade and stop
driving automation. Mark all pre-existing entries 'posted' to preserve prior
behaviour. (Runs only on upgrade, never on a fresh install.)
"""
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE fusion_clock_schedule
SET state = 'posted',
posted_date = COALESCE(posted_date, now())
WHERE state IS NULL OR state = 'draft'
""")

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',

View File

@@ -30,6 +30,7 @@ export class FusionClockShiftPlanner extends Component {
error: "",
dirtyCount: 0,
invalidCount: 0,
draftCount: 0,
collapsed: {},
editor: {
open: false,
@@ -89,6 +90,15 @@ export class FusionClockShiftPlanner extends Component {
this.state.shifts = data.shifts || [];
this.state.dirtyCount = 0;
this.state.invalidCount = 0;
let draft = 0;
for (const emp of this.state.employees) {
for (const key in emp.cells || {}) {
if (emp.cells[key] && emp.cells[key].state === "draft") {
draft += 1;
}
}
}
this.state.draftCount = draft;
this.state.error = "";
this.closeCellEditor();
}
@@ -194,6 +204,34 @@ export class FusionClockShiftPlanner extends Component {
}
}
async postWeek() {
if (this.state.dirtyCount) {
this.notification.add("Save your changes before posting.", { type: "warning" });
return;
}
if (!window.confirm("Post this week's schedule? Employees will be emailed their shifts, and reminders/absence checks will start using it.")) {
return;
}
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/post_week", {
week_start: this.state.weekStart,
});
if (result.error) {
this.notification.add(result.error, { type: "danger" });
} else {
this._applyData(result.data);
this.notification.add(
`Posted ${result.posted || 0} shift(s); notified ${result.notified || 0} employee(s).`,
{ type: "success" },
);
}
} catch (error) {
this.notification.add(error.message || "Could not post the schedule.", { type: "danger" });
}
this.state.saving = false;
}
openCellEditor(employee, day, ev) {
if (this.state.loading || this.state.saving) {
return;

View File

@@ -28,6 +28,10 @@
Save
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
</button>
<button class="btn btn-success" t-on-click="() => this.postWeek()" t-att-disabled="state.loading or state.saving or state.dirtyCount" t-att-title="state.dirtyCount ? 'Save your changes before posting' : 'Publish this week and email employees their shifts'">
<i class="fa fa-paper-plane me-1"/> Post Schedule
<t t-if="state.draftCount">(<t t-esc="state.draftCount"/> draft)</t>
</button>
</div>
</div>
@@ -100,7 +104,7 @@
</td>
<t t-foreach="state.days" t-as="day" t-key="employee.id + '_' + day.date">
<t t-set="cell" t-value="employee.cells[day.date]"/>
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source === 'fallback' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source !== 'schedule' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
t-on-click="(ev) => this.openCellEditor(employee, day, ev)">
<input class="fclk-planner__shift-input"
t-att-value="cell.input"

View File

@@ -4,3 +4,4 @@ from . import test_nfc_models
from . import test_clock_nfc_kiosk
from . import test_shift_planner
from . import test_photo_retention
from . import test_schedule_driven

View File

@@ -0,0 +1,170 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import date, timedelta
from odoo import fields
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
try:
from freezegun import freeze_time
except ImportError: # freezegun may not be present on the runtime image
freeze_time = None
# 2026-06-01 is a Monday, 2026-06-02 a Tuesday, 2026-06-06 a Saturday.
MON = date(2026, 6, 1)
TUE = date(2026, 6, 2)
SAT = date(2026, 6, 6)
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestScheduleDriven(TransactionCase):
"""Attendance automation must be driven by the employee's real schedule
(posted planner entry -> recurring shift), never the global default."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Employee = cls.env['hr.employee']
cls.Schedule = cls.env['fusion.clock.schedule']
cls.Shift = cls.env['fusion.clock.shift']
cls.Attendance = cls.env['hr.attendance']
cls.Log = cls.env['fusion.clock.activity.log']
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.emp = cls.Employee.create({
'name': 'Schedule Test',
'x_fclk_enable_clock': True,
'work_email': 'sched.test@example.com',
'tz': 'UTC',
})
# Mon-Fri 09:00-17:00 recurring baseline (assigned per-test where needed).
cls.shift = cls.Shift.create({
'name': 'Test Day Shift',
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
'day_mon': True, 'day_tue': True, 'day_wed': True,
'day_thu': True, 'day_fri': True, 'day_sat': False, 'day_sun': False,
})
cls.ICP.set_param('fusion_clock.enable_employee_notifications', 'True')
cls.ICP.set_param('fusion_clock.max_shift_hours', '16')
def _post(self, day, **vals):
v = {
'employee_id': self.emp.id, 'schedule_date': day, 'state': 'posted',
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
}
v.update(vals)
return self.Schedule.create(v)
# ----- resolver matrix (time-independent) -----
def test_posted_working_is_scheduled(self):
self._post(MON)
plan = self.emp._get_fclk_day_plan(MON)
self.assertTrue(plan['scheduled'])
self.assertEqual(plan['source'], 'schedule')
def test_posted_off_is_not_scheduled(self):
self._post(MON, is_off=True)
plan = self.emp._get_fclk_day_plan(MON)
self.assertFalse(plan['scheduled'])
self.assertTrue(plan['is_off'])
self.assertEqual(plan['source'], 'schedule')
def test_draft_entry_is_ignored(self):
self.emp.x_fclk_shift_id = self.shift
self._post(MON, state='draft') # draft on a Monday the shift covers
plan = self.emp._get_fclk_day_plan(MON)
# Draft ignored -> falls through to the recurring baseline.
self.assertTrue(plan['scheduled'])
self.assertEqual(plan['source'], 'shift')
def test_recurring_shift_covers_weekday(self):
self.emp.x_fclk_shift_id = self.shift
plan = self.emp._get_fclk_day_plan(MON)
self.assertTrue(plan['scheduled'])
self.assertEqual(plan['source'], 'shift')
def test_recurring_shift_skips_uncovered_weekday(self):
self.emp.x_fclk_shift_id = self.shift
plan = self.emp._get_fclk_day_plan(SAT) # Saturday not in the pattern
self.assertFalse(plan['scheduled'])
self.assertEqual(plan['source'], 'none')
def test_nothing_scheduled(self):
plan = self.emp._get_fclk_day_plan(MON) # no posted entry, no shift
self.assertFalse(plan['scheduled'])
self.assertEqual(plan['source'], 'none')
self.assertEqual(plan['label'], '') # portal card -> "Not scheduled"
def test_planner_edit_resets_to_draft(self):
posted = self._post(MON)
self.assertEqual(posted.state, 'posted')
# Re-applying the cell via the planner path must drop it back to draft.
self.Schedule.fclk_apply_planner_cell(self.emp, MON, {'input': '8:00 - 16:00'})
self.assertEqual(posted.state, 'draft')
# ----- reminder cron -----
def test_no_reminder_when_not_scheduled(self):
# Not scheduled today -> the cron must stay completely silent.
self.Attendance._cron_fusion_employee_reminders()
self.assertNotEqual(self.emp.x_fclk_last_reminder_date, fields.Date.context_today(self.emp))
def test_reminder_fires_for_scheduled_late(self):
if freeze_time is None:
self.skipTest("freezegun not available")
with freeze_time("2026-06-01 12:00:00"): # Monday noon, shift started 09:00
self._post(MON, start_time=9.0)
self.Attendance._cron_fusion_employee_reminders()
self.assertEqual(self.emp.x_fclk_last_reminder_date, MON)
def test_no_early_reminder_for_late_shift(self):
if freeze_time is None:
self.skipTest("freezegun not available")
with freeze_time("2026-06-01 12:00:00"): # noon, but shift starts 14:00
self._post(MON, start_time=14.0, end_time=22.0)
self.Attendance._cron_fusion_employee_reminders()
self.assertFalse(self.emp.x_fclk_last_reminder_date)
# ----- absence cron -----
def test_absence_for_scheduled_noshow(self):
if freeze_time is None:
self.skipTest("freezegun not available")
with freeze_time("2026-06-02 09:00:00"): # Tuesday -> yesterday = Monday
self._post(MON) # scheduled Monday, no attendance
self.Attendance._cron_fusion_check_absences()
self.assertEqual(self.Log.search_count([
('employee_id', '=', self.emp.id), ('log_type', '=', 'absent'),
]), 1)
def test_no_absence_when_not_scheduled(self):
if freeze_time is None:
self.skipTest("freezegun not available")
with freeze_time("2026-06-02 09:00:00"): # yesterday Monday, nothing scheduled
self.Attendance._cron_fusion_check_absences()
self.assertEqual(self.Log.search_count([
('employee_id', '=', self.emp.id), ('log_type', '=', 'absent'),
]), 0)
# ----- auto clock-out (OT-aware safety cap) -----
def test_auto_clockout_only_past_cap(self):
now = fields.Datetime.now()
recent = self.Attendance.create({
'employee_id': self.emp.id,
'check_in': now - timedelta(hours=2),
})
emp2 = self.Employee.create({
'name': 'Schedule Test 2', 'x_fclk_enable_clock': True, 'tz': 'UTC',
})
stale = self.Attendance.create({
'employee_id': emp2.id,
'check_in': now - timedelta(hours=17),
})
self.Attendance._cron_fusion_auto_clock_out()
self.assertFalse(recent.check_out, "Under-cap shift must stay open (overtime).")
self.assertTrue(stale.check_out, "Over-cap shift must be auto-closed.")

View File

@@ -102,15 +102,18 @@ class TestShiftPlannerModels(TransactionCase):
'start_time': 10.0,
'end_time': 18.0,
'break_minutes': 60,
'state': 'posted',
})
planned = self.employee._get_fclk_day_plan(planned_date)
fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1))
# Posted dated entry wins; the next day (no entry) falls back to the
# employee's recurring shift, which now reports source 'shift'.
self.assertEqual(planned['source'], 'schedule')
self.assertEqual(planned['start_time'], 10.0)
self.assertEqual(planned['hours'], 7.0)
self.assertEqual(fallback['source'], 'fallback')
self.assertEqual(fallback['source'], 'shift')
self.assertEqual(fallback['start_time'], 8.0)

View File

@@ -20,6 +20,7 @@
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
<field name="planned_hours"/>
<field name="state" widget="badge" decoration-success="state == 'posted'" decoration-warning="state == 'draft'"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
@@ -47,6 +48,8 @@
</group>
<group>
<field name="note"/>
<field name="state"/>
<field name="posted_date" readonly="1"/>
<field name="department_id" readonly="1"/>
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
</group>
@@ -65,6 +68,9 @@
<field name="schedule_date"/>
<filter name="off" string="OFF" domain="[('is_off', '=', True)]"/>
<filter name="working" string="Working" domain="[('is_off', '=', False)]"/>
<separator/>
<filter name="posted" string="Posted" domain="[('state', '=', 'posted')]"/>
<filter name="draft" string="Draft" domain="[('state', '=', 'draft')]"/>
<filter name="group_department" string="Department" context="{'group_by': 'department_id'}"/>
<filter name="group_date" string="Date" context="{'group_by': 'schedule_date'}"/>
</search>

View File

@@ -39,6 +39,15 @@
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group string="Working Days" col="7">
<field name="day_mon"/>
<field name="day_tue"/>
<field name="day_wed"/>
<field name="day_thu"/>
<field name="day_fri"/>
<field name="day_sat"/>
<field name="day_sun"/>
</group>
<group string="Assigned Employees">
<field name="employee_ids" nolabel="1" colspan="2">
<list>