feat(fusion_clock): native shift roles (fusion.clock.role) [A1-A3]

Replaces Odoo Planning's planning.role: name+colour model with the same
1-11 palette, employee default/allowed role fields, Employee Roles editor,
role_id on shift template + schedule with default resolution, ACLs, menus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-04 20:42:04 -04:00
parent 023fc95acd
commit b4ca85e291
13 changed files with 286 additions and 1 deletions

View File

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

View File

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

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

@@ -58,6 +58,20 @@ class FusionClockSchedule(models.Model):
store=True,
)
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(
'res.company',
string='Company',
@@ -292,10 +306,21 @@ class FusionClockSchedule(models.Model):
new_schedule = self.browse()
new_value = ''
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 = {
'employee_id': employee.id,
'schedule_date': date_obj,
'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')),
'start_time': parsed.get('start_time') or 0.0,
'end_time': parsed.get('end_time') or 0.0,
@@ -349,6 +374,9 @@ class FusionClockSchedule(models.Model):
'hours': schedule.planned_hours,
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
'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 '',
}
plan = employee._get_fclk_day_plan(date_obj)
@@ -366,6 +394,9 @@ class FusionClockSchedule(models.Model):
'hours': plan.get('hours') or 0.0,
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
'note': '',
'role_id': False,
'role_name': '',
'role_color': '',
}
@api.model

View File

@@ -42,6 +42,12 @@ class FusionClockShift(models.Model):
)
active = fields.Boolean(default=True)
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
# 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.",
)
# 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
x_fclk_pending_reason = fields.Boolean(
string='Pending Reason Required',

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_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_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

@@ -11,3 +11,10 @@ from . import test_settings
from . import test_clock_kiosk
from . import test_break_rules
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,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

@@ -121,6 +121,20 @@
sequence="15"
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"
name="Schedule Audit"
parent="menu_fusion_clock_scheduling"
@@ -196,6 +210,13 @@
sequence="20"
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"
name="Break Rules"
parent="menu_fusion_clock_config"

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

@@ -16,10 +16,12 @@
<field name="department_id"/>
<field name="is_off"/>
<field name="shift_id"/>
<field name="role_id" optional="show"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
<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="company_id" groups="base.group_multi_company"/>
</list>
@@ -38,6 +40,8 @@
<field name="schedule_date"/>
<field name="is_off"/>
<field name="shift_id"/>
<field name="role_id" options="{'no_quick_create': True}"/>
<field name="recurrence_id" readonly="1"/>
</group>
<group>
<field name="start_time" widget="float_time"/>

View File

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