# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) import logging from datetime import datetime, timedelta from odoo import models, fields, api from odoo.tools import float_round _logger = logging.getLogger(__name__) class HrAttendance(models.Model): _inherit = 'hr.attendance' x_fclk_location_id = fields.Many2one( 'fusion.clock.location', string='Clock Location', help="The geofenced location where employee clocked in.", ) x_fclk_clock_source = fields.Selection( [ ('portal', 'Portal'), ('systray', 'Systray'), ('kiosk', 'Kiosk'), ('manual', 'Manual'), ('auto', 'Auto Clock-Out'), ], string='Clock Source', help="How this attendance was recorded.", ) x_fclk_in_distance = fields.Float( string='Check-In Distance (m)', digits=(10, 2), help="Distance from location center at clock-in, in meters.", ) x_fclk_out_distance = fields.Float( string='Check-Out Distance (m)', digits=(10, 2), help="Distance from location center at clock-out, in meters.", ) x_fclk_break_minutes = fields.Float( string='Break (min)', default=0.0, help="Break duration in minutes to deduct from worked hours.", ) x_fclk_net_hours = fields.Float( string='Net Hours', compute='_compute_net_hours', store=True, help="Worked hours minus break deduction.", ) x_fclk_penalty_ids = fields.One2many( 'fusion.clock.penalty', 'attendance_id', string='Penalties', ) x_fclk_auto_clocked_out = fields.Boolean( string='Auto Clocked Out', default=False, help="Set to true if this attendance was automatically closed.", ) x_fclk_grace_used = fields.Boolean( string='Grace Period Used', default=False, help="Whether the grace period was consumed before auto clock-out.", ) @api.depends('worked_hours', 'x_fclk_break_minutes') def _compute_net_hours(self): for att in self: break_hours = (att.x_fclk_break_minutes or 0.0) / 60.0 raw = att.worked_hours or 0.0 att.x_fclk_net_hours = max(raw - break_hours, 0.0) @api.model def _cron_fusion_auto_clock_out(self): """Cron job: auto clock-out employees after shift + grace period. Runs every 15 minutes. Finds open attendances that have exceeded the maximum shift length plus grace period, and closes them. """ ICP = self.env['ir.config_parameter'].sudo() if ICP.get_param('fusion_clock.enable_auto_clockout', 'True') != 'True': return max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '12.0')) grace_min = float(ICP.get_param('fusion_clock.grace_period_minutes', '15')) clock_out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0')) now = fields.Datetime.now() # Find all open attendances (no check_out) open_attendances = self.sudo().search([ ('check_out', '=', False), ]) for att in open_attendances: check_in = att.check_in if not check_in: continue # Calculate the scheduled end + grace for this attendance check_in_date = check_in.date() out_h = int(clock_out_hour) out_m = int((clock_out_hour - out_h) * 60) scheduled_end = datetime.combine( check_in_date, datetime.min.time().replace(hour=out_h, minute=out_m), ) deadline = scheduled_end + timedelta(minutes=grace_min) # Also check max shift safety net max_deadline = check_in + timedelta(hours=max_shift) # Use the earlier of the two deadlines effective_deadline = min(deadline, max_deadline) if now > effective_deadline: # Auto clock-out at the deadline time (not now) clock_out_time = min(effective_deadline, now) try: att.sudo().write({ 'check_out': clock_out_time, 'x_fclk_auto_clocked_out': True, 'x_fclk_grace_used': True, 'x_fclk_clock_source': 'auto', }) # Apply break deduction employee = att.employee_id threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0')) if (att.worked_hours or 0) >= threshold: break_min = employee._get_fclk_break_minutes() att.sudo().write({'x_fclk_break_minutes': break_min}) # Post chatter message att.sudo().message_post( body=f"Auto clocked out at {clock_out_time.strftime('%H:%M')} " f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h", message_type='comment', subtype_xmlid='mail.mt_note', ) _logger.info( "Fusion Clock: Auto clocked out %s (attendance %s)", employee.name, att.id, ) except Exception as e: _logger.error( "Fusion Clock: Failed to auto clock-out attendance %s: %s", att.id, str(e), )