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:
@@ -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'
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user