feat(fusion_clock): schedule parity — overnight, split shifts, open shifts [B1-B3]

is_open + crosses_midnight fields; employee_id optional (open shifts);
company_id computed w/ env.company fallback; drop hard one-per-day UNIQUE
(allow split + open). Overnight math in planned_hours/_check_schedule_times/
scheduled_times. _get_fclk_day_plan resolves multiple posted rows into ONE
work-window so penalties/overtime/absence stay correct. Migration drops the
old constraint defensively. Tests for overnight, window, open shifts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-04 21:04:58 -04:00
parent d35d5f4b34
commit 68aaa132ee
6 changed files with 252 additions and 23 deletions

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# The per-day model now allows split shifts. The attendance contract
# (_get_fclk_day_plan) MUST still hand the rest of the pipeline a single
# work-window so penalties / overtime / absence stay correct.
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestMultiShiftWindow(TransactionCase):
def setUp(self):
super().setUp()
self.S = self.env['fusion.clock.schedule']
self.emp = self.env['hr.employee'].create({'name': 'Sam Split'})
def test_split_shift_resolves_to_single_window(self):
self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
'start_time': 8.0, 'end_time': 12.0, 'break_minutes': 0.0, 'state': 'posted'})
self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
'start_time': 13.0, 'end_time': 17.0, 'break_minutes': 0.0, 'state': 'posted'})
plan = self.emp._get_fclk_day_plan(date(2026, 6, 1))
self.assertTrue(plan['scheduled'])
self.assertEqual(plan['start_time'], 8.0, "window starts at earliest shift")
self.assertEqual(plan['end_time'], 17.0, "window ends at latest shift")
self.assertAlmostEqual(plan['hours'], 8.0, places=2, msg="worked hours = sum of shifts")
def test_draft_shift_excluded_from_window(self):
self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
'start_time': 8.0, 'end_time': 12.0, 'break_minutes': 0.0, 'state': 'posted'})
self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
'start_time': 13.0, 'end_time': 17.0, 'break_minutes': 0.0, 'state': 'draft'})
plan = self.emp._get_fclk_day_plan(date(2026, 6, 1))
self.assertEqual(plan['end_time'], 12.0, "draft shift must not widen the window")
def test_all_off_rows_resolve_to_off(self):
self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
'is_off': True, 'state': 'posted'})
plan = self.emp._get_fclk_day_plan(date(2026, 6, 1))
self.assertTrue(plan['is_off'])
self.assertFalse(plan['scheduled'])
def test_open_shift_does_not_feed_employee_plan(self):
# An open shift (no employee) on the same day must not affect anyone.
self.S.create({'is_open': True, 'schedule_date': date(2026, 6, 1),
'start_time': 9.0, 'end_time': 17.0, 'state': 'posted'})
plan = self.emp._get_fclk_day_plan(date(2026, 6, 1))
self.assertFalse(plan['scheduled'], "open shift is not assigned to this employee")