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>
171 lines
7.0 KiB
Python
171 lines
7.0 KiB
Python
# -*- 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.")
|