Merge: remove Odoo Planning dependency from fusion_clock

Native roles + recurrence + publish/notify + open shifts/self-assign; portal
Schedule folded in; fusion_planning retired. Deployed to entech (admin) as
fusion_clock 19.0.5.0.0; 8 planning.slot + 1 planning.role migrated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-04 21:59:22 -04:00
42 changed files with 3254 additions and 78 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Clock', 'name': 'Fusion Clock',
'version': '19.0.4.2.0', 'version': '19.0.5.0.0',
'category': 'Human Resources/Attendances', 'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """ 'description': """
@@ -54,6 +54,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'data/ir_config_parameter_data.xml', 'data/ir_config_parameter_data.xml',
'data/clock_break_rule_data.xml', 'data/clock_break_rule_data.xml',
'data/ir_cron_data.xml', 'data/ir_cron_data.xml',
'data/clock_recurrence_cron.xml',
# Reports (must load before mail templates that reference them) # Reports (must load before mail templates that reference them)
'report/clock_report_template.xml', 'report/clock_report_template.xml',
'report/clock_employee_report.xml', 'report/clock_employee_report.xml',
@@ -72,6 +73,8 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/clock_dashboard_views.xml', 'views/clock_dashboard_views.xml',
'views/hr_employee_views.xml', 'views/hr_employee_views.xml',
'views/clock_schedule_views.xml', 'views/clock_schedule_views.xml',
'views/clock_role_views.xml',
'views/clock_recurrence_views.xml',
'views/clock_break_rule_views.xml', 'views/clock_break_rule_views.xml',
# Wizards (must load before clock_menus.xml since menu references wizard action) # Wizards (must load before clock_menus.xml since menu references wizard action)
'wizard/clock_nfc_enrollment_views.xml', 'wizard/clock_nfc_enrollment_views.xml',
@@ -82,12 +85,14 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/portal_timesheet_templates.xml', 'views/portal_timesheet_templates.xml',
'views/portal_report_templates.xml', 'views/portal_report_templates.xml',
'views/portal_payslip_templates.xml', 'views/portal_payslip_templates.xml',
'views/portal_schedule_templates.xml',
'views/kiosk_templates.xml', 'views/kiosk_templates.xml',
'views/kiosk_nfc_templates.xml', 'views/kiosk_nfc_templates.xml',
], ],
'assets': { 'assets': {
'web.assets_frontend': [ 'web.assets_frontend': [
'fusion_clock/static/src/css/portal_clock.css', 'fusion_clock/static/src/css/portal_clock.css',
'fusion_clock/static/src/css/portal_schedule.css',
'fusion_clock/static/src/scss/nfc_kiosk.scss', 'fusion_clock/static/src/scss/nfc_kiosk.scss',
'fusion_clock/static/src/scss/pin_kiosk.scss', 'fusion_clock/static/src/scss/pin_kiosk.scss',
'fusion_clock/static/src/js/fusion_clock_portal.js', 'fusion_clock/static/src/js/fusion_clock_portal.js',

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from . import portal_clock from . import portal_clock
from . import portal_schedule
from . import clock_api from . import clock_api
from . import clock_kiosk from . import clock_kiosk
from . import clock_nfc_kiosk from . import clock_nfc_kiosk

View File

@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Portal "My Schedule" tab. Folded in from the retired fusion_planning bridge —
# now reads ONLY the native fusion.clock.schedule (no planning.slot), so it
# works on Community Odoo.
import logging
from collections import OrderedDict
from datetime import timedelta
from urllib.parse import quote
from odoo import http, fields
from odoo.exceptions import ValidationError
from odoo.http import request
_logger = logging.getLogger(__name__)
class FusionClockSchedulePortal(http.Controller):
"""Exposes the employee's published shifts on the portal Schedule tab."""
@http.route('/my/clock/schedule', type='http', auth='user', website=True)
def portal_schedule(self, **kw):
employee = request.env.user.employee_id
if not employee:
return request.redirect('/my')
now_utc = fields.Datetime.now()
today_local = fields.Datetime.context_timestamp(request.env.user, now_utc).date()
horizon_local = today_local + timedelta(days=60)
Schedule = request.env['fusion.clock.schedule'].sudo()
cutoff = employee.company_id.fclk_self_unassign_days_before or 0
entries = []
for sch in Schedule.search([
('employee_id', '=', employee.id),
('state', '=', 'posted'),
('is_off', '=', False),
('schedule_date', '>=', today_local),
('schedule_date', '<=', horizon_local),
], order='schedule_date asc', limit=200):
day = sch.schedule_date
entries.append((
(day, int(round((sch.start_time or 0.0) * 60))),
day,
{
'day_label': day.strftime('%a').upper(),
'day_num': day.strftime('%d'),
'date_full': day.strftime('%b %d, %Y'),
'time_range': '%s - %s' % (
Schedule.fclk_float_to_display(sch.start_time),
Schedule.fclk_float_to_display(sch.end_time),
),
'duration_hours': round(sch.planned_hours or 0.0, 1),
'role_name': sch.role_id.name if sch.role_id else '',
'role_color': sch.role_id._get_color_from_code() if sch.role_id else '',
'note': sch.note or '',
'schedule_id': sch.id,
'releasable': (day - today_local).days >= cutoff,
},
))
entries.sort(key=lambda e: e[0])
# Open shifts the employee may claim: company-scoped, future, and either
# role-eligible (allowed-role list contains the shift role) or roleless.
open_shifts = []
for row in Schedule.search([
('is_open', '=', True),
('state', '=', 'posted'),
('company_id', '=', employee.company_id.id),
('schedule_date', '>=', today_local),
('schedule_date', '<=', horizon_local),
], order='schedule_date asc, start_time asc', limit=100):
if row.role_id and employee.x_fclk_role_ids and row.role_id not in employee.x_fclk_role_ids:
continue
d = row.schedule_date
open_shifts.append({
'id': row.id,
'date_full': d.strftime('%a, %b %d'),
'time_range': '%s - %s' % (
Schedule.fclk_float_to_display(row.start_time),
Schedule.fclk_float_to_display(row.end_time),
),
'role_name': row.role_id.name if row.role_id else '',
'duration_hours': round(row.planned_hours or 0.0, 1),
})
groups = OrderedDict()
for _key, day, item in entries:
delta_days = (day - today_local).days
if delta_days == 0:
bucket_key = 'Today'
elif delta_days == 1:
bucket_key = 'Tomorrow'
elif 0 <= delta_days <= 6:
bucket_key = day.strftime('%A')
else:
bucket_key = day.strftime('%b %d')
groups.setdefault(bucket_key, []).append(item)
next_slot_data = None
if entries:
first = entries[0][2]
next_slot_data = {
'date': entries[0][1].strftime('%a, %b %d'),
'time': first['time_range'].split(' - ')[0],
'role': first['role_name'],
}
values = {
'employee': employee,
'groups': groups,
'slot_count': len(entries),
'next_slot': next_slot_data,
'open_shifts': open_shifts,
'error': kw.get('err'),
'success': kw.get('ok'),
'page_name': 'fusion_clock_schedule',
'show_payslips': 'hr.payslip' in request.env,
}
return request.render('fusion_clock.portal_schedule_page', values)
@http.route('/my/clock/schedule/claim', type='http', auth='user',
methods=['POST'], website=True)
def claim_open_shift(self, schedule_id=None, **kw):
employee = request.env.user.employee_id
if not employee or not schedule_id:
return request.redirect('/my/clock/schedule')
Schedule = request.env['fusion.clock.schedule'].sudo()
sch = Schedule.browse(int(schedule_id))
try:
Schedule.fclk_claim_open_shift(sch, employee)
return request.redirect('/my/clock/schedule?ok=claimed')
except ValidationError as exc:
return request.redirect(
'/my/clock/schedule?err=' + quote(str(exc.args[0] if exc.args else exc)))
@http.route('/my/clock/schedule/release', type='http', auth='user',
methods=['POST'], website=True)
def release_shift(self, schedule_id=None, **kw):
employee = request.env.user.employee_id
if not employee or not schedule_id:
return request.redirect('/my/clock/schedule')
Schedule = request.env['fusion.clock.schedule'].sudo()
sch = Schedule.browse(int(schedule_id))
try:
Schedule.fclk_release_shift(sch, employee)
return request.redirect('/my/clock/schedule?ok=released')
except ValidationError as exc:
return request.redirect(
'/my/clock/schedule?err=' + quote(str(exc.args[0] if exc.args else exc)))

View File

@@ -79,9 +79,26 @@ class FusionClockShiftPlanner(http.Controller):
('company_id', 'in', request.env.user.company_ids.ids), ('company_id', 'in', request.env.user.company_ids.ids),
], order='sequence, name') ], order='sequence, name')
open_rows = Schedule.search([
('is_open', '=', True),
('company_id', 'in', request.env.user.company_ids.ids),
('schedule_date', '>=', start),
('schedule_date', '<=', days[-1]),
], order='schedule_date, start_time')
open_by_day = {}
for row in open_rows:
open_by_day.setdefault(str(row.schedule_date), []).append({
'id': row.id,
'label': row.fclk_display_value(),
'role_name': row.role_id.name or '',
'role_color': row.role_id._get_color_from_code(True) if row.role_id else '',
'hours_display': Schedule.fclk_hours_display(row.planned_hours),
})
return { return {
'week_start': str(start), 'week_start': str(start),
'week_end': str(days[-1]), 'week_end': str(days[-1]),
'open_shifts': open_by_day,
'days': [{ 'days': [{
'date': str(day), 'date': str(day),
'weekday': day.strftime('%a').upper(), 'weekday': day.strftime('%a').upper(),
@@ -166,23 +183,7 @@ class FusionClockShiftPlanner(http.Controller):
end = start + timedelta(days=6) end = start + timedelta(days=6)
employees = self._manager_employees() employees = self._manager_employees()
Schedule = request.env['fusion.clock.schedule'].sudo() Schedule = request.env['fusion.clock.schedule'].sudo()
posted_count, notified = Schedule.fclk_publish_range(employees, start, end)
entries = Schedule.search([
('employee_id', 'in', employees.ids),
('schedule_date', '>=', start),
('schedule_date', '<=', end),
('state', '!=', 'posted'),
])
posted_count = len(entries)
affected = entries.mapped('employee_id')
if entries:
entries.write({'state': 'posted', 'posted_date': fields.Datetime.now()})
notified = 0
for employee in affected:
if Schedule.fclk_email_posted_week(employee, start, end):
notified += 1
return { return {
'success': True, 'success': True,
'posted': posted_count, 'posted': posted_count,
@@ -190,6 +191,30 @@ class FusionClockShiftPlanner(http.Controller):
'data': self._load_week_data(start), 'data': self._load_week_data(start),
} }
@http.route('/fusion_clock/shift_planner/publish_range', type='jsonrpc', auth='user', methods=['POST'])
def publish_range(self, date_from=None, date_to=None, employee_ids=None, message=None,
week_start=None, **kw):
"""Publish & Notify over an arbitrary date range, optionally limited to a
subset of employees, with an optional custom message in the email."""
if not self._check_manager():
return {'error': 'Access denied.'}
start = fields.Date.to_date(date_from) or self._week_start(week_start)
end = fields.Date.to_date(date_to) or (start + timedelta(days=6))
if end < start:
return {'success': False, 'message': 'End date must be on or after the start date.'}
employees = self._manager_employees()
if employee_ids:
wanted = {int(eid) for eid in employee_ids}
employees = employees.filtered(lambda e: e.id in wanted)
Schedule = request.env['fusion.clock.schedule'].sudo()
posted_count, notified = Schedule.fclk_publish_range(employees, start, end, message=message)
return {
'success': True,
'posted': posted_count,
'notified': notified,
'data': self._load_week_data(week_start),
}
@http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST']) @http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST'])
def copy_previous_week(self, week_start=None, **kw): def copy_previous_week(self, week_start=None, **kw):
if not self._check_manager(): if not self._check_manager():
@@ -237,6 +262,81 @@ class FusionClockShiftPlanner(http.Controller):
'data': self._load_week_data(start), 'data': self._load_week_data(start),
} }
@http.route('/fusion_clock/shift_planner/set_recurrence', type='jsonrpc', auth='user', methods=['POST'])
def set_recurrence(self, employee_id=None, date=None, repeat=None, week_start=None, **kw):
"""Make the shift at (employee, date) recurring and generate it forward."""
if not self._check_manager():
return {'error': 'Access denied.'}
Schedule = request.env['fusion.clock.schedule'].sudo()
schedule = Schedule.search([
('employee_id', '=', int(employee_id or 0)),
('schedule_date', '=', date),
], limit=1)
if not schedule:
return {'success': False, 'message': 'Save this shift before repeating it.'}
try:
Schedule.fclk_attach_recurrence(schedule, repeat or {})
except ValidationError as exc:
return {'success': False, 'message': str(exc.args[0] if exc.args else exc)}
return {'success': True, 'data': self._load_week_data(week_start)}
@http.route('/fusion_clock/shift_planner/clear_recurrence', type='jsonrpc', auth='user', methods=['POST'])
def clear_recurrence(self, employee_id=None, date=None, week_start=None, **kw):
"""Stop the recurrence seeded at (employee, date); keep posted rows."""
if not self._check_manager():
return {'error': 'Access denied.'}
Schedule = request.env['fusion.clock.schedule'].sudo()
schedule = Schedule.search([
('employee_id', '=', int(employee_id or 0)),
('schedule_date', '=', date),
], limit=1)
if schedule:
Schedule.fclk_clear_recurrence(schedule)
return {'success': True, 'data': self._load_week_data(week_start)}
@http.route('/fusion_clock/shift_planner/create_open_shift', type='jsonrpc', auth='user', methods=['POST'])
def create_open_shift(self, date=None, start_time=None, end_time=None, role_id=None,
count=1, break_minutes=0.0, week_start=None, **kw):
"""Create one or more open (unassignable) shifts for a day."""
if not self._check_manager():
return {'error': 'Access denied.'}
Schedule = request.env['fusion.clock.schedule'].sudo()
company = request.env.company
try:
Schedule.fclk_create_open_shifts(
company, date, start_time, end_time,
role_id=role_id, count=count, break_minutes=break_minutes)
except ValidationError as exc:
return {'success': False, 'message': str(exc.args[0] if exc.args else exc)}
return {'success': True, 'data': self._load_week_data(week_start)}
@http.route('/fusion_clock/shift_planner/delete_open_shift', type='jsonrpc', auth='user', methods=['POST'])
def delete_open_shift(self, schedule_id=None, week_start=None, **kw):
if not self._check_manager():
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()
return {'success': True, 'data': self._load_week_data(week_start)}
@http.route('/fusion_clock/shift_planner/bulk_apply', type='jsonrpc', auth='user', methods=['POST'])
def bulk_apply(self, employee_ids=None, date=None, payload=None, week_start=None, **kw):
"""Apply one shift to several employees at once (Apply Also To)."""
if not self._check_manager():
return {'error': 'Access denied.'}
employees = self._manager_employees()
wanted = {int(eid) for eid in (employee_ids or [])}
employees = employees.filtered(lambda e: e.id in wanted)
if not employees:
return {'success': False, 'message': 'Pick at least one employee.'}
Schedule = request.env['fusion.clock.schedule'].sudo()
try:
Schedule.fclk_bulk_apply(employees, date, payload or {}, request.env.user)
except ValidationError as exc:
return {'success': False, 'message': str(exc.args[0] if exc.args else exc)}
return {'success': True, 'data': self._load_week_data(week_start)}
@http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST']) @http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST'])
def export_xlsx(self, week_start=None, **kw): def export_xlsx(self, week_start=None, **kw):
if not self._check_manager(): if not self._check_manager():

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<!-- Recurring Shift Generation: rolls every recurrence's horizon forward.
Odoo 19 dropped numbercall; an active recurring cron runs forever. -->
<record id="cron_generate_recurring_shifts" model="ir.cron">
<field name="name">Fusion Clock: Generate Recurring Shifts</field>
<field name="model_id" ref="fusion_clock.model_fusion_clock_schedule_recurrence"/>
<field name="state">code</field>
<field name="code">model._cron_generate()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="priority">75</field>
</record>
</odoo>

View File

