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:
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
154
fusion_clock/controllers/portal_schedule.py
Normal file
154
fusion_clock/controllers/portal_schedule.py
Normal 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)))
|
||||||
@@ -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():
|
||||||
|
|||||||
17
fusion_clock/data/clock_recurrence_cron.xml
Normal file
17
fusion_clock/data/clock_recurrence_cron.xml
Normal 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>
|
||||||
@@ -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 08–12 and 13–17 → plan window 08–17, 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 (A1–A3), recurrence (A4–A5), send (A6), portal (A7), migration (A8), multi/overnight/open/self-assign/bulk (B1–B5), 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).
|
||||||
@@ -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 1–11
|
||||||
|
- `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.
|
||||||
40
fusion_clock/migrations/19.0.5.0.0/post-migrate.py
Normal file
40
fusion_clock/migrations/19.0.5.0.0/post-migrate.py
Normal 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)
|
||||||
65
fusion_clock/migrations/19.0.5.0.0/pre-migrate.py
Normal file
65
fusion_clock/migrations/19.0.5.0.0/pre-migrate.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
154
fusion_clock/models/clock_recurrence.py
Normal file
154
fusion_clock/models/clock_recurrence.py
Normal 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()
|
||||||
46
fusion_clock/models/clock_role.py
Normal file
46
fusion_clock/models/clock_role.py
Normal 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 '')
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
156
fusion_clock/static/src/css/portal_schedule.css
Normal file
156
fusion_clock/static/src/css/portal_schedule.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 & 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)">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
54
fusion_clock/tests/test_multishift_window.py
Normal file
54
fusion_clock/tests/test_multishift_window.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
#
|
||||||
|
# The per-day model now allows split shifts. The attendance contract
|
||||||
|
# (_get_fclk_day_plan) MUST still hand the rest of the pipeline a single
|
||||||
|
# work-window so penalties / overtime / absence stay correct.
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
class TestMultiShiftWindow(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.S = self.env['fusion.clock.schedule']
|
||||||
|
self.emp = self.env['hr.employee'].create({'name': 'Sam Split'})
|
||||||
|
|
||||||
|
def test_split_shift_resolves_to_single_window(self):
|
||||||
|
self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
|
||||||
|
'start_time': 8.0, 'end_time': 12.0, 'break_minutes': 0.0, 'state': 'posted'})
|
||||||
|
self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
|
||||||
|
'start_time': 13.0, 'end_time': 17.0, 'break_minutes': 0.0, 'state': 'posted'})
|
||||||
|
plan = self.emp._get_fclk_day_plan(date(2026, 6, 1))
|
||||||
|
self.assertTrue(plan['scheduled'])
|
||||||
|
self.assertEqual(plan['start_time'], 8.0, "window starts at earliest shift")
|
||||||
|
self.assertEqual(plan['end_time'], 17.0, "window ends at latest shift")
|
||||||
|
self.assertAlmostEqual(plan['hours'], 8.0, places=2, msg="worked hours = sum of shifts")
|
||||||
|
|
||||||
|
def test_draft_shift_excluded_from_window(self):
|
||||||
|
self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
|
||||||
|
'start_time': 8.0, 'end_time': 12.0, 'break_minutes': 0.0, 'state': 'posted'})
|
||||||
|
self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
|
||||||
|
'start_time': 13.0, 'end_time': 17.0, 'break_minutes': 0.0, 'state': 'draft'})
|
||||||
|
plan = self.emp._get_fclk_day_plan(date(2026, 6, 1))
|
||||||
|
self.assertEqual(plan['end_time'], 12.0, "draft shift must not widen the window")
|
||||||
|
|
||||||
|
def test_all_off_rows_resolve_to_off(self):
|
||||||
|
self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
|
||||||
|
'is_off': True, 'state': 'posted'})
|
||||||
|
plan = self.emp._get_fclk_day_plan(date(2026, 6, 1))
|
||||||
|
self.assertTrue(plan['is_off'])
|
||||||
|
self.assertFalse(plan['scheduled'])
|
||||||
|
|
||||||
|
def test_open_shift_does_not_feed_employee_plan(self):
|
||||||
|
# An open shift (no employee) on the same day must not affect anyone.
|
||||||
|
self.S.create({'is_open': True, 'schedule_date': date(2026, 6, 1),
|
||||||
|
'start_time': 9.0, 'end_time': 17.0, 'state': 'posted'})
|
||||||
|
plan = self.emp._get_fclk_day_plan(date(2026, 6, 1))
|
||||||
|
self.assertFalse(plan['scheduled'], "open shift is not assigned to this employee")
|
||||||
84
fusion_clock/tests/test_open_shift.py
Normal file
84
fusion_clock/tests/test_open_shift.py
Normal 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)
|
||||||
40
fusion_clock/tests/test_overnight.py
Normal file
40
fusion_clock/tests/test_overnight.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
class TestOvernight(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.Schedule = self.env['fusion.clock.schedule']
|
||||||
|
self.emp = self.env['hr.employee'].create({'name': 'Nox'})
|
||||||
|
|
||||||
|
def test_overnight_hours_and_flag(self):
|
||||||
|
sch = self.Schedule.create({
|
||||||
|
'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
|
||||||
|
'start_time': 22.0, 'end_time': 6.0, 'break_minutes': 30.0, 'state': 'posted'})
|
||||||
|
self.assertTrue(sch.crosses_midnight)
|
||||||
|
# 22:00 -> 06:00 = 8h, minus 30m break = 7.5h
|
||||||
|
self.assertAlmostEqual(sch.planned_hours, 7.5, places=2)
|
||||||
|
|
||||||
|
def test_overnight_scheduled_out_is_next_day(self):
|
||||||
|
self.Schedule.create({
|
||||||
|
'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1),
|
||||||
|
'start_time': 22.0, 'end_time': 6.0, 'break_minutes': 0.0, 'state': 'posted'})
|
||||||
|
sin, sout = self.emp._get_fclk_scheduled_times(date(2026, 6, 1))
|
||||||
|
self.assertGreater(sout, sin)
|
||||||
|
self.assertAlmostEqual((sout - sin).total_seconds() / 3600.0, 8.0, places=1)
|
||||||
|
|
||||||
|
def test_overnight_is_allowed_by_constraint(self):
|
||||||
|
# Must not raise now that overnight is supported.
|
||||||
|
sch = self.Schedule.create({
|
||||||
|
'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 2),
|
||||||
|
'start_time': 20.0, 'end_time': 4.0, 'break_minutes': 60.0, 'state': 'posted'})
|
||||||
|
self.assertAlmostEqual(sch.planned_hours, 7.0, places=2) # 8h - 1h break
|
||||||
53
fusion_clock/tests/test_planning_migration.py
Normal file
53
fusion_clock/tests/test_planning_migration.py
Normal 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")
|
||||||
55
fusion_clock/tests/test_publish_range.py
Normal file
55
fusion_clock/tests/test_publish_range.py
Normal 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)))
|
||||||
95
fusion_clock/tests/test_recurrence.py
Normal file
95
fusion_clock/tests/test_recurrence.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
class TestRecurrence(TransactionCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.emp = self.env['hr.employee'].create({'name': 'Rita'})
|
||||||
|
self.Schedule = self.env['fusion.clock.schedule']
|
||||||
|
|
||||||
|
def _seed(self, day):
|
||||||
|
return self.Schedule.create({
|
||||||
|
'employee_id': self.emp.id,
|
||||||
|
'schedule_date': day,
|
||||||
|
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
|
||||||
|
'state': 'posted',
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_weekly_until_generates_inclusive_series(self):
|
||||||
|
seed = self._seed(date(2026, 6, 1))
|
||||||
|
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||||
|
'repeat_type': 'until', 'repeat_until': date(2026, 6, 29)})
|
||||||
|
rows = self.Schedule.search([('recurrence_id', '=', rule.id)], order='schedule_date')
|
||||||
|
self.assertEqual(
|
||||||
|
rows.mapped('schedule_date'),
|
||||||
|
[date(2026, 6, 1), date(2026, 6, 8), date(2026, 6, 15),
|
||||||
|
date(2026, 6, 22), date(2026, 6, 29)])
|
||||||
|
# Generated (non-seed) rows are draft until posted.
|
||||||
|
generated = rows.filtered(lambda r: r.schedule_date != date(2026, 6, 1))
|
||||||
|
self.assertTrue(all(r.state == 'draft' for r in generated))
|
||||||
|
|
||||||
|
def test_x_times_counts_seed(self):
|
||||||
|
seed = self._seed(date(2026, 6, 1))
|
||||||
|
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||||
|
'repeat_type': 'x_times', 'repeat_number': 3})
|
||||||
|
rows = self.Schedule.search([('recurrence_id', '=', rule.id)])
|
||||||
|
self.assertEqual(len(rows), 3, "3 repetitions = seed + 2 generated")
|
||||||
|
|
||||||
|
def test_interval_two_weeks(self):
|
||||||
|
seed = self._seed(date(2026, 6, 1))
|
||||||
|
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
'repeat_interval': 2, 'repeat_unit': 'week',
|
||||||
|
'repeat_type': 'until', 'repeat_until': date(2026, 7, 1)})
|
||||||
|
rows = self.Schedule.search([('recurrence_id', '=', rule.id)], order='schedule_date')
|
||||||
|
self.assertEqual(rows.mapped('schedule_date'),
|
||||||
|
[date(2026, 6, 1), date(2026, 6, 15), date(2026, 6, 29)])
|
||||||
|
|
||||||
|
def test_stop_deletes_future_drafts_keeps_posted(self):
|
||||||
|
seed = self._seed(date(2026, 6, 1))
|
||||||
|
rule = self.Schedule.fclk_attach_recurrence(seed, {
|
||||||
|
'repeat_interval': 1, 'repeat_unit': 'week',
|
||||||
|
'repeat_type': 'x_times', 'repeat_number': 4})
|
||||||
|
# Post one generated occurrence.
|
||||||
|
gen = self.Schedule.search([
|
||||||
|
('recurrence_id', '=', rule.id), ('schedule_date', '=', date(2026, 6, 8))])
|
||||||
|
gen.state = 'posted'
|
||||||
|
rule._stop(date(2026, 6, 2))
|
||||||
|
remaining = self.Schedule.search([('recurrence_id', '=', rule.id)]).mapped('schedule_date')
|
||||||
|
self.assertIn(date(2026, 6, 1), remaining) # seed, before cutoff
|
||||||
|
self.assertIn(date(2026, 6, 8), remaining) # posted, kept
|
||||||
|
self.assertNotIn(date(2026, 6, 15), remaining) # future draft, removed
|
||||||
|
self.assertNotIn(date(2026, 6, 22), remaining)
|
||||||
|
|
||||||
|
def test_leave_day_skipped(self):
|
||||||
|
self.env['fusion.clock.leave.request'].create({
|
||||||
|
'employee_id': self.emp.id, '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())
|
||||||
49
fusion_clock/tests/test_role.py
Normal file
49
fusion_clock/tests/test_role.py
Normal 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")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
72
fusion_clock/views/clock_recurrence_views.xml
Normal file
72
fusion_clock/views/clock_recurrence_views.xml
Normal 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>
|
||||||
94
fusion_clock/views/clock_role_views.xml
Normal file
94
fusion_clock/views/clock_role_views.xml
Normal 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>
|
||||||
@@ -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')]"/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
215
fusion_clock/views/portal_schedule_templates.xml
Normal file
215
fusion_clock/views/portal_schedule_templates.xml
Normal 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>
|
||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -41,6 +41,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</setting>
|
</setting>
|
||||||
|
<setting id="fclk_recurring_open_shifts" string="Recurring & 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>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
|
|||||||
Reference in New Issue
Block a user