# -*- 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_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 _get_fclk_day_plan(self, date): """Return the effective plan for a local date. Dated schedules are the source of truth. If none exists, the legacy employee shift/global settings remain the fallback. """ self.ensure_one() Schedule = self.env['fusion.clock.schedule'].sudo() schedule = self._get_fclk_schedule_for_date(date) if schedule: return { 'source': 'schedule', 'schedule_id': schedule.id, 'is_off': schedule.is_off, 'start_time': schedule.start_time, 'end_time': schedule.end_time, 'break_minutes': schedule.break_minutes, 'hours': schedule.planned_hours, 'label': schedule.fclk_display_value(), } if self.x_fclk_shift_id: shift = self.x_fclk_shift_id hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0) return { 'source': 'fallback', 'schedule_id': False, 'is_off': False, 'start_time': shift.start_time, 'end_time': shift.end_time, 'break_minutes': shift.break_minutes, 'hours': hours, 'label': '%s - %s' % ( Schedule.fclk_float_to_display(shift.start_time), Schedule.fclk_float_to_display(shift.end_time), ), } 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')) hours = max((end_time - start_time) - (break_minutes / 60.0), 0.0) return { 'source': 'fallback', 'schedule_id': False, 'is_off': False, 'start_time': start_time, 'end_time': end_time, 'break_minutes': break_minutes, 'hours': hours, 'label': '%s - %s' % ( Schedule.fclk_float_to_display(start_time), Schedule.fclk_float_to_display(end_time), ), } 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_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)) ) 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)