@@ -0,0 +1,300 @@
# Remove Odoo Planning Dependency — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use `- [ ]`.
**Goal:** Make `fusion_clock` fully Community-installable by re-fitting Odoo Planning's role + recurrence + send onto the native per-day `fusion.clock.schedule`, folding `fusion_planning` in, and retiring it — with full Planning feature parity.
**Architecture:** All new code lands in `fusion_clock` (deps stay `hr_attendance, hr, portal, mail, resource` — no `planning`). New native models `fusion.clock.role` and `fusion.clock.schedule.recurrence`; additive fields on `fusion.clock.schedule` / `fusion.clock.shift` / `hr.employee`. The attendance contract `_get_fclk_day_plan` keeps its one-window-per-day shape (multi/overnight/open resolve into it). A guarded, idempotent post-migration ports the live planning data, then `fusion_planning` is uninstalled.
**Tech Stack:** Odoo 19, Python, OWL, QWeb, xlsxwriter. Verify on Entech LXC 111 clone; deploy gated revert-on-failure.
**Reference (read before coding each piece):** Enterprise Planning source on Entech at `/mnt/extra-addons/_dependencies/planning/` (`models/planning_role.py`, `models/planning_recurrency.py`, `models/planning_planning.py`, `data/mail_template_data.xml`). Spec: `fusion_clock/docs/superpowers/specs/2026-06-04-remove-planning-dependency-design.md`.
---
## File map
**Create:**
- `fusion_clock/models/clock_role.py``fusion.clock.role`
- `fusion_clock/models/clock_recurrence.py``fusion.clock.schedule.recurrence` + generation engine
- `fusion_clock/views/clock_role_views.xml` — role list/form/action/menu + Employee Roles editor
- `fusion_clock/views/clock_recurrence_views.xml` — recurrence list/form/action/menu
- `fusion_clock/views/portal_schedule_templates.xml` — portal Schedule tab (folded from fusion_planning)
- `fusion_clock/controllers/portal_schedule.py``/my/clock/schedule` (+ self-assign endpoints)
- `fusion_clock/static/src/css/portal_schedule.css` — folded from fusion_planning
- `fusion_clock/data/clock_recurrence_cron.xml` — recurrence generation cron
- `fusion_clock/tests/test_role.py`, `test_recurrence.py`, `test_publish_range.py`, `test_open_shift.py`, `test_overnight.py`, `test_multishift_window.py`, `test_planning_migration.py`
- `fusion_clock/migrations/19.0.5.0.0/post-migrate.py` — planning→native data migration
**Modify:**
- `fusion_clock/models/__init__.py` — register `clock_role`, `clock_recurrence`
- `fusion_clock/models/hr_employee.py``x_fclk_default_role_id`, `x_fclk_role_ids`; `_get_fclk_day_plan` multi-window
- `fusion_clock/models/clock_shift.py``role_id`
- `fusion_clock/models/clock_schedule.py``role_id`, `recurrence_id`, `is_open`, `crosses_midnight`; overnight math; constraint relax; `fclk_email_posted_range`; recurrence helpers
- `fusion_clock/models/res_config_settings.py` + `res_company.py``fclk_planning_generation_months`, `fclk_self_unassign_days_before`
- `fusion_clock/controllers/shift_planner.py` — recurrence, publish-range, open-shift, bulk-apply endpoints
- `fusion_clock/static/src/js/fusion_clock_shift_planner.js` + `.xml` — role chip, Repeat dialog, Publish&Notify, open lane, bulk apply
- `fusion_clock/views/clock_shift_views.xml`, `clock_schedule_views.xml` — role fields; recurrence/open columns
- `fusion_clock/views/portal_*_templates.xml` (clock, timesheets, reports, payslip list+detail) — inline Schedule nav button
- `fusion_clock/data/mail_template_data.xml` — schedule publish email
- `fusion_clock/security/ir.model.access.csv` — role + recurrence ACLs
- `fusion_clock/views/clock_menus.xml` — Roles + Recurrences config menus
- `fusion_clock/__manifest__.py` — version `19.0.5.0.0`; new data/asset files
**Retire (on deploy):** uninstall `fusion_planning`; optional uninstall `planning`.
---
## PHASE A — Core parity (roles, recurrence, send, portal, migration)
### Task A1: `fusion.clock.role` model
**Files:** Create `models/clock_role.py`; Modify `models/__init__.py`, `security/ir.model.access.csv`.
- [ ] Write `test_role.py`: create role, default color in 1..11, `_get_color_from_code(False)` returns `#`-hex.
- [ ] Implement (copied from `planning_role.py`, trimmed):
```python
from random import randint
from odoo import fields, models
class FusionClockRole(models.Model):
_name = 'fusion.clock.role'
_description = 'Clock Shift Role'
_order = 'sequence, name'
def _get_default_color(self):
return randint(1, 11)
name = fields.Char(required=True, translate=True)
color = fields.Integer(default=_get_default_color)
active = fields.Boolean(default=True)
sequence = fields.Integer(default=10)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
_COLOR_HEX = {0:'#008784',1:'#EE4B39',2:'#F29648',3:'#F4C609',4:'#55B7EA',
5:'#71405B',6:'#E86869',7:'#008784',8:'#267283',9:'#BF1255',
10:'#2BAF73',11:'#8754B0'}
def _get_color_from_code(self, is_open_shift=False):
self.ensure_one()
hexv = self._COLOR_HEX.get(self.color, '#008784')
return hexv + ('80' if is_open_shift else '')
```
- [ ] Register in `__init__.py` (add `from . import clock_role` before `clock_shift`).
- [ ] ACL rows: `model_fusion_clock_role` → user read; manager RWCU; portal read.
- [ ] Run `test_role` on Entech clone; commit.
### Task A2: Employee role fields + Roles editor view/menu
**Files:** Modify `models/hr_employee.py`, `views/clock_role_views.xml` (create), `views/clock_menus.xml`, `security/ir.model.access.csv`.
- [ ] Add to `hr.employee`:
```python
x_fclk_default_role_id = fields.Many2one('fusion.clock.role', string='Default Shift Role')
x_fclk_role_ids = fields.Many2many('fusion.clock.role', 'fclk_employee_role_rel',
'employee_id', 'role_id', string='Allowed Shift Roles')
```
- [ ] `clock_role_views.xml`: role list/form/action + `action_fclk_employee_role_editor` (editable hr.employee list with `x_fclk_default_role_id` + `x_fclk_role_ids`, copied from `fusion_planning/views/hr_employee_role_views.xml`, native fields, `multi_edit="1"`).
- [ ] Menus under fusion_clock config: "Roles" (role action) + "Employee Roles" (editor), `groups="group_fusion_clock_manager"`.
- [ ] Commit.
### Task A3: `role_id` on shift template + schedule + default
**Files:** Modify `models/clock_shift.py`, `models/clock_schedule.py`, `views/clock_shift_views.xml`, `views/clock_schedule_views.xml`.
- [ ] `clock_shift.py`: `role_id = fields.Many2one('fusion.clock.role')`.
- [ ] `clock_schedule.py`: `role_id = fields.Many2one('fusion.clock.role')`; default in `fclk_apply_planner_cell` vals from `shift.role_id` or `employee.x_fclk_default_role_id`; include `role_id`+`role_color` in `fclk_cell_payload`.
- [ ] Add `role_id` to shift form/list + schedule list/form.
- [ ] Commit.
### Task A4: `fusion.clock.schedule.recurrence` model + engine + cron
**Files:** Create `models/clock_recurrence.py`, `data/clock_recurrence_cron.xml`; Modify `models/__init__.py`, `models/clock_schedule.py`, `models/res_company.py`, ACL csv, manifest.
- [ ] Write `test_recurrence.py`: weekly interval=1 forever generates rows on same weekday up to horizon; `until` caps at date; `x_times` caps at N; `_stop` deletes future drafts only, keeps posted; leave days skipped.
- [ ] `res_company.py`: `fclk_planning_generation_months = fields.Integer(default=6)`.
- [ ] Implement model (design copied from `planning_recurrency.py`, re-fit to per-day):
```python
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class FusionClockScheduleRecurrence(models.Model):
_name = 'fusion.clock.schedule.recurrence'
_description = 'Clock Schedule Recurrence'
schedule_ids = fields.One2many('fusion.clock.schedule', 'recurrence_id')
repeat_interval = fields.Integer('Repeat Every', default=1, required=True)
repeat_unit = fields.Selection([('day','Days'),('week','Weeks'),('month','Months'),('year','Years')],
default='week', required=True)
repeat_type = fields.Selection([('forever','Forever'),('until','Until'),('x_times','Number of Repetitions')],
default='forever', required=True)
repeat_until = fields.Date('Repeat Until')
repeat_number = fields.Integer('Repetitions')
last_generated_date = fields.Date(readonly=True)
company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company)
_check_interval_pos = models.Constraint('CHECK(repeat_interval >= 1)', 'Repeat every must be >= 1.')
@api.constrains('repeat_type', 'repeat_until')
def _check_until(self):
for r in self:
if r.repeat_type == 'until' and not r.repeat_until:
raise ValidationError(_('Set an end date for "Until" recurrences.'))
def _delta(self, n):
unit = {'day':'days','week':'weeks','month':'months','year':'years'}[self.repeat_unit]
return relativedelta(**{unit: self.repeat_interval * n})
def _horizon(self):
months = int(self.env['ir.config_parameter'].sudo().get_param('fusion_clock.generation_months')
or self.company_id.fclk_planning_generation_months or 6)
return fields.Date.today() + relativedelta(months=months)
def _generate(self, stop_date=False):
Schedule = self.env['fusion.clock.schedule'].sudo()
for r in self:
seed = Schedule.search([('recurrence_id','=',r.id)], order='schedule_date desc', limit=1)
if not seed:
continue
limit = min([d for d in [r.repeat_until,
stop_date or r._horizon()] if d])
existing = Schedule.search_count([('recurrence_id','=',r.id)])
vals_list, last = [], seed.schedule_date
i = 1
while True:
nxt = seed.schedule_date + r._delta(i); i += 1
if nxt > limit:
break
if r.repeat_type == 'x_times' and existing + len(vals_list) >= r.repeat_number:
break
if Schedule.search_count([('recurrence_id','=',r.id),('schedule_date','=',nxt)]):
continue
if seed.employee_id and seed.employee_id._fclk_on_leave(nxt):
continue
vals_list.append({
'employee_id': seed.employee_id.id, 'schedule_date': nxt,
'shift_id': seed.shift_id.id or False, 'is_off': seed.is_off,
'start_time': seed.start_time, 'end_time': seed.end_time,
'break_minutes': seed.break_minutes, 'role_id': seed.role_id.id or False,
'recurrence_id': r.id, 'state': 'draft',
})
last = nxt
if vals_list:
Schedule.create(vals_list)
r.last_generated_date = last
def _stop(self, from_date):
self.env['fusion.clock.schedule'].sudo().search([
('recurrence_id','in', self.ids), ('schedule_date','>=', from_date),
('state','=','draft')]).unlink()
@api.model
def _cron_generate(self):
self.search([])._generate()
```
- [ ] `hr_employee.py`: add `_fclk_on_leave(date)` (True if an approved `fusion.clock.leave.request` covers date — read existing leave model first).
- [ ] Cron `data/clock_recurrence_cron.xml`: daily, `model._cron_generate()`, no `numbercall` (Odoo 19).
- [ ] ACL: recurrence manager RWCU, user read.
- [ ] Run `test_recurrence`; commit.
### Task A5: Recurrence on schedule + planner "Repeat…" wiring
**Files:** Modify `models/clock_schedule.py`, `controllers/shift_planner.py`, planner `.js`/`.xml`.
- [ ] `clock_schedule.py`: `recurrence_id = fields.Many2one('fusion.clock.schedule.recurrence', ondelete='set null')`; method `fclk_attach_recurrence(schedule, repeat_vals)` creating the rule, linking the seed, calling `_generate()`.
- [ ] Controller endpoint `/fusion_clock/shift_planner/set_recurrence` (manager-gated) → calls `fclk_attach_recurrence`; `/clear_recurrence``_stop(today)` + unlink rule.
- [ ] Planner cell editor: "Repeat…" button → small dialog (interval/unit/type/until/number) → POST; show a recurrence badge on recurring cells.
- [ ] Commit.
### Task A6: Publish & Notify over a range (generalise post_week)
**Files:** Modify `models/clock_schedule.py`, `controllers/shift_planner.py`, planner `.js`/`.xml`, `data/mail_template_data.xml`.
- [ ] `clock_schedule.py`: rename/extend `fclk_email_posted_week``fclk_email_posted_range(employee, start, end)` (keep a thin `_week` wrapper). Add `fclk_publish_range(employees, start, end, message=None)` posting drafts + emailing.
- [ ] Controller `/fusion_clock/shift_planner/publish_range` (range + optional employee_ids + message) → `fclk_publish_range`; keep `post_week` calling it for the visible week.
- [ ] Mail template `mail_template_fclk_schedule_published` (copy/reword from planning `data/mail_template_data.xml`; obey Odoo-19 mail rules: no `url_encode`).
- [ ] Planner: "Publish & Notify…" dialog (date range + message). Commit.
### Task A7: Portal Schedule tab folded into fusion_clock
**Files:** Create `controllers/portal_schedule.py`, `views/portal_schedule_templates.xml`, `static/src/css/portal_schedule.css`; Modify `controllers/__init__.py`, portal nav templates, manifest.
- [ ] Move controller from `fusion_planning/controllers/portal_schedule.py`; **delete the `planning.slot` branch**; read only `fusion.clock.schedule`; role colour from `role_id._get_color_from_code()`.
- [ ] Move template → `fusion_clock.portal_schedule_page`; move css.
- [ ] Inline a "Schedule" nav `<a href="/my/clock/schedule">` into each `.fclk-nav-bar` (clock, timesheets, reports, payslip list, payslip detail) between Timesheets and Reports. Keep `.fclk-nav-bar` structure stable.
- [ ] Manifest: add template + css asset. Commit.
### Task A8: planning → native data migration
**Files:** Create `migrations/19.0.5.0.0/post-migrate.py`.
- [ ] Guarded + idempotent (marker `fusion_clock.planning_migrated`):
- roles: `planning.role``fusion.clock.role` (name, color); build id map.
- employees: `default_planning_role_id``x_fclk_default_role_id`; `planning_role_ids``x_fclk_role_ids`.
- slots: `planning.slot``fusion.clock.schedule` (resource→employee, local date+float times, role via map, posted if published).
- log anything unusual (overnight/open/multi handled by Phase B rules).
- [ ] Write `test_planning_migration.py` (stub planning models or skip if absent — guard with `'planning.slot' in env`).
- [ ] Commit.
---
## PHASE B — Multi-shift / overnight / open shifts / self-assign / bulk
### Task B1: schedule fields + constraint relax
**Files:** Modify `models/clock_schedule.py`.
- [ ] Add `is_open = fields.Boolean()`, `crosses_midnight = fields.Boolean(compute store)`.
- [ ] Make `employee_id` `required=False`; add `@api.constrains` requiring employee unless `is_open`.
- [ ] Replace `_employee_date_unique` Constraint with `models.UniqueIndex('(employee_id, schedule_date) WHERE employee_id IS NOT NULL AND recurrence_id IS NULL AND is_open = false')` — allow intentional multi via recurrence/open; finalise predicate so existing 144 single rows pass. Write `test_open_shift.py` first (open row needs no employee; two open rows same day allowed).
- [ ] Commit.
### Task B2: overnight math
**Files:** Modify `models/clock_schedule.py`, `models/hr_employee.py`.
- [ ] `test_overnight.py`: 22:00→06:00 with 30m break → 7.5h; scheduled out is next-day.
- [ ] `_compute_planned_hours`: if `end<=start``(24-start)+end-break/60`; set `crosses_midnight`.
- [ ] `_check_schedule_times`: allow `end<=start` (remove the overnight block) but keep break < shift length.
- [ ] `hr_employee._get_fclk_scheduled_times`: when crossing midnight, out datetime += 1 day.
- [ ] Commit.
### Task B3: multi-shift day-plan work-window
**Files:** Modify `models/hr_employee.py`.
- [ ] `test_multishift_window.py`: two posted shifts 0812 and 1317 → plan window 0817, hours = sum worked; penalties unaffected (one window).
- [ ] `_get_fclk_day_plan`: search ALL posted assigned rows for the date; if >1, earliest start / latest end, summed breaks, summed hours; single row + none unchanged.
- [ ] Commit.
### Task B4: open shifts in planner
**Files:** Modify `controllers/shift_planner.py`, planner `.js`/`.xml`.
- [ ] `_load_week_data`: include an "Open Shifts" pseudo-row (is_open rows by day).
- [ ] Endpoints `/create_open_shift`, `/bulk_apply` (apply a cell to many employee_ids — replaces `x_fc_additional_resource_ids`).
- [ ] Planner UI: open-shift lane + "Apply to…" multi-select. Commit.
### Task B5: portal self-assign / unassign
**Files:** Modify `controllers/portal_schedule.py`, `views/portal_schedule_templates.xml`, `res_company.py`/`res_config_settings.py`.
- [ ] `res_company`: `fclk_self_unassign_days_before = fields.Integer(default=1)`.
- [ ] Portal endpoints `/my/clock/schedule/claim/<id>` (open→assign me) and `/unassign/<id>` (respect days-before).
- [ ] Template: show open shifts + Claim button; show Unassign on own upcoming shifts when allowed. Commit.
---
## PHASE C — Manifest, verify, deploy
### Task C1: manifest + config settings UI
- [ ] `__manifest__.py` → version `19.0.5.0.0`; add data files (`clock_role_views.xml`, `clock_recurrence_views.xml`, `clock_recurrence_cron.xml`, `portal_schedule_templates.xml`), asset `portal_schedule.css`; mail template already in `data/`.
- [ ] `res_config_settings.py` + view: expose `fclk_planning_generation_months`, `fclk_self_unassign_days_before` (Integer — config_parameter ok).
- [ ] Commit.
### Task C2: clone-verify on Entech
- [ ] Clone `admin``admin_fctest` (pg_dump|psql inside LXC 111).
- [ ] Stage branch `fusion_clock` into an isolated `_test` addons dir shadowing prod; `-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0` on the clone; assert exit 0 + "Modules loaded".
- [ ] Run `--test-tags /fusion_clock` on the clone; assert green.
- [ ] `odoo shell` on clone: assert 144 schedule rows intact, 8 slots + 1 role migrated, portal `/my/clock/schedule` renders; `env.cr.rollback()`.
### Task C3: deploy to Entech prod (gated)
- [ ] Backup DB (`pg_dump -Fc`) + module dir copy OUTSIDE addons path.
- [ ] scp branch `fusion_clock` → pve-worker5 → `pct push` into `/mnt/extra-addons/custom/fusion_clock` (swap; keep backup).
- [ ] `systemctl stop odoo; runuser -u odoo -- odoo -c ... -d admin -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 --logfile=/tmp/up.log`. **Restart only if RC==0 + "Modules loaded"**, else restore backup, no restart.
- [ ] `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` then `systemctl start odoo`.
### Task C4: retire fusion_planning (+ optional planning)
- [ ] After prod `-u` healthy + migration verified: uninstall `fusion_planning` (Apps → uninstall, or `env['ir.module.module'].search([('name','=','fusion_planning')]).button_immediate_uninstall()` via shell with backup).
- [ ] Verify portal Schedule tab still present (now served by fusion_clock); attendance/penalty crons intact.
- [ ] Optional, last, gated: uninstall `planning`/`web_gantt` (destructive — only after migration confirmed). Leave if any doubt.
---
## Self-review notes
- Spec coverage: roles (A1A3), recurrence (A4A5), send (A6), portal (A7), migration (A8), multi/overnight/open/self-assign/bulk (B1B5), Community manifest + deploy (C). All §5§7 items mapped.
- Risk: recurrence engine + multi-window are new — covered by `test_recurrence`, `test_multishift_window`, `test_overnight`.
- Verification is batch (Entech clone), not per-step (no local docker from Mac).

