415 lines
14 KiB
Python
415 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Copyright 2026 Nexa Systems Inc.
|
||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||
|
||
import re
|
||
|
||
from odoo import api, fields, models, _
|
||
from odoo.exceptions import ValidationError
|
||
|
||
|
||
class FusionClockSchedule(models.Model):
|
||
_name = 'fusion.clock.schedule'
|
||
_description = 'Clock Shift Schedule Entry'
|
||
_order = 'schedule_date, employee_id'
|
||
_rec_name = 'display_name'
|
||
|
||
employee_id = fields.Many2one(
|
||
'hr.employee',
|
||
string='Employee',
|
||
required=True,
|
||
index=True,
|
||
ondelete='cascade',
|
||
)
|
||
schedule_date = fields.Date(
|
||
string='Date',
|
||
required=True,
|
||
index=True,
|
||
)
|
||
shift_id = fields.Many2one(
|
||
'fusion.clock.shift',
|
||
string='Shift Template',
|
||
ondelete='set null',
|
||
)
|
||
is_off = fields.Boolean(
|
||
string='Off',
|
||
default=False,
|
||
index=True,
|
||
)
|
||
start_time = fields.Float(
|
||
string='Start Time',
|
||
default=9.0,
|
||
)
|
||
end_time = fields.Float(
|
||
string='End Time',
|
||
default=17.0,
|
||
)
|
||
break_minutes = fields.Float(
|
||
string='Break (min)',
|
||
default=30.0,
|
||
)
|
||
planned_hours = fields.Float(
|
||
string='Hours',
|
||
compute='_compute_planned_hours',
|
||
store=True,
|
||
)
|
||
note = fields.Char(string='Note')
|
||
company_id = fields.Many2one(
|
||
'res.company',
|
||
string='Company',
|
||
related='employee_id.company_id',
|
||
store=True,
|
||
readonly=True,
|
||
)
|
||
department_id = fields.Many2one(
|
||
'hr.department',
|
||
string='Department',
|
||
related='employee_id.department_id',
|
||
store=True,
|
||
readonly=True,
|
||
)
|
||
display_name = fields.Char(
|
||
compute='_compute_display_name',
|
||
store=True,
|
||
)
|
||
|
||
_employee_date_unique = models.Constraint(
|
||
'UNIQUE(employee_id, schedule_date)',
|
||
'Only one shift schedule is allowed per employee per day.',
|
||
)
|
||
|
||
@api.depends('is_off', 'start_time', 'end_time', 'break_minutes')
|
||
def _compute_planned_hours(self):
|
||
for rec in self:
|
||
if rec.is_off:
|
||
rec.planned_hours = 0.0
|
||
continue
|
||
raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0)
|
||
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')
|
||
def _compute_display_name(self):
|
||
for rec in self:
|
||
emp = rec.employee_id.name or ''
|
||
date_str = str(rec.schedule_date) if rec.schedule_date else ''
|
||
rec.display_name = f"{emp} - {date_str} - {rec.fclk_display_value()}"
|
||
|
||
@api.constrains('is_off', 'start_time', 'end_time', 'break_minutes')
|
||
def _check_schedule_times(self):
|
||
for rec in self:
|
||
if rec.break_minutes < 0:
|
||
raise ValidationError(_("Break minutes cannot be negative."))
|
||
if rec.is_off:
|
||
continue
|
||
if rec.start_time < 0 or rec.start_time >= 24:
|
||
raise ValidationError(_("Start time must be between 00:00 and 23:59."))
|
||
if rec.end_time <= 0 or rec.end_time > 24:
|
||
raise ValidationError(_("End time must be between 00:01 and 24:00."))
|
||
if rec.end_time <= rec.start_time:
|
||
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
|
||
shift_minutes = (rec.end_time - rec.start_time) * 60.0
|
||
if rec.break_minutes >= shift_minutes:
|
||
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))
|
||
|
||
@api.onchange('shift_id')
|
||
def _onchange_shift_id(self):
|
||
for rec in self:
|
||
if rec.shift_id:
|
||
rec.is_off = False
|
||
rec.start_time = rec.shift_id.start_time
|
||
rec.end_time = rec.shift_id.end_time
|
||
rec.break_minutes = rec.shift_id.break_minutes
|
||
|
||
@api.model
|
||
def fclk_float_to_display(self, value):
|
||
value = float(value or 0.0)
|
||
hour = int(value)
|
||
minute = int(round((value - hour) * 60))
|
||
if minute == 60:
|
||
hour += 1
|
||
minute = 0
|
||
suffix = 'am' if hour < 12 or hour == 24 else 'pm'
|
||
display_hour = hour % 12
|
||
if display_hour == 0:
|
||
display_hour = 12
|
||
return f"{display_hour}:{minute:02d} {suffix}"
|
||
|
||
def fclk_display_value(self):
|
||
self.ensure_one()
|
||
if self.is_off:
|
||
return 'OFF'
|
||
return (
|
||
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.start_time)} - "
|
||
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.end_time)}"
|
||
)
|
||
|
||
@api.model
|
||
def fclk_hours_display(self, hours):
|
||
hours = float(hours or 0.0)
|
||
whole = int(hours)
|
||
minutes = int(round((hours - whole) * 60))
|
||
if minutes == 60:
|
||
whole += 1
|
||
minutes = 0
|
||
return f"{whole}:{minutes:02d}"
|
||
|
||
@api.model
|
||
def _fclk_parse_time_part(self, raw):
|
||
text = (raw or '').strip().lower().replace('.', '')
|
||
match = re.match(r'^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$', text)
|
||
if not match:
|
||
raise ValidationError(_("Could not understand time '%s'.") % raw)
|
||
hour = int(match.group(1))
|
||
minute = int(match.group(2) or 0)
|
||
meridiem = match.group(3)
|
||
if minute < 0 or minute > 59:
|
||
raise ValidationError(_("Minutes must be between 00 and 59."))
|
||
if meridiem:
|
||
if hour < 1 or hour > 12:
|
||
raise ValidationError(_("12-hour times must use hours from 1 to 12."))
|
||
if meridiem == 'am':
|
||
hour = 0 if hour == 12 else hour
|
||
else:
|
||
hour = 12 if hour == 12 else hour + 12
|
||
elif hour > 24:
|
||
raise ValidationError(_("Hours must be between 0 and 24."))
|
||
return hour + (minute / 60.0)
|
||
|
||
@api.model
|
||
def fclk_parse_planner_input(self, input_value, default_break_minutes=30.0):
|
||
text = (input_value or '').strip()
|
||
if not text:
|
||
return {'clear': True}
|
||
if text.upper() == 'OFF':
|
||
return {
|
||
'clear': False,
|
||
'is_off': True,
|
||
'shift_id': False,
|
||
'start_time': 0.0,
|
||
'end_time': 0.0,
|
||
'break_minutes': 0.0,
|
||
}
|
||
|
||
normalized = (
|
||
text.replace('–', '-')
|
||
.replace('—', '-')
|
||
.replace(' to ', '-')
|
||
.replace(' TO ', '-')
|
||
)
|
||
parts = [p.strip() for p in normalized.split('-', 1)]
|
||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||
raise ValidationError(_("Enter a shift as '9-5', '9:00-5:30', '9:00 am - 5:30 pm', or OFF."))
|
||
start = self._fclk_parse_time_part(parts[0])
|
||
end = self._fclk_parse_time_part(parts[1])
|
||
if end <= start and end + 12 <= 24:
|
||
end += 12
|
||
if end <= start:
|
||
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
|
||
return {
|
||
'clear': False,
|
||
'is_off': False,
|
||
'shift_id': False,
|
||
'start_time': start,
|
||
'end_time': end,
|
||
'break_minutes': float(default_break_minutes or 0.0),
|
||
}
|
||
|
||
@api.model
|
||
def fclk_values_from_planner_payload(self, payload, employee):
|
||
payload = payload or {}
|
||
if 'start_time' in payload and 'end_time' in payload and not payload.get('shift_id'):
|
||
if payload.get('is_off'):
|
||
return {
|
||
'clear': False,
|
||
'is_off': True,
|
||
'shift_id': False,
|
||
'start_time': 0.0,
|
||
'end_time': 0.0,
|
||
'break_minutes': 0.0,
|
||
}
|
||
return {
|
||
'clear': False,
|
||
'is_off': False,
|
||
'shift_id': False,
|
||
'start_time': float(payload.get('start_time') or 0.0),
|
||
'end_time': float(payload.get('end_time') or 0.0),
|
||
'break_minutes': float(payload.get('break_minutes') or 0.0),
|
||
}
|
||
shift_id = int(payload.get('shift_id') or 0)
|
||
if shift_id:
|
||
shift = self.env['fusion.clock.shift'].sudo().browse(shift_id)
|
||
if not shift.exists():
|
||
raise ValidationError(_("Selected shift template no longer exists."))
|
||
return {
|
||
'clear': False,
|
||
'shift_id': shift.id,
|
||
'is_off': False,
|
||
'start_time': shift.start_time,
|
||
'end_time': shift.end_time,
|
||
'break_minutes': shift.break_minutes,
|
||
}
|
||
|
||
default_break = employee._get_fclk_break_minutes() if employee else 30.0
|
||
return self.fclk_parse_planner_input(payload.get('input', ''), default_break)
|
||
|
||
@api.model
|
||
def fclk_snapshot(self, schedule):
|
||
if not schedule:
|
||
return ''
|
||
return schedule.fclk_display_value()
|
||
|
||
@api.model
|
||
def fclk_apply_planner_cell(self, employee, schedule_date, payload, user=None):
|
||
self = self.sudo()
|
||
employee = employee.sudo()
|
||
date_obj = fields.Date.to_date(schedule_date)
|
||
if not employee.exists() or not date_obj:
|
||
raise ValidationError(_("Invalid employee or schedule date."))
|
||
|
||
existing = self.search([
|
||
('employee_id', '=', employee.id),
|
||
('schedule_date', '=', date_obj),
|
||
], limit=1)
|
||
old_value = self.fclk_snapshot(existing)
|
||
parsed = self.fclk_values_from_planner_payload(payload, employee)
|
||
|
||
if parsed.get('clear'):
|
||
if existing:
|
||
existing.unlink()
|
||
new_schedule = self.browse()
|
||
new_value = ''
|
||
else:
|
||
vals = {
|
||
'employee_id': employee.id,
|
||
'schedule_date': date_obj,
|
||
'shift_id': parsed.get('shift_id') or False,
|
||
'is_off': bool(parsed.get('is_off')),
|
||
'start_time': parsed.get('start_time') or 0.0,
|
||
'end_time': parsed.get('end_time') or 0.0,
|
||
'break_minutes': parsed.get('break_minutes') or 0.0,
|
||
'note': payload.get('note') or False,
|
||
}
|
||
if existing:
|
||
existing.write(vals)
|
||
new_schedule = existing
|
||
else:
|
||
new_schedule = self.create(vals)
|
||
new_value = new_schedule.fclk_display_value()
|
||
|
||
if old_value != new_value:
|
||
self.env['fusion.clock.schedule.audit'].sudo().create({
|
||
'schedule_id': new_schedule.id if new_schedule else False,
|
||
'employee_id': employee.id,
|
||
'schedule_date': date_obj,
|
||
'old_value': old_value,
|
||
'new_value': new_value,
|
||
'changed_by_id': (user or self.env.user).id,
|
||
'changed_at': fields.Datetime.now(),
|
||
'company_id': employee.company_id.id,
|
||
'department_id': employee.department_id.id,
|
||
})
|
||
return new_schedule
|
||
|
||
@api.model
|
||
def fclk_cell_payload(self, employee, date_obj, schedule=None):
|
||
schedule = schedule or self.search([
|
||
('employee_id', '=', employee.id),
|
||
('schedule_date', '=', date_obj),
|
||
], limit=1)
|
||
Schedule = self.env['fusion.clock.schedule']
|
||
if schedule:
|
||
return {
|
||
'schedule_id': schedule.id,
|
||
'source': 'schedule',
|
||
'input': schedule.fclk_display_value(),
|
||
'label': schedule.fclk_display_value(),
|
||
'is_off': schedule.is_off,
|
||
'shift_id': schedule.shift_id.id or False,
|
||
'start_time': schedule.start_time,
|
||
'end_time': schedule.end_time,
|
||
'break_minutes': schedule.break_minutes,
|
||
'hours': schedule.planned_hours,
|
||
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
|
||
'note': schedule.note or '',
|
||
}
|
||
|
||
plan = employee._get_fclk_day_plan(date_obj)
|
||
return {
|
||
'schedule_id': False,
|
||
'source': plan.get('source') or 'fallback',
|
||
'input': plan.get('label') or '',
|
||
'label': plan.get('label') or '',
|
||
'is_off': plan.get('is_off', False),
|
||
'shift_id': False,
|
||
'start_time': plan.get('start_time') or 0.0,
|
||
'end_time': plan.get('end_time') or 0.0,
|
||
'break_minutes': plan.get('break_minutes') or 0.0,
|
||
'hours': plan.get('hours') or 0.0,
|
||
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
|
||
'note': '',
|
||
}
|
||
|
||
|
||
class FusionClockScheduleAudit(models.Model):
|
||
_name = 'fusion.clock.schedule.audit'
|
||
_description = 'Clock Schedule Change Audit'
|
||
_order = 'changed_at desc, id desc'
|
||
_rec_name = 'display_name'
|
||
|
||
schedule_id = fields.Many2one(
|
||
'fusion.clock.schedule',
|
||
string='Schedule',
|
||
ondelete='set null',
|
||
index=True,
|
||
)
|
||
employee_id = fields.Many2one(
|
||
'hr.employee',
|
||
string='Employee',
|
||
required=True,
|
||
index=True,
|
||
ondelete='cascade',
|
||
)
|
||
schedule_date = fields.Date(
|
||
string='Schedule Date',
|
||
required=True,
|
||
index=True,
|
||
)
|
||
old_value = fields.Char(string='Old Value')
|
||
new_value = fields.Char(string='New Value')
|
||
changed_by_id = fields.Many2one(
|
||
'res.users',
|
||
string='Changed By',
|
||
required=True,
|
||
ondelete='restrict',
|
||
)
|
||
changed_at = fields.Datetime(
|
||
string='Changed At',
|
||
default=fields.Datetime.now,
|
||
required=True,
|
||
index=True,
|
||
)
|
||
company_id = fields.Many2one(
|
||
'res.company',
|
||
string='Company',
|
||
index=True,
|
||
)
|
||
department_id = fields.Many2one(
|
||
'hr.department',
|
||
string='Department',
|
||
index=True,
|
||
)
|
||
display_name = fields.Char(
|
||
compute='_compute_display_name',
|
||
store=True,
|
||
)
|
||
|
||
@api.depends('employee_id', 'schedule_date', 'old_value', 'new_value')
|
||
def _compute_display_name(self):
|
||
for rec in self:
|
||
rec.display_name = "%s - %s: %s -> %s" % (
|
||
rec.employee_id.name or '',
|
||
rec.schedule_date or '',
|
||
rec.old_value or 'blank',
|
||
rec.new_value or 'blank',
|
||
)
|