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:
@@ -24,6 +24,14 @@ def migrate(cr, version):
|
|||||||
lives in fusion.clock.schedule._fclk_port_planning_data so it can be unit
|
lives in fusion.clock.schedule._fclk_port_planning_data so it can be unit
|
||||||
tested on an Enterprise clone where planning is installed."""
|
tested on an Enterprise clone where planning is installed."""
|
||||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
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()
|
ICP = env['ir.config_parameter'].sudo()
|
||||||
if ICP.get_param(_MARKER):
|
if ICP.get_param(_MARKER):
|
||||||
_logger.info("Fusion Clock: planning data already migrated; skipping.")
|
_logger.info("Fusion Clock: planning data already migrated; skipping.")
|
||||||
|
|||||||
@@ -21,10 +21,16 @@ class FusionClockSchedule(models.Model):
|
|||||||
employee_id = fields.Many2one(
|
employee_id = fields.Many2one(
|
||||||
'hr.employee',
|
'hr.employee',
|
||||||
string='Employee',
|
string='Employee',
|
||||||
required=True,
|
required=False, # open (unassigned) shifts have no employee until claimed
|
||||||
index=True,
|
index=True,
|
||||||
ondelete='cascade',
|
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(
|
schedule_date = fields.Date(
|
||||||
string='Date',
|
string='Date',
|
||||||
required=True,
|
required=True,
|
||||||
@@ -57,6 +63,13 @@ class FusionClockSchedule(models.Model):
|
|||||||
compute='_compute_planned_hours',
|
compute='_compute_planned_hours',
|
||||||
store=True,
|
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')
|
note = fields.Char(string='Note')
|
||||||
role_id = fields.Many2one(
|
role_id = fields.Many2one(
|
||||||
'fusion.clock.role',
|
'fusion.clock.role',
|
||||||
@@ -75,9 +88,10 @@ class FusionClockSchedule(models.Model):
|
|||||||
company_id = fields.Many2one(
|
company_id = fields.Many2one(
|
||||||
'res.company',
|
'res.company',
|
||||||
string='Company',
|
string='Company',
|
||||||
related='employee_id.company_id',
|
compute='_compute_fclk_company',
|
||||||
store=True,
|
store=True,
|
||||||
readonly=True,
|
readonly=False,
|
||||||
|
index=True,
|
||||||
)
|
)
|
||||||
department_id = fields.Many2one(
|
department_id = fields.Many2one(
|
||||||
'hr.department',
|
'hr.department',
|
||||||
@@ -100,18 +114,41 @@ class FusionClockSchedule(models.Model):
|
|||||||
)
|
)
|
||||||
posted_date = fields.Datetime(string='Posted On', readonly=True)
|
posted_date = fields.Datetime(string='Posted On', readonly=True)
|
||||||
|
|
||||||
_employee_date_unique = models.Constraint(
|
# No hard UNIQUE(employee, date): the per-day model now allows split shifts
|
||||||
'UNIQUE(employee_id, schedule_date)',
|
# and open (unassigned) shifts. The shift planner still manages one cell per
|
||||||
'Only one shift schedule is allowed per employee per day.',
|
# 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')
|
@api.depends('is_off', 'start_time', 'end_time', 'break_minutes')
|
||||||
def _compute_planned_hours(self):
|
def _compute_planned_hours(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
|
rec.crosses_midnight = False
|
||||||
if rec.is_off:
|
if rec.is_off:
|
||||||
rec.planned_hours = 0.0
|
rec.planned_hours = 0.0
|
||||||
continue
|
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)
|
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')
|
@api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time')
|
||||||
@@ -130,11 +167,13 @@ class FusionClockSchedule(models.Model):
|
|||||||
continue
|
continue
|
||||||
if rec.start_time < 0 or rec.start_time >= 24:
|
if rec.start_time < 0 or rec.start_time >= 24:
|
||||||
raise ValidationError(_("Start time must be between 00:00 and 23:59."))
|
raise ValidationError(_("Start time must be between 00:00 and 23:59."))
|
||||||
if rec.end_time <= 0 or rec.end_time > 24:
|
if rec.end_time < 0 or rec.end_time > 24:
|
||||||
raise ValidationError(_("End time must be between 00:01 and 24:00."))
|
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:
|
if rec.end_time <= rec.start_time:
|
||||||
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
|
shift_minutes = ((24.0 - rec.start_time) + rec.end_time) * 60.0
|
||||||
shift_minutes = (rec.end_time - rec.start_time) * 60.0
|
else:
|
||||||
|
shift_minutes = (rec.end_time - rec.start_time) * 60.0
|
||||||
if rec.break_minutes >= shift_minutes:
|
if rec.break_minutes >= shift_minutes:
|
||||||
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))
|
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))
|
||||||
|
|
||||||
|
|||||||
@@ -200,23 +200,60 @@ class HrEmployee(models.Model):
|
|||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
Schedule = self.env['fusion.clock.schedule'].sudo()
|
Schedule = self.env['fusion.clock.schedule'].sudo()
|
||||||
schedule = self._get_fclk_schedule_for_date(date)
|
date_obj = fields.Date.to_date(date)
|
||||||
if schedule and schedule.state == 'posted':
|
|
||||||
|
# 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 {
|
return {
|
||||||
'source': 'schedule',
|
'source': 'schedule',
|
||||||
'schedule_id': schedule.id,
|
'schedule_id': working[0].id,
|
||||||
'scheduled': not schedule.is_off,
|
'scheduled': True,
|
||||||
'is_off': schedule.is_off,
|
'is_off': False,
|
||||||
'start_time': schedule.start_time,
|
'start_time': start,
|
||||||
'end_time': schedule.end_time,
|
'end_time': end,
|
||||||
'break_minutes': schedule.break_minutes,
|
'break_minutes': sum(working.mapped('break_minutes')),
|
||||||
'hours': schedule.planned_hours,
|
'hours': sum(working.mapped('planned_hours')),
|
||||||
'label': schedule.fclk_display_value(),
|
'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
|
shift = self.x_fclk_shift_id
|
||||||
if shift and shift.covers_weekday(date):
|
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 {
|
return {
|
||||||
'source': 'shift',
|
'source': 'shift',
|
||||||
'schedule_id': False,
|
'schedule_id': False,
|
||||||
@@ -226,6 +263,7 @@ class HrEmployee(models.Model):
|
|||||||
'end_time': shift.end_time,
|
'end_time': shift.end_time,
|
||||||
'break_minutes': shift.break_minutes,
|
'break_minutes': shift.break_minutes,
|
||||||
'hours': hours,
|
'hours': hours,
|
||||||
|
'crosses_midnight': crosses,
|
||||||
'label': '%s - %s' % (
|
'label': '%s - %s' % (
|
||||||
Schedule.fclk_float_to_display(shift.start_time),
|
Schedule.fclk_float_to_display(shift.start_time),
|
||||||
Schedule.fclk_float_to_display(shift.end_time),
|
Schedule.fclk_float_to_display(shift.end_time),
|
||||||
@@ -242,6 +280,7 @@ class HrEmployee(models.Model):
|
|||||||
'schedule_id': False,
|
'schedule_id': False,
|
||||||
'scheduled': False,
|
'scheduled': False,
|
||||||
'is_off': False,
|
'is_off': False,
|
||||||
|
'crosses_midnight': False,
|
||||||
'start_time': start_time,
|
'start_time': start_time,
|
||||||
'end_time': end_time,
|
'end_time': end_time,
|
||||||
'break_minutes': break_minutes,
|
'break_minutes': break_minutes,
|
||||||
@@ -320,6 +359,9 @@ class HrEmployee(models.Model):
|
|||||||
local_out = local_tz.localize(
|
local_out = local_tz.localize(
|
||||||
datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m))
|
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_in = local_in.astimezone(utc).replace(tzinfo=None)
|
||||||
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
|
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
|
||||||
|
|||||||
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