View File

@@ -0,0 +1,366 @@
# Remove the Odoo Planning dependency from the Fusion Clock family
**Date:** 2026-06-04
**Module:** `fusion_clock` (absorbs `fusion_planning`, which is retired)
**Status:** Design — awaiting spec review
---
## 1. Goal
Make the Fusion Clock product family **fully Community-installable** (no Odoo
Enterprise `planning` dependency) **and** simplify the Entech deployment, while
**preserving every scheduling capability** currently delivered through Odoo
Planning. No feature is removed; the work is sequenced, not trimmed.
Driver (confirmed): **Both** — ship to clients without Enterprise *and* cut the
barely-used Planning Gantt out of Entech.
## 2. Current state (verified)
`fusion_clock` itself does **not** depend on `planning`. Its deps are clean:
`hr_attendance, hr, portal, mail, resource`. The entire Odoo-`planning` coupling
lives in **one bridge module, `fusion_planning`**:
| Coupling point | Where |
|---|---|
| `depends: ['planning']` | `fusion_planning/__manifest__.py` |
| `_inherit = 'planning.slot'` (+ `x_fc_additional_resource_ids`, auto-publish `create()`) | `fusion_planning/models/planning_slot.py` |
| inherits `planning.planning_view_form`, uses `planning.group_planning_manager` | `fusion_planning/views/planning_slot_views.xml` |
| uses `hr.employee.default_planning_role_id` / `planning_role_ids`, menu under `planning.planning_menu_settings` | `fusion_planning/views/hr_employee_role_views.xml` |
| reads `planning.slot` for the portal Schedule tab (already merges with native schedule) | `fusion_planning/controllers/portal_schedule.py` |
| the Planning **Gantt** backend UI (`web_gantt`, Enterprise) | Odoo Planning |
**Live Entech data (LXC 111, DB `admin`, Enterprise):**
| Table | Rows |
|---|---|
| `planning.slot` | **8** (7 published) |
| `planning.role` | **1** |
| `fusion.clock.schedule` (native per-day planner) | **144** |
| `fusion.clock.shift` (native templates) | 6 |
The **native** per-day planner (`fusion.clock.schedule` + the OWL shift planner)
is the real workhorse. Odoo Planning is essentially vestigial here.
No other module in the repo references `planning` or `fusion_planning` (the one
grep hit in `fusion_plating` is the English word "Planning" in a selection — noise).
## 3. Decision & rationale
**Chosen approach: re-fit Planning's *logic* onto the native per-day model
(`fusion.clock.schedule`), extended to full feature parity. Retire
`fusion_planning` by folding everything into `fusion_clock`.**
Rejected alternative: *vendor `planning.slot` + `planning.recurrency` +
`planning.planning` wholesale*. Why rejected:
1. **The Gantt can't come to Community anyway.** `web_gantt` is Enterprise.
Vendoring `planning.slot`'s datetime model still leaves us building a
non-Gantt UI — so wholesale vendoring buys the heavy data model but not the UI.
2. **Bugs live in the attendance pipeline, not the recurrence engine.**
fusion_clock's penalties (money), overtime, absence detection, reminders,
portal and dashboard all read one contract: `hr.employee._get_fclk_day_plan()`
off `fusion.clock.schedule`. Option 1 leaves that pipeline's data source
**untouched** and confines new code to isolated, testable features. Wholesale
vendoring forces either a dual schedule model or a rewire of that
money-critical pipeline onto Planning's datetime model **plus** a migration of
the 144 live rows — the highest-risk change possible, in exactly the code we
most need to keep correct.
3. **Reuse is still honoured.** We copy the parts that copy cleanly
(`planning.role` near-verbatim, the recurrence **field design + repeat
semantics**, the **mail templates**) and re-fit only the generation loop —
which is *less* code on the per-day model because it drops the
resource-interval/DST math.
**Two honest deltas vs Odoo Planning (only these):**
- **The Gantt drag-drop board** → replaced by the native weekly OWL planner.
Capability preserved, UX differs. (Accepted by owner.) Drag-drop is a possible
future enhancement, out of scope here.
- **Full resource-calendar-aware generation.** Planning's recurrence consults
resource work-intervals, flexible-resource flags and contract-end dates when
generating. The native re-fit uses the employee weekday pattern and skips
approved-leave days. This covers the real case; the heavy resource-calendar
engine is overkill at Entech's scale (8 slots). Documented simplification.
## 4. End-state architecture
- **`fusion_clock`** becomes self-contained and Community-installable. It owns:
native scheduling (existing), **roles**, **recurrence**, **publish/notify
(send)**, the **portal Schedule tab**, and **open / multi / overnight shift**
support. Manifest deps unchanged (`hr_attendance, hr, portal, mail, resource`)
— crucially **no `planning`**.
- **`fusion_planning`** is **retired** — its functionality is folded into
`fusion_clock`, then it is uninstalled on Entech.
- The attendance automation contract (`_get_fclk_day_plan`) is **unchanged** in
shape; new schedule capabilities resolve into the same single per-day
work-window it already returns (see §5.4).
## 5. Detailed design
### 5.1 New model: `fusion.clock.role` (copied from `planning.role`)
Near-verbatim copy of `planning/models/planning_role.py`:
- `name` — Char, required, translate
- `color` — Integer, default random 111
- `active` — Boolean, default True
- `sequence` — Integer
- `company_id` — Many2one `res.company` (added for fusion multi-company
consistency; Planning's role had none)
- Copy `_get_color_from_code(is_open_shift)` → returns the fullcalendar-compatible
hex used to colour shifts on the portal Schedule tab.
- Drop `resource_ids` m2m and `slot_properties_definition` (unused here).
### 5.2 `hr.employee` — native role fields
- `x_fclk_default_role_id` — Many2one `fusion.clock.role` (fills new shifts)
- `x_fclk_role_ids` — Many2many `fusion.clock.role` (allowed roles)
(Migrated from `default_planning_role_id` / `planning_role_ids`.)
**Employee Roles editor** — port `fusion_planning/views/hr_employee_role_views.xml`
to the native fields; reparent the menu from `planning.planning_menu_settings`
to a fusion_clock config menu; gate with `group_fusion_clock_manager`.
### 5.3 `fusion.clock.shift` (existing template) — additions
- `role_id` — Many2one `fusion.clock.role` (a template can carry a default role)
### 5.4 `fusion.clock.schedule` (existing per-day) — additions
**Part A additions** (ship with the core dependency removal; the
`UNIQUE(employee_id, schedule_date)` one-shift/day constraint is **kept**):
- `role_id` — Many2one `fusion.clock.role`; default from `shift_id.role_id` or
`employee_id.x_fclk_default_role_id`. Drives portal colour/label.
- `recurrence_id` — Many2one `fusion.clock.schedule.recurrence` (set when a rule
generated the row); `ondelete='set null'`.
In Part A the attendance contract `_get_fclk_day_plan` is **completely
unchanged** (one posted row per employee per day, exactly as today).
**Part B additions** (parity for currently-unused Planning capabilities; built
after A — see §10):
- `is_open` — Boolean; an **open / unassigned** shift available for self-assign.
- `crosses_midnight` — Boolean (overnight support).
- **Constraint changes:** replace the hard `UNIQUE(employee_id, schedule_date)`
with a **partial unique** that still forbids accidental duplicate assigned
rows while allowing intentional multiple shifts/day. Exact predicate finalised
in the plan; use `models.Constraint` / `models.UniqueIndex` per Odoo-19 rules.
`employee_id` becomes **not required** *only* when `is_open = True` (enforced by
a Python `@api.constrains`).
- **Overnight:** relax `_check_schedule_times` to permit `end_time <= start_time`
as crossing midnight (set `crosses_midnight`); update `_compute_planned_hours`
(`(24 - start) + end - break`) and `_get_fclk_scheduled_times` (out datetime is
next day).
**The attendance contract stays single-window in Part B too.**
`_get_fclk_day_plan(date)` still returns one plan per employee per day:
- 0 assigned rows → not scheduled (unchanged).
- 1 assigned row → that row (unchanged).
- N assigned rows for the day → resolve to one work-window = earliest start →
latest end across that day's assigned shifts; break = sum of breaks. This keeps
penalties/overtime/absence math **unchanged in shape** while letting managers
schedule split shifts and employees see each shift on the portal.
- `is_open` rows never feed any employee's plan until self-assigned.
This is the key safety property: **multi-shift / overnight / open-shift live in
the scheduling + UI + portal layers; the money-critical attendance layer keeps
its existing one-window contract.**
### 5.5 New model: `fusion.clock.schedule.recurrence` (design copied from `planning.recurrency`)
Fields (copied semantics):
- `repeat_interval` — Integer, default 1, `CHECK(repeat_interval >= 1)`
- `repeat_unit` — Selection day/week/month/year, default week
- `repeat_type` — Selection forever/until/x_times, default forever
- `repeat_until` — Date (required when `repeat_type='until'`)
- `repeat_number` — Integer (`>= 0`)
- `last_generated_date` — Date, readonly
- `company_id` — Many2one res.company
- `schedule_ids` — One2many `fusion.clock.schedule`
**Generation** (`_generate(stop_date=False)`), re-fit of `_repeat_slot` onto the
per-day model — much simpler (no resource-interval/DST math):
- Seed = the schedule entry the rule was created from (employee, weekday,
start/end/break/role).
- Emit per-day `fusion.clock.schedule` rows at the cadence (`repeat_interval` ×
`repeat_unit`) up to a horizon = `min(repeat_until, today + company.fclk_planning_generation_months, repeat_number cap)`.
- **Skip** dates the employee has an approved `fusion.clock.leave.request`
(the "resource-calendar-aware" simplification).
- Generated rows are created in **draft** (must be posted/published to drive
automation), carrying `recurrence_id`.
- Idempotent via `last_generated_date` (never regenerate past rows).
- `_stop(from_date)` deletes future **draft** rows of the rule (copy of
Planning's `_delete_slot`); posted rows are kept.
**Cron** `_cron_generate_recurring_schedules` (copy of Planning's
`_cron_schedule_next` shape) rolls the horizon forward. Odoo-19: no `numbercall`;
`active=True` recurring cron.
### 5.6 Manager UI — native OWL planner extensions
The existing weekly planner (`fusion_clock_shift_planner.js/xml` + controller
`shift_planner.py`) gains, alongside its current toolbar (Prev/This/Next week,
Copy Previous Week, Export XLSX, Save, Post Schedule):
- **Role** shown/edited per cell (colour chip from `role_id._get_color_from_code`).
- **Repeat…** control in the cell editor → creates a `fusion.clock.schedule.recurrence`
for that cell and generates rows; a backend list view manages/stops recurrences.
- **Publish & Notify** (generalises the existing `post_week`): pick a date range
(default current week) + optional employee subset + optional message → posts
matching draft rows and emails each affected employee their posted shifts for
the range (see §5.8).
- **Open shifts lane** + **bulk apply** ("Apply Also To" replacement): create an
open shift, or apply one cell's shift to several selected employees in one go.
All planner endpoints stay gated by `group_fusion_clock_manager` (unchanged).
### 5.7 Portal — fold the Schedule tab into `fusion_clock`
- Move the controller `/my/clock/schedule` into `fusion_clock`
(`controllers/portal_clock.py` or a new `portal_schedule.py`), reading **only**
`fusion.clock.schedule` (drop the `planning.slot` branch). Role colour/label
come from `role_id`.
- Move the template `fusion_planning.portal_schedule_page`
`fusion_clock.portal_schedule_page`.
- Add the **Schedule** nav button **inline** in each fusion_clock portal page's
`.fclk-nav-bar` (clock, timesheets, reports, payslips list, payslip detail),
replacing `fusion_planning`'s cross-module xpath inherits. Keep the
`.fclk-nav-bar` structure stable (no shared-template refactor — see the known
Odoo-19 xpath-inheritor gotcha).
- **Self-assign / unassign** of open shifts on the portal (respect a company
"days before shift" setting, mirroring Planning's `allow_self_unassign`).
### 5.8 Mail templates (copied, reworded)
Port Planning's "send schedule" templates (`planning/data/mail_template_data.xml`)
as the basis for fusion_clock's publish/notify email; reword for the Fusion
portal link. The native `fusion.clock.schedule.fclk_email_posted_week()` is
generalised to `fclk_email_posted_range(employee, start, end)`.
Follow Odoo-19 mail.template rules (no `url_encode` in QWeb; `ctx` is
`env.context`).
### 5.9 Security & menus
- `fusion.clock.role` + `fusion.clock.schedule.recurrence``ir.model.access.csv`
(manager write, user read as needed) + appropriate `ir.rule`s.
- Employee Roles editor menu + a Recurrences menu under the fusion_clock
configuration menu, gated `group_fusion_clock_manager`.
## 6. Data migration & retirement
### 6.1 Migration (in `fusion_clock`, guarded, idempotent)
A post-migration step (new module version) that runs only where Planning data
exists (`if 'planning.role' in env` / `'planning.slot' in env`):
1. **Roles:** each `planning.role` → find-or-create `fusion.clock.role`
(name + color). Build an id map.
2. **Employee roles:** `default_planning_role_id``x_fclk_default_role_id`;
`planning_role_ids``x_fclk_role_ids` (via the map).
3. **Slots:** each `planning.slot``fusion.clock.schedule`:
`resource_id`→employee, local date + local float start/end (employee tz),
break derived from span vs `allocated_hours`, `role_id` via map,
`state = posted` if published else draft. Unusual slots (overnight / multi /
open) handled by the §5.4 rules; anything unexpected is logged, not dropped.
Idempotent via a one-time `ir.config_parameter` marker.
Volume is tiny (8 slots, 1 role) — fast and low-risk.
### 6.2 Retire `fusion_planning`
After `fusion_clock` provides the Schedule tab + roles + migration, **uninstall
`fusion_planning`** (its portal templates, nav xpath-inherits and the
`x_fc_additional_resource_ids` m2m are removed). fusion_clock now owns the
Schedule tab inline. **Optionally** uninstall `planning` / `web_gantt` afterwards
(separate, gated cleanup — destructive, so done last and only on sign-off).
### 6.3 Community-install guarantee
After the change, `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_clock`
must install on **Community** with no `planning` present (the migration is
guarded; no runtime code references `planning.*`). Add this as a smoke check.
## 7. Entech rollout (gated, revert-on-failure)
1. **Backup** DB + module dir (outside the addons path).
2. **Clone-verify**: clone `admin` → upgrade `fusion_clock` (+migration) on the
clone → assert: 144 native rows intact, 8 slots + 1 role migrated, roles +
recurrence + portal Schedule render, attendance/penalty tests green.
3. **Prod upgrade** `fusion_clock` (stop → `-u` → start **only if RC==0 +
"Modules loaded"**, else restore backup, no restart). Clear asset bundle
attachments; restart.
4. **Uninstall `fusion_planning`**.
5. **Optional**: uninstall `planning` / `web_gantt` (final, on sign-off).
## 8. Feature-parity matrix
| Planning feature | Preserved as |
|---|---|
| Assign shifts, weekly board | Native OWL planner (extended) |
| Gantt drag-drop timeline | ❗→ native weekly planner (Gantt can't be Community) |
| Shift templates | `fusion.clock.shift` (exists) + `role_id` |
| Roles + colour | `fusion.clock.role` (copied) + portal colour |
| Employee default/allowed roles | `x_fclk_default_role_id` / `x_fclk_role_ids` + editor |
| Recurrence (N day/week/month/year; forever/until/N-times) + cron | `fusion.clock.schedule.recurrence` (copied design) |
| Send / publish + email | Publish & Notify over a range (copied templates) |
| Multiple shifts/day | per-day model + single work-window contract (§5.4) |
| Overnight shifts | `crosses_midnight` (§5.4) |
| Open shifts + self-assign/unassign | `is_open` + portal self-assign |
| Auto-publish on create | native option (kept) |
| "Apply Also To" multi-employee | native bulk-apply |
| Allocated hours, portal My Schedule | `planned_hours`; Schedule tab folded in |
| Attendance/penalty/overtime/absence | **UNCHANGED** (per-day contract preserved) |
| Resource-calendar-aware generation | simplified: weekday pattern + skip leave |
## 9. Testing strategy
- **Unit:** role + colour; recurrence generation across each repeat_type/unit;
`_stop` deletes future drafts only; publish-range posts + emails; migration maps
roles/slots/employee-roles; overnight `planned_hours` + scheduled_times;
open-shift self-assign; multi-shift day-plan → correct single work-window.
- **Regression:** existing attendance / penalty / overtime / absence / dashboard
tests stay green (data source unchanged).
- **Community smoke:** install `fusion_clock` on Community `modsdev` (no planning).
- Odoo-19 test runner: `--http-port=0 --gevent-port=0`, `--test-tags /fusion_clock`.
## 10. Sequencing
**Decision: Part A and Part B ship together in one release** (full Planning
parity at once). The A/B labels below are **internal build phases** for the
implementation plan (so each gets its own review checkpoint), not separate
deployments.
- **Phase A:** drop the planning dep with parity for everything in real use —
roles, recurrence, publish/notify, portal fold-in, migration, retire
`fusion_planning`. (Per-day model keeps one-shift/day here.)
- **Phase B:** the remaining Planning capabilities — multi-shift/day, overnight,
open shifts + self-assign, "Apply Also To" bulk — using the safe single-window
attendance contract; isolated from the attendance engine.
Both phases are validated together before the single Entech rollout in §7.
## 11. Risks & open questions
- **Recurrence correctness** is new code — mitigated by isolation + unit tests
across every repeat_type/unit and the idempotent `last_generated_date` guard.
- **Multi-shift day-plan resolution** (§5.4) is the subtlest change; covered by a
dedicated test asserting the work-window and that penalties are unaffected.
- **Licensing:** the role model is generic, the recurrence loop is re-fit/original,
and mail templates are reworded — so near-verbatim Enterprise code is minimal.
Flag for the resale build; owner's call.
- **Resolved:** Parts A and B ship together in one release (§10).
## 12. Out of scope
- Drag-and-drop on the native planner (future enhancement).
- Full resource working-interval / flexible-resource / contract-end recurrence
math (deliberate simplification, §3).
- Uninstalling `planning` from Entech is optional and gated separately.

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# One-time port of Odoo Enterprise Planning data into the native Fusion Clock
# models, so a deployment that previously used the `planning` bridge keeps all
# its roles, employee role assignments and shifts after `planning` /
# `fusion_planning` are removed.
#
# Guarded: a no-op on Community / fresh installs where planning data is absent.
# Idempotent: a marker param stops it re-running.
import logging
from odoo import api, SUPERUSER_ID
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""Drop the legacy one-shift-per-day constraint and attempt the planning ->
native port. The port (fusion.clock.schedule._fclk_port_planning_data) is
marker-guarded and self-defers: because fusion_clock doesn't depend on
planning, planning's ORM may not be loaded here, in which case the deploy
shell step finishes the port. Lives in the model so it's unit-testable."""
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")
counts = env['fusion.clock.schedule'].sudo()._fclk_port_planning_data()
if counts.get('deferred'):
_logger.info("Fusion Clock: planning models not loaded during migration; "
"data will be ported by the deploy shell step.")
else:
_logger.info("Fusion Clock: planning -> native migration: %s", counts)

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Defensive pre-migration: re-link orphaned fusion_clock config-parameter
# external IDs.
#
# Booleans/floats saved through the Settings UI go in via set_param(), which
# creates the ir_config_parameter row WITHOUT an ir_model_data external id. If a
# param later also appears in the noupdate data/ir_config_parameter_data.xml,
# a plain `-u` can't match it by external id, treats it as new, and the INSERT
# trips the UNIQUE(key) constraint -> "Failed to load registry".
#
# This runs BEFORE the data files load: for every config record in the XML whose
# param already exists but whose external id is missing, we create the external
# id pointing at the existing param. The noupdate load then matches + skips it,
# so the existing (possibly customised) value is preserved.
import logging
import os
from lxml import etree
from odoo.modules.module import get_module_path
_logger = logging.getLogger(__name__)
def migrate(cr, version):
module_path = get_module_path('fusion_clock')
if not module_path:
return
xml_path = os.path.join(module_path, 'data', 'ir_config_parameter_data.xml')
if not os.path.exists(xml_path):
return
tree = etree.parse(xml_path)
fixed = 0
for rec in tree.findall('.//record[@model="ir.config_parameter"]'):
xmlid = rec.get('id')
key_node = rec.find('./field[@name="key"]')
if not xmlid or key_node is None or not (key_node.text or '').strip():
continue
key = key_node.text.strip()
cr.execute("SELECT id FROM ir_config_parameter WHERE key = %s", (key,))
param = cr.fetchone()
if not param:
continue # not set yet -> the noupdate load will create it cleanly
cr.execute(
"SELECT id FROM ir_model_data WHERE module = 'fusion_clock' AND name = %s",
(xmlid,))
if cr.fetchone():
continue # already linked
cr.execute("""
INSERT INTO ir_model_data (module, name, model, res_id, noupdate, create_date, write_date)
VALUES ('fusion_clock', %s, 'ir.config_parameter', %s, true, now(), now())
""", (xmlid, param[0]))
fixed += 1
if fixed:
_logger.info(
"Fusion Clock: re-linked %s orphaned config-parameter external id(s).", fixed)

View File

@@ -10,7 +10,9 @@ from . import clock_report
from . import res_config_settings from . import res_config_settings
from . import clock_activity_log from . import clock_activity_log
from . import clock_leave_request from . import clock_leave_request
from . import clock_role
from . import clock_shift from . import clock_shift
from . import clock_recurrence
from . import clock_schedule from . import clock_schedule
from . import clock_correction from . import clock_correction
from . import res_company from . import res_company

View File

@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Native recurring-shift engine. The field design and repeat semantics are
# adapted from Odoo Enterprise ``planning.recurrency`` (repeat every N
# days/weeks/months/years; forever / until / N-times), but the generation loop
# targets Fusion Clock's per-day ``fusion.clock.schedule`` rows instead of
# datetime ``planning.slot`` records — so there is no resource-calendar / DST
# machinery to carry. Generated rows are born ``draft`` and must be posted
# (published) before any attendance automation acts on them.
import logging
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
# Hard safety cap on iterations when projecting a recurrence forward, so a
# misconfigured rule can never loop unbounded (5 years of daily shifts).
_MAX_OCCURRENCES = 365 * 5
class FusionClockScheduleRecurrence(models.Model):
_name = 'fusion.clock.schedule.recurrence'
_description = 'Clock Schedule Recurrence'
_rec_name = 'display_name'
schedule_ids = fields.One2many(
'fusion.clock.schedule', 'recurrence_id', string='Generated Shifts')
repeat_interval = fields.Integer('Repeat Every', default=1, required=True)
repeat_unit = fields.Selection(
[('day', 'Days'), ('week', 'Weeks'), ('month', 'Months'), ('year', 'Years')],
string='Repeat Unit', default='week', required=True)
repeat_type = fields.Selection(
[('forever', 'Forever'), ('until', 'Until'), ('x_times', 'Number of Repetitions')],
string='Until', default='forever', required=True)
repeat_until = fields.Date('Repeat Until')
repeat_number = fields.Integer('Repetitions', default=1)
last_generated_date = fields.Date(readonly=True)
company_id = fields.Many2one(
'res.company', string='Company', required=True,
default=lambda self: self.env.company)
display_name = fields.Char(compute='_compute_display_name')
_check_interval_positive = models.Constraint(
'CHECK(repeat_interval >= 1)', 'The repeat interval must be at least 1.')
@api.constrains('repeat_type', 'repeat_until')
def _check_until(self):
for rec in self:
if rec.repeat_type == 'until' and not rec.repeat_until:
raise ValidationError(_('Set an end date for an "Until" recurrence.'))
@api.constrains('repeat_type', 'repeat_number')
def _check_number(self):
for rec in self:
if rec.repeat_type == 'x_times' and rec.repeat_number < 1:
raise ValidationError(_('The number of repetitions must be at least 1.'))
@api.depends('repeat_type', 'repeat_interval', 'repeat_unit', 'repeat_until', 'repeat_number')
def _compute_display_name(self):
units = dict(self._fields['repeat_unit'].selection)
for rec in self:
unit = units.get(rec.repeat_unit, rec.repeat_unit)
if rec.repeat_type == 'forever':
rec.display_name = _('Every %(n)s %(u)s, forever', n=rec.repeat_interval, u=unit)
elif rec.repeat_type == 'until':
rec.display_name = _('Every %(n)s %(u)s until %(d)s',
n=rec.repeat_interval, u=unit, d=rec.repeat_until)
else:
rec.display_name = _('Every %(n)s %(u)s, %(c)s times',
n=rec.repeat_interval, u=unit, c=rec.repeat_number)
def _delta(self, n):
"""relativedelta for the n-th occurrence after the seed."""
self.ensure_one()
key = {'day': 'days', 'week': 'weeks', 'month': 'months', 'year': 'years'}[self.repeat_unit]
return relativedelta(**{key: self.repeat_interval * n})
def _horizon(self):
"""Furthest date we pre-generate to when the recurrence has no end."""
self.ensure_one()
months = self.company_id.fclk_planning_generation_months or 6
return fields.Date.today() + relativedelta(months=months)
def _generate(self, stop_date=False):
"""Materialise per-day schedule rows for each recurrence up to its
horizon. Idempotent: dates already covered for the rule are skipped and
``last_generated_date`` advances."""
Schedule = self.env['fusion.clock.schedule'].sudo()
for rec in self:
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.
rec.unlink()
continue
anchor = Schedule.search(
[('recurrence_id', '=', rec.id)], order='schedule_date asc', limit=1)
bounds = [stop_date or rec._horizon()]
if rec.repeat_until:
bounds.append(rec.repeat_until)
limit = min(bounds)
existing = Schedule.search_count([('recurrence_id', '=', rec.id)])
vals_list, last = [], rec.last_generated_date
for i in range(1, _MAX_OCCURRENCES + 1):
nxt = anchor.schedule_date + rec._delta(i)
if nxt > limit:
break
if rec.repeat_type == 'x_times' and existing + len(vals_list) >= rec.repeat_number:
break
if Schedule.search_count(
[('recurrence_id', '=', rec.id), ('schedule_date', '=', nxt)]):
continue
if anchor.employee_id and anchor.employee_id._fclk_on_leave(nxt):
continue
vals_list.append({
'employee_id': anchor.employee_id.id or False,
'schedule_date': nxt,
'shift_id': anchor.shift_id.id or False,
'role_id': anchor.role_id.id or False,
'is_off': anchor.is_off,
# is_open is added in the Phase B schedule extension; guard so
# the engine works whether or not that field exists yet.
'is_open': bool(getattr(anchor, 'is_open', False)),
'start_time': anchor.start_time,
'end_time': anchor.end_time,
'break_minutes': anchor.break_minutes,
'note': anchor.note or False,
'recurrence_id': rec.id,
'state': 'draft',
})
last = nxt
if vals_list:
Schedule.create(vals_list)
rec.last_generated_date = last
def _stop(self, from_date):
"""Delete future DRAFT rows of these rules (posted rows are kept)."""
self.env['fusion.clock.schedule'].sudo().search([
('recurrence_id', 'in', self.ids),
('schedule_date', '>=', from_date),
('state', '=', 'draft'),
]).unlink()
@api.model
def _cron_generate(self):
"""Roll every recurrence's horizon forward (called daily)."""
self.search([])._generate()

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Native shift role. Re-implements the small, useful subset of Odoo
# Enterprise ``planning.role`` (name + colour) so Fusion Clock can colour and
# label shifts on the portal without depending on the Enterprise Planning app.
from random import randint
from odoo import fields, models
class FusionClockRole(models.Model):
_name = 'fusion.clock.role'
_description = 'Clock Shift Role'
_order = 'sequence, name'
_rec_name = 'name'
def _get_default_color(self):
return randint(1, 11)
name = fields.Char(required=True, translate=True)
color = fields.Integer(default=_get_default_color)
active = fields.Boolean(default=True)
sequence = fields.Integer(default=10)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
)
# Kanban colour code (1-11) -> hex, mirroring planning.role._get_color_from_code
# so the portal Schedule tab shows the same palette Planning used.
_COLOR_HEX = {
0: '#008784', 1: '#EE4B39', 2: '#F29648', 3: '#F4C609',
4: '#55B7EA', 5: '#71405B', 6: '#E86869', 7: '#008784',
8: '#267283', 9: '#BF1255', 10: '#2BAF73', 11: '#8754B0',
}
def _get_color_from_code(self, is_open_shift=False):
"""Return a hex colour for this role. Open shifts get an '80' alpha
suffix (matching Planning's open-shift transparency convention)."""
self.ensure_one()
hex_value = self._COLOR_HEX.get(self.color, '#008784')
return hex_value + ('80' if is_open_shift else '')

View File

@@ -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,13 +63,35 @@ 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(
'fusion.clock.role',
string='Role',
help="Shift role — drives the colour/label shown on the employee's "
"portal schedule. Defaults from the shift template or the "
"employee's Default Shift Role.",
)
recurrence_id = fields.Many2one(
'fusion.clock.schedule.recurrence',
string='Recurrence',
ondelete='set null',
index=True,
help="Set when this entry was generated by a recurring rule.",
)
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',
@@ -86,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')
@@ -116,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."))
@@ -292,10 +345,21 @@ class FusionClockSchedule(models.Model):
new_schedule = self.browse() new_schedule = self.browse()
new_value = '' new_value = ''
else: else:
# Resolve the role: explicit payload role wins, then the shift
# template's role, then the employee's default role.
role_id = payload.get('role_id')
if not role_id:
shift_id = parsed.get('shift_id')
shift = self.env['fusion.clock.shift'].browse(shift_id) if shift_id else None
if shift and shift.role_id:
role_id = shift.role_id.id
elif employee.x_fclk_default_role_id:
role_id = employee.x_fclk_default_role_id.id
vals = { vals = {
'employee_id': employee.id, 'employee_id': employee.id,
'schedule_date': date_obj, 'schedule_date': date_obj,
'shift_id': parsed.get('shift_id') or False, 'shift_id': parsed.get('shift_id') or False,
'role_id': int(role_id) if role_id else False,
'is_off': bool(parsed.get('is_off')), 'is_off': bool(parsed.get('is_off')),
'start_time': parsed.get('start_time') or 0.0, 'start_time': parsed.get('start_time') or 0.0,
'end_time': parsed.get('end_time') or 0.0, 'end_time': parsed.get('end_time') or 0.0,
@@ -349,6 +413,10 @@ class FusionClockSchedule(models.Model):
'hours': schedule.planned_hours, 'hours': schedule.planned_hours,
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours), 'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
'note': schedule.note or '', 'note': schedule.note or '',
'role_id': schedule.role_id.id or False,
'role_name': schedule.role_id.name or '',
'role_color': schedule.role_id._get_color_from_code() if schedule.role_id else '',
'recurring': bool(schedule.recurrence_id),
} }
plan = employee._get_fclk_day_plan(date_obj) plan = employee._get_fclk_day_plan(date_obj)
@@ -366,25 +434,122 @@ class FusionClockSchedule(models.Model):
'hours': plan.get('hours') or 0.0, 'hours': plan.get('hours') or 0.0,
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0), 'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
'note': '', 'note': '',
'role_id': False,
'role_name': '',
'role_color': '',
} }
@api.model @api.model
def fclk_email_posted_week(self, employee, week_start, week_end): def fclk_attach_recurrence(self, schedule, repeat_vals):
"""Email one employee a summary of their POSTED shifts for the week.""" """Attach a recurrence rule to a seed schedule cell and generate it
forward. ``repeat_vals`` mirrors the recurrence fields."""
schedule = schedule.sudo()
if not schedule:
raise ValidationError(_("Pick a shift to repeat first."))
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',
'repeat_type': repeat_vals.get('repeat_type') or 'forever',
'repeat_until': repeat_vals.get('repeat_until') or False,
'repeat_number': int(repeat_vals.get('repeat_number') or 1),
'company_id': schedule.company_id.id or self.env.company.id,
})
schedule.recurrence_id = rule.id
rule._generate()
return rule
@api.model
def fclk_clear_recurrence(self, schedule):
"""Detach + stop the recurrence on a seed cell (keeps posted rows)."""
schedule = schedule.sudo()
rule = schedule.recurrence_id
if rule:
rule._stop(fields.Date.today())
schedule.recurrence_id = False
if not rule.schedule_ids:
rule.unlink()
return True
# ----- Open shifts + bulk apply (native "Apply Also To" / self-assign) -----
@api.model
def fclk_create_open_shifts(self, company, date_obj, start, end,
role_id=False, count=1, break_minutes=0.0, note=None):
"""Create N open (unassigned) shifts for a day, available to claim."""
date_obj = fields.Date.to_date(date_obj)
if not date_obj:
raise ValidationError(_("Pick a date for the open shift."))
company_id = (company.id if company else False) or self.env.company.id
vals_list = [{
'is_open': True,
'schedule_date': date_obj,
'start_time': float(start or 0.0),
'end_time': float(end or 0.0),
'break_minutes': float(break_minutes or 0.0),
'role_id': int(role_id) if role_id else False,
'company_id': company_id,
'note': note or False,
'state': 'posted',
} for _i in range(max(1, int(count or 1)))]
return self.sudo().create(vals_list)
@api.model
def fclk_claim_open_shift(self, schedule, employee):
"""Assign an open shift to an employee (portal self-assign)."""
schedule = schedule.sudo()
employee = employee.sudo()
if not schedule or not schedule.is_open:
raise ValidationError(_("This shift is no longer available."))
if not employee:
raise ValidationError(_("No employee to assign this shift to."))
# If the shift carries a role and the employee has an explicit allowed
# list, enforce eligibility (no list = eligible for anything).
if schedule.role_id and employee.x_fclk_role_ids \
and schedule.role_id not in employee.x_fclk_role_ids:
raise ValidationError(_("You are not eligible for this shift's role."))
schedule.write({'employee_id': employee.id, 'is_open': False})
return schedule
@api.model
def fclk_release_shift(self, schedule, employee):
"""Release a claimed shift back to the open pool (portal self-unassign),
respecting the company's days-before cutoff."""
schedule = schedule.sudo()
if not schedule or schedule.employee_id != employee.sudo():
raise ValidationError(_("You can only release your own shift."))
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."))
schedule.write({'employee_id': False, 'is_open': True})
return schedule
@api.model
def fclk_bulk_apply(self, employees, date_obj, payload, user=None):
"""Apply the same shift payload to several employees in one go
(native replacement for Planning's 'Apply Also To')."""
results = self.browse()
for employee in employees:
results |= self.fclk_apply_planner_cell(employee, date_obj, dict(payload or {}), user)
return results
@api.model
def fclk_email_posted_range(self, employee, start, end, message=None):
"""Email one employee a summary of their POSTED shifts between two
dates (inclusive). Optional ``message`` is shown above the schedule."""
employee = employee.sudo() employee = employee.sudo()
if not employee.work_email: if not employee.work_email:
return False return False
from .hr_attendance import _fclk_email_wrap from .hr_attendance import _fclk_email_wrap
entries = self.sudo().search([ entries = self.sudo().search([
('employee_id', '=', employee.id), ('employee_id', '=', employee.id),
('schedule_date', '>=', week_start), ('schedule_date', '>=', start),
('schedule_date', '<=', week_end), ('schedule_date', '<=', end),
('state', '=', 'posted'), ('state', '=', 'posted'),
]) ])
by_date = {entry.schedule_date: entry for entry in entries} by_date = {entry.schedule_date: entry for entry in entries}
rows = [] rows = []
day = week_start day = start
while day <= week_end: while day <= end:
entry = by_date.get(day) entry = by_date.get(day)
rows.append(( rows.append((
day.strftime('%a %b %d'), day.strftime('%a %b %d'),
@@ -392,20 +557,23 @@ class FusionClockSchedule(models.Model):
)) ))
day += timedelta(days=1) day += timedelta(days=1)
company = employee.company_id or self.env.company company = employee.company_id or self.env.company
summary = (
f'Hello <strong>{employee.name}</strong>, your shifts for '
f'<strong>{start.strftime("%b %d")} - {end.strftime("%b %d, %Y")}</strong> '
f'have been posted.'
)
if message:
summary += f'<br/><br/>{message}'
body = _fclk_email_wrap( body = _fclk_email_wrap(
company_name=company.name or '', company_name=company.name or '',
title='Your Posted Schedule', title='Your Posted Schedule',
summary=( summary=summary,
f'Hello <strong>{employee.name}</strong>, your shifts for ' sections=[('Schedule', rows)],
f'<strong>{week_start.strftime("%b %d")} - {week_end.strftime("%b %d, %Y")}</strong> ' note='Log in to <a href="/my/clock/schedule" style="color:#10B981;">your portal</a> for details.',
f'have been posted.'
),
sections=[('This Week', rows)],
note='Log in to <a href="/my/clock" style="color:#10B981;">your portal</a> for details.',
) )
try: try:
mail = self.env['mail.mail'].sudo().create({ mail = self.env['mail.mail'].sudo().create({
'subject': f'Your schedule: {week_start.strftime("%b %d")} - {week_end.strftime("%b %d")}', 'subject': f'Your schedule: {start.strftime("%b %d")} - {end.strftime("%b %d")}',
'email_from': company.email or '', 'email_from': company.email or '',
'email_to': employee.work_email, 'email_to': employee.work_email,
'body_html': body, 'body_html': body,
@@ -419,6 +587,136 @@ class FusionClockSchedule(models.Model):
) )
return False return False
@api.model
def fclk_email_posted_week(self, employee, week_start, week_end):
"""Back-compat wrapper — email one employee their posted week."""
return self.fclk_email_posted_range(employee, week_start, week_end)
@api.model
def fclk_publish_range(self, employees, start, end, message=None):
"""Post every draft shift in [start, end] for the given employees and
email each affected employee. Returns (posted_count, notified_count)."""
Schedule = self.sudo()
domain = [
('employee_id', 'in', employees.ids),
('schedule_date', '>=', start),
('schedule_date', '<=', end),
('state', '!=', 'posted'),
]
# Never auto-post open (unassigned) shifts (Phase B field).
if 'is_open' in Schedule._fields:
domain.append(('is_open', '=', False))
drafts = Schedule.search(domain)
posted = len(drafts)
affected = drafts.mapped('employee_id')
if drafts:
drafts.write({'state': 'posted', 'posted_date': fields.Datetime.now()})
notified = 0
for employee in affected:
if Schedule.fclk_email_posted_range(employee, start, end, message=message):
notified += 1
return posted, notified
@api.model
def _fclk_port_planning_data(self):
"""Port Odoo Planning data (roles, employee roles, slots) into the
native models. Idempotent (marker-guarded). Returns a dict of counts.
Because fusion_clock does NOT depend on planning, during a `-u` planning
may load AFTER us, so its ORM models aren't available in the migration's
registry. When that happens we set ``deferred`` and do nothing; the
deploy then runs this again from `odoo shell`, where the whole registry
(planning included) is loaded. Called by the 19.0.5.0.0 migration, the
deploy shell step, and tests."""
import pytz
counts = {'roles': 0, 'employees': 0, 'slots': 0, 'skipped': 0, 'deferred': False}
env = self.env
ICP = env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.planning_migrated'):
return counts
# Do the planning tables exist at all? (raw SQL — independent of whether
# planning's ORM models are loaded in this registry.)
env.cr.execute(
"SELECT to_regclass('public.planning_role'), to_regclass('public.planning_slot')")
role_tbl, slot_tbl = env.cr.fetchone()
if not role_tbl and not slot_tbl:
ICP.set_param('fusion_clock.planning_migrated', '1') # Community / fresh
return counts
# Tables exist but the ORM models may not be loaded yet -> defer.
if 'planning.slot' not in env or 'planning.role' not in env:
counts['deferred'] = True
return counts
has_roles = bool(role_tbl)
has_slots = bool(slot_tbl)
Role = env['fusion.clock.role'].sudo()
role_map = {}
if has_roles:
for prole in env['planning.role'].sudo().with_context(active_test=False).search([]):
target = Role.with_context(active_test=False).search(
[('name', '=ilike', prole.name)], limit=1) or Role.create({
'name': prole.name, 'color': prole.color or 1, 'active': prole.active})
role_map[prole.id] = target.id
counts['roles'] = len(role_map)
Employee = env['hr.employee'].sudo().with_context(active_test=False)
for emp in Employee.search([]):
vals = {}
if emp._fields.get('default_planning_role_id') and emp.default_planning_role_id:
mapped = role_map.get(emp.default_planning_role_id.id)
if mapped:
vals['x_fclk_default_role_id'] = mapped
if emp._fields.get('planning_role_ids') and emp.planning_role_ids:
mapped_ids = [role_map[r.id] for r in emp.planning_role_ids if r.id in role_map]
if mapped_ids:
vals['x_fclk_role_ids'] = [(6, 0, mapped_ids)]
if vals:
emp.write(vals)
counts['employees'] += 1
if has_slots:
Schedule = self.sudo()
for slot in env['planning.slot'].sudo().search([], order='start_datetime'):
if not slot.start_datetime or not slot.end_datetime:
counts['skipped'] += 1
continue
employee = slot.employee_id if 'employee_id' in slot._fields else False
tz_name = ((employee.tz if employee else False)
or (slot.resource_id.tz if slot.resource_id else False)
or env.company.partner_id.tz or 'UTC')
try:
tz = pytz.timezone(tz_name)
except Exception:
tz = pytz.UTC
local_start = pytz.utc.localize(slot.start_datetime).astimezone(tz)
local_end = pytz.utc.localize(slot.end_datetime).astimezone(tz)
span_hours = (slot.end_datetime - slot.start_datetime).total_seconds() / 3600.0
allocated = slot.allocated_hours if 'allocated_hours' in slot._fields else span_hours
vals = {
'employee_id': employee.id if employee else False,
'is_open': not bool(employee),
'schedule_date': local_start.date(),
'start_time': round(local_start.hour + local_start.minute / 60.0, 2),
'end_time': round(local_end.hour + local_end.minute / 60.0, 2),
'break_minutes': round(max(0.0, span_hours - (allocated or span_hours)) * 60.0, 0),
'role_id': role_map.get(slot.role_id.id) if slot.role_id else False,
'note': slot.name or False,
'state': 'posted' if slot.state == 'published' else 'draft',
}
with env.cr.savepoint():
try:
Schedule.create(vals)
counts['slots'] += 1
except Exception as exc:
counts['skipped'] += 1
_logger.warning("Fusion Clock: skip planning.slot %s (%s).", slot.id, exc)
ICP.set_param('fusion_clock.planning_migrated', '1')
return counts
class FusionClockScheduleAudit(models.Model): class FusionClockScheduleAudit(models.Model):
_name = 'fusion.clock.schedule.audit' _name = 'fusion.clock.schedule.audit'

View File

@@ -42,6 +42,12 @@ class FusionClockShift(models.Model):
) )
active = fields.Boolean(default=True) active = fields.Boolean(default=True)
color = fields.Char(string='Color', default='#3B82F6') color = fields.Char(string='Color', default='#3B82F6')
role_id = fields.Many2one(
'fusion.clock.role',
string='Default Role',
help="Role assigned to shifts created from this template "
"(drives the colour/label on the employee's portal schedule).",
)
# Weekday pattern — which days this recurring shift applies as the baseline # Weekday pattern — which days this recurring shift applies as the baseline
# when there is no posted planner entry for the day. Default Mon-Fri. # when there is no posted planner entry for the day. Default Mon-Fri.

