Files
Odoo-Modules/fusion_clock/tests/test_schedule_driven.py
gsinghpal 2aaa1a57e7 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>
2026-05-30 21:54:05 -04:00

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.")