Adds the NFC card UID field (Char, unique, manager-only) that the kiosk will use to identify employees by card tap. Includes the tests package with three post-install tests covering write, uniqueness, and nullable multi-row behaviour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
236 lines
8.3 KiB
Python
236 lines
8.3 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.",
|
|
)
|
|
|
|
# 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.",
|
|
)
|
|
|
|
# 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.",
|
|
)
|
|
|
|
_sql_constraints = [
|
|
(
|
|
'fclk_nfc_card_uid_unique',
|
|
'UNIQUE(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 _get_fclk_break_minutes(self):
|
|
"""Return effective break minutes for this employee.
|
|
Priority: employee override > shift > global setting.
|
|
"""
|
|
self.ensure_one()
|
|
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_scheduled_times(self, date):
|
|
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
|
|
|
|
Uses employee shift if assigned, otherwise 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()
|
|
if self.x_fclk_shift_id:
|
|
in_hour = self.x_fclk_shift_id.start_time
|
|
out_hour = self.x_fclk_shift_id.end_time
|
|
else:
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
|
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.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))
|
|
)
|
|
|
|
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):
|
|
"""Return the expected work hours for this employee's shift."""
|
|
self.ensure_one()
|
|
if self.x_fclk_shift_id:
|
|
return self.x_fclk_shift_id.scheduled_hours
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
|
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
|
break_hrs = self._get_fclk_break_minutes() / 60.0
|
|
return max((out_hour - in_hour) - break_hrs, 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)
|