diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index 7d92de95..fa527181 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Clock', - 'version': '19.0.5.0.0', + 'version': '19.0.5.0.1', 'category': 'Human Resources/Attendances', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'description': """ diff --git a/fusion_clock/controllers/shift_planner.py b/fusion_clock/controllers/shift_planner.py index 03f81618..65e92d62 100644 --- a/fusion_clock/controllers/shift_planner.py +++ b/fusion_clock/controllers/shift_planner.py @@ -316,8 +316,10 @@ class FusionClockShiftPlanner(http.Controller): return {'error': 'Access denied.'} Schedule = request.env['fusion.clock.schedule'].sudo() row = Schedule.browse(int(schedule_id or 0)) - if row.exists() and row.is_open: - row.unlink() + if not (row.exists() and row.is_open): + return {'success': False, + 'message': 'That open shift is no longer available (it may have just been claimed).'} + row.unlink() return {'success': True, 'data': self._load_week_data(week_start)} @http.route('/fusion_clock/shift_planner/bulk_apply', type='jsonrpc', auth='user', methods=['POST']) diff --git a/fusion_clock/models/clock_recurrence.py b/fusion_clock/models/clock_recurrence.py index caf8767a..531d8c1d 100644 --- a/fusion_clock/models/clock_recurrence.py +++ b/fusion_clock/models/clock_recurrence.py @@ -96,7 +96,9 @@ class FusionClockScheduleRecurrence(models.Model): seed = Schedule.search( [('recurrence_id', '=', rec.id)], order='schedule_date desc', limit=1) if not seed: - # No anchor row -> nothing to repeat; drop the empty rule. + # No anchor row left -> nothing to repeat; drop the empty rule. + _logger.info( + "Fusion Clock: recurrence %s has no shifts left; removing it.", rec.id) rec.unlink() continue anchor = Schedule.search( @@ -150,5 +152,13 @@ class FusionClockScheduleRecurrence(models.Model): @api.model def _cron_generate(self): - """Roll every recurrence's horizon forward (called daily).""" - self.search([])._generate() + """Roll every recurrence's horizon forward (called daily). Each rule is + isolated in its own savepoint so one bad recurrence can't abort + generation for all the others.""" + for rec in self.search([]): + try: + with self.env.cr.savepoint(): + rec._generate() + except Exception: + _logger.exception( + "Fusion Clock: recurrence %s failed to generate; skipping.", rec.id) diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index 6860bebb..59dd984e 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -332,16 +332,21 @@ class FusionClockSchedule(models.Model): if not employee.exists() or not date_obj: raise ValidationError(_("Invalid employee or schedule date.")) - existing = self.search([ + # The UNIQUE(employee, date) constraint was dropped to allow split + # shifts, so a day can hold several rows. The planner cell is the single + # authoritative entry for the day: an edit collapses to one row, a clear + # removes them all. (Open shifts have no employee, so they never match.) + same_day = self.search([ ('employee_id', '=', employee.id), ('schedule_date', '=', date_obj), - ], limit=1) + ], order='id') + existing = same_day[:1] old_value = self.fclk_snapshot(existing) parsed = self.fclk_values_from_planner_payload(payload, employee) if parsed.get('clear'): - if existing: - existing.unlink() + if same_day: + same_day.unlink() new_schedule = self.browse() new_value = '' else: @@ -373,6 +378,7 @@ class FusionClockSchedule(models.Model): if existing: existing.write(vals) new_schedule = existing + (same_day - existing).unlink() # collapse any split rows into one else: new_schedule = self.create(vals) new_value = new_schedule.fclk_display_value() @@ -446,6 +452,10 @@ class FusionClockSchedule(models.Model): schedule = schedule.sudo() if not schedule: raise ValidationError(_("Pick a shift to repeat first.")) + # Replace any existing recurrence so we never orphan a rule that would + # otherwise keep generating shifts forever. + if schedule.recurrence_id: + self.fclk_clear_recurrence(schedule) rule = self.env['fusion.clock.schedule.recurrence'].sudo().create({ 'repeat_interval': int(repeat_vals.get('repeat_interval') or 1), 'repeat_unit': repeat_vals.get('repeat_unit') or 'week', @@ -517,6 +527,8 @@ class FusionClockSchedule(models.Model): schedule = schedule.sudo() if not schedule or schedule.employee_id != employee.sudo(): raise ValidationError(_("You can only release your own shift.")) + if schedule.state != 'posted' or schedule.is_open: + raise ValidationError(_("Only a posted shift assigned to you can be released.")) cutoff = schedule.company_id.fclk_self_unassign_days_before or 0 if (schedule.schedule_date - fields.Date.today()).days < cutoff: raise ValidationError(_("It is too late to release this shift.")) diff --git a/fusion_clock/static/src/js/fusion_clock_shift_planner.js b/fusion_clock/static/src/js/fusion_clock_shift_planner.js index 9c33e1c2..025ee138 100644 --- a/fusion_clock/static/src/js/fusion_clock_shift_planner.js +++ b/fusion_clock/static/src/js/fusion_clock_shift_planner.js @@ -428,7 +428,14 @@ export class FusionClockShiftPlanner extends Component { schedule_id: id, week_start: this.state.weekStart, }); - if (!result.error) { + if (result.error || result.success === false) { + this.notification.add(result.error || result.message || "Could not remove open shift.", { + type: "warning", + }); + if (result.data) { + this._applyData(result.data); + } + } else { this._applyData(result.data); } } catch (error) { diff --git a/fusion_clock/tests/test_open_shift.py b/fusion_clock/tests/test_open_shift.py index 8608c822..21df17e2 100644 --- a/fusion_clock/tests/test_open_shift.py +++ b/fusion_clock/tests/test_open_shift.py @@ -75,6 +75,15 @@ class TestOpenShift(TransactionCase): self.assertTrue(sch.is_open) self.assertFalse(sch.employee_id) + def test_release_draft_shift_rejected(self): + emp = self.env['hr.employee'].create({'name': 'Drafty'}) + future = date.today() + timedelta(days=30) + sch = self.S.create({ + 'employee_id': emp.id, 'schedule_date': future, + 'start_time': 9.0, 'end_time': 17.0, 'state': 'draft'}) + with self.assertRaises(ValidationError): + self.S.fclk_release_shift(sch, emp) + def test_bulk_apply_to_many_employees(self): e1 = self.env['hr.employee'].create({'name': 'Bulk A'}) e2 = self.env['hr.employee'].create({'name': 'Bulk B'}) diff --git a/fusion_clock/tests/test_recurrence.py b/fusion_clock/tests/test_recurrence.py index d47c79e0..f35392cf 100644 --- a/fusion_clock/tests/test_recurrence.py +++ b/fusion_clock/tests/test_recurrence.py @@ -83,6 +83,21 @@ class TestRecurrence(TransactionCase): self.assertNotIn(date(2026, 6, 8), dates, "Leave day should be skipped") self.assertIn(date(2026, 6, 15), dates) + def test_reattach_recurrence_replaces_old_rule(self): + seed = self._seed(date(2026, 6, 1)) + rule1 = self.Schedule.fclk_attach_recurrence(seed, { + 'repeat_interval': 1, 'repeat_unit': 'week', + 'repeat_type': 'x_times', 'repeat_number': 3}) + rule1_id = rule1.id + rule2 = self.Schedule.fclk_attach_recurrence(seed, { + 'repeat_interval': 1, 'repeat_unit': 'week', + 'repeat_type': 'x_times', 'repeat_number': 2}) + # The old rule must be gone (not left generating forever) and the seed + # must point at the new rule. + self.assertFalse( + self.env['fusion.clock.schedule.recurrence'].browse(rule1_id).exists()) + self.assertEqual(seed.recurrence_id, rule2) + def test_clear_recurrence_unlinks_rule_when_empty(self): seed = self._seed(date(2026, 6, 1)) rule = self.Schedule.fclk_attach_recurrence(seed, { diff --git a/fusion_clock/tests/test_shift_planner.py b/fusion_clock/tests/test_shift_planner.py index 112b27f8..768dadd0 100644 --- a/fusion_clock/tests/test_shift_planner.py +++ b/fusion_clock/tests/test_shift_planner.py @@ -67,6 +67,23 @@ class TestShiftPlannerModels(TransactionCase): self.assertEqual(schedule.planned_hours, 8.0) self.assertEqual(self.Schedule.fclk_hours_display(schedule.planned_hours), '8:00') + def test_planner_cell_collapses_split_rows(self): + # Editing a day's planner cell must collapse any split rows into one + # (the planner cell is the authoritative single entry for the day). + d = date(2026, 1, 20) + self.Schedule.create({ + 'employee_id': self.employee.id, 'schedule_date': d, + 'start_time': 8.0, 'end_time': 12.0, 'state': 'posted'}) + self.Schedule.create({ + 'employee_id': self.employee.id, 'schedule_date': d, + 'start_time': 13.0, 'end_time': 17.0, 'state': 'posted'}) + self.assertEqual(self.Schedule.search_count([ + ('employee_id', '=', self.employee.id), ('schedule_date', '=', d)]), 2) + self.Schedule.fclk_apply_planner_cell(self.employee, d, {'input': '9-5'}) + self.assertEqual(self.Schedule.search_count([ + ('employee_id', '=', self.employee.id), ('schedule_date', '=', d)]), 1, + "planner edit should collapse split rows to one") + def test_overnight_range_is_accepted(self): # Overnight shifts (end on/before start) are supported as of 19.0.5.0.0. sch = self.Schedule.create({