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:
54
fusion_clock/tests/test_multishift_window.py
Normal file
54
fusion_clock/tests/test_multishift_window.py
Normal 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")
|
||||
46
fusion_clock/tests/test_open_shift.py
Normal file
46
fusion_clock/tests/test_open_shift.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestOpenShift(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.S = self.env['fusion.clock.schedule']
|
||||
|
||||
def test_open_shift_needs_no_employee_and_gets_company(self):
|
||||
sch = self.S.create({
|
||||
'is_open': True, 'schedule_date': date(2026, 6, 1),
|
||||
'start_time': 9.0, 'end_time': 17.0, 'state': 'posted'})
|
||||
self.assertFalse(sch.employee_id)
|
||||
self.assertTrue(sch.company_id, "open shift falls back to the active company")
|
||||
|
||||
def test_assigned_shift_requires_employee(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.S.create({
|
||||
'schedule_date': date(2026, 6, 1),
|
||||
'start_time': 9.0, 'end_time': 17.0})
|
||||
|
||||
def test_two_open_shifts_same_day_allowed(self):
|
||||
d = date(2026, 6, 1)
|
||||
self.S.create({'is_open': True, 'schedule_date': d, 'start_time': 8.0, 'end_time': 12.0})
|
||||
self.S.create({'is_open': True, 'schedule_date': d, 'start_time': 13.0, 'end_time': 17.0})
|
||||
self.assertEqual(
|
||||
self.S.search_count([('is_open', '=', True), ('schedule_date', '=', d)]), 2)
|
||||
|
||||
def test_split_shift_for_same_employee_allowed(self):
|
||||
emp = self.env['hr.employee'].create({'name': 'Splitter'})
|
||||
d = date(2026, 6, 1)
|
||||
self.S.create({'employee_id': emp.id, 'schedule_date': d, 'start_time': 8.0, 'end_time': 12.0})
|
||||
self.S.create({'employee_id': emp.id, 'schedule_date': d, 'start_time': 13.0, 'end_time': 17.0})
|
||||
self.assertEqual(
|
||||
self.S.search_count([('employee_id', '=', emp.id), ('schedule_date', '=', d)]), 2,
|
||||
"the hard one-shift-per-day uniqueness is gone")
|
||||
40
fusion_clock/tests/test_overnight.py
Normal file
40
fusion_clock/tests/test_overnight.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestOvernight(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Schedule = self.env['fusion.clock.schedule']
|
||||
self.emp = self.env['hr.employee'].create({'name': 'Nox'})
|
||||
|
||||
def test_overnight_hours_and_flag(self):
|
||||
sch = self.Schedule.create({
|
||||
'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
|
||||
'start_time': 22.0, 'end_time': 6.0, 'break_minutes': 30.0, 'state': 'posted'})
|
||||
self.assertTrue(sch.crosses_midnight)
|
||||
# 22:00 -> 06:00 = 8h, minus 30m break = 7.5h
|
||||
self.assertAlmostEqual(sch.planned_hours, 7.5, places=2)
|
||||
|
||||
def test_overnight_scheduled_out_is_next_day(self):
|
||||
self.Schedule.create({
|
||||
'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
|
||||
'start_time': 22.0, 'end_time': 6.0, 'break_minutes': 0.0, 'state': 'posted'})
|
||||
sin, sout = self.emp._get_fclk_scheduled_times(date(2026, 6, 1))
|
||||
self.assertGreater(sout, sin)
|
||||
self.assertAlmostEqual((sout - sin).total_seconds() / 3600.0, 8.0, places=1)
|
||||
|
||||
def test_overnight_is_allowed_by_constraint(self):
|
||||
# Must not raise now that overnight is supported.
|
||||
sch = self.Schedule.create({
|
||||
'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 2),
|
||||
'start_time': 20.0, 'end_time': 4.0, 'break_minutes': 60.0, 'state': 'posted'})
|
||||
self.assertAlmostEqual(sch.planned_hours, 7.0, places=2) # 8h - 1h break
|
||||
Reference in New Issue
Block a user