From 2aaa1a57e7e3831044b33d163631a713c6004a82 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 30 May 2026 21:54:05 -0400 Subject: [PATCH] feat(fusion_clock): schedule-driven attendance automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fusion_clock/__manifest__.py | 2 +- fusion_clock/controllers/clock_api.py | 10 +- fusion_clock/controllers/clock_kiosk.py | 4 +- fusion_clock/controllers/clock_nfc_kiosk.py | 4 +- fusion_clock/controllers/shift_planner.py | 35 +++ .../migrations/19.0.3.12.1/post-migrate.py | 24 ++ fusion_clock/models/clock_schedule.py | 72 ++++- fusion_clock/models/clock_shift.py | 22 ++ fusion_clock/models/hr_attendance.py | 267 +++++++++--------- fusion_clock/models/hr_employee.py | 35 ++- fusion_clock/models/res_config_settings.py | 7 +- .../src/js/fusion_clock_shift_planner.js | 38 +++ .../src/xml/fusion_clock_shift_planner.xml | 6 +- fusion_clock/tests/__init__.py | 1 + fusion_clock/tests/test_schedule_driven.py | 170 +++++++++++ fusion_clock/tests/test_shift_planner.py | 5 +- fusion_clock/views/clock_schedule_views.xml | 6 + fusion_clock/views/clock_shift_views.xml | 9 + 18 files changed, 557 insertions(+), 160 deletions(-) create mode 100644 fusion_clock/migrations/19.0.3.12.1/post-migrate.py create mode 100644 fusion_clock/tests/test_schedule_driven.py diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index dcac26c4..a5042c61 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -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': """ diff --git a/fusion_clock/controllers/clock_api.py b/fusion_clock/controllers/clock_api.py index fc2efe57..244af06a 100644 --- a/fusion_clock/controllers/clock_api.py +++ b/fusion_clock/controllers/clock_api.py @@ -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, ) diff --git a/fusion_clock/controllers/clock_kiosk.py b/fusion_clock/controllers/clock_kiosk.py index 55448d0d..3c19c04c 100644 --- a/fusion_clock/controllers/clock_kiosk.py +++ b/fusion_clock/controllers/clock_kiosk.py @@ -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', diff --git a/fusion_clock/controllers/clock_nfc_kiosk.py b/fusion_clock/controllers/clock_nfc_kiosk.py index 86731fad..40865e7e 100644 --- a/fusion_clock/controllers/clock_nfc_kiosk.py +++ b/fusion_clock/controllers/clock_nfc_kiosk.py @@ -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', diff --git a/fusion_clock/controllers/shift_planner.py b/fusion_clock/controllers/shift_planner.py index e10f2505..6f3f6a3b 100644 --- a/fusion_clock/controllers/shift_planner.py +++ b/fusion_clock/controllers/shift_planner.py @@ -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(): diff --git a/fusion_clock/migrations/19.0.3.12.1/post-migrate.py b/fusion_clock/migrations/19.0.3.12.1/post-migrate.py new file mode 100644 index 00000000..a7a4b468 --- /dev/null +++ b/fusion_clock/migrations/19.0.3.12.1/post-migrate.py @@ -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' + """) diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index 0c021653..e734cf6f 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -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 {employee.name}, your shifts for ' + f'{week_start.strftime("%b %d")} - {week_end.strftime("%b %d, %Y")} ' + f'have been posted.' + ), + sections=[('This Week', rows)], + note='Log in to your portal 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' diff --git a/fusion_clock/models/clock_shift.py b/fusion_clock/models/clock_shift.py index f788aeed..c86ad164 100644 --- a/fusion_clock/models/clock_shift.py +++ b/fusion_clock/models/clock_shift.py @@ -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).""" diff --git a/fusion_clock/models/hr_attendance.py b/fusion_clock/models/hr_attendance.py index 0f953cbd..9a551b67 100644 --- a/fusion_clock/models/hr_attendance.py +++ b/fusion_clock/models/hr_attendance.py @@ -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): diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py index fc808c9a..0b55524f 100644 --- a/fusion_clock/models/hr_employee.py +++ b/fusion_clock/models/hr_employee.py @@ -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): diff --git a/fusion_clock/models/res_config_settings.py b/fusion_clock/models/res_config_settings.py index df6b9fb5..308063f5 100644 --- a/fusion_clock/models/res_config_settings.py +++ b/fusion_clock/models/res_config_settings.py @@ -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', diff --git a/fusion_clock/static/src/js/fusion_clock_shift_planner.js b/fusion_clock/static/src/js/fusion_clock_shift_planner.js index dae74005..f725dbe6 100644 --- a/fusion_clock/static/src/js/fusion_clock_shift_planner.js +++ b/fusion_clock/static/src/js/fusion_clock_shift_planner.js @@ -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; diff --git a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml index 1e34684b..e3a96537 100644 --- a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml +++ b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml @@ -28,6 +28,10 @@ Save () + @@ -100,7 +104,7 @@ - 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.") diff --git a/fusion_clock/tests/test_shift_planner.py b/fusion_clock/tests/test_shift_planner.py index 1a86aaaf..60b1299e 100644 --- a/fusion_clock/tests/test_shift_planner.py +++ b/fusion_clock/tests/test_shift_planner.py @@ -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) diff --git a/fusion_clock/views/clock_schedule_views.xml b/fusion_clock/views/clock_schedule_views.xml index f75da224..585c3ccd 100644 --- a/fusion_clock/views/clock_schedule_views.xml +++ b/fusion_clock/views/clock_schedule_views.xml @@ -20,6 +20,7 @@ + @@ -47,6 +48,8 @@ + + @@ -65,6 +68,9 @@ + + + diff --git a/fusion_clock/views/clock_shift_views.xml b/fusion_clock/views/clock_shift_views.xml index aadab6e8..2e86984f 100644 --- a/fusion_clock/views/clock_shift_views.xml +++ b/fusion_clock/views/clock_shift_views.xml @@ -39,6 +39,15 @@ + + + + + + + + +