From 68aaa132ee9b7abf1992036b3c867f7ae52bdede Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 21:04:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(fusion=5Fclock):=20schedule=20parity=20?= =?UTF-8?q?=E2=80=94=20overnight,=20split=20shifts,=20open=20shifts=20[B1-?= =?UTF-8?q?B3]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../migrations/19.0.5.0.0/post-migrate.py | 8 +++ fusion_clock/models/clock_schedule.py | 63 ++++++++++++++---- fusion_clock/models/hr_employee.py | 64 +++++++++++++++---- fusion_clock/tests/test_multishift_window.py | 54 ++++++++++++++++ fusion_clock/tests/test_open_shift.py | 46 +++++++++++++ fusion_clock/tests/test_overnight.py | 40 ++++++++++++ 6 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 fusion_clock/tests/test_multishift_window.py create mode 100644 fusion_clock/tests/test_open_shift.py create mode 100644 fusion_clock/tests/test_overnight.py diff --git a/fusion_clock/migrations/19.0.5.0.0/post-migrate.py b/fusion_clock/migrations/19.0.5.0.0/post-migrate.py index c5005e5a..9a9c9e2e 100644 --- a/fusion_clock/migrations/19.0.5.0.0/post-migrate.py +++ b/fusion_clock/migrations/19.0.5.0.0/post-migrate.py @@ -24,6 +24,14 @@ def migrate(cr, version): lives in fusion.clock.schedule._fclk_port_planning_data so it can be unit tested on an Enterprise clone where planning is installed.""" env = api.Environment(cr, SUPERUSER_ID, {}) + + # Phase B drops the hard one-shift-per-day uniqueness so split/open shifts + # are allowed. Odoo drops removed declarative constraints on upgrade, but be + # explicit so the upgrade can never leave the old constraint behind. + cr.execute( + "ALTER TABLE fusion_clock_schedule " + "DROP CONSTRAINT IF EXISTS fusion_clock_schedule_employee_date_unique") + ICP = env['ir.config_parameter'].sudo() if ICP.get_param(_MARKER): _logger.info("Fusion Clock: planning data already migrated; skipping.") diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index 5e15e7c0..73dd1069 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -21,10 +21,16 @@ class FusionClockSchedule(models.Model): employee_id = fields.Many2one( 'hr.employee', string='Employee', - required=True, + required=False, # open (unassigned) shifts have no employee until claimed index=True, ondelete='cascade', ) + is_open = fields.Boolean( + string='Open Shift', + default=False, + index=True, + help="An unassigned shift any eligible employee can claim from the portal.", + ) schedule_date = fields.Date( string='Date', required=True, @@ -57,6 +63,13 @@ class FusionClockSchedule(models.Model): compute='_compute_planned_hours', store=True, ) + crosses_midnight = fields.Boolean( + string='Overnight', + compute='_compute_planned_hours', + store=True, + help="Set automatically when the shift ends on the next day " + "(end time on or before start time).", + ) note = fields.Char(string='Note') role_id = fields.Many2one( 'fusion.clock.role', @@ -75,9 +88,10 @@ class FusionClockSchedule(models.Model): company_id = fields.Many2one( 'res.company', string='Company', - related='employee_id.company_id', + compute='_compute_fclk_company', store=True, - readonly=True, + readonly=False, + index=True, ) department_id = fields.Many2one( 'hr.department', @@ -100,18 +114,41 @@ class FusionClockSchedule(models.Model): ) posted_date = fields.Datetime(string='Posted On', readonly=True) - _employee_date_unique = models.Constraint( - 'UNIQUE(employee_id, schedule_date)', - 'Only one shift schedule is allowed per employee per day.', - ) + # No hard UNIQUE(employee, date): the per-day model now allows split shifts + # and open (unassigned) shifts. The shift planner still manages one cell per + # day in place; the attendance contract (_get_fclk_day_plan) resolves + # multiple posted rows into a single work-window. + + @api.depends('employee_id') + def _compute_fclk_company(self): + for rec in self: + if rec.employee_id: + rec.company_id = rec.employee_id.company_id + elif not rec.company_id: + rec.company_id = self.env.company + + @api.constrains('employee_id', 'is_open') + def _check_employee_or_open(self): + for rec in self: + if not rec.employee_id and not rec.is_open: + raise ValidationError( + _("A shift must have an employee unless it is an open shift.")) @api.depends('is_off', 'start_time', 'end_time', 'break_minutes') def _compute_planned_hours(self): for rec in self: + rec.crosses_midnight = False if rec.is_off: rec.planned_hours = 0.0 continue - raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0) + start = rec.start_time or 0.0 + end = rec.end_time or 0.0 + if end <= start: + # Overnight: the shift ends on the following day. + rec.crosses_midnight = True + raw_hours = (24.0 - start) + end + else: + raw_hours = end - start rec.planned_hours = round(max(raw_hours - ((rec.break_minutes or 0.0) / 60.0), 0.0), 2) @api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time') @@ -130,11 +167,13 @@ class FusionClockSchedule(models.Model): continue if rec.start_time < 0 or rec.start_time >= 24: raise ValidationError(_("Start time must be between 00:00 and 23:59.")) - if rec.end_time <= 0 or rec.end_time > 24: - raise ValidationError(_("End time must be between 00:01 and 24:00.")) + if rec.end_time < 0 or rec.end_time > 24: + raise ValidationError(_("End time must be between 00:00 and 24:00.")) + # Overnight shifts (end on/before start) are allowed and span midnight. if rec.end_time <= rec.start_time: - raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet.")) - shift_minutes = (rec.end_time - rec.start_time) * 60.0 + shift_minutes = ((24.0 - rec.start_time) + rec.end_time) * 60.0 + else: + shift_minutes = (rec.end_time - rec.start_time) * 60.0 if rec.break_minutes >= shift_minutes: raise ValidationError(_("Break duration must be shorter than the scheduled shift.")) diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py index 51c7b833..63e5d70d 100644 --- a/fusion_clock/models/hr_employee.py +++ b/fusion_clock/models/hr_employee.py @@ -200,23 +200,60 @@ class HrEmployee(models.Model): """ self.ensure_one() Schedule = self.env['fusion.clock.schedule'].sudo() - schedule = self._get_fclk_schedule_for_date(date) - if schedule and schedule.state == 'posted': + date_obj = fields.Date.to_date(date) + + # All POSTED, assigned (non-open) rows for the day. The model now allows + # split shifts, so resolve several rows into one work-window that the + # whole attendance pipeline keys off — earliest start to latest end. + posted = Schedule.search([ + ('employee_id', '=', self.id), + ('schedule_date', '=', date_obj), + ('state', '=', 'posted'), + ('is_open', '=', False), + ]) if date_obj else Schedule.browse() + working = posted.filtered(lambda s: not s.is_off) + if working: + start = min(working.mapped('start_time')) + + def _eff_end(s): + return (s.end_time + 24.0) if s.crosses_midnight else s.end_time + win_end_eff = max(_eff_end(s) for s in working) + crosses = win_end_eff > 24.0 + end = win_end_eff - 24.0 if crosses else win_end_eff return { 'source': 'schedule', - 'schedule_id': schedule.id, - 'scheduled': not schedule.is_off, - 'is_off': schedule.is_off, - 'start_time': schedule.start_time, - 'end_time': schedule.end_time, - 'break_minutes': schedule.break_minutes, - 'hours': schedule.planned_hours, - 'label': schedule.fclk_display_value(), + 'schedule_id': working[0].id, + 'scheduled': True, + 'is_off': False, + 'start_time': start, + 'end_time': end, + 'break_minutes': sum(working.mapped('break_minutes')), + 'hours': sum(working.mapped('planned_hours')), + 'crosses_midnight': crosses, + 'label': '%s - %s' % ( + Schedule.fclk_float_to_display(start), + Schedule.fclk_float_to_display(end), + ), + } + if posted: # every posted row for the day is OFF + return { + 'source': 'schedule', + 'schedule_id': posted[0].id, + 'scheduled': False, + 'is_off': True, + 'start_time': 0.0, + 'end_time': 0.0, + 'break_minutes': 0.0, + 'hours': 0.0, + 'crosses_midnight': False, + 'label': 'OFF', } shift = self.x_fclk_shift_id if shift and shift.covers_weekday(date): - hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0) + crosses = shift.end_time <= shift.start_time + raw = ((24.0 - shift.start_time) + shift.end_time) if crosses else (shift.end_time - shift.start_time) + hours = max(raw - (shift.break_minutes / 60.0), 0.0) return { 'source': 'shift', 'schedule_id': False, @@ -226,6 +263,7 @@ class HrEmployee(models.Model): 'end_time': shift.end_time, 'break_minutes': shift.break_minutes, 'hours': hours, + 'crosses_midnight': crosses, 'label': '%s - %s' % ( Schedule.fclk_float_to_display(shift.start_time), Schedule.fclk_float_to_display(shift.end_time), @@ -242,6 +280,7 @@ class HrEmployee(models.Model): 'schedule_id': False, 'scheduled': False, 'is_off': False, + 'crosses_midnight': False, 'start_time': start_time, 'end_time': end_time, 'break_minutes': break_minutes, @@ -320,6 +359,9 @@ class HrEmployee(models.Model): local_out = local_tz.localize( datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m)) ) + # Overnight shift: scheduled clock-out lands on the following day. + if plan.get('crosses_midnight'): + local_out = local_out + timedelta(days=1) scheduled_in = local_in.astimezone(utc).replace(tzinfo=None) scheduled_out = local_out.astimezone(utc).replace(tzinfo=None) diff --git a/fusion_clock/tests/test_multishift_window.py b/fusion_clock/tests/test_multishift_window.py new file mode 100644 index 00000000..e774f28d --- /dev/null +++ b/fusion_clock/tests/test_multishift_window.py @@ -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") diff --git a/fusion_clock/tests/test_open_shift.py b/fusion_clock/tests/test_open_shift.py new file mode 100644 index 00000000..7c9b05dd --- /dev/null +++ b/fusion_clock/tests/test_open_shift.py @@ -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") diff --git a/fusion_clock/tests/test_overnight.py b/fusion_clock/tests/test_overnight.py new file mode 100644 index 00000000..2827b9fb --- /dev/null +++ b/fusion_clock/tests/test_overnight.py @@ -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