# -*- coding: utf-8 -*- # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) import logging import pytz from datetime import datetime, timedelta from odoo import models, fields, api from odoo.tools import float_round from .tz_utils import get_local_today, get_local_day_boundaries _logger = logging.getLogger(__name__) def _fclk_utc_to_local_str(dt, employee, fmt='%I:%M %p'): """Convert a naive UTC datetime to a formatted string in the employee's timezone.""" import pytz tz_name = ( employee.resource_id.tz or (employee.user_id.partner_id.tz if employee.user_id else False) or employee.company_id.partner_id.tz or 'UTC' ) utc_dt = pytz.UTC.localize(dt) local_dt = utc_dt.astimezone(pytz.timezone(tz_name)) return local_dt.strftime(fmt) _FCLK_ACCENT = '#10B981' _FCLK_FONT = ("-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif") def _fclk_email_section(heading, rows): """Build a details table matching the Fusion email design system.""" if not rows: return '' html = ( '' f'' ) for label, value in rows: if value is None or value == '' or value is False: continue html += ( f'' f'' f'' f'' ) html += '
{heading}
{label}{value}
' return html def _fclk_email_wrap( company_name, title, summary, sections=None, note=None, attachments_note=None, extra_html='', ): """Build a complete Fusion Clock email matching the Fusion design system. No user signatures are appended. """ parts = [ f'
', f'
', '
', f'

' f'{company_name}

', f'

{title}

', f'

{summary}

', ] if sections: for heading, rows in sections: parts.append(_fclk_email_section(heading, rows)) if note: parts.append( f'
' f'

{note}

' ) if extra_html: parts.append(extra_html) if attachments_note: parts.append( '
' '

' f'Attached: {attachments_note}

' ) parts.append('
') parts.append( '
' '

' f'{company_name}
' 'This is an automated notification from Fusion Clock.

