# -*- 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-In Location', help="The geofenced location where employee clocked in.", ) x_fclk_out_location_id = fields.Many2one( 'fusion.clock.location', string='Clock-Out Location', help="The geofenced location where employee clocked out.", ) x_fclk_clock_source = fields.Selection( [ ('portal', 'Portal'), ('portal_fab', 'Portal FAB'), ('systray', 'Systray'), ('backend_fab', 'Backend FAB'), ('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.", ) # -- Pay period grouping fields -- x_fclk_pay_period = fields.Char( string='Pay Period', compute='_compute_pay_period', store=True, help="Human-readable pay period label for grouping.", ) x_fclk_pay_period_start = fields.Date( string='Period Start', compute='_compute_pay_period', store=True, help="Pay period start date, used for chronological ordering.", ) # ------------------------------------------------------------------ # CRUD overrides: auto-apply break on any attendance with check_out # ------------------------------------------------------------------ @api.model_create_multi def create(self, vals_list): records = super().create(vals_list) records._auto_apply_break() return records def write(self, vals): res = super().write(vals) if 'check_out' in vals or 'worked_hours' in vals: self._auto_apply_break() return res def _auto_apply_break(self): """Apply break deduction to completed attendances that don't have it. Only applies when: - auto_deduct_break setting is enabled - check_out is set (completed shift) - worked_hours >= break threshold - break_minutes is still 0 (not manually set or already applied) """ ICP = self.env['ir.config_parameter'].sudo() if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True': return threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0')) for att in self: if not att.check_out: continue if (att.x_fclk_break_minutes or 0) > 0: continue if (att.worked_hours or 0) < threshold: continue emp = att.employee_id if emp: break_min = emp._get_fclk_break_minutes() att.sudo().write({'x_fclk_break_minutes': break_min}) @api.model def action_backfill_breaks(self): """Apply break deduction to all historical records that are missing it.""" ICP = self.env['ir.config_parameter'].sudo() threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0')) records = self.sudo().search([ ('check_out', '!=', False), ('x_fclk_break_minutes', '=', 0), ]) count = 0 for att in records: if (att.worked_hours or 0) < threshold: continue emp = att.employee_id if emp: break_min = emp._get_fclk_break_minutes() att.write({'x_fclk_break_minutes': break_min}) count += 1 _logger.info("Fusion Clock: Backfilled break on %d attendance records.", count) return count # ------------------------------------------------------------------ @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.depends('check_in') def _compute_pay_period(self): ICP = self.env['ir.config_parameter'].sudo() schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly') anchor_str = ICP.get_param('fusion_clock.pay_period_start', '') if anchor_str: try: anchor = fields.Date.from_string(anchor_str) except Exception: anchor = None else: anchor = None for att in self: if not att.check_in: att.x_fclk_pay_period = False att.x_fclk_pay_period_start = False continue ref_date = att.check_in.date() period_start, period_end = self._calc_period( schedule_type, anchor, ref_date, ) att.x_fclk_pay_period_start = period_start att.x_fclk_pay_period = ( f"{period_start.strftime('%b %d')} - " f"{period_end.strftime('%b %d, %Y')}" ) @staticmethod def _calc_period(schedule_type, anchor, ref_date): """Calculate pay period start/end for a given date.""" if not anchor: anchor = ref_date.replace(day=1) if schedule_type == 'weekly': days_diff = (ref_date - anchor).days period_num = days_diff // 7 period_start = anchor + timedelta(days=period_num * 7) period_end = period_start + timedelta(days=6) elif schedule_type == 'biweekly': days_diff = (ref_date - anchor).days period_num = days_diff // 14 period_start = anchor + timedelta(days=period_num * 14) period_end = period_start + timedelta(days=13) elif schedule_type == 'semi_monthly': if ref_date.day <= 15: period_start = ref_date.replace(day=1) period_end = ref_date.replace(day=15) else: period_start = ref_date.replace(day=16) next_month = ref_date.replace(day=28) + timedelta(days=4) period_end = next_month - timedelta(days=next_month.day) elif schedule_type == 'monthly': period_start = ref_date.replace(day=1) next_month = ref_date.replace(day=28) + timedelta(days=4) period_end = next_month - timedelta(days=next_month.day) else: days_diff = (ref_date - anchor).days period_num = days_diff // 14 period_start = anchor + timedelta(days=period_num * 14) period_end = period_start + timedelta(days=13) return period_start, period_end @api.model def _read_group(self, domain, groupby=(), aggregates=(), having=(), offset=0, limit=None, order=None): """Sort pay period groups chronologically (newest first) using the stored Date field instead of alphabetical Char order.""" if 'x_fclk_pay_period' in groupby: order = 'x_fclk_pay_period_start:max desc' return super()._read_group( domain, groupby, aggregates, having, offset, limit, order, ) def action_recompute_pay_periods(self): """Recompute pay period for all attendance records. Called from settings.""" all_atts = self.sudo().search([]) all_atts._compute_pay_period() return True @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), )