# -*- 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'), ('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_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 = employee._get_fclk_scheduled_hours() if employee else daily_threshold 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: auto clock-out employees after shift + grace period.""" 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')) office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '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 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() _, scheduled_out = employee._get_fclk_scheduled_times(check_in_date) deadline = scheduled_out + timedelta(minutes=grace_min) max_deadline = check_in + timedelta(hours=max_shift) effective_deadline = min(deadline, max_deadline) if now > effective_deadline: 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 threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0')) if (att.worked_hours or 0) >= threshold: break_min = employee._get_fclk_break_minutes() att.sudo().write({'x_fclk_break_minutes': break_min}) att.sudo().message_post( body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} " f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h", message_type='comment', subtype_xmlid='mail.mt_note', ) # Log to activity log 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', }) # Set pending reason employee.sudo().write({'x_fclk_pending_reason': True}) # Notify office user 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_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: yesterday = get_local_today(self.env, emp) - timedelta(days=1) if yesterday.weekday() >= 5: 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), ], 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) @api.model def _cron_fusion_employee_reminders(self): """Cron job: send clock-in/out reminders to employees.""" 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')) now = fields.Datetime.now() employees = self.env['hr.employee'].sudo().search([ ('x_fclk_enable_clock', '=', True), ]) for emp in employees: today = get_local_today(self.env, emp) if today.weekday() >= 5: continue if emp.x_fclk_last_reminder_date == today: continue scheduled_in, scheduled_out = emp._get_fclk_scheduled_times(today) is_checked_in = emp.attendance_state == 'checked_in' # Missed clock-in reminder reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min) if not is_checked_in and now > reminder_deadline: 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}) # Clock-out reminder reminder_before_end = scheduled_out - timedelta(minutes=reminder_out_min) if is_checked_in and now > reminder_before_end and now < scheduled_out: self._fclk_send_employee_reminder( emp, "Clock-Out Reminder", f"Hi {emp.name}, your shift ends at {_fclk_utc_to_local_str(scheduled_out, emp)}. " f"Don't forget to clock out.", ) emp.sudo().write({'x_fclk_last_reminder_date': today}) @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)