View File

@@ -33,6 +33,21 @@ class HrEmployee(models.Model):
help="Assigned shift schedule. Leave empty to use global defaults.", help="Assigned shift schedule. Leave empty to use global defaults.",
) )
# Shift roles (native replacement for Odoo Planning's employee role fields)
x_fclk_default_role_id = fields.Many2one(
'fusion.clock.role',
string='Default Shift Role',
help="Pre-fills the role on every new shift created for this employee.",
)
x_fclk_role_ids = fields.Many2many(
'fusion.clock.role',
'fclk_employee_role_rel',
'employee_id',
'role_id',
string='Allowed Shift Roles',
help="Roles this employee is allowed to be scheduled for.",
)
# Pending reason enforcement # Pending reason enforcement
x_fclk_pending_reason = fields.Boolean( x_fclk_pending_reason = fields.Boolean(
string='Pending Reason Required', string='Pending Reason Required',
@@ -158,6 +173,19 @@ class HrEmployee(models.Model):
('schedule_date', '=', date_obj), ('schedule_date', '=', date_obj),
], limit=1) ], limit=1)
def _fclk_on_leave(self, date):
"""True if an approved leave request covers ``date`` for this employee.
Used by the recurrence engine to skip generating shifts on days off."""
self.ensure_one()
date_obj = fields.Date.to_date(date)
if not date_obj:
return False
return bool(self.env['fusion.clock.leave.request'].sudo().search_count([
('employee_id', '=', self.id),
('leave_date', '<=', date_obj),
('date_to', '>=', date_obj),
]))
def _get_fclk_day_plan(self, date): def _get_fclk_day_plan(self, date):
"""Return the effective plan for a local date, with an explicit """Return the effective plan for a local date, with an explicit
``scheduled`` flag that ALL attendance automation keys off. ``scheduled`` flag that ALL attendance automation keys off.
@@ -172,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,
@@ -198,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),
@@ -214,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,
@@ -292,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)