' ) parts.append('
') return ''.join(parts) 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'), ('portal_fab', 'Portal FAB'), ('systray', 'Systray'), ('backend_fab', 'Backend FAB'), ('kiosk', 'Kiosk'), ('nfc_kiosk', 'NFC Kiosk'), ('manual', 'Manual'), ('auto', 'Auto Clock-Out'), ], string='Clock Source', tracking=True, 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_check_in_photo = fields.Binary( string='Check-In Photo', attachment=True, help="Front-camera photo captured at NFC kiosk clock-in.", ) x_fclk_check_out_photo = fields.Binary( string='Check-Out Photo', attachment=True, help="Front-camera photo captured at NFC kiosk clock-out.", ) x_fclk_break_minutes = fields.Float( string='Break (min)', default=0.0, tracking=True, 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, tracking=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.", ) # Overtime x_fclk_overtime_hours = fields.Float( string='Overtime (h)', compute='_compute_overtime_hours', store=True, help="Hours beyond the scheduled shift for this day.", ) x_fclk_is_overtime = fields.Boolean( string='Has Overtime', compute='_compute_overtime_hours', store=True, ) # Photo verification x_fclk_checkin_photo = fields.Binary( string='Check-In Photo', attachment=True, help="Selfie captured at clock-in for verification.", ) @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('x_fclk_net_hours') def _compute_overtime_hours(self): ICP = self.env['ir.config_parameter'].sudo() enable_ot = ICP.get_param('fusion_clock.enable_overtime', 'True') == 'True' daily_threshold = float(ICP.get_param('fusion_clock.daily_overtime_threshold', '8.0')) for att in self: if not enable_ot or not att.check_out: att.x_fclk_overtime_hours = 0.0 att.x_fclk_is_overtime = False continue employee = att.employee_id scheduled_hours = daily_threshold if employee: local_date = get_local_today(self.env, employee) if att.check_in: tz_name = ( employee.resource_id.tz or (employee.user_id.partner_id.tz if employee.user_id else False) or employee.company_id.partner_id.tz or 'UTC' ) local_date = pytz.UTC.localize(att.check_in).astimezone(pytz.timezone(tz_name)).date() scheduled_hours = employee._get_fclk_scheduled_hours(local_date) net = att.x_fclk_net_hours or 0.0 if net > scheduled_hours: att.x_fclk_overtime_hours = round(net - scheduled_hours, 2) att.x_fclk_is_overtime = True else: att.x_fclk_overtime_hours = 0.0 att.x_fclk_is_overtime = False @api.model def _cron_fusion_auto_clock_out(self): """Cron job: safety-net auto clock-out. Overtime past the scheduled end is expected, so this NEVER closes a shift at the scheduled end. It only closes an attendance left open longer than the max-shift safety cap (someone forgot to clock out), and flags the employee to explain on their next clock-in. """ 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', '16.0')) office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0')) threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0')) now = fields.Datetime.now() open_attendances = self.sudo().search([('check_out', '=', False)]) ActivityLog = self.env['fusion.clock.activity.log'].sudo() for att in open_attendances: check_in = att.check_in if not check_in: continue effective_deadline = check_in + timedelta(hours=max_shift) if now <= effective_deadline: continue employee = att.employee_id emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC') check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date() clock_out_time = effective_deadline try: with self.env.cr.savepoint(): att.sudo().write({ 'check_out': clock_out_time, 'x_fclk_auto_clocked_out': True, 'x_fclk_grace_used': True, 'x_fclk_clock_source': 'auto', }) if (att.worked_hours or 0) >= threshold: att.sudo().write( {'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)} ) att.sudo().message_post( body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} " f"(max-shift cap reached). Net hours: {att.x_fclk_net_hours:.1f}h", message_type='comment', subtype_xmlid='mail.mt_note', ) ActivityLog.create({ 'employee_id': employee.id, 'log_type': 'auto_clock_out', 'description': f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')}. " f"Net hours: {att.x_fclk_net_hours:.1f}h", 'attendance_id': att.id, 'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False, 'source': 'system', }) employee.sudo().write({'x_fclk_pending_reason': True}) self._fclk_notify_office( office_user_id, f"Auto Clock-Out: {employee.name}", f"{employee.name} was auto-clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')}. " f"Please review and correct if needed.", 'hr.attendance', att.id, ) _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), ) @api.model def _cron_fusion_wipe_old_photos(self): """Cron job: delete clock-in/out verification photos older than the configured retention window (``fusion_clock.photo_retention_days``). Only the images are removed — the attendance records, worked hours and penalties are kept. The photos are attachment-backed binary fields, so we unlink the underlying ir.attachment rows directly, which reclaims the filestore space. Set the retention to 0 to disable the wipe entirely.""" ICP = self.env['ir.config_parameter'].sudo() retention_days = int(ICP.get_param('fusion_clock.photo_retention_days', '60') or 0) if retention_days <= 0: return # 0 / unset → auto-wipe disabled cutoff = fields.Datetime.now() - timedelta(days=retention_days) old_attendances = self.sudo().search([('check_in', '<', cutoff)]) if not old_attendances: return Attachment = self.env['ir.attachment'].sudo() photo_fields = [ 'x_fclk_check_in_photo', # NFC kiosk clock-in selfie 'x_fclk_check_out_photo', # NFC kiosk clock-out selfie 'x_fclk_checkin_photo', # legacy portal clock-in photo ] wiped = 0 # Batch the attendances so the res_id IN (...) list stays bounded, and # isolate each batch in a savepoint so one bad row can't abort the rest. for offset in range(0, len(old_attendances), 500): batch_ids = old_attendances[offset:offset + 500].ids photos = Attachment.search([ ('res_model', '=', 'hr.attendance'), ('res_field', 'in', photo_fields), ('res_id', 'in', batch_ids), ]) if not photos: continue try: with self.env.cr.savepoint(): count = len(photos) photos.unlink() wiped += count except Exception as e: _logger.error("Fusion Clock: Failed to wipe a photo batch: %s", e) if wiped: _logger.info( "Fusion Clock: Wiped %s clock verification photo(s) older than %s days.", wiped, retention_days, ) @api.model def _cron_fusion_check_absences(self): """Cron job: check for absent employees (no attendance on workday).""" ICP = self.env['ir.config_parameter'].sudo() office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0')) max_absences = int(ICP.get_param('fusion_clock.max_monthly_absences', '3')) employees = self.env['hr.employee'].sudo().search([ ('x_fclk_enable_clock', '=', True), ]) ActivityLog = self.env['fusion.clock.activity.log'].sudo() LeaveRequest = self.env['fusion.clock.leave.request'].sudo() for emp in employees: try: with self.env.cr.savepoint(): yesterday = get_local_today(self.env, emp) - timedelta(days=1) # Only days the employee was actually scheduled to work # (posted shift or covering recurring shift) can count as an # absence. Off days and unscheduled days are never flagged. if not emp._get_fclk_day_plan(yesterday).get('scheduled'): continue day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp) holidays = self.env['resource.calendar.leaves'].sudo().search([ ('resource_id', '=', False), ('date_from', '<=', day_end), ('date_to', '>=', day_start), ]) if holidays: continue att_count = self.sudo().search_count([ ('employee_id', '=', emp.id), ('check_in', '>=', day_start), ('check_in', '<', day_end), ]) if att_count > 0: continue leave = LeaveRequest.search([ ('employee_id', '=', emp.id), ('leave_date', '<=', yesterday), ('date_to', '>=', yesterday), ], limit=1) if leave: continue ActivityLog.create({ 'employee_id': emp.id, 'log_type': 'absent', 'log_date': day_start, 'description': f"No attendance recorded for {yesterday}", 'source': 'system', }) emp.sudo().write({'x_fclk_pending_reason': True}) month_start = yesterday.replace(day=1) month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp) absence_count = ActivityLog.search_count([ ('employee_id', '=', emp.id), ('log_type', '=', 'absent'), ('log_date', '>=', month_boundary_start), ]) if absence_count >= max_absences: self._fclk_notify_office( office_user_id, f"Excessive Absences: {emp.name}", f"{emp.name} has {absence_count} absences this month " f"(threshold: {max_absences}). Please review.", 'hr.employee', emp.id, ) _logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday) except Exception as e: _logger.error("Fusion Clock: absence check failed for %s: %s", emp.name, e) @api.model def _cron_fusion_employee_reminders(self): """Cron job: schedule-driven clock-in / clock-out reminders. Reminders only go to employees actually SCHEDULED to work today (posted shift or covering recurring shift). Someone not scheduled — or whose shift simply hasn't started yet — is never pinged. """ ICP = self.env['ir.config_parameter'].sudo() if ICP.get_param('fusion_clock.enable_employee_notifications', 'True') != 'True': return reminder_in_min = float(ICP.get_param('fusion_clock.reminder_before_shift_minutes', '30')) reminder_out_min = float(ICP.get_param('fusion_clock.reminder_before_end_minutes', '15')) max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0')) now = fields.Datetime.now() employees = self.env['hr.employee'].sudo().search([ ('x_fclk_enable_clock', '=', True), ]) for emp in employees: try: with self.env.cr.savepoint(): today = get_local_today(self.env, emp) if not emp._get_fclk_day_plan(today).get('scheduled'): continue if emp.x_fclk_last_reminder_date == today: continue is_checked_in = emp.attendance_state == 'checked_in' if not is_checked_in: # Missed clock-in — only after THIS employee's own shift # start (+ threshold), so a late shift is never pinged early. scheduled_in, _scheduled_out = emp._get_fclk_scheduled_times(today) if now <= scheduled_in + timedelta(minutes=reminder_in_min): continue today_start, _ = get_local_day_boundaries(self.env, today, emp) has_attendance = self.sudo().search_count([ ('employee_id', '=', emp.id), ('check_in', '>=', today_start), ]) if has_attendance == 0: self._fclk_send_employee_reminder( emp, "Clock-In Reminder", f"Hi {emp.name}, you haven't clocked in yet today. " f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.", ) emp.sudo().write({'x_fclk_last_reminder_date': today}) else: # Still-clocked-in nudge (OT-aware): only as the max-shift # safety cap approaches, never at the scheduled end. open_att = self.sudo().search([ ('employee_id', '=', emp.id), ('check_out', '=', False), ], order='check_in desc', limit=1) if not open_att or not open_att.check_in: continue cap = open_att.check_in + timedelta(hours=max_shift) if cap - timedelta(minutes=reminder_out_min) < now < cap: self._fclk_send_employee_reminder( emp, "Clock-Out Reminder", f"Hi {emp.name}, you're still clocked in. " f"Remember to clock out when you leave.", ) emp.sudo().write({'x_fclk_last_reminder_date': today}) except Exception as e: _logger.error("Fusion Clock: reminder failed for %s: %s", emp.name, e) @api.model def _cron_fusion_weekly_summary(self): """Cron job: send weekly summary email to employees (Monday 8 AM).""" ICP = self.env['ir.config_parameter'].sudo() if ICP.get_param('fusion_clock.send_weekly_summary', 'True') != 'True': return employees = self.env['hr.employee'].sudo().search([ ('x_fclk_enable_clock', '=', True), ]) company = self.env.company company_email = company.email or '' company_name = company.name or '' for emp in employees: if not emp.work_email: continue today = get_local_today(self.env, emp) if today.weekday() != 0: continue week_start = today - timedelta(days=7) week_end = today - timedelta(days=1) ws_fmt = week_start.strftime('%b %d, %Y') we_fmt = week_end.strftime('%b %d, %Y') ws_start, _ = get_local_day_boundaries(self.env, week_start, emp) _, we_end = get_local_day_boundaries(self.env, week_end, emp) atts = self.sudo().search([ ('employee_id', '=', emp.id), ('check_in', '>=', ws_start), ('check_in', '<', we_end), ('check_out', '!=', False), ]) total_net = round(sum(a.x_fclk_net_hours or 0 for a in atts), 1) total_ot = round(sum(a.x_fclk_overtime_hours or 0 for a in atts), 1) penalty_count = self.env['fusion.clock.penalty'].sudo().search_count([ ('employee_id', '=', emp.id), ('date', '>=', week_start), ('date', '<=', week_end), ]) ActivityLog = self.env['fusion.clock.activity.log'].sudo() absence_count = ActivityLog.search_count([ ('employee_id', '=', emp.id), ('log_type', '=', 'absent'), ('log_date', '>=', ws_start), ('log_date', '<', we_end), ]) streak = emp.x_fclk_ontime_streak or 0 emp_company = emp.company_id or company body = _fclk_email_wrap( company_name=emp_company.name or company_name, title='Weekly Summary', summary=( f'Hello {emp.name}, here is your attendance ' f'summary for {ws_fmt} to {we_fmt}.' ), sections=[('Summary', [ ('Total Hours', f'{total_net}h'), ('Overtime', f'{total_ot}h'), ('Penalties', str(penalty_count)), ('Absences', str(absence_count)), ('On-Time Streak', f'{streak} days'), ])], note='Log in to ' 'your portal to view full details.', ) try: from_email = emp_company.email or company_email mail = self.env['mail.mail'].sudo().create({ 'subject': f'Your Weekly Attendance Summary ({ws_fmt} - {we_fmt})', 'email_from': from_email, 'email_to': emp.work_email, 'body_html': body, 'auto_delete': True, }) mail.send() except Exception as e: _logger.error("Fusion Clock: Failed to send weekly summary to %s: %s", emp.name, e) @api.model def _fclk_notify_office(self, office_user_id, summary, note, res_model, res_id): """Create a mail.activity for the office user.""" if not office_user_id: return office_user = self.env['res.users'].sudo().browse(office_user_id) if not office_user.exists(): return try: self.env['mail.activity'].sudo().create({ 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, 'summary': summary, 'note': note, 'user_id': office_user_id, 'res_model_id': self.env['ir.model']._get_id(res_model), 'res_id': res_id, 'date_deadline': get_local_today(self.env), }) except Exception as e: _logger.error("Fusion Clock: Failed to create office activity: %s", e) @api.model def _fclk_send_employee_reminder(self, employee, subject, body): """Send a notification to an employee via internal note.""" try: if employee.user_id: employee.user_id.sudo().notify_info( message=body, title=subject, sticky=False, ) except Exception: pass try: if employee.work_email: company = employee.company_id or self.env.company company_email = company.email or '' company_name = company.name or '' html_body = _fclk_email_wrap( company_name=company_name, title=subject, summary=body, note='Log in to ' 'your portal to view your attendance details.', ) mail_values = { 'subject': f"Fusion Clock: {subject}", 'email_from': company_email, 'body_html': html_body, 'email_to': employee.work_email, 'auto_delete': True, } self.env['mail.mail'].sudo().create(mail_values).send() except Exception as e: _logger.error("Fusion Clock: Failed to send reminder to %s: %s", employee.name, e)