feat(fusion_clock): native recurring shifts engine [A4-A5]
fusion.clock.schedule.recurrence (repeat every N day/week/month/year; forever/until/N-times) re-fit from planning.recurrency onto per-day rows; daily generation cron; _fclk_on_leave skip; planner Repeat…/Stop-repeat UI + endpoints; recurrence + role indicators on cells. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
95
fusion_clock/tests/test_recurrence.py
Normal file
95
fusion_clock/tests/test_recurrence.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# -*- 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 TestRecurrence(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.emp = self.env['hr.employee'].create({'name': 'Rita'})
|
||||
self.Schedule = self.env['fusion.clock.schedule']
|
||||
|
||||
def _seed(self, day):
|
||||
return self.Schedule.create({
|
||||
'employee_id': self.emp.id,
|
||||
'schedule_date': day,
|
||||
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
|
||||
'state': 'posted',
|
||||
})
|
||||
|
||||
def test_weekly_until_generates_inclusive_series(self):
|
||||
seed = self._seed(date(2026, 6, 1))
|
||||
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||
'repeat_type': 'until', 'repeat_until': date(2026, 6, 29)})
|
||||
rows = self.Schedule.search([('recurrence_id', '=', rule.id)], order='schedule_date')
|
||||
self.assertEqual(
|
||||
rows.mapped('schedule_date'),
|
||||
[date(2026, 6, 1), date(2026, 6, 8), date(2026, 6, 15),
|
||||
date(2026, 6, 22), date(2026, 6, 29)])
|
||||
# Generated (non-seed) rows are draft until posted.
|
||||
generated = rows.filtered(lambda r: r.schedule_date != date(2026, 6, 1))
|
||||
self.assertTrue(all(r.state == 'draft' for r in generated))
|
||||
|
||||
def test_x_times_counts_seed(self):
|
||||
seed = self._seed(date(2026, 6, 1))
|
||||
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||
'repeat_type': 'x_times', 'repeat_number': 3})
|
||||
rows = self.Schedule.search([('recurrence_id', '=', rule.id)])
|
||||
self.assertEqual(len(rows), 3, "3 repetitions = seed + 2 generated")
|
||||
|
||||
def test_interval_two_weeks(self):
|
||||
seed = self._seed(date(2026, 6, 1))
|
||||
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||
'repeat_interval': 2, 'repeat_unit': 'week',
|
||||
'repeat_type': 'until', 'repeat_until': date(2026, 7, 1)})
|
||||
rows = self.Schedule.search([('recurrence_id', '=', rule.id)], order='schedule_date')
|
||||
self.assertEqual(rows.mapped('schedule_date'),
|
||||
[date(2026, 6, 1), date(2026, 6, 15), date(2026, 6, 29)])
|
||||
|
||||
def test_stop_deletes_future_drafts_keeps_posted(self):
|
||||
seed = self._seed(date(2026, 6, 1))
|
||||
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||
'repeat_type': 'x_times', 'repeat_number': 4})
|
||||
# Post one generated occurrence.
|
||||
gen = self.Schedule.search([
|
||||
('recurrence_id', '=', rule.id), ('schedule_date', '=', date(2026, 6, 8))])
|
||||
gen.state = 'posted'
|
||||
rule._stop(date(2026, 6, 2))
|
||||
remaining = self.Schedule.search([('recurrence_id', '=', rule.id)]).mapped('schedule_date')
|
||||
self.assertIn(date(2026, 6, 1), remaining) # seed, before cutoff
|
||||
self.assertIn(date(2026, 6, 8), remaining) # posted, kept
|
||||
self.assertNotIn(date(2026, 6, 15), remaining) # future draft, removed
|
||||
self.assertNotIn(date(2026, 6, 22), remaining)
|
||||
|
||||
def test_leave_day_skipped(self):
|
||||
self.env['fusion.clock.leave.request'].create({
|
||||
'employee_id': self.emp.id,
|
||||
'leave_date': date(2026, 6, 8), 'date_to': date(2026, 6, 8)})
|
||||
seed = self._seed(date(2026, 6, 1))
|
||||
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||
'repeat_type': 'until', 'repeat_until': date(2026, 6, 15)})
|
||||
dates = self.Schedule.search([('recurrence_id', '=', rule.id)]).mapped('schedule_date')
|
||||
self.assertNotIn(date(2026, 6, 8), dates, "Leave day should be skipped")
|
||||
self.assertIn(date(2026, 6, 15), dates)
|
||||
|
||||
def test_clear_recurrence_unlinks_rule_when_empty(self):
|
||||
seed = self._seed(date(2026, 6, 1))
|
||||
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||
'repeat_type': 'x_times', 'repeat_number': 3})
|
||||
rule_id = rule.id
|
||||
self.Schedule.fclk_clear_recurrence(seed)
|
||||
# Seed kept (it's posted), future drafts gone, seed detached.
|
||||
self.assertFalse(seed.recurrence_id)
|
||||
self.assertFalse(self.env['fusion.clock.schedule.recurrence'].browse(rule_id).exists())
|
||||
Reference in New Issue
Block a user