Files
Odoo-Modules/fusion_clock/models/hr_employee.py
gsinghpal 68aaa132ee feat(fusion_clock): schedule parity — overnight, split shifts, open shifts [B1-B3]
is_open + crosses_midnight fields; employee_id optional (open shifts);
company_id computed w/ env.company fallback; drop hard one-per-day UNIQUE
(allow split + open). Overnight math in planned_hours/_check_schedule_times/
scheduled_times. _get_fclk_day_plan resolves multiple posted rows into ONE
work-window so penalties/overtime/absence stay correct. Migration drops the
old constraint defensively. Tests for overnight, window, open shifts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:04:58 -04:00

421 lines
16 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import datetime, timedelta
from odoo import models, fields, api
from .tz_utils import get_local_today, get_local_day_boundaries
class HrEmployee(models.Model):
_inherit = 'hr.employee'
x_fclk_default_location_id = fields.Many2one(
'fusion.clock.location',
string='Default Clock Location',
help="The default location shown on this employee's clock page.",
)
x_fclk_enable_clock = fields.Boolean(
string='Enable Fusion Clock',
default=True,
help="If unchecked, this employee cannot use the Fusion Clock portal/systray.",
)
x_fclk_break_minutes = fields.Float(
string='Custom Break (min)',
default=0.0,
help="Override default break duration for this employee. 0 = use shift or company default.",
)
# Shift scheduling
x_fclk_shift_id = fields.Many2one(
'fusion.clock.shift',
string='Work Shift',
help="Assigned shift schedule. Leave empty to use global defaults.",
)
# Shift roles (native replacement for Odoo Planning's employee role fields)
x_fclk_default_role_id = fields.Many2one(
'fusion.clock.role',
string='Default Shift Role',
help="Pre-fills the role on every new shift created for this employee.",
)
x_fclk_role_ids = fields.Many2many(
'fusion.clock.role',
'fclk_employee_role_rel',
'employee_id',
'role_id',
string='Allowed Shift Roles',
help="Roles this employee is allowed to be scheduled for.",
)
# Pending reason enforcement
x_fclk_pending_reason = fields.Boolean(
string='Pending Reason Required',
default=False,
help="If set, employee must explain a missed clock-out before clocking in again.",
)
# Attendance exemption (owners / anyone who works but is not "on the clock").
# Exempt employees are skipped by absence detection, auto-clock-out and
# reminders, and never see the missed-clock-out reason dialog.
x_fclk_exempt_from_attendance = fields.Boolean(
string='Exempt from Attendance Tracking',
default=False,
help="If set, this employee is never flagged absent, auto-clocked-out, "
"reminded, or asked to explain a missed clock-out. Use for owners "
"and others who work but are not on the clock. The Fusion Clock "
"'Owner' role grants this automatically.",
)
# Kiosk PIN
x_fclk_kiosk_pin = fields.Char(
string='Kiosk PIN',
help="PIN code for kiosk clock-in/out identification.",
groups="fusion_clock.group_fusion_clock_manager",
)
# NFC card (kiosk identification)
x_fclk_nfc_card_uid = fields.Char(
string='NFC Card UID',
index=True,
copy=False,
groups="fusion_clock.group_fusion_clock_manager",
help="Hex UID of the NFC card assigned to this employee. "
"Format: uppercase, colon-separated, e.g. 04:A2:B5:62:C1:80. "
"Same card the employee uses for door access.",
)
# Enforce NFC card-UID uniqueness ONLY when a UID is set. Odoo 19 silently ignores
# the legacy `_sql_constraints` list (see repo-root CLAUDE.md rule 9), so this never
# created a DB constraint. Use the declarative UniqueIndex with a partial WHERE so the
# many employees without a card can share a blank/NULL value, while two employees can
# never be assigned the same physical card.
_fclk_nfc_card_uid_unique = models.UniqueIndex(
"(x_fclk_nfc_card_uid) WHERE x_fclk_nfc_card_uid IS NOT NULL AND x_fclk_nfc_card_uid != ''",
'This NFC card is already assigned to another employee.',
)
# On-time streak
x_fclk_ontime_streak = fields.Integer(
string='On-Time Streak',
default=0,
help="Consecutive workdays clocked in on time.",
)
# Absence tracking (computed)
x_fclk_absences_this_month = fields.Integer(
string='Absences This Month',
compute='_compute_absence_counts',
)
x_fclk_absences_this_year = fields.Integer(
string='Absences This Year',
compute='_compute_absence_counts',
)
# Overtime tracking (computed)
x_fclk_overtime_this_week = fields.Float(
string='Overtime This Week (h)',
compute='_compute_overtime',
)
x_fclk_overtime_this_month = fields.Float(
string='Overtime This Month (h)',
compute='_compute_overtime',
)
# Activity log relation
x_fclk_activity_log_ids = fields.One2many(
'fusion.clock.activity.log',
'employee_id',
string='Activity Logs',
)
# Leave request relation
x_fclk_leave_request_ids = fields.One2many(
'fusion.clock.leave.request',
'employee_id',
string='Leave Requests',
)
# Correction request relation
x_fclk_correction_ids = fields.One2many(
'fusion.clock.correction',
'employee_id',
string='Correction Requests',
)
# Reminder tracking
x_fclk_last_reminder_date = fields.Date(
string='Last Reminder Date',
help="Tracks the last date a reminder was sent to avoid duplicates.",
)
def _fclk_is_attendance_exempt(self):
"""True when this employee is exempt from attendance automation.
Exempt = the per-employee checkbox is set, OR the linked user holds the
Fusion Clock 'Owner' role. Exempt employees are never flagged absent,
auto-clocked-out, reminded, or shown the missed-clock-out reason dialog.
"""
self.ensure_one()
if self.x_fclk_exempt_from_attendance:
return True
user = self.user_id
return bool(user) and user.has_group('fusion_clock.group_fusion_clock_owner')
def _get_fclk_schedule_for_date(self, date):
"""Return this employee's dated Fusion Clock schedule for a local date."""
self.ensure_one()
date_obj = fields.Date.to_date(date)
if not date_obj:
return self.env['fusion.clock.schedule']
return self.env['fusion.clock.schedule'].sudo().search([
('employee_id', '=', self.id),
('schedule_date', '=', date_obj),
], 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):
"""Return the effective plan for a local date, with an explicit
``scheduled`` flag that ALL attendance automation keys off.
Resolution order:
1. POSTED planner entry (``fusion.clock.schedule`` state='posted').
Draft entries are ignored, so the recurring baseline still applies
until the team lead posts the schedule.
2. The employee's recurring shift, IF it covers this weekday.
3. Otherwise: not scheduled. The global default times are returned
only as a display hint; ``scheduled`` stays False so nothing fires.
"""
self.ensure_one()
Schedule = self.env['fusion.clock.schedule'].sudo()
date_obj = fields.Date.to_date(date)
# 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 {
'source': 'schedule',
'schedule_id': working[0].id,
'scheduled': True,
'is_off': False,
'start_time': start,
'end_time': end,
'break_minutes': sum(working.mapped('break_minutes')),
'hours': sum(working.mapped('planned_hours')),
'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
if shift and shift.covers_weekday(date):
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 {
'source': 'shift',
'schedule_id': False,
'scheduled': True,
'is_off': False,
'start_time': shift.start_time,
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
'hours': hours,
'crosses_midnight': crosses,
'label': '%s - %s' % (
Schedule.fclk_float_to_display(shift.start_time),
Schedule.fclk_float_to_display(shift.end_time),
),
}
# Not scheduled — global default times are a display hint only.
ICP = self.env['ir.config_parameter'].sudo()
start_time = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
end_time = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
break_minutes = float(ICP.get_param('fusion_clock.default_break_minutes', '30'))
return {
'source': 'none',
'schedule_id': False,
'scheduled': False,
'is_off': False,
'crosses_midnight': False,
'start_time': start_time,
'end_time': end_time,
'break_minutes': break_minutes,
'hours': 0.0,
'label': '',
}
def _get_fclk_break_minutes(self, date=None):
"""Return effective break minutes for this employee.
Priority: dated schedule > employee override > shift > global setting.
"""
self.ensure_one()
if date:
plan = self._get_fclk_day_plan(date)
if plan.get('source') == 'schedule' and not plan.get('is_off'):
return plan.get('break_minutes') or 0.0
if self.x_fclk_break_minutes > 0:
return self.x_fclk_break_minutes
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0:
return self.x_fclk_shift_id.break_minutes
return float(
self.env['ir.config_parameter'].sudo().get_param(
'fusion_clock.default_break_minutes', '30'
)
)
def _get_fclk_break_rule(self):
"""Return the statutory break rule for this employee.
Resolution: company's province -> matching rule; else the global default
rule; else an empty recordset (caller treats as zero break). Read via
sudo so the portal net-hours compute can resolve it without a direct ACL.
"""
self.ensure_one()
Rule = self.env['fusion.clock.break.rule'].sudo()
rule = Rule.browse()
state = self.company_id.state_id
if state:
rule = Rule.search([('state_id', '=', state.id)], limit=1)
if not rule:
rule = Rule.search([('is_default', '=', True)], limit=1)
return rule
def _get_fclk_scheduled_times(self, date):
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
Uses dated schedule first, employee shift second, then global settings.
The configured hours are interpreted in the employee's local
timezone and converted to naive-UTC datetimes so they can be
compared with Odoo's UTC-based ``fields.Datetime.now()``.
"""
import pytz
self.ensure_one()
plan = self._get_fclk_day_plan(date)
in_hour = plan.get('start_time') or 0.0
out_hour = plan.get('end_time') or 0.0
in_h = int(in_hour)
in_m = int((in_hour - in_h) * 60)
out_h = int(out_hour)
out_m = int((out_hour - out_h) * 60)
tz_name = (
self.resource_id.tz
or (self.user_id.partner_id.tz if self.user_id else False)
or self.company_id.partner_id.tz
or 'UTC'
)
local_tz = pytz.timezone(tz_name)
utc = pytz.UTC
local_in = local_tz.localize(
datetime.combine(date, datetime.min.time().replace(hour=in_h, minute=in_m))
)
local_out = local_tz.localize(
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_out = local_out.astimezone(utc).replace(tzinfo=None)
return scheduled_in, scheduled_out
def _get_fclk_scheduled_hours(self, date=None):
"""Return the expected work hours for this employee's shift."""
self.ensure_one()
plan = self._get_fclk_day_plan(date or get_local_today(self.env, self))
if plan.get('is_off'):
return 0.0
return plan.get('hours') or 0.0
def _compute_absence_counts(self):
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
for emp in self:
today = get_local_today(self.env, emp)
month_start = today.replace(day=1)
year_start = today.replace(month=1, day=1)
month_start_utc, _ = get_local_day_boundaries(self.env, month_start, emp)
year_start_utc, _ = get_local_day_boundaries(self.env, year_start, emp)
emp.x_fclk_absences_this_month = ActivityLog.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'absent'),
('log_date', '>=', month_start_utc),
])
emp.x_fclk_absences_this_year = ActivityLog.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'absent'),
('log_date', '>=', year_start_utc),
])
def _compute_overtime(self):
Attendance = self.env['hr.attendance'].sudo()
for emp in self:
today = get_local_today(self.env, emp)
week_start = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
week_start_utc, _ = get_local_day_boundaries(self.env, week_start, emp)
month_start_utc, _ = get_local_day_boundaries(self.env, month_start, emp)
week_atts = Attendance.search([
('employee_id', '=', emp.id),
('check_in', '>=', week_start_utc),
('check_out', '!=', False),
])
emp.x_fclk_overtime_this_week = sum(a.x_fclk_overtime_hours or 0 for a in week_atts)
month_atts = Attendance.search([
('employee_id', '=', emp.id),
('check_in', '>=', month_start_utc),
('check_out', '!=', False),
])
emp.x_fclk_overtime_this_month = sum(a.x_fclk_overtime_hours or 0 for a in month_atts)