View File

@@ -14,3 +14,14 @@ class ResCompany(models.Model):
domain="[('company_id', '=', id)]", domain="[('company_id', '=', id)]",
help="Clock location bound to the on-site kiosk (NFC and PIN) for this company.", help="Clock location bound to the on-site kiosk (NFC and PIN) for this company.",
) )
fclk_planning_generation_months = fields.Integer(
string='Schedule Generation Horizon (months)',
default=6,
help="How many months ahead recurring shifts are pre-generated.",
)
fclk_self_unassign_days_before = fields.Integer(
string='Self-Unassign Cutoff (days before shift)',
default=1,
help="Employees may release an open shift they claimed up to this many "
"days before it starts.",
)

View File

@@ -245,6 +245,19 @@ class ResConfigSettings(models.TransientModel):
help="Which clock location is bound to the NFC kiosk for this company. " help="Which clock location is bound to the NFC kiosk for this company. "
"Required when the kiosk is enabled.", "Required when the kiosk is enabled.",
) )
fclk_planning_generation_months = fields.Integer(
related='company_id.fclk_planning_generation_months',
readonly=False,
string='Schedule Generation Horizon (months)',
help="How many months ahead recurring shifts are pre-generated.",
)
fclk_self_unassign_days_before = fields.Integer(
related='company_id.fclk_self_unassign_days_before',
readonly=False,
string='Self-Unassign Cutoff (days before shift)',
help="Employees may release an open shift they claimed up to this many "
"days before it starts.",
)
fclk_photo_retention_days = fields.Integer( fclk_photo_retention_days = fields.Integer(
string='Auto-Wipe Photos After (days)', string='Auto-Wipe Photos After (days)',
config_parameter='fusion_clock.photo_retention_days', config_parameter='fusion_clock.photo_retention_days',

View File

@@ -28,3 +28,8 @@ access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_sh
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0 access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1 access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1 access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_role_user,fusion.clock.role.user,model_fusion_clock_role,group_fusion_clock_user,1,0,0,0
access_fusion_clock_role_manager,fusion.clock.role.manager,model_fusion_clock_role,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_role_portal,fusion.clock.role.portal,model_fusion_clock_role,base.group_portal,1,0,0,0
access_fusion_clock_recurrence_user,fusion.clock.schedule.recurrence.user,model_fusion_clock_schedule_recurrence,group_fusion_clock_user,1,0,0,0
access_fusion_clock_recurrence_manager,fusion.clock.schedule.recurrence.manager,model_fusion_clock_schedule_recurrence,group_fusion_clock_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
28 access_fusion_clock_schedule_portal fusion.clock.schedule.portal model_fusion_clock_schedule base.group_portal 1 0 0 0
29 access_fusion_clock_nfc_enrollment_wizard_manager fusion.clock.nfc.enrollment.wizard.manager model_fusion_clock_nfc_enrollment_wizard group_fusion_clock_manager 1 1 1 1
30 access_fusion_clock_break_rule_manager fusion.clock.break.rule.manager model_fusion_clock_break_rule group_fusion_clock_manager 1 1 1 1
31 access_fusion_clock_role_user fusion.clock.role.user model_fusion_clock_role group_fusion_clock_user 1 0 0 0
32 access_fusion_clock_role_manager fusion.clock.role.manager model_fusion_clock_role group_fusion_clock_manager 1 1 1 1
33 access_fusion_clock_role_portal fusion.clock.role.portal model_fusion_clock_role base.group_portal 1 0 0 0
34 access_fusion_clock_recurrence_user fusion.clock.schedule.recurrence.user model_fusion_clock_schedule_recurrence group_fusion_clock_user 1 0 0 0
35 access_fusion_clock_recurrence_manager fusion.clock.schedule.recurrence.manager model_fusion_clock_schedule_recurrence group_fusion_clock_manager 1 1 1 1

View File

@@ -0,0 +1,156 @@
/* Fusion Planning - Portal Schedule
* Inherits Fusion Clock dark-theme tokens (--fclk-card, --fclk-green, etc.)
*/
/* ---- 4-tab nav fit (keep items grouped at center, just tighter padding) ---- */
.fclk-nav-item {
padding: 8px 19px !important;
}
/* ---- Next Shift hero card ---- */
.fpl-next-shift {
text-align: center;
padding: 20px 16px;
}
.fpl-next-label {
font-size: 11px;
color: var(--fclk-text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 6px;
}
.fpl-next-date {
font-size: 18px;
color: var(--fclk-text);
font-weight: 600;
margin-bottom: 4px;
}
.fpl-next-time {
font-size: 32px;
color: var(--fclk-green);
font-weight: 700;
letter-spacing: 0.02em;
margin-bottom: 6px;
}
.fpl-next-role {
display: inline-block;
font-size: 12px;
color: var(--fclk-text-dim);
background: rgba(16, 185, 129, 0.08);
border: 1px solid rgba(16, 185, 129, 0.18);
padding: 4px 12px;
border-radius: 12px;
}
/* ---- Empty state ---- */
.fpl-empty-card {
text-align: center;
padding: 28px 16px;
}
.fpl-empty-icon {
margin-bottom: 12px;
opacity: 0.7;
}
.fpl-empty-title {
font-size: 16px;
color: var(--fclk-text);
font-weight: 600;
margin-bottom: 6px;
}
.fpl-empty-sub {
font-size: 13px;
color: var(--fclk-text-dim);
}
/* ---- Group headers ---- */
.fpl-group {
margin-bottom: 18px;
}
.fpl-group-title {
font-size: 13px;
color: var(--fclk-text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
margin: 12px 16px 8px;
}
.fpl-list {
display: flex;
flex-direction: column;
gap: 8px;
}
/* ---- Shift item polish ---- */
.fpl-shift-item .fclk-recent-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.fpl-shift-note {
font-size: 11px;
color: var(--fclk-text-dim);
margin-top: 2px;
font-style: italic;
}
/* ---- Claim / release feedback + open shifts ---- */
.fpl-flash {
margin: 0 16px 12px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
}
.fpl-flash-err {
background: rgba(239, 68, 68, 0.10);
border: 1px solid rgba(239, 68, 68, 0.30);
color: #ef4444;
}
.fpl-flash-ok {
background: rgba(16, 185, 129, 0.10);
border: 1px solid rgba(16, 185, 129, 0.25);
color: var(--fclk-green);
}
.fpl-open-item {
align-items: center;
justify-content: space-between;
}
.fpl-claim-form,
.fpl-release-form {
display: inline-block;
margin: 0;
}
.fpl-release-btn {
display: block;
margin-top: 4px;
background: transparent;
border: 1px solid rgba(239, 68, 68, 0.35);
color: #ef4444;
font-size: 11px;
border-radius: 6px;
padding: 2px 8px;
cursor: pointer;
}
.fpl-release-btn:hover {
background: rgba(239, 68, 68, 0.10);
}
/* ---- Bottom padding so nav doesn't cover last shift ---- */
.fclk-container {
padding-bottom: 80px;
}

View File

@@ -45,7 +45,13 @@ export class FusionClockShiftPlanner extends Component {
error: "", error: "",
top: 0, top: 0,
left: 0, left: 0,
recurring: false,
showRepeat: false,
repeat: { interval: 1, unit: "week", type: "forever", until: "", number: 4 },
}, },
publish: { open: false, from: "", to: "", message: "" },
openShifts: {},
openShift: { open: false, date: "", start: "09:00", end: "17:00", count: 1 },
}); });
onWillStart(async () => { onWillStart(async () => {
@@ -88,6 +94,7 @@ export class FusionClockShiftPlanner extends Component {
this.state.departments = data.departments || []; this.state.departments = data.departments || [];
this.state.employees = data.employees || []; this.state.employees = data.employees || [];
this.state.shifts = data.shifts || []; this.state.shifts = data.shifts || [];
this.state.openShifts = data.open_shifts || {};
this.state.dirtyCount = 0; this.state.dirtyCount = 0;
this.state.invalidCount = 0; this.state.invalidCount = 0;
let draft = 0; let draft = 0;
@@ -258,9 +265,212 @@ export class FusionClockShiftPlanner extends Component {
this.state.editor.breakMinutes = breakMinutes; this.state.editor.breakMinutes = breakMinutes;
this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours); this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours);
this.state.editor.error = cell.error || ""; this.state.editor.error = cell.error || "";
this.state.editor.recurring = !!cell.recurring;
this.state.editor.showRepeat = false;
this._positionActiveEditor(anchor); this._positionActiveEditor(anchor);
} }
toggleRepeatPanel() {
this.state.editor.showRepeat = !this.state.editor.showRepeat;
}
onRepeatField(field, ev) {
const value = ev.target.value;
this.state.editor.repeat[field] =
field === "interval" || field === "number" ? Number(value) : value;
}
async setRecurrence() {
const editor = this.state.editor;
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/set_recurrence", {
employee_id: editor.employeeId,
date: editor.date,
week_start: this.state.weekStart,
repeat: {
repeat_interval: editor.repeat.interval,
repeat_unit: editor.repeat.unit,
repeat_type: editor.repeat.type,
repeat_until: editor.repeat.until || false,
repeat_number: editor.repeat.number,
},
});
if (result.error || result.success === false) {
this.notification.add(result.error || result.message || "Could not repeat shift.", {
type: "danger",
});
} else {
this._applyData(result.data);
this.notification.add("Recurring shift created.", { type: "success" });
}
} catch (error) {
this.notification.add(error.message || "Could not repeat shift.", { type: "danger" });
}
this.state.saving = false;
}
async clearRecurrence() {
const editor = this.state.editor;
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/clear_recurrence", {
employee_id: editor.employeeId,
date: editor.date,
week_start: this.state.weekStart,
});
if (result.error) {
this.notification.add(result.error, { type: "danger" });
} else {
this._applyData(result.data);
this.notification.add("Recurrence stopped.", { type: "success" });
}
} catch (error) {
this.notification.add(error.message || "Could not stop recurrence.", { type: "danger" });
}
this.state.saving = false;
}
togglePublishPanel() {
this.state.publish.open = !this.state.publish.open;
if (this.state.publish.open && !this.state.publish.from) {
this.state.publish.from = this.state.weekStart;
this.state.publish.to = this.state.weekEnd;
}
}
onPublishField(field, ev) {
this.state.publish[field] = ev.target.value;
}
async publishRange() {
const publish = this.state.publish;
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/publish_range", {
date_from: publish.from,
date_to: publish.to,
message: publish.message,
week_start: this.state.weekStart,
});
if (result.error || result.success === false) {
this.notification.add(result.error || result.message || "Could not publish.", {
type: "danger",
});
} else {
this._applyData(result.data);
this.state.publish.open = false;
this.notification.add(
`Published ${result.posted} shift(s); notified ${result.notified} employee(s).`,
{ type: "success" }
);
}
} catch (error) {
this.notification.add(error.message || "Could not publish.", { type: "danger" });
}
this.state.saving = false;
}
_timeStrToFloat(str) {
const [h, m] = (str || "0:0").split(":").map(Number);
return (h || 0) + (m || 0) / 60;
}
getOpenShiftsForDay(date) {
return this.state.openShifts[date] || [];
}
get hasOpenShifts() {
return Object.keys(this.state.openShifts || {}).length > 0;
}
toggleOpenShiftPanel() {
this.state.openShift.open = !this.state.openShift.open;
if (this.state.openShift.open && !this.state.openShift.date) {
this.state.openShift.date = this.state.weekStart;
}
}
onOpenShiftField(field, ev) {
this.state.openShift[field] = ev.target.value;
}
async addOpenShift() {
const os = this.state.openShift;
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/create_open_shift", {
date: os.date || this.state.weekStart,
start_time: this._timeStrToFloat(os.start),
end_time: this._timeStrToFloat(os.end),
count: Number(os.count) || 1,
week_start: this.state.weekStart,
});
if (result.error || result.success === false) {
this.notification.add(result.error || result.message || "Could not add open shift.", {
type: "danger",
});
} else {
this._applyData(result.data);
this.state.openShift.open = false;
this.notification.add("Open shift added.", { type: "success" });
}
} catch (error) {
this.notification.add(error.message || "Could not add open shift.", { type: "danger" });
}
this.state.saving = false;
}
async deleteOpenShift(id) {
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/delete_open_shift", {
schedule_id: id,
week_start: this.state.weekStart,
});
if (!result.error) {
this._applyData(result.data);
}
} catch (error) {
this.notification.add(error.message || "Could not remove open shift.", { type: "danger" });
}
this.state.saving = false;
}
async bulkApplyDept() {
const editor = this.state.editor;
const employee = this.state.employees.find((e) => e.id === editor.employeeId);
if (!employee) {
return;
}
const department = this.state.departments.find((d) => d.id === employee.department_id);
const ids = (department && department.employee_ids) || [employee.id];
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/bulk_apply", {
employee_ids: ids,
date: editor.date,
week_start: this.state.weekStart,
payload: {
start_time: Number(editor.startValue),
end_time: Number(editor.endValue),
break_minutes: editor.breakMinutes || 0,
},
});
if (result.error || result.success === false) {
this.notification.add(result.error || result.message || "Could not apply.", {
type: "danger",
});
} else {
this._applyData(result.data);
this.notification.add(`Applied to ${ids.length} employee(s).`, { type: "success" });
}
} catch (error) {
this.notification.add(error.message || "Could not apply.", { type: "danger" });
}
this.state.saving = false;
}
closeCellEditor() { closeCellEditor() {
this.state.editor.open = false; this.state.editor.open = false;
this.activeCellAnchor = null; this.activeCellAnchor = null;

View File

@@ -217,6 +217,131 @@
padding: 4px; padding: 4px;
vertical-align: top; vertical-align: top;
background: var(--fclk-planner-card, #ffffff); background: var(--fclk-planner-card, #ffffff);
position: relative;
}
.fclk-planner__cell-recur {
position: absolute;
top: 2px;
right: 4px;
font-size: 9px;
opacity: 0.6;
pointer-events: none;
}
.fclk-planner__cell-role {
position: absolute;
bottom: 3px;
right: 4px;
width: 8px;
height: 8px;
border-radius: 50%;
pointer-events: none;
}
.fclk-planner__publish-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin: 0 10px 10px;
padding: 10px 12px;
background: var(--fclk-planner-card, #ffffff);
border: 1px solid var(--fclk-planner-border, #d8dadd);
border-radius: 6px;
.fclk-planner__publish-msg {
flex: 1 1 220px;
min-width: 160px;
}
}
.fclk-planner__open-strip {
margin: 0 10px 10px;
padding: 8px 12px;
background: var(--fclk-planner-card, #ffffff);
border: 1px dashed var(--fclk-planner-border, #d8dadd);
border-radius: 6px;
.fclk-planner__open-strip-title {
font-size: 12px;
font-weight: 600;
opacity: 0.75;
margin-bottom: 6px;
}
.fclk-planner__open-cols {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.fclk-planner__open-col {
min-width: 120px;
}
.fclk-planner__open-day {
font-size: 11px;
font-weight: 600;
opacity: 0.6;
margin-bottom: 4px;
}
.fclk-planner__open-chip {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
padding: 3px 6px;
margin-bottom: 4px;
background: var(--fclk-planner-fallback, #fff8e5);
border-radius: 4px;
}
.fclk-planner__open-role {
font-size: 10px;
opacity: 0.7;
}
.fclk-planner__open-del {
margin-left: auto;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
line-height: 1;
opacity: 0.6;
}
.fclk-planner__open-del:hover {
opacity: 1;
}
}
.fclk-planner__repeat-panel {
border-top: 1px solid var(--fclk-planner-border, #d8dadd);
margin-top: 6px;
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
.fclk-planner__repeat-row {
display: flex;
align-items: center;
gap: 6px;
select,
input {
flex: 1;
min-width: 0;
}
}
.fclk-planner__repeat-int {
max-width: 64px;
flex: 0 0 auto;
}
} }
.fclk-planner__shift-cell--fallback { .fclk-planner__shift-cell--fallback {

View File

@@ -32,6 +32,52 @@
<i class="fa fa-paper-plane me-1"/> Post Schedule <i class="fa fa-paper-plane me-1"/> Post Schedule
<t t-if="state.draftCount">(<t t-esc="state.draftCount"/> draft)</t> <t t-if="state.draftCount">(<t t-esc="state.draftCount"/> draft)</t>
</button> </button>
<button class="btn btn-outline-success" t-on-click="() => this.togglePublishPanel()" t-att-disabled="state.loading or state.saving" title="Publish a custom date range and notify employees">
<i class="fa fa-calendar-check-o me-1"/> Publish…
</button>
<button class="btn btn-outline-secondary" t-on-click="() => this.toggleOpenShiftPanel()" t-att-disabled="state.loading or state.saving" title="Create an open shift employees can claim">
<i class="fa fa-plus me-1"/> Open Shift…
</button>
</div>
</div>
<div t-if="state.publish.open" class="fclk-planner__publish-panel">
<label>From <input type="date" t-att-value="state.publish.from" t-on-change="(ev) => this.onPublishField('from', ev)"/></label>
<label>To <input type="date" t-att-value="state.publish.to" t-on-change="(ev) => this.onPublishField('to', ev)"/></label>
<input type="text" class="fclk-planner__publish-msg" placeholder="Optional message to employees…"
t-att-value="state.publish.message" t-on-change="(ev) => this.onPublishField('message', ev)"/>
<button class="btn btn-success btn-sm" t-on-click="() => this.publishRange()" t-att-disabled="state.saving">
<i class="fa fa-paper-plane me-1"/> Publish &amp; Notify
</button>
<button class="btn btn-light btn-sm" t-on-click="() => this.togglePublishPanel()">Cancel</button>
</div>
<div t-if="state.openShift.open" class="fclk-planner__publish-panel">
<label>Date <input type="date" t-att-value="state.openShift.date" t-on-change="(ev) => this.onOpenShiftField('date', ev)"/></label>
<label>Start <input type="time" t-att-value="state.openShift.start" t-on-change="(ev) => this.onOpenShiftField('start', ev)"/></label>
<label>End <input type="time" t-att-value="state.openShift.end" t-on-change="(ev) => this.onOpenShiftField('end', ev)"/></label>
<label>Count <input type="number" min="1" class="fclk-planner__repeat-int" t-att-value="state.openShift.count" t-on-change="(ev) => this.onOpenShiftField('count', ev)"/></label>
<button class="btn btn-secondary btn-sm" t-on-click="() => this.addOpenShift()" t-att-disabled="state.saving">
<i class="fa fa-plus me-1"/> Add Open Shift
</button>
<button class="btn btn-light btn-sm" t-on-click="() => this.toggleOpenShiftPanel()">Cancel</button>
</div>
<div t-if="hasOpenShifts" class="fclk-planner__open-strip">
<div class="fclk-planner__open-strip-title"><i class="fa fa-bullhorn me-1"/> Open Shifts (employees can claim)</div>
<div class="fclk-planner__open-cols">
<t t-foreach="state.days" t-as="day" t-key="'open_' + day.date">
<div class="fclk-planner__open-col" t-if="getOpenShiftsForDay(day.date).length">
<div class="fclk-planner__open-day"><t t-esc="day.weekday"/> <t t-esc="day.label"/></div>
<t t-foreach="getOpenShiftsForDay(day.date)" t-as="op" t-key="op.id">
<div class="fclk-planner__open-chip">
<span><t t-esc="op.label"/></span>
<span t-if="op.role_name" class="fclk-planner__open-role"><t t-esc="op.role_name"/></span>
<button class="fclk-planner__open-del" t-on-click="() => this.deleteOpenShift(op.id)" title="Remove open shift">×</button>
</div>
</t>
</div>
</t>
</div> </div>
</div> </div>
@@ -115,6 +161,13 @@
<div class="fclk-planner__cell-error" t-if="cell.error"> <div class="fclk-planner__cell-error" t-if="cell.error">
<t t-esc="cell.error"/> <t t-esc="cell.error"/>
</div> </div>
<span class="fclk-planner__cell-recur" t-if="cell.recurring"
title="Recurring shift">
<i class="fa fa-repeat"/>
</span>
<span class="fclk-planner__cell-role" t-if="cell.role_color"
t-att-style="'background-color: ' + cell.role_color + ';'"
t-att-title="cell.role_name"/>
</td> </td>
<td class="fclk-planner__hours-cell"> <td class="fclk-planner__hours-cell">
<t t-esc="cell.hours_display || '0:00'"/> <t t-esc="cell.hours_display || '0:00'"/>
@@ -182,12 +235,63 @@
<t t-esc="state.editor.error"/> <t t-esc="state.editor.error"/>
</div> </div>
<div class="fclk-planner__repeat-panel" t-if="state.editor.showRepeat">
<div class="fclk-planner__repeat-row">
<span>Every</span>
<input type="number" min="1" class="fclk-planner__repeat-int"
t-att-value="state.editor.repeat.interval"
t-on-change="(ev) => this.onRepeatField('interval', ev)"/>
<select t-on-change="(ev) => this.onRepeatField('unit', ev)">
<option value="day" t-att-selected="state.editor.repeat.unit === 'day'">day(s)</option>
<option value="week" t-att-selected="state.editor.repeat.unit === 'week'">week(s)</option>
<option value="month" t-att-selected="state.editor.repeat.unit === 'month'">month(s)</option>
<option value="year" t-att-selected="state.editor.repeat.unit === 'year'">year(s)</option>
</select>
</div>
<div class="fclk-planner__repeat-row">
<select t-on-change="(ev) => this.onRepeatField('type', ev)">
<option value="forever" t-att-selected="state.editor.repeat.type === 'forever'">Forever</option>
<option value="until" t-att-selected="state.editor.repeat.type === 'until'">Until date</option>
<option value="x_times" t-att-selected="state.editor.repeat.type === 'x_times'"># of times</option>
</select>
<input type="date" t-if="state.editor.repeat.type === 'until'"
t-att-value="state.editor.repeat.until"
t-on-change="(ev) => this.onRepeatField('until', ev)"/>
<input type="number" min="1" t-if="state.editor.repeat.type === 'x_times'"
class="fclk-planner__repeat-int"
t-att-value="state.editor.repeat.number"
t-on-change="(ev) => this.onRepeatField('number', ev)"/>
</div>
<button type="button" class="btn btn-sm btn-primary w-100"
t-on-click="() => this.setRecurrence()">
<i class="fa fa-check me-1"/> Apply recurrence
</button>
</div>
<div class="fclk-planner__editor-actions"> <div class="fclk-planner__editor-actions">
<button type="button" <button type="button"
class="btn btn-sm btn-light" class="btn btn-sm btn-light"
t-on-click="() => this.clearActiveCell()"> t-on-click="() => this.clearActiveCell()">
<i class="fa fa-eraser me-1"/> Clear <i class="fa fa-eraser me-1"/> Clear
</button> </button>
<button type="button"
t-if="!state.editor.recurring"
class="btn btn-sm btn-light"
t-on-click="() => this.toggleRepeatPanel()">
<i class="fa fa-repeat me-1"/> Repeat…
</button>
<button type="button"
t-if="state.editor.recurring"
class="btn btn-sm btn-warning"
t-on-click="() => this.clearRecurrence()">
<i class="fa fa-ban me-1"/> Stop repeat
</button>
<button type="button"
class="btn btn-sm btn-light"
t-on-click="() => this.bulkApplyDept()"
title="Apply this shift to everyone in the same department">
<i class="fa fa-users me-1"/> Apply to dept
</button>
<button type="button" <button type="button"
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary"
t-on-click="() => this.applyEditorRange(true)"> t-on-click="() => this.applyEditorRange(true)">

View File

@@ -11,3 +11,10 @@ from . import test_settings
from . import test_clock_kiosk from . import test_clock_kiosk
from . import test_break_rules from . import test_break_rules
from . import test_pending_reason_exempt from . import test_pending_reason_exempt
from . import test_role
from . import test_recurrence
from . import test_publish_range
from . import test_open_shift
from . import test_overnight
from . import test_multishift_window
from . import test_planning_migration

View 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")

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import date, timedelta
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")
def test_claim_open_shift_assigns_to_employee(self):
emp = self.env['hr.employee'].create({'name': 'Claimer'})
op = self.S.create({
'is_open': True, 'schedule_date': date(2026, 6, 10),
'start_time': 9.0, 'end_time': 17.0, 'state': 'posted'})
self.S.fclk_claim_open_shift(op, emp)
self.assertFalse(op.is_open)
self.assertEqual(op.employee_id, emp)
def test_claim_enforces_role_eligibility(self):
forklift = self.env['fusion.clock.role'].create({'name': 'Forklift', 'color': 2})
desk = self.env['fusion.clock.role'].create({'name': 'Desk', 'color': 3})
emp = self.env['hr.employee'].create({
'name': 'Picky', 'x_fclk_role_ids': [(6, 0, [desk.id])]})
op = self.S.create({
'is_open': True, 'schedule_date': date(2026, 6, 10),
'start_time': 9.0, 'end_time': 17.0, 'role_id': forklift.id, 'state': 'posted'})
with self.assertRaises(ValidationError):
self.S.fclk_claim_open_shift(op, emp)
def test_release_returns_shift_to_open_pool(self):
emp = self.env['hr.employee'].create({'name': 'Releaser'})
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': 'posted'})
self.S.fclk_release_shift(sch, emp)
self.assertTrue(sch.is_open)
self.assertFalse(sch.employee_id)
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'})
d = date(2026, 6, 10)
self.S.fclk_bulk_apply(e1 + e2, d, {'start_time': 8.0, 'end_time': 16.0, 'break_minutes': 0.0})
self.assertEqual(
self.S.search_count([('schedule_date', '=', d), ('employee_id', 'in', (e1 + e2).ids)]), 2)

View 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

View File

@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Integration test for the planning -> native port. Runs only where Odoo
# Planning is installed (Enterprise); a no-op skip on Community / local dev.
from datetime import datetime
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestPlanningMigration(TransactionCase):
def test_port_planning_data(self):
if 'planning.slot' not in self.env:
self.skipTest('planning not installed (Community / local dev)')
# Ensure the port actually runs (it is marker-guarded for production).
self.env['ir.config_parameter'].sudo().search(
[('key', '=', 'fusion_clock.planning_migrated')]).unlink()
prole = self.env['planning.role'].create({'name': 'PortLead', 'color': 5})
emp = self.env['hr.employee'].create({'name': 'Porty McPort'})
if 'default_planning_role_id' in emp._fields:
emp.default_planning_role_id = prole.id
self.env['planning.slot'].create({
'resource_id': emp.resource_id.id,
'company_id': emp.company_id.id,
'start_datetime': datetime(2026, 6, 1, 14, 0, 0),
'end_datetime': datetime(2026, 6, 1, 22, 0, 0),
'role_id': prole.id,
'state': 'published',
})
counts = self.env['fusion.clock.schedule']._fclk_port_planning_data()
self.assertGreaterEqual(counts['roles'], 1)
self.assertTrue(
self.env['fusion.clock.role'].search([('name', '=ilike', 'PortLead')]),
"planning.role should be ported to a native fusion.clock.role")
emp.invalidate_recordset()
if 'default_planning_role_id' in emp._fields:
self.assertTrue(emp.x_fclk_default_role_id,
"employee default planning role should be ported")
sched = self.env['fusion.clock.schedule'].search([('employee_id', '=', emp.id)])
self.assertTrue(sched, "published planning.slot should become a native schedule row")
self.assertEqual(sched[0].state, 'posted',
"published slots port as posted schedule entries")

View File

@@ -0,0 +1,55 @@
# -*- 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 TestPublishRange(TransactionCase):
def setUp(self):
super().setUp()
self.Schedule = self.env['fusion.clock.schedule']
self.emp = self.env['hr.employee'].create({
'name': 'Pat', 'work_email': 'pat@example.com'})
def _draft(self, day):
return self.Schedule.create({
'employee_id': self.emp.id, 'schedule_date': day,
'start_time': 9.0, 'end_time': 17.0, 'state': 'draft'})
def test_publish_range_posts_drafts(self):
d1, d2 = date(2026, 6, 1), date(2026, 6, 3)
self._draft(d1)
self._draft(d2)
posted, _notified = self.Schedule.fclk_publish_range(self.emp, d1, d2)
self.assertEqual(posted, 2)
rows = self.Schedule.search([('employee_id', '=', self.emp.id)])
self.assertTrue(all(r.state == 'posted' for r in rows))
self.assertTrue(all(r.posted_date for r in rows))
def test_publish_range_skips_already_posted(self):
d = date(2026, 6, 1)
self.Schedule.create({
'employee_id': self.emp.id, 'schedule_date': d,
'start_time': 9.0, 'end_time': 17.0, 'state': 'posted'})
posted, _notified = self.Schedule.fclk_publish_range(self.emp, d, d)
self.assertEqual(posted, 0, "Already-posted rows are not re-posted")
def test_publish_range_respects_bounds(self):
inside = self._draft(date(2026, 6, 5))
outside = self._draft(date(2026, 6, 20))
posted, _notified = self.Schedule.fclk_publish_range(
self.emp, date(2026, 6, 1), date(2026, 6, 7))
self.assertEqual(posted, 1)
self.assertEqual(inside.state, 'posted')
self.assertEqual(outside.state, 'draft')
def test_email_posted_range_no_email_returns_false(self):
emp2 = self.env['hr.employee'].create({'name': 'NoEmail'})
self.assertFalse(
self.Schedule.fclk_email_posted_range(emp2, date(2026, 6, 1), date(2026, 6, 2)))

View 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, 'reason': 'Vacation',
'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())

View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestFusionClockRole(TransactionCase):
def test_default_color_in_range(self):
role = self.env['fusion.clock.role'].create({'name': 'Cashier'})
self.assertTrue(1 <= role.color <= 11, "Default colour should be 1..11")
def test_color_hex_and_open_alpha(self):
role = self.env['fusion.clock.role'].create({'name': 'Red', 'color': 1})
self.assertEqual(role._get_color_from_code(), '#EE4B39')
self.assertEqual(role._get_color_from_code(True), '#EE4B3980')
def test_employee_default_and_allowed_roles(self):
lead = self.env['fusion.clock.role'].create({'name': 'Lead', 'color': 3})
cashier = self.env['fusion.clock.role'].create({'name': 'Cashier', 'color': 4})
emp = self.env['hr.employee'].create({
'name': 'Bob',
'x_fclk_default_role_id': lead.id,
'x_fclk_role_ids': [(6, 0, [lead.id, cashier.id])],
})
self.assertEqual(emp.x_fclk_default_role_id, lead)
self.assertIn(cashier, emp.x_fclk_role_ids)
def test_schedule_inherits_employee_default_role(self):
lead = self.env['fusion.clock.role'].create({'name': 'Lead', 'color': 3})
emp = self.env['hr.employee'].create({'name': 'Cara', 'x_fclk_default_role_id': lead.id})
sch = self.env['fusion.clock.schedule'].fclk_apply_planner_cell(
emp, fields.Date.today(), {'input': '9-5'})
self.assertEqual(sch.role_id, lead,
"A new shift should inherit the employee's default role")
def test_schedule_role_from_shift_template(self):
stock = self.env['fusion.clock.role'].create({'name': 'Stock', 'color': 5})
shift = self.env['fusion.clock.shift'].create({
'name': 'Morning', 'start_time': 8.0, 'end_time': 16.0, 'role_id': stock.id})
emp = self.env['hr.employee'].create({'name': 'Dan'})
sch = self.env['fusion.clock.schedule'].fclk_apply_planner_cell(
emp, fields.Date.today(), {'shift_id': shift.id})
self.assertEqual(sch.role_id, stock,
"Shift-template role should win when employee has no default")

View File

@@ -3,12 +3,8 @@
import json import json
from datetime import date, timedelta from datetime import date, timedelta
from psycopg2 import IntegrityError
from odoo import fields from odoo import fields
from odoo.exceptions import ValidationError
from odoo.tests.common import HttpCase, TransactionCase, tagged from odoo.tests.common import HttpCase, TransactionCase, tagged
from odoo.tools.misc import mute_logger
@tagged('-at_install', 'post_install', 'fusion_clock') @tagged('-at_install', 'post_install', 'fusion_clock')
@@ -34,19 +30,22 @@ class TestShiftPlannerModels(TransactionCase):
cls.employee.x_fclk_shift_id = cls.default_shift.id cls.employee.x_fclk_shift_id = cls.default_shift.id
cls.schedule_date = date(2026, 1, 5) cls.schedule_date = date(2026, 1, 5)
def test_unique_employee_date_schedule(self): def test_multiple_shifts_per_day_allowed(self):
# The hard one-shift-per-day UNIQUE was dropped in 19.0.5.0.0 to support
# split shifts; the day-plan resolves several rows into one work-window.
self.Schedule.create({ self.Schedule.create({
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'schedule_date': self.schedule_date, 'schedule_date': self.schedule_date,
'is_off': True, 'start_time': 8.0, 'end_time': 12.0,
}) })
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'): self.Schedule.create({
with self.env.cr.savepoint(): 'employee_id': self.employee.id,
self.Schedule.create({ 'schedule_date': self.schedule_date,
'employee_id': self.employee.id, 'start_time': 13.0, 'end_time': 17.0,
'schedule_date': self.schedule_date, })
'is_off': True, self.assertEqual(self.Schedule.search_count([
}) ('employee_id', '=', self.employee.id),
('schedule_date', '=', self.schedule_date)]), 2)
def test_off_schedule_has_zero_hours(self): def test_off_schedule_has_zero_hours(self):
schedule = self.Schedule.create({ schedule = self.Schedule.create({
@@ -68,15 +67,18 @@ class TestShiftPlannerModels(TransactionCase):
self.assertEqual(schedule.planned_hours, 8.0) self.assertEqual(schedule.planned_hours, 8.0)
self.assertEqual(self.Schedule.fclk_hours_display(schedule.planned_hours), '8:00') self.assertEqual(self.Schedule.fclk_hours_display(schedule.planned_hours), '8:00')
def test_invalid_same_day_range_is_rejected(self): def test_overnight_range_is_accepted(self):
with self.assertRaises(ValidationError): # Overnight shifts (end on/before start) are supported as of 19.0.5.0.0.
self.Schedule.create({ sch = self.Schedule.create({
'employee_id': self.employee.id, 'employee_id': self.employee.id,
'schedule_date': date(2026, 1, 8), 'schedule_date': date(2026, 1, 8),
'start_time': 17.0, 'start_time': 17.0,
'end_time': 9.0, 'end_time': 9.0,
'break_minutes': 30, 'break_minutes': 30,
}) })
self.assertTrue(sch.crosses_midnight)
# 17:00 -> 09:00 = 16h, minus 30m break = 15.5h
self.assertAlmostEqual(sch.planned_hours, 15.5, places=2)
def test_apply_planner_cell_creates_audit(self): def test_apply_planner_cell_creates_audit(self):
schedule_date = date(2026, 1, 9) schedule_date = date(2026, 1, 9)

View File

@@ -121,6 +121,20 @@
sequence="15" sequence="15"
groups="group_fusion_clock_manager"/> groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_employee_roles"
name="Employee Roles"
parent="menu_fusion_clock_scheduling"
action="action_fclk_employee_role_editor"
sequence="17"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_recurrences"
name="Recurring Shifts"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_recurrence"
sequence="18"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_schedule_audit" <menuitem id="menu_fusion_clock_schedule_audit"
name="Schedule Audit" name="Schedule Audit"
parent="menu_fusion_clock_scheduling" parent="menu_fusion_clock_scheduling"
@@ -196,6 +210,13 @@
sequence="20" sequence="20"
groups="group_fusion_clock_manager"/> groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_roles_config"
name="Shift Roles"
parent="menu_fusion_clock_config"
action="action_fusion_clock_role"
sequence="22"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_break_rules" <menuitem id="menu_fusion_clock_break_rules"
name="Break Rules" name="Break Rules"
parent="menu_fusion_clock_config" parent="menu_fusion_clock_config"

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_clock_recurrence_list" model="ir.ui.view">
<field name="name">fusion.clock.schedule.recurrence.list</field>
<field name="model">fusion.clock.schedule.recurrence</field>
<field name="arch" type="xml">
<list>
<field name="display_name"/>
<field name="repeat_interval"/>
<field name="repeat_unit"/>
<field name="repeat_type"/>
<field name="repeat_until"/>
<field name="repeat_number"/>
<field name="last_generated_date"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_fusion_clock_recurrence_form" model="ir.ui.view">
<field name="name">fusion.clock.schedule.recurrence.form</field>
<field name="model">fusion.clock.schedule.recurrence</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<label for="repeat_interval" string="Repeat Every"/>
<div class="o_row">
<field name="repeat_interval" class="oe_inline"/>
<field name="repeat_unit" class="oe_inline"/>
</div>
<field name="repeat_type"/>
<field name="repeat_until"
invisible="repeat_type != 'until'"
required="repeat_type == 'until'"/>
<field name="repeat_number" invisible="repeat_type != 'x_times'"/>
</group>
<group>
<field name="last_generated_date" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group string="Generated Shifts">
<field name="schedule_ids" nolabel="1" colspan="2">
<list>
<field name="schedule_date"/>
<field name="employee_id"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="state"/>
</list>
</field>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_clock_recurrence" model="ir.actions.act_window">
<field name="name">Recurring Shifts</field>
<field name="res_model">fusion.clock.schedule.recurrence</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">No recurring shifts yet</p>
<p>Recurring shifts are created from the Shift Planner — click a cell,
then <b>Repeat…</b>. They appear here so you can review or stop them.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================
Shift Roles (native replacement for planning.role)
============================================================ -->
<record id="view_fusion_clock_role_list" model="ir.ui.view">
<field name="name">fusion.clock.role.list</field>
<field name="model">fusion.clock.role</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="color" widget="integer"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="active" column_invisible="1"/>
</list>
</field>
</record>
<record id="view_fusion_clock_role_form" model="ir.ui.view">
<field name="name">fusion.clock.role.form</field>
<field name="model">fusion.clock.role</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="color" widget="integer"/>
</group>
<group>
<field name="sequence"/>
<field name="active"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_clock_role" model="ir.actions.act_window">
<field name="name">Shift Roles</field>
<field name="res_model">fusion.clock.role</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Create your first shift role</p>
<p>Roles colour and label shifts on each employee's portal schedule
(e.g. "Cashier", "Stockroom", "Shift Lead").</p>
</field>
</record>
<!-- ============================================================
Employee Roles editor — fast bulk assignment of default/allowed
roles per employee (ported from fusion_planning, native fields).
============================================================ -->
<record id="view_fclk_employee_role_editor_list" model="ir.ui.view">
<field name="name">hr.employee.list.fclk.role.editor</field>
<field name="model">hr.employee</field>
<field name="arch" type="xml">
<list string="Employee Roles" editable="bottom" multi_edit="1"
default_order="department_id, name">
<field name="name" readonly="1"/>
<field name="job_title" readonly="1" optional="show"/>
<field name="department_id" readonly="1" optional="show"/>
<field name="x_fclk_default_role_id" string="Default Role"
options="{'no_quick_create': True}" widget="many2one"/>
<field name="x_fclk_role_ids" string="All Allowed Roles"
widget="many2many_tags"
options="{'no_quick_create': True, 'color_field': 'color'}"
optional="show"/>
<field name="active" column_invisible="1"/>
</list>
</field>
</record>
<record id="action_fclk_employee_role_editor" model="ir.actions.act_window">
<field name="name">Employee Roles</field>
<field name="res_model">hr.employee</field>
<field name="view_mode">list</field>
<field name="view_id" ref="view_fclk_employee_role_editor_list"/>
<field name="domain">[('active', '=', True)]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Set the Default Role and allowed Roles for each employee
</p>
<p>Click any cell under <b>Default Role</b> or <b>All Allowed Roles</b>
and start typing. The Default Role pre-fills every new shift you
create for that employee.</p>
</field>
</record>
</odoo>

View File

@@ -15,11 +15,14 @@
<field name="employee_id"/> <field name="employee_id"/>
<field name="department_id"/> <field name="department_id"/>
<field name="is_off"/> <field name="is_off"/>
<field name="is_open" optional="hide"/>
<field name="shift_id"/> <field name="shift_id"/>
<field name="role_id" optional="show"/>
<field name="start_time" widget="float_time"/> <field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/> <field name="end_time" widget="float_time"/>
<field name="break_minutes"/> <field name="break_minutes"/>
<field name="planned_hours"/> <field name="planned_hours"/>
<field name="recurrence_id" optional="hide"/>
<field name="state" widget="badge" decoration-success="state == 'posted'" decoration-warning="state == 'draft'"/> <field name="state" widget="badge" decoration-success="state == 'posted'" decoration-warning="state == 'draft'"/>
<field name="company_id" groups="base.group_multi_company"/> <field name="company_id" groups="base.group_multi_company"/>
</list> </list>
@@ -34,10 +37,14 @@
<sheet> <sheet>
<group> <group>
<group> <group>
<field name="employee_id"/> <field name="employee_id" required="not is_open"/>
<field name="is_open"/>
<field name="schedule_date"/> <field name="schedule_date"/>
<field name="is_off"/> <field name="is_off"/>
<field name="shift_id"/> <field name="shift_id"/>
<field name="role_id" options="{'no_quick_create': True}"/>
<field name="recurrence_id" readonly="1"/>
<field name="crosses_midnight" readonly="1"/>
</group> </group>
<group> <group>
<field name="start_time" widget="float_time"/> <field name="start_time" widget="float_time"/>
@@ -68,6 +75,7 @@
<field name="schedule_date"/> <field name="schedule_date"/>
<filter name="off" string="OFF" domain="[('is_off', '=', True)]"/> <filter name="off" string="OFF" domain="[('is_off', '=', True)]"/>
<filter name="working" string="Working" domain="[('is_off', '=', False)]"/> <filter name="working" string="Working" domain="[('is_off', '=', False)]"/>
<filter name="open" string="Open Shifts" domain="[('is_open', '=', True)]"/>
<separator/> <separator/>
<filter name="posted" string="Posted" domain="[('state', '=', 'posted')]"/> <filter name="posted" string="Posted" domain="[('state', '=', 'posted')]"/>
<filter name="draft" string="Draft" domain="[('state', '=', 'draft')]"/> <filter name="draft" string="Draft" domain="[('state', '=', 'draft')]"/>

View File

@@ -36,6 +36,7 @@
<field name="sequence"/> <field name="sequence"/>
<field name="active"/> <field name="active"/>
<field name="color" widget="color"/> <field name="color" widget="color"/>
<field name="role_id" options="{'no_quick_create': True}"/>
<field name="company_id" groups="base.group_multi_company"/> <field name="company_id" groups="base.group_multi_company"/>
</group> </group>
</group> </group>

View File

@@ -303,6 +303,19 @@
</svg> </svg>
<span>Timesheets</span> <span>Timesheets</span>
</a> </a>
<a href="/my/clock/schedule" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
<line x1="8" y1="14" x2="10" y2="14"/>
<line x1="14" y1="14" x2="16" y2="14"/>
<line x1="8" y1="18" x2="10" y2="18"/>
<line x1="14" y1="18" x2="16" y2="18"/>
</svg>
<span>Schedule</span>
</a>
<a href="/my/clock/reports" class="fclk-nav-item"> <a href="/my/clock/reports" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>

View File

@@ -64,6 +64,19 @@
</svg> </svg>
<span>Timesheets</span> <span>Timesheets</span>
</a> </a>
<a href="/my/clock/schedule" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
<line x1="8" y1="14" x2="10" y2="14"/>
<line x1="14" y1="14" x2="16" y2="14"/>
<line x1="8" y1="18" x2="10" y2="18"/>
<line x1="14" y1="18" x2="16" y2="18"/>
</svg>
<span>Schedule</span>
</a>
<a href="/my/clock/reports" class="fclk-nav-item"> <a href="/my/clock/reports" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
@@ -166,6 +179,19 @@
</svg> </svg>
<span>Timesheets</span> <span>Timesheets</span>
</a> </a>
<a href="/my/clock/schedule" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
<line x1="8" y1="14" x2="10" y2="14"/>
<line x1="14" y1="14" x2="16" y2="14"/>
<line x1="8" y1="18" x2="10" y2="18"/>
<line x1="14" y1="18" x2="16" y2="18"/>
</svg>
<span>Schedule</span>
</a>
<a href="/my/clock/reports" class="fclk-nav-item"> <a href="/my/clock/reports" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>

View File

@@ -77,6 +77,19 @@
</svg> </svg>
<span>Timesheets</span> <span>Timesheets</span>
</a> </a>
<a href="/my/clock/schedule" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
<line x1="8" y1="14" x2="10" y2="14"/>
<line x1="14" y1="14" x2="16" y2="14"/>
<line x1="8" y1="18" x2="10" y2="18"/>
<line x1="14" y1="18" x2="16" y2="18"/>
</svg>
<span>Schedule</span>
</a>
<a href="/my/clock/reports" class="fclk-nav-item fclk-nav-active"> <a href="/my/clock/reports" class="fclk-nav-item fclk-nav-active">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>

View File

@@ -0,0 +1,215 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="portal_schedule_page" name="Fusion Clock My Schedule">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="False"/>
<t t-set="no_breadcrumbs" t-value="True"/>
<t t-set="no_header" t-value="True"/>
<div class="fclk-app">
<div class="fclk-container">
<!-- Header -->
<div class="fclk-header">
<div class="fclk-date">My Schedule</div>
<h1 class="fclk-greeting">Hello, <t t-esc="employee.name.split(' ')[0]"/></h1>
</div>
<!-- Claim / release feedback -->
<div class="fpl-flash fpl-flash-err" t-if="error">
<t t-esc="error"/>
</div>
<div class="fpl-flash fpl-flash-ok" t-if="success">
<t t-if="success == 'claimed'">Shift claimed — it's now on your schedule.</t>
<t t-elif="success == 'released'">Shift released back to the open pool.</t>
<t t-else="">Done.</t>
</div>
<!-- Open shifts available to claim -->
<t t-if="open_shifts">
<div class="fpl-group">
<div class="fpl-group-title">Open Shifts — Available to Claim</div>
<div class="fpl-list">
<t t-foreach="open_shifts" t-as="op">
<div class="fclk-recent-item fpl-open-item">
<div class="fclk-recent-info">
<div class="fclk-recent-location">
<t t-esc="op['date_full']"/>
<t t-if="op['role_name']"> · <t t-esc="op['role_name']"/></t>
</div>
<div class="fclk-recent-times"><t t-esc="op['time_range']"/></div>
</div>
<form method="post" action="/my/clock/schedule/claim" class="fpl-claim-form">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="schedule_id" t-att-value="op['id']"/>
<button type="submit" class="btn btn-sm btn-success">Claim</button>
</form>
</div>
</t>
</div>
</div>
</t>
<!-- Next Shift Card (if any upcoming) -->
<t t-if="next_slot">
<div class="fclk-status-card fpl-next-shift">
<div class="fpl-next-label">Next Shift</div>
<div class="fpl-next-date"><t t-esc="next_slot['date']"/></div>
<div class="fpl-next-time"><t t-esc="next_slot['time']"/></div>
<div class="fpl-next-role" t-if="next_slot['role']">
<t t-esc="next_slot['role']"/>
</div>
</div>
</t>
<t t-else="">
<div class="fclk-status-card fpl-empty-card">
<div class="fpl-empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="1.5">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
</div>
<div class="fpl-empty-title">No upcoming shifts</div>
<div class="fpl-empty-sub">Your manager hasn't published any shifts yet.</div>
</div>
</t>
<!-- Summary Row -->
<div class="fclk-stats-row" t-if="slot_count">
<div class="fclk-stat-card">
<div class="fclk-stat-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
</svg>
<span>Upcoming</span>
</div>
<div class="fclk-stat-value">
<t t-esc="slot_count"/>
</div>
<div class="fclk-stat-target">shifts scheduled</div>
</div>
<div class="fclk-stat-card">
<div class="fclk-stat-header">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>Total Hours</span>
</div>
<div class="fclk-stat-value">
<t t-esc="'%.1f' % sum(s['duration_hours'] for items in groups.values() for s in items)"/>h
</div>
<div class="fclk-stat-target">across upcoming</div>
</div>
</div>
<!-- Grouped Schedule List -->
<t t-foreach="groups.items()" t-as="group">
<div class="fpl-group">
<div class="fpl-group-title">
<t t-esc="group[0]"/>
</div>
<div class="fpl-list">
<t t-foreach="group[1]" t-as="item">
<div class="fclk-recent-item fpl-shift-item">
<div class="fclk-recent-date">
<div class="fclk-recent-day-name">
<t t-esc="item['day_label']"/>
</div>
<div class="fclk-recent-day-num">
<t t-esc="item['day_num']"/>
</div>
</div>
<div class="fclk-recent-info">
<div class="fclk-recent-location">
<t t-if="item['role_name']">
<t t-esc="item['role_name']"/>
</t>
<t t-else="">
Shift
</t>
</div>
<div class="fclk-recent-times">
<t t-esc="item['time_range']"/>
</div>
<div class="fpl-shift-note" t-if="item['note']">
<t t-esc="item['note']"/>
</div>
</div>
<div class="fclk-recent-hours">
<t t-esc="'%.1f' % item['duration_hours']"/>h
<form t-if="item.get('releasable')" method="post"
action="/my/clock/schedule/release" class="fpl-release-form">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="schedule_id" t-att-value="item['schedule_id']"/>
<button type="submit" class="fpl-release-btn" title="Release this shift">Release</button>
</form>
</div>
</div>
</t>
</div>
</div>
</t>
<!-- Navigation Bar -->
<div class="fclk-nav-bar">
<a href="/my/clock" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>Clock</span>
</a>
<a href="/my/clock/timesheets" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span>Timesheets</span>
</a>
<a href="/my/clock/schedule" class="fclk-nav-item fclk-nav-active">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
<line x1="8" y1="14" x2="10" y2="14"/>
<line x1="14" y1="14" x2="16" y2="14"/>
<line x1="8" y1="18" x2="10" y2="18"/>
<line x1="14" y1="18" x2="16" y2="18"/>
</svg>
<span>Schedule</span>
</a>
<a href="/my/clock/reports" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<span>Reports</span>
</a>
<t t-if="show_payslips">
<a href="/my/clock/payslips" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="5" width="20" height="14" rx="2"/>
<line x1="2" y1="10" x2="22" y2="10"/>
</svg>
<span>Payslips</span>
</a>
</t>
</div>
</div>
</div>
</t>
</template>
</odoo>

View File

@@ -128,6 +128,19 @@
</svg> </svg>
<span>Timesheets</span> <span>Timesheets</span>
</a> </a>
<a href="/my/clock/schedule" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
<line x1="8" y1="14" x2="10" y2="14"/>
<line x1="14" y1="14" x2="16" y2="14"/>
<line x1="8" y1="18" x2="10" y2="18"/>
<line x1="14" y1="18" x2="16" y2="18"/>
</svg>
<span>Schedule</span>
</a>
<a href="/my/clock/reports" class="fclk-nav-item"> <a href="/my/clock/reports" class="fclk-nav-item">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>

View File

@@ -41,6 +41,19 @@
</div> </div>
</div> </div>
</setting> </setting>
<setting id="fclk_recurring_open_shifts" string="Recurring &amp; Open Shifts"
help="Controls how far ahead recurring shifts generate and when employees may release shifts they claimed.">
<div class="content-group">
<div class="row mt16">
<label for="fclk_planning_generation_months" string="Generate ahead (months)" class="col-lg-7 o_light_label"/>
<field name="fclk_planning_generation_months"/>
</div>
<div class="row mt16">
<label for="fclk_self_unassign_days_before" string="Self-unassign cutoff (days)" class="col-lg-7 o_light_label"/>
<field name="fclk_self_unassign_days_before"/>
</div>
</div>
</setting>
</block> </block>
<!-- ============================================================ --> <!-- ============================================================ -->