Initial commit
This commit is contained in:
8
fusion_clock/models/__init__.py
Normal file
8
fusion_clock/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import clock_location
|
||||
from . import hr_attendance
|
||||
from . import hr_employee
|
||||
from . import clock_penalty
|
||||
from . import clock_report
|
||||
from . import res_config_settings
|
||||
182
fusion_clock/models/clock_location.py
Normal file
182
fusion_clock/models/clock_location.py
Normal file
@@ -0,0 +1,182 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockLocation(models.Model):
|
||||
_name = 'fusion.clock.location'
|
||||
_description = 'Clock-In Location'
|
||||
_order = 'sequence, name'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(string='Location Name', required=True)
|
||||
address = fields.Char(string='Address')
|
||||
latitude = fields.Float(string='Latitude', digits=(10, 7))
|
||||
longitude = fields.Float(string='Longitude', digits=(10, 7))
|
||||
radius = fields.Integer(
|
||||
string='Radius (meters)',
|
||||
default=100,
|
||||
help="Geofence radius in meters. Employees must be within this distance to clock in/out.",
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
required=True,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
employee_ids = fields.Many2many(
|
||||
'hr.employee',
|
||||
'fusion_clock_location_employee_rel',
|
||||
'location_id',
|
||||
'employee_id',
|
||||
string='Allowed Employees',
|
||||
)
|
||||
all_employees = fields.Boolean(
|
||||
string='All Employees',
|
||||
default=True,
|
||||
help="If checked, all employees can clock in/out at this location.",
|
||||
)
|
||||
color = fields.Char(string='Map Color', default='#10B981')
|
||||
note = fields.Text(string='Notes')
|
||||
timezone = fields.Selection(
|
||||
'_tz_get',
|
||||
string='Timezone',
|
||||
default=lambda self: self.env.user.tz or 'UTC',
|
||||
)
|
||||
|
||||
# Computed
|
||||
attendance_count = fields.Integer(
|
||||
string='Total Attendances',
|
||||
compute='_compute_attendance_count',
|
||||
)
|
||||
map_url = fields.Char(
|
||||
string='Map Preview URL',
|
||||
compute='_compute_map_url',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _tz_get(self):
|
||||
import pytz
|
||||
return [(tz, tz) for tz in sorted(pytz.all_timezones_set)]
|
||||
|
||||
@api.depends('latitude', 'longitude', 'radius')
|
||||
def _compute_map_url(self):
|
||||
api_key = self.env['ir.config_parameter'].sudo().get_param('fusion_clock.google_maps_api_key', '')
|
||||
for rec in self:
|
||||
if rec.latitude and rec.longitude and api_key:
|
||||
rec.map_url = (
|
||||
f"https://maps.googleapis.com/maps/api/staticmap?"
|
||||
f"center={rec.latitude},{rec.longitude}&zoom=16&size=600x300&maptype=roadmap"
|
||||
f"&markers=color:green%7C{rec.latitude},{rec.longitude}"
|
||||
f"&key={api_key}"
|
||||
)
|
||||
else:
|
||||
rec.map_url = False
|
||||
|
||||
def _compute_attendance_count(self):
|
||||
for rec in self:
|
||||
rec.attendance_count = self.env['hr.attendance'].search_count([
|
||||
('x_fclk_location_id', '=', rec.id),
|
||||
])
|
||||
|
||||
def action_geocode_address(self):
|
||||
"""Geocode the address to get lat/lng using Google Geocoding API.
|
||||
Falls back to Nominatim (OpenStreetMap) if Google fails.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.address:
|
||||
raise UserError(_("Please enter an address first."))
|
||||
|
||||
# Try Google first
|
||||
api_key = self.env['ir.config_parameter'].sudo().get_param('fusion_clock.google_maps_api_key', '')
|
||||
if api_key:
|
||||
try:
|
||||
url = 'https://maps.googleapis.com/maps/api/geocode/json'
|
||||
params = {'address': self.address, 'key': api_key}
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
data = response.json()
|
||||
|
||||
if data.get('status') == 'OK' and data.get('results'):
|
||||
location = data['results'][0]['geometry']['location']
|
||||
self.write({
|
||||
'latitude': location['lat'],
|
||||
'longitude': location['lng'],
|
||||
})
|
||||
formatted = data['results'][0].get('formatted_address', '')
|
||||
if formatted:
|
||||
self.address = formatted
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Geocoding Successful'),
|
||||
'message': _('Location set to: %s, %s') % (location['lat'], location['lng']),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
elif data.get('status') == 'REQUEST_DENIED':
|
||||
_logger.warning("Google Geocoding API denied. Enable the Geocoding API in Google Cloud Console. Falling back to Nominatim.")
|
||||
else:
|
||||
_logger.warning("Google geocoding returned: %s. Trying Nominatim fallback.", data.get('status'))
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.warning("Google geocoding network error: %s. Trying Nominatim fallback.", e)
|
||||
|
||||
# Fallback: Nominatim (OpenStreetMap) - free, no API key needed
|
||||
try:
|
||||
url = 'https://nominatim.openstreetmap.org/search'
|
||||
params = {
|
||||
'q': self.address,
|
||||
'format': 'json',
|
||||
'limit': 1,
|
||||
}
|
||||
headers = {'User-Agent': 'FusionClock/1.0 (Odoo Module)'}
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
data = response.json()
|
||||
|
||||
if data and len(data) > 0:
|
||||
lat = float(data[0]['lat'])
|
||||
lng = float(data[0]['lon'])
|
||||
display_name = data[0].get('display_name', '')
|
||||
self.write({
|
||||
'latitude': lat,
|
||||
'longitude': lng,
|
||||
})
|
||||
if display_name:
|
||||
self.address = display_name
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Geocoding Successful (via OpenStreetMap)'),
|
||||
'message': _('Location set to: %s, %s') % (lat, lng),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
else:
|
||||
raise UserError(_("Could not geocode address. No results found. Try a more specific address."))
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Network error during geocoding: %s") % str(e))
|
||||
|
||||
def action_view_attendances(self):
|
||||
"""Open attendance records for this location."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Attendances at %s') % self.name,
|
||||
'res_model': 'hr.attendance',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('x_fclk_location_id', '=', self.id)],
|
||||
'context': {'default_x_fclk_location_id': self.id},
|
||||
}
|
||||
67
fusion_clock/models/clock_penalty.py
Normal file
67
fusion_clock/models/clock_penalty.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class FusionClockPenalty(models.Model):
|
||||
_name = 'fusion.clock.penalty'
|
||||
_description = 'Clock Penalty'
|
||||
_order = 'date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
attendance_id = fields.Many2one(
|
||||
'hr.attendance',
|
||||
string='Attendance',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
penalty_type = fields.Selection(
|
||||
[
|
||||
('late_in', 'Late Clock-In'),
|
||||
('early_out', 'Early Clock-Out'),
|
||||
],
|
||||
string='Penalty Type',
|
||||
required=True,
|
||||
)
|
||||
scheduled_time = fields.Datetime(string='Scheduled Time')
|
||||
actual_time = fields.Datetime(string='Actual Time')
|
||||
difference_minutes = fields.Float(
|
||||
string='Difference (min)',
|
||||
compute='_compute_difference',
|
||||
store=True,
|
||||
)
|
||||
date = fields.Date(string='Date', required=True, index=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
related='employee_id.company_id',
|
||||
store=True,
|
||||
)
|
||||
note = fields.Text(string='Note')
|
||||
|
||||
display_name = fields.Char(compute='_compute_display_name', store=True)
|
||||
|
||||
@api.depends('scheduled_time', 'actual_time')
|
||||
def _compute_difference(self):
|
||||
for rec in self:
|
||||
if rec.scheduled_time and rec.actual_time:
|
||||
delta = abs((rec.actual_time - rec.scheduled_time).total_seconds())
|
||||
rec.difference_minutes = round(delta / 60.0, 1)
|
||||
else:
|
||||
rec.difference_minutes = 0.0
|
||||
|
||||
@api.depends('employee_id', 'penalty_type', 'date')
|
||||
def _compute_display_name(self):
|
||||
labels = dict(self._fields['penalty_type'].selection)
|
||||
for rec in self:
|
||||
emp = rec.employee_id.name or ''
|
||||
ptype = labels.get(rec.penalty_type, '')
|
||||
rec.display_name = f"{emp} - {ptype} ({rec.date})" if rec.date else f"{emp} - {ptype}"
|
||||
425
fusion_clock/models/clock_report.py
Normal file
425
fusion_clock/models/clock_report.py
Normal file
@@ -0,0 +1,425 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockReport(models.Model):
|
||||
_name = 'fusion.clock.report'
|
||||
_description = 'Clock Period Report'
|
||||
_order = 'date_end desc, employee_id'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Report Name',
|
||||
compute='_compute_name',
|
||||
store=True,
|
||||
)
|
||||
date_start = fields.Date(string='Period Start', required=True, index=True)
|
||||
date_end = fields.Date(string='Period End', required=True, index=True)
|
||||
schedule_type = fields.Selection(
|
||||
[
|
||||
('weekly', 'Weekly'),
|
||||
('biweekly', 'Bi-Weekly'),
|
||||
('semi_monthly', 'Semi-Monthly'),
|
||||
('monthly', 'Monthly'),
|
||||
],
|
||||
string='Schedule Type',
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
('generated', 'Generated'),
|
||||
('sent', 'Sent'),
|
||||
],
|
||||
string='Status',
|
||||
default='draft',
|
||||
index=True,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
index=True,
|
||||
help="Empty = batch report for all employees.",
|
||||
)
|
||||
is_batch = fields.Boolean(
|
||||
string='Batch Report',
|
||||
compute='_compute_is_batch',
|
||||
store=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
required=True,
|
||||
)
|
||||
|
||||
# Computed totals
|
||||
total_hours = fields.Float(string='Total Worked Hours', compute='_compute_totals', store=True)
|
||||
net_hours = fields.Float(string='Net Hours', compute='_compute_totals', store=True)
|
||||
total_breaks = fields.Float(string='Total Breaks (min)', compute='_compute_totals', store=True)
|
||||
total_penalties = fields.Integer(string='Penalty Count', compute='_compute_totals', store=True)
|
||||
days_worked = fields.Integer(string='Days Worked', compute='_compute_totals', store=True)
|
||||
attendance_ids = fields.Many2many(
|
||||
'hr.attendance',
|
||||
'fusion_clock_report_attendance_rel',
|
||||
'report_id',
|
||||
'attendance_id',
|
||||
string='Attendance Records',
|
||||
)
|
||||
|
||||
# PDF
|
||||
report_pdf = fields.Binary(string='Report PDF', attachment=True)
|
||||
report_pdf_filename = fields.Char(string='PDF Filename')
|
||||
|
||||
@api.depends('employee_id', 'date_start', 'date_end')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
if rec.employee_id:
|
||||
rec.name = f"{rec.employee_id.name} - {rec.date_start} to {rec.date_end}"
|
||||
else:
|
||||
rec.name = f"Batch Report - {rec.date_start} to {rec.date_end}"
|
||||
|
||||
@api.depends('employee_id')
|
||||
def _compute_is_batch(self):
|
||||
for rec in self:
|
||||
rec.is_batch = not bool(rec.employee_id)
|
||||
|
||||
@api.depends('attendance_ids', 'attendance_ids.worked_hours',
|
||||
'attendance_ids.x_fclk_net_hours', 'attendance_ids.x_fclk_break_minutes')
|
||||
def _compute_totals(self):
|
||||
for rec in self:
|
||||
atts = rec.attendance_ids
|
||||
rec.total_hours = sum(a.worked_hours or 0.0 for a in atts)
|
||||
rec.net_hours = sum(a.x_fclk_net_hours or 0.0 for a in atts)
|
||||
rec.total_breaks = sum(a.x_fclk_break_minutes or 0.0 for a in atts)
|
||||
rec.total_penalties = self.env['fusion.clock.penalty'].search_count([
|
||||
('employee_id', '=', rec.employee_id.id),
|
||||
('date', '>=', rec.date_start),
|
||||
('date', '<=', rec.date_end),
|
||||
]) if rec.employee_id else 0
|
||||
# Count unique dates
|
||||
dates = set()
|
||||
for a in atts:
|
||||
if a.check_in:
|
||||
dates.add(a.check_in.date())
|
||||
rec.days_worked = len(dates)
|
||||
|
||||
def action_generate_report(self):
|
||||
"""Generate the PDF report for this record."""
|
||||
self.ensure_one()
|
||||
self._collect_attendance_records()
|
||||
self._generate_pdf()
|
||||
self.state = 'generated'
|
||||
|
||||
def action_send_report(self):
|
||||
"""Send the report via email."""
|
||||
self.ensure_one()
|
||||
if self.state != 'generated':
|
||||
raise UserError(_("Please generate the report first."))
|
||||
self._send_report_email()
|
||||
self.state = 'sent'
|
||||
|
||||
def _collect_attendance_records(self):
|
||||
"""Link attendance records for the period and employee."""
|
||||
self.ensure_one()
|
||||
domain = [
|
||||
('check_in', '>=', fields.Datetime.to_datetime(self.date_start)),
|
||||
('check_in', '<', fields.Datetime.to_datetime(self.date_end + timedelta(days=1))),
|
||||
('check_out', '!=', False),
|
||||
]
|
||||
if self.employee_id:
|
||||
domain.append(('employee_id', '=', self.employee_id.id))
|
||||
else:
|
||||
domain.append(('employee_id.company_id', '=', self.company_id.id))
|
||||
|
||||
attendances = self.env['hr.attendance'].search(domain)
|
||||
self.attendance_ids = [(6, 0, attendances.ids)]
|
||||
|
||||
def _generate_pdf(self):
|
||||
"""Render the QWeb report to PDF and store it."""
|
||||
self.ensure_one()
|
||||
if self.employee_id:
|
||||
report_ref = 'fusion_clock.action_report_clock_employee'
|
||||
else:
|
||||
report_ref = 'fusion_clock.action_report_clock_batch'
|
||||
|
||||
report = self.env['ir.actions.report']._get_report_from_name(report_ref)
|
||||
if not report:
|
||||
# Fallback to employee report
|
||||
report_ref = 'fusion_clock.action_report_clock_employee'
|
||||
|
||||
pdf_content, _ = self.env['ir.actions.report']._render_qweb_pdf(
|
||||
report_ref, [self.id]
|
||||
)
|
||||
filename = f"clock_report_{self.date_start}_{self.date_end}"
|
||||
if self.employee_id:
|
||||
filename += f"_{self.employee_id.name.replace(' ', '_')}"
|
||||
filename += ".pdf"
|
||||
|
||||
self.write({
|
||||
'report_pdf': base64.b64encode(pdf_content),
|
||||
'report_pdf_filename': filename,
|
||||
})
|
||||
|
||||
def _send_report_email(self):
|
||||
"""Send the report via mail template."""
|
||||
self.ensure_one()
|
||||
if self.employee_id:
|
||||
template = self.env.ref('fusion_clock.mail_template_clock_employee_report', raise_if_not_found=False)
|
||||
else:
|
||||
template = self.env.ref('fusion_clock.mail_template_clock_batch_report', raise_if_not_found=False)
|
||||
|
||||
if template:
|
||||
template.send_mail(self.id, force_send=True)
|
||||
else:
|
||||
_logger.warning("Fusion Clock: Mail template not found for report %s", self.id)
|
||||
|
||||
@api.model
|
||||
def _cron_generate_period_reports(self):
|
||||
"""Cron: Generate reports when a pay period ends."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
auto_generate = ICP.get_param('fusion_clock.auto_generate_reports', 'True')
|
||||
if auto_generate != 'True':
|
||||
return
|
||||
|
||||
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
period_start_str = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
|
||||
today = fields.Date.today()
|
||||
period_start, period_end = self._calculate_current_period(schedule_type, period_start_str, today)
|
||||
|
||||
# Only generate if yesterday was the end of a period
|
||||
if period_end != today - timedelta(days=1):
|
||||
return
|
||||
|
||||
_logger.info("Fusion Clock: Generating reports for period %s to %s", period_start, period_end)
|
||||
|
||||
companies = self.env['res.company'].search([])
|
||||
for company in companies:
|
||||
employees = self.env['hr.employee'].search([
|
||||
('company_id', '=', company.id),
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
|
||||
# Generate individual reports
|
||||
for employee in employees:
|
||||
existing = self.search([
|
||||
('employee_id', '=', employee.id),
|
||||
('date_start', '=', period_start),
|
||||
('date_end', '=', period_end),
|
||||
], limit=1)
|
||||
if not existing:
|
||||
report = self.create({
|
||||
'date_start': period_start,
|
||||
'date_end': period_end,
|
||||
'schedule_type': schedule_type,
|
||||
'employee_id': employee.id,
|
||||
'company_id': company.id,
|
||||
})
|
||||
try:
|
||||
report.action_generate_report()
|
||||
# Auto-send if configured
|
||||
send_employee = ICP.get_param('fusion_clock.send_employee_reports', 'True')
|
||||
if send_employee == 'True':
|
||||
report.action_send_report()
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Error generating report for %s: %s", employee.name, e)
|
||||
|
||||
# Generate batch report
|
||||
existing_batch = self.search([
|
||||
('employee_id', '=', False),
|
||||
('date_start', '=', period_start),
|
||||
('date_end', '=', period_end),
|
||||
('company_id', '=', company.id),
|
||||
], limit=1)
|
||||
if not existing_batch:
|
||||
batch = self.create({
|
||||
'date_start': period_start,
|
||||
'date_end': period_end,
|
||||
'schedule_type': schedule_type,
|
||||
'employee_id': False,
|
||||
'company_id': company.id,
|
||||
})
|
||||
try:
|
||||
batch.action_generate_report()
|
||||
batch.action_send_report()
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Error generating batch report: %s", e)
|
||||
|
||||
@api.model
|
||||
def _backfill_historical_reports(self):
|
||||
"""Create reports for all past pay periods that have attendance data
|
||||
but no corresponding report. Does NOT send emails.
|
||||
Called from settings button or post_init_hook on install.
|
||||
"""
|
||||
HrAtt = self.env['hr.attendance']
|
||||
today = fields.Date.today()
|
||||
|
||||
# Get all distinct periods that have completed attendance records
|
||||
all_atts = HrAtt.search([
|
||||
('check_out', '!=', False),
|
||||
('x_fclk_pay_period_start', '!=', False),
|
||||
], order='x_fclk_pay_period_start')
|
||||
|
||||
# Group by (period_start, pay_period_label) to find distinct periods
|
||||
period_map = {}
|
||||
for att in all_atts:
|
||||
key = att.x_fclk_pay_period_start
|
||||
if key and key not in period_map:
|
||||
period_map[key] = att.x_fclk_pay_period
|
||||
|
||||
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
|
||||
|
||||
created_count = 0
|
||||
for period_start_date, period_label in period_map.items():
|
||||
# Calculate period end from the start date
|
||||
_, period_end = HrAtt._calc_period(
|
||||
schedule_type, anchor, period_start_date,
|
||||
)
|
||||
|
||||
# Skip the current (incomplete) period
|
||||
if period_end >= today:
|
||||
continue
|
||||
|
||||
# Find all employees with attendance in this period
|
||||
period_atts = HrAtt.search([
|
||||
('check_out', '!=', False),
|
||||
('x_fclk_pay_period_start', '=', period_start_date),
|
||||
])
|
||||
employee_ids = period_atts.mapped('employee_id')
|
||||
|
||||
for employee in employee_ids:
|
||||
# Check if report already exists
|
||||
existing = self.search([
|
||||
('employee_id', '=', employee.id),
|
||||
('date_start', '=', period_start_date),
|
||||
('date_end', '=', period_end),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
emp_atts = period_atts.filtered(
|
||||
lambda a, e=employee: a.employee_id == e
|
||||
)
|
||||
report = self.create({
|
||||
'date_start': period_start_date,
|
||||
'date_end': period_end,
|
||||
'schedule_type': schedule_type,
|
||||
'employee_id': employee.id,
|
||||
'company_id': employee.company_id.id,
|
||||
'attendance_ids': [(6, 0, emp_atts.ids)],
|
||||
})
|
||||
try:
|
||||
report._generate_pdf()
|
||||
report.state = 'generated'
|
||||
created_count += 1
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Fusion Clock backfill: PDF failed for %s period %s: %s",
|
||||
employee.name, period_label, e,
|
||||
)
|
||||
report.state = 'generated'
|
||||
created_count += 1
|
||||
|
||||
# Batch report for this period
|
||||
existing_batch = self.search([
|
||||
('employee_id', '=', False),
|
||||
('date_start', '=', period_start_date),
|
||||
('date_end', '=', period_end),
|
||||
], limit=1)
|
||||
if not existing_batch and period_atts:
|
||||
company = period_atts[0].employee_id.company_id
|
||||
batch = self.create({
|
||||
'date_start': period_start_date,
|
||||
'date_end': period_end,
|
||||
'schedule_type': schedule_type,
|
||||
'employee_id': False,
|
||||
'company_id': company.id,
|
||||
'attendance_ids': [(6, 0, period_atts.ids)],
|
||||
})
|
||||
try:
|
||||
batch._generate_pdf()
|
||||
batch.state = 'generated'
|
||||
created_count += 1
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Fusion Clock backfill: batch PDF failed for %s: %s",
|
||||
period_label, e,
|
||||
)
|
||||
batch.state = 'generated'
|
||||
created_count += 1
|
||||
|
||||
# Commit after each period to avoid losing everything on error
|
||||
self.env.cr.commit() # pylint: disable=invalid-commit
|
||||
|
||||
_logger.info(
|
||||
"Fusion Clock: Backfill complete. Created %d reports.", created_count,
|
||||
)
|
||||
return created_count
|
||||
|
||||
@api.model
|
||||
def _calculate_current_period(self, schedule_type, period_start_str, reference_date):
|
||||
"""Calculate the period start/end dates based on schedule type."""
|
||||
from dateutil.relativedelta import relativedelta
|
||||
import datetime
|
||||
|
||||
if period_start_str:
|
||||
try:
|
||||
anchor = fields.Date.from_string(period_start_str)
|
||||
except Exception:
|
||||
anchor = reference_date.replace(day=1)
|
||||
else:
|
||||
anchor = reference_date.replace(day=1)
|
||||
|
||||
if schedule_type == 'weekly':
|
||||
days_diff = (reference_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 = (reference_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 reference_date.day <= 15:
|
||||
period_start = reference_date.replace(day=1)
|
||||
period_end = reference_date.replace(day=15)
|
||||
else:
|
||||
period_start = reference_date.replace(day=16)
|
||||
# Last day of month
|
||||
next_month = reference_date.replace(day=28) + timedelta(days=4)
|
||||
period_end = next_month - timedelta(days=next_month.day)
|
||||
|
||||
elif schedule_type == 'monthly':
|
||||
period_start = reference_date.replace(day=1)
|
||||
next_month = reference_date.replace(day=28) + timedelta(days=4)
|
||||
period_end = next_month - timedelta(days=next_month.day)
|
||||
|
||||
else:
|
||||
# Default biweekly
|
||||
days_diff = (reference_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
|
||||
330
fusion_clock/models/hr_attendance.py
Normal file
330
fusion_clock/models/hr_attendance.py
Normal file
@@ -0,0 +1,330 @@
|
||||
# -*- 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),
|
||||
)
|
||||
100
fusion_clock/models/hr_employee.py
Normal file
100
fusion_clock/models/hr_employee.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# -*- 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
|
||||
|
||||
|
||||
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 company default.",
|
||||
)
|
||||
x_fclk_period_hours = fields.Float(
|
||||
string='Period Hours',
|
||||
compute='_compute_fclk_period_hours',
|
||||
help="Net hours worked in the current pay period.",
|
||||
)
|
||||
x_fclk_period_label = fields.Char(
|
||||
string='Current Period',
|
||||
compute='_compute_fclk_period_hours',
|
||||
help="Label for the current pay period.",
|
||||
)
|
||||
|
||||
def _get_fclk_break_minutes(self):
|
||||
"""Return effective break minutes for this employee."""
|
||||
self.ensure_one()
|
||||
if self.x_fclk_break_minutes > 0:
|
||||
return self.x_fclk_break_minutes
|
||||
return float(
|
||||
self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_clock.default_break_minutes', '30'
|
||||
)
|
||||
)
|
||||
|
||||
def action_fclk_open_attendance(self):
|
||||
"""Open attendance list for this employee, grouped by pay period."""
|
||||
self.ensure_one()
|
||||
list_view = self.env.ref('fusion_clock.view_hr_attendance_list_by_period')
|
||||
search_view = self.env.ref('fusion_clock.view_hr_attendance_search_by_period')
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': f'{self.name} - Attendance',
|
||||
'res_model': 'hr.attendance',
|
||||
'view_mode': 'list,form',
|
||||
'views': [(list_view.id, 'list'), (False, 'form')],
|
||||
'search_view_id': (search_view.id, search_view.name),
|
||||
'domain': [('employee_id', '=', self.id)],
|
||||
'context': {
|
||||
'search_default_group_pay_period': 1,
|
||||
'default_employee_id': self.id,
|
||||
},
|
||||
}
|
||||
|
||||
def _compute_fclk_period_hours(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', '')
|
||||
anchor = None
|
||||
if anchor_str:
|
||||
try:
|
||||
anchor = fields.Date.from_string(anchor_str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
today = fields.Date.today()
|
||||
Attendance = self.env['hr.attendance']
|
||||
period_start, period_end = Attendance._calc_period(schedule_type, anchor, today)
|
||||
|
||||
period_label = (
|
||||
f"{period_start.strftime('%b %d')} - "
|
||||
f"{period_end.strftime('%b %d, %Y')}"
|
||||
)
|
||||
start_dt = datetime.combine(period_start, datetime.min.time())
|
||||
end_dt = datetime.combine(period_end + timedelta(days=1), datetime.min.time())
|
||||
|
||||
for emp in self:
|
||||
atts = Attendance.sudo().search([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', start_dt),
|
||||
('check_in', '<', end_dt),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
emp.x_fclk_period_hours = round(
|
||||
sum(a.x_fclk_net_hours or 0 for a in atts), 1
|
||||
)
|
||||
emp.x_fclk_period_label = period_label
|
||||
215
fusion_clock/models/res_config_settings.py
Normal file
215
fusion_clock/models/res_config_settings.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import timedelta
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
# -- Work Schedule --
|
||||
fclk_default_clock_in_time = fields.Float(
|
||||
string='Default Clock-In Time',
|
||||
config_parameter='fusion_clock.default_clock_in_time',
|
||||
default=9.0,
|
||||
help="Default scheduled clock-in time (24h format, e.g. 9.0 = 9:00 AM).",
|
||||
)
|
||||
fclk_default_clock_out_time = fields.Float(
|
||||
string='Default Clock-Out Time',
|
||||
config_parameter='fusion_clock.default_clock_out_time',
|
||||
default=17.0,
|
||||
help="Default scheduled clock-out time (24h format, e.g. 17.0 = 5:00 PM).",
|
||||
)
|
||||
|
||||
# -- Break --
|
||||
fclk_default_break_minutes = fields.Float(
|
||||
string='Default Break Duration (min)',
|
||||
config_parameter='fusion_clock.default_break_minutes',
|
||||
default=30.0,
|
||||
help="Default unpaid break duration in minutes.",
|
||||
)
|
||||
fclk_auto_deduct_break = fields.Boolean(
|
||||
string='Auto-Deduct Break',
|
||||
config_parameter='fusion_clock.auto_deduct_break',
|
||||
default=True,
|
||||
help="Automatically deduct break from worked hours on clock-out.",
|
||||
)
|
||||
fclk_break_threshold_hours = fields.Float(
|
||||
string='Break Threshold (hours)',
|
||||
config_parameter='fusion_clock.break_threshold_hours',
|
||||
default=5.0,
|
||||
help="Only deduct break if shift is longer than this many hours.",
|
||||
)
|
||||
|
||||
# -- Grace Period & Auto Clock-Out --
|
||||
fclk_grace_period_minutes = fields.Float(
|
||||
string='Grace Period (min)',
|
||||
config_parameter='fusion_clock.grace_period_minutes',
|
||||
default=15.0,
|
||||
help="Minutes allowed after scheduled end before auto clock-out.",
|
||||
)
|
||||
fclk_enable_auto_clockout = fields.Boolean(
|
||||
string='Enable Auto Clock-Out',
|
||||
config_parameter='fusion_clock.enable_auto_clockout',
|
||||
default=True,
|
||||
)
|
||||
fclk_max_shift_hours = fields.Float(
|
||||
string='Max Shift Length (hours)',
|
||||
config_parameter='fusion_clock.max_shift_hours',
|
||||
default=12.0,
|
||||
help="Maximum shift length before auto clock-out (safety net).",
|
||||
)
|
||||
|
||||
# -- Penalties --
|
||||
fclk_enable_penalties = fields.Boolean(
|
||||
string='Enable Penalty Tracking',
|
||||
config_parameter='fusion_clock.enable_penalties',
|
||||
default=True,
|
||||
)
|
||||
fclk_penalty_grace_minutes = fields.Float(
|
||||
string='Penalty Grace (min)',
|
||||
config_parameter='fusion_clock.penalty_grace_minutes',
|
||||
default=5.0,
|
||||
help="Minutes of grace before a late/early penalty is recorded.",
|
||||
)
|
||||
|
||||
# -- Pay Period --
|
||||
fclk_pay_period_type = fields.Selection(
|
||||
[
|
||||
('weekly', 'Weekly'),
|
||||
('biweekly', 'Bi-Weekly'),
|
||||
('semi_monthly', 'Semi-Monthly'),
|
||||
('monthly', 'Monthly'),
|
||||
],
|
||||
string='Pay Period',
|
||||
config_parameter='fusion_clock.pay_period_type',
|
||||
default='biweekly',
|
||||
help="How often pay periods repeat. Semi-Monthly uses 1st-15th and 16th-end; "
|
||||
"Weekly and Bi-Weekly use the anchor date below.",
|
||||
)
|
||||
fclk_pay_period_start = fields.Date(
|
||||
string='Pay Period Anchor Date',
|
||||
help="The first day of any real pay period. All periods are calculated "
|
||||
"forward and backward from this date. For example, if your biweekly "
|
||||
"pay period runs Jan 17 - Jan 30, set this to Jan 17.",
|
||||
)
|
||||
fclk_pay_period_preview = fields.Char(
|
||||
string='Current Period Preview',
|
||||
compute='_compute_pay_period_preview',
|
||||
help="Shows the current pay period based on today's date and your settings.",
|
||||
)
|
||||
|
||||
# -- Reports --
|
||||
fclk_auto_generate_reports = fields.Boolean(
|
||||
string='Auto-Generate Reports',
|
||||
config_parameter='fusion_clock.auto_generate_reports',
|
||||
default=True,
|
||||
)
|
||||
fclk_report_recipient_emails = fields.Char(
|
||||
string='Report Recipient Emails',
|
||||
config_parameter='fusion_clock.report_recipient_emails',
|
||||
help="Comma-separated email addresses for batch report delivery.",
|
||||
)
|
||||
fclk_send_employee_reports = fields.Boolean(
|
||||
string='Send Employee Copies',
|
||||
config_parameter='fusion_clock.send_employee_reports',
|
||||
default=True,
|
||||
help="Send individual report copies to each employee's work email.",
|
||||
)
|
||||
|
||||
# -- Google Maps --
|
||||
fclk_google_maps_api_key = fields.Char(
|
||||
string='Google Maps API Key',
|
||||
config_parameter='fusion_clock.google_maps_api_key',
|
||||
)
|
||||
|
||||
# -- Sounds --
|
||||
fclk_enable_sounds = fields.Boolean(
|
||||
string='Enable Clock Sounds',
|
||||
config_parameter='fusion_clock.enable_sounds',
|
||||
default=True,
|
||||
)
|
||||
|
||||
@api.depends('fclk_pay_period_type', 'fclk_pay_period_start')
|
||||
def _compute_pay_period_preview(self):
|
||||
HrAtt = self.env['hr.attendance']
|
||||
today = fields.Date.context_today(self)
|
||||
for rec in self:
|
||||
schedule = rec.fclk_pay_period_type or 'biweekly'
|
||||
anchor = rec.fclk_pay_period_start
|
||||
if not anchor and schedule in ('weekly', 'biweekly'):
|
||||
rec.fclk_pay_period_preview = 'Set anchor date to see preview'
|
||||
continue
|
||||
period_start, period_end = HrAtt._calc_period(schedule, anchor, today)
|
||||
rec.fclk_pay_period_preview = (
|
||||
f"{period_start.strftime('%b %d, %Y')} - "
|
||||
f"{period_end.strftime('%b %d, %Y')}"
|
||||
)
|
||||
|
||||
def action_backfill_reports(self):
|
||||
"""Generate reports for all historical pay periods without sending email."""
|
||||
count = self.env['fusion.clock.report'].sudo()._backfill_historical_reports()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Historical Reports',
|
||||
'message': f'Created {count} reports for past pay periods.',
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
def action_backfill_breaks(self):
|
||||
"""Apply break deduction to all past attendance records missing it,
|
||||
then regenerate any existing report PDFs so they reflect the new totals."""
|
||||
count = self.env['hr.attendance'].sudo().action_backfill_breaks()
|
||||
|
||||
# Regenerate existing report PDFs so stored files match updated totals
|
||||
if count:
|
||||
reports = self.env['fusion.clock.report'].sudo().search([
|
||||
('state', 'in', ['generated', 'sent']),
|
||||
])
|
||||
for report in reports:
|
||||
try:
|
||||
report._generate_pdf()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Break Backfill',
|
||||
'message': f'Applied break deduction to {count} attendance records. Report PDFs regenerated.',
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
res = super().get_values()
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
anchor_str = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
if anchor_str:
|
||||
try:
|
||||
res['fclk_pay_period_start'] = fields.Date.from_string(anchor_str)
|
||||
except Exception:
|
||||
res['fclk_pay_period_start'] = False
|
||||
return res
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
val = self.fclk_pay_period_start
|
||||
ICP.set_param(
|
||||
'fusion_clock.pay_period_start',
|
||||
fields.Date.to_string(val) if val else '',
|
||||
)
|
||||
# Recompute all pay periods so existing records match current settings
|
||||
self.env['hr.attendance'].sudo().search([
|
||||
('check_in', '!=', False),
|
||||
])._compute_pay_period()
|
||||
Reference in New Issue
Block a user