-
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 @@
+
+
+
+
+
+
+
+
+
|