feat(fusion_clock): schedule-driven attendance automation
Reminders, absence detection, late/early penalties, and auto-clock-out are now driven by each employee's real schedule (posted planner entry -> recurring shift), never the global 9-5 default. Employees who aren't scheduled get no reminders/absence. Overtime past the scheduled end is never cut off — auto clock-out only fires at a max-shift safety cap (default raised 12 -> 16h). Team leads build the planner in draft and Post it (publishes + emails employees). - hr.employee._get_fclk_day_plan: explicit `scheduled` flag; posted-only planner entries (drafts ignored), else recurring shift covering that weekday, else not-scheduled; sources 'schedule'/'shift'/'none'. - fusion.clock.shift: day_mon..day_sun weekday pattern + covers_weekday(). - fusion.clock.schedule: draft/posted state + posted_date; planner edits reset to draft; fclk_email_posted_week notification. - Rewrote the reminder / absence / auto-clock-out crons: schedule-gated, per-employee savepoints, OT-aware cap, weekend hardcode removed. - Penalties + all three clock-in paths skip days the employee isn't scheduled. - shift_planner: Post Week route + planner Post button + draft count. - Migration backfills pre-existing schedule entries to 'posted' so they keep driving automation after upgrade. - Tests: resolver matrix, cron gating, OT cap; fixed the existing planner test for the new state/source semantics. Design: docs/superpowers/specs/2026-05-30-schedule-driven-attendance-design.md Frontend footprint kept at zero to avoid colliding with the concurrent employee-portal (payslips) work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Clock',
|
'name': 'Fusion Clock',
|
||||||
'version': '19.0.3.12.0',
|
'version': '19.0.3.12.1',
|
||||||
'category': 'Human Resources/Attendances',
|
'category': 'Human Resources/Attendances',
|
||||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ class FusionClockAPI(http.Controller):
|
|||||||
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
|
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
|
||||||
return
|
return
|
||||||
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
|
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
|
||||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
if not day_plan.get('scheduled'):
|
||||||
|
# No late/early penalties on days the employee isn't scheduled to work.
|
||||||
return
|
return
|
||||||
|
|
||||||
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
|
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
|
||||||
@@ -282,7 +283,8 @@ class FusionClockAPI(http.Controller):
|
|||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
today = get_local_today(request.env, employee)
|
today = get_local_today(request.env, employee)
|
||||||
day_plan = employee._get_fclk_day_plan(today)
|
day_plan = employee._get_fclk_day_plan(today)
|
||||||
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
# "Unscheduled" = a posted OFF day OR a day with no schedule at all.
|
||||||
|
is_scheduled_off = not day_plan.get('scheduled')
|
||||||
|
|
||||||
geo_info = {
|
geo_info = {
|
||||||
'latitude': latitude,
|
'latitude': latitude,
|
||||||
@@ -325,7 +327,7 @@ class FusionClockAPI(http.Controller):
|
|||||||
if is_scheduled_off:
|
if is_scheduled_off:
|
||||||
self._log_activity(
|
self._log_activity(
|
||||||
employee, 'unscheduled_shift',
|
employee, 'unscheduled_shift',
|
||||||
f"Clocked in on a scheduled OFF day at {location.name}.",
|
f"Clocked in on an unscheduled day at {location.name}.",
|
||||||
attendance=attendance, location=location,
|
attendance=attendance, location=location,
|
||||||
latitude=latitude, longitude=longitude, distance=distance,
|
latitude=latitude, longitude=longitude, distance=distance,
|
||||||
source=source,
|
source=source,
|
||||||
@@ -335,7 +337,7 @@ class FusionClockAPI(http.Controller):
|
|||||||
request.env['hr.attendance'].sudo()._fclk_notify_office(
|
request.env['hr.attendance'].sudo()._fclk_notify_office(
|
||||||
office_user_id,
|
office_user_id,
|
||||||
f"Unscheduled Shift: {employee.name}",
|
f"Unscheduled Shift: {employee.name}",
|
||||||
f"{employee.name} clocked in on a scheduled OFF day.",
|
f"{employee.name} clocked in on an unscheduled day.",
|
||||||
'hr.attendance',
|
'hr.attendance',
|
||||||
attendance.id,
|
attendance.id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class FusionClockKiosk(http.Controller):
|
|||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
today = get_local_today(request.env, employee)
|
today = get_local_today(request.env, employee)
|
||||||
day_plan = employee._get_fclk_day_plan(today)
|
day_plan = employee._get_fclk_day_plan(today)
|
||||||
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
is_scheduled_off = not day_plan.get('scheduled')
|
||||||
|
|
||||||
geo_info = {
|
geo_info = {
|
||||||
'latitude': latitude,
|
'latitude': latitude,
|
||||||
@@ -133,7 +133,7 @@ class FusionClockKiosk(http.Controller):
|
|||||||
if is_scheduled_off:
|
if is_scheduled_off:
|
||||||
api._log_activity(
|
api._log_activity(
|
||||||
employee, 'unscheduled_shift',
|
employee, 'unscheduled_shift',
|
||||||
f"Kiosk clock-in on a scheduled OFF day at {location.name}",
|
f"Kiosk clock-in on an unscheduled day at {location.name}",
|
||||||
attendance=attendance, location=location,
|
attendance=attendance, location=location,
|
||||||
latitude=latitude, longitude=longitude, distance=distance,
|
latitude=latitude, longitude=longitude, distance=distance,
|
||||||
source='kiosk',
|
source='kiosk',
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
today = get_local_today(request.env, employee)
|
today = get_local_today(request.env, employee)
|
||||||
day_plan = employee._get_fclk_day_plan(today)
|
day_plan = employee._get_fclk_day_plan(today)
|
||||||
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
is_scheduled_off = not day_plan.get('scheduled')
|
||||||
|
|
||||||
geo_info = {
|
geo_info = {
|
||||||
'latitude': 0,
|
'latitude': 0,
|
||||||
@@ -352,7 +352,7 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
if is_scheduled_off:
|
if is_scheduled_off:
|
||||||
api._log_activity(
|
api._log_activity(
|
||||||
employee, 'unscheduled_shift',
|
employee, 'unscheduled_shift',
|
||||||
f"NFC kiosk clock-in on a scheduled OFF day at {location.name}",
|
f"NFC kiosk clock-in on an unscheduled day at {location.name}",
|
||||||
attendance=attendance, location=location,
|
attendance=attendance, location=location,
|
||||||
latitude=0, longitude=0, distance=0,
|
latitude=0, longitude=0, distance=0,
|
||||||
source='nfc_kiosk',
|
source='nfc_kiosk',
|
||||||
|
|||||||
@@ -155,6 +155,41 @@ class FusionClockShiftPlanner(http.Controller):
|
|||||||
'data': self._load_week_data(week_start),
|
'data': self._load_week_data(week_start),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@http.route('/fusion_clock/shift_planner/post_week', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
|
def post_week(self, week_start=None, **kw):
|
||||||
|
"""Publish (post) the viewed week's draft entries so automation acts on
|
||||||
|
them, and email each newly-affected employee their posted shifts."""
|
||||||
|
if not self._check_manager():
|
||||||
|
return {'error': 'Access denied.'}
|
||||||
|
|
||||||
|
start = self._week_start(week_start)
|
||||||
|
end = start + timedelta(days=6)
|
||||||
|
employees = self._manager_employees()
|
||||||
|
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||||
|
|
||||||
|
entries = Schedule.search([
|
||||||
|
('employee_id', 'in', employees.ids),
|
||||||
|
('schedule_date', '>=', start),
|
||||||
|
('schedule_date', '<=', end),
|
||||||
|
('state', '!=', 'posted'),
|
||||||
|
])
|
||||||
|
posted_count = len(entries)
|
||||||
|
affected = entries.mapped('employee_id')
|
||||||
|
if entries:
|
||||||
|
entries.write({'state': 'posted', 'posted_date': fields.Datetime.now()})
|
||||||
|
|
||||||
|
notified = 0
|
||||||
|
for employee in affected:
|
||||||
|
if Schedule.fclk_email_posted_week(employee, start, end):
|
||||||
|
notified += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'posted': posted_count,
|
||||||
|
'notified': notified,
|
||||||
|
'data': self._load_week_data(start),
|
||||||
|
}
|
||||||
|
|
||||||
@http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST'])
|
@http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
def copy_previous_week(self, week_start=None, **kw):
|
def copy_previous_week(self, week_start=None, **kw):
|
||||||
if not self._check_manager():
|
if not self._check_manager():
|
||||||
|
|||||||
24
fusion_clock/migrations/19.0.3.12.1/post-migrate.py
Normal file
24
fusion_clock/migrations/19.0.3.12.1/post-migrate.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""Backfill schedule state on upgrade to 19.0.3.12.0.
|
||||||
|
|
||||||
|
Before this version there was no draft/posted concept — every dated
|
||||||
|
``fusion.clock.schedule`` entry was authoritative and drove reminders, absence
|
||||||
|
checks and penalties. The new ``state`` field defaults to 'draft', and the
|
||||||
|
schedule resolver now only acts on POSTED entries. Without this backfill, every
|
||||||
|
pre-existing schedule entry would silently become draft on upgrade and stop
|
||||||
|
driving automation. Mark all pre-existing entries 'posted' to preserve prior
|
||||||
|
behaviour. (Runs only on upgrade, never on a fresh install.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fusion_clock_schedule
|
||||||
|
SET state = 'posted',
|
||||||
|
posted_date = COALESCE(posted_date, now())
|
||||||
|
WHERE state IS NULL OR state = 'draft'
|
||||||
|
""")
|
||||||
@@ -2,11 +2,15 @@
|
|||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
from odoo.exceptions import ValidationError
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FusionClockSchedule(models.Model):
|
class FusionClockSchedule(models.Model):
|
||||||
_name = 'fusion.clock.schedule'
|
_name = 'fusion.clock.schedule'
|
||||||
@@ -72,6 +76,15 @@ class FusionClockSchedule(models.Model):
|
|||||||
compute='_compute_display_name',
|
compute='_compute_display_name',
|
||||||
store=True,
|
store=True,
|
||||||
)
|
)
|
||||||
|
state = fields.Selection(
|
||||||
|
[('draft', 'Draft'), ('posted', 'Posted')],
|
||||||
|
string='Status',
|
||||||
|
default='draft',
|
||||||
|
index=True,
|
||||||
|
help="Only POSTED entries drive reminders, absence checks and penalties. "
|
||||||
|
"Draft entries are ignored by automation until the team lead posts them.",
|
||||||
|
)
|
||||||
|
posted_date = fields.Datetime(string='Posted On', readonly=True)
|
||||||
|
|
||||||
_employee_date_unique = models.Constraint(
|
_employee_date_unique = models.Constraint(
|
||||||
'UNIQUE(employee_id, schedule_date)',
|
'UNIQUE(employee_id, schedule_date)',
|
||||||
@@ -288,6 +301,10 @@ class FusionClockSchedule(models.Model):
|
|||||||
'end_time': parsed.get('end_time') or 0.0,
|
'end_time': parsed.get('end_time') or 0.0,
|
||||||
'break_minutes': parsed.get('break_minutes') or 0.0,
|
'break_minutes': parsed.get('break_minutes') or 0.0,
|
||||||
'note': payload.get('note') or False,
|
'note': payload.get('note') or False,
|
||||||
|
# Any planner edit returns the cell to draft; it must be re-posted
|
||||||
|
# before automation acts on it.
|
||||||
|
'state': 'draft',
|
||||||
|
'posted_date': False,
|
||||||
}
|
}
|
||||||
if existing:
|
if existing:
|
||||||
existing.write(vals)
|
existing.write(vals)
|
||||||
@@ -321,6 +338,7 @@ class FusionClockSchedule(models.Model):
|
|||||||
return {
|
return {
|
||||||
'schedule_id': schedule.id,
|
'schedule_id': schedule.id,
|
||||||
'source': 'schedule',
|
'source': 'schedule',
|
||||||
|
'state': schedule.state,
|
||||||
'input': schedule.fclk_display_value(),
|
'input': schedule.fclk_display_value(),
|
||||||
'label': schedule.fclk_display_value(),
|
'label': schedule.fclk_display_value(),
|
||||||
'is_off': schedule.is_off,
|
'is_off': schedule.is_off,
|
||||||
@@ -336,7 +354,8 @@ class FusionClockSchedule(models.Model):
|
|||||||
plan = employee._get_fclk_day_plan(date_obj)
|
plan = employee._get_fclk_day_plan(date_obj)
|
||||||
return {
|
return {
|
||||||
'schedule_id': False,
|
'schedule_id': False,
|
||||||
'source': plan.get('source') or 'fallback',
|
'source': plan.get('source') or 'none',
|
||||||
|
'state': False,
|
||||||
'input': plan.get('label') or '',
|
'input': plan.get('label') or '',
|
||||||
'label': plan.get('label') or '',
|
'label': plan.get('label') or '',
|
||||||
'is_off': plan.get('is_off', False),
|
'is_off': plan.get('is_off', False),
|
||||||
@@ -349,6 +368,57 @@ class FusionClockSchedule(models.Model):
|
|||||||
'note': '',
|
'note': '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def fclk_email_posted_week(self, employee, week_start, week_end):
|
||||||
|
"""Email one employee a summary of their POSTED shifts for the week."""
|
||||||
|
employee = employee.sudo()
|
||||||
|
if not employee.work_email:
|
||||||
|
return False
|
||||||
|
from .hr_attendance import _fclk_email_wrap
|
||||||
|
entries = self.sudo().search([
|
||||||
|
('employee_id', '=', employee.id),
|
||||||
|
('schedule_date', '>=', week_start),
|
||||||
|
('schedule_date', '<=', week_end),
|
||||||
|
('state', '=', 'posted'),
|
||||||
|
])
|
||||||
|
by_date = {entry.schedule_date: entry for entry in entries}
|
||||||
|
rows = []
|
||||||
|
day = week_start
|
||||||
|
while day <= week_end:
|
||||||
|
entry = by_date.get(day)
|
||||||
|
rows.append((
|
||||||
|
day.strftime('%a %b %d'),
|
||||||
|
entry.fclk_display_value() if entry else 'Not scheduled',
|
||||||
|
))
|
||||||
|
day += timedelta(days=1)
|
||||||
|
company = employee.company_id or self.env.company
|
||||||
|
body = _fclk_email_wrap(
|
||||||
|
company_name=company.name or '',
|
||||||
|
title='Your Posted Schedule',
|
||||||
|
summary=(
|
||||||
|
f'Hello <strong>{employee.name}</strong>, your shifts for '
|
||||||
|
f'<strong>{week_start.strftime("%b %d")} - {week_end.strftime("%b %d, %Y")}</strong> '
|
||||||
|
f'have been posted.'
|
||||||
|
),
|
||||||
|
sections=[('This Week', rows)],
|
||||||
|
note='Log in to <a href="/my/clock" style="color:#10B981;">your portal</a> for details.',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
mail = self.env['mail.mail'].sudo().create({
|
||||||
|
'subject': f'Your schedule: {week_start.strftime("%b %d")} - {week_end.strftime("%b %d")}',
|
||||||
|
'email_from': company.email or '',
|
||||||
|
'email_to': employee.work_email,
|
||||||
|
'body_html': body,
|
||||||
|
'auto_delete': True,
|
||||||
|
})
|
||||||
|
mail.send()
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.error(
|
||||||
|
"Fusion Clock: failed to email posted schedule to %s: %s", employee.name, exc
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class FusionClockScheduleAudit(models.Model):
|
class FusionClockScheduleAudit(models.Model):
|
||||||
_name = 'fusion.clock.schedule.audit'
|
_name = 'fusion.clock.schedule.audit'
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ class FusionClockShift(models.Model):
|
|||||||
)
|
)
|
||||||
active = fields.Boolean(default=True)
|
active = fields.Boolean(default=True)
|
||||||
color = fields.Char(string='Color', default='#3B82F6')
|
color = fields.Char(string='Color', default='#3B82F6')
|
||||||
|
|
||||||
|
# Weekday pattern — which days this recurring shift applies as the baseline
|
||||||
|
# when there is no posted planner entry for the day. Default Mon-Fri.
|
||||||
|
day_mon = fields.Boolean(string='Mon', default=True)
|
||||||
|
day_tue = fields.Boolean(string='Tue', default=True)
|
||||||
|
day_wed = fields.Boolean(string='Wed', default=True)
|
||||||
|
day_thu = fields.Boolean(string='Thu', default=True)
|
||||||
|
day_fri = fields.Boolean(string='Fri', default=True)
|
||||||
|
day_sat = fields.Boolean(string='Sat', default=False)
|
||||||
|
day_sun = fields.Boolean(string='Sun', default=False)
|
||||||
|
|
||||||
employee_ids = fields.One2many(
|
employee_ids = fields.One2many(
|
||||||
'hr.employee',
|
'hr.employee',
|
||||||
'x_fclk_shift_id',
|
'x_fclk_shift_id',
|
||||||
@@ -56,6 +67,17 @@ class FusionClockShift(models.Model):
|
|||||||
for rec in self:
|
for rec in self:
|
||||||
rec.employee_count = len(rec.employee_ids)
|
rec.employee_count = len(rec.employee_ids)
|
||||||
|
|
||||||
|
def covers_weekday(self, date):
|
||||||
|
"""Return True if this recurring shift applies on the given date's
|
||||||
|
weekday (Mon=0 .. Sun=6)."""
|
||||||
|
self.ensure_one()
|
||||||
|
date_obj = fields.Date.to_date(date)
|
||||||
|
if not date_obj:
|
||||||
|
return False
|
||||||
|
days = (self.day_mon, self.day_tue, self.day_wed, self.day_thu,
|
||||||
|
self.day_fri, self.day_sat, self.day_sun)
|
||||||
|
return bool(days[date_obj.weekday()])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def scheduled_hours(self):
|
def scheduled_hours(self):
|
||||||
"""Return the scheduled work hours for this shift (excluding break)."""
|
"""Return the scheduled work hours for this shift (excluding break)."""
|
||||||
|
|||||||
@@ -250,64 +250,55 @@ class HrAttendance(models.Model):
|
|||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _cron_fusion_auto_clock_out(self):
|
def _cron_fusion_auto_clock_out(self):
|
||||||
"""Cron job: auto clock-out employees after shift + grace period."""
|
"""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()
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
if ICP.get_param('fusion_clock.enable_auto_clockout', 'True') != 'True':
|
if ICP.get_param('fusion_clock.enable_auto_clockout', 'True') != 'True':
|
||||||
return
|
return
|
||||||
|
|
||||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '12.0'))
|
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.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'))
|
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()
|
now = fields.Datetime.now()
|
||||||
|
open_attendances = self.sudo().search([('check_out', '=', False)])
|
||||||
open_attendances = self.sudo().search([
|
|
||||||
('check_out', '=', False),
|
|
||||||
])
|
|
||||||
|
|
||||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||||
|
|
||||||
for att in open_attendances:
|
for att in open_attendances:
|
||||||
check_in = att.check_in
|
check_in = att.check_in
|
||||||
if not check_in:
|
if not check_in:
|
||||||
continue
|
continue
|
||||||
|
effective_deadline = check_in + timedelta(hours=max_shift)
|
||||||
|
if now <= effective_deadline:
|
||||||
|
continue
|
||||||
|
|
||||||
employee = att.employee_id
|
employee = att.employee_id
|
||||||
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
|
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()
|
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
|
||||||
max_deadline = check_in + timedelta(hours=max_shift)
|
clock_out_time = effective_deadline
|
||||||
day_plan = employee._get_fclk_day_plan(check_in_date)
|
try:
|
||||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
with self.env.cr.savepoint():
|
||||||
effective_deadline = max_deadline
|
|
||||||
else:
|
|
||||||
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
|
|
||||||
deadline = scheduled_out + timedelta(minutes=grace_min)
|
|
||||||
effective_deadline = min(deadline, max_deadline)
|
|
||||||
|
|
||||||
if now > effective_deadline:
|
|
||||||
clock_out_time = min(effective_deadline, now)
|
|
||||||
try:
|
|
||||||
att.sudo().write({
|
att.sudo().write({
|
||||||
'check_out': clock_out_time,
|
'check_out': clock_out_time,
|
||||||
'x_fclk_auto_clocked_out': True,
|
'x_fclk_auto_clocked_out': True,
|
||||||
'x_fclk_grace_used': True,
|
'x_fclk_grace_used': True,
|
||||||
'x_fclk_clock_source': 'auto',
|
'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:
|
if (att.worked_hours or 0) >= threshold:
|
||||||
break_min = employee._get_fclk_break_minutes(check_in_date)
|
att.sudo().write(
|
||||||
att.sudo().write({'x_fclk_break_minutes': break_min})
|
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
|
||||||
|
)
|
||||||
att.sudo().message_post(
|
att.sudo().message_post(
|
||||||
body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} "
|
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",
|
f"(max-shift cap reached). Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||||
message_type='comment',
|
message_type='comment',
|
||||||
subtype_xmlid='mail.mt_note',
|
subtype_xmlid='mail.mt_note',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log to activity log
|
|
||||||
ActivityLog.create({
|
ActivityLog.create({
|
||||||
'employee_id': employee.id,
|
'employee_id': employee.id,
|
||||||
'log_type': 'auto_clock_out',
|
'log_type': 'auto_clock_out',
|
||||||
@@ -317,11 +308,7 @@ class HrAttendance(models.Model):
|
|||||||
'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False,
|
'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False,
|
||||||
'source': 'system',
|
'source': 'system',
|
||||||
})
|
})
|
||||||
|
|
||||||
# Set pending reason
|
|
||||||
employee.sudo().write({'x_fclk_pending_reason': True})
|
employee.sudo().write({'x_fclk_pending_reason': True})
|
||||||
|
|
||||||
# Notify office user
|
|
||||||
self._fclk_notify_office(
|
self._fclk_notify_office(
|
||||||
office_user_id,
|
office_user_id,
|
||||||
f"Auto Clock-Out: {employee.name}",
|
f"Auto Clock-Out: {employee.name}",
|
||||||
@@ -330,16 +317,15 @@ class HrAttendance(models.Model):
|
|||||||
'hr.attendance',
|
'hr.attendance',
|
||||||
att.id,
|
att.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"Fusion Clock: Auto clocked out %s (attendance %s)",
|
"Fusion Clock: Auto clocked out %s (attendance %s)",
|
||||||
employee.name, att.id,
|
employee.name, att.id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(
|
_logger.error(
|
||||||
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
|
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
|
||||||
att.id, str(e),
|
att.id, str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _cron_fusion_wipe_old_photos(self):
|
def _cron_fusion_wipe_old_photos(self):
|
||||||
@@ -407,127 +393,144 @@ class HrAttendance(models.Model):
|
|||||||
LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
|
LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
|
||||||
|
|
||||||
for emp in employees:
|
for emp in employees:
|
||||||
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
|
try:
|
||||||
|
with self.env.cr.savepoint():
|
||||||
|
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
|
||||||
|
|
||||||
if yesterday.weekday() >= 5:
|
# Only days the employee was actually scheduled to work
|
||||||
continue
|
# (posted shift or covering recurring shift) can count as an
|
||||||
day_plan = emp._get_fclk_day_plan(yesterday)
|
# absence. Off days and unscheduled days are never flagged.
|
||||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
if not emp._get_fclk_day_plan(yesterday).get('scheduled'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
|
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
|
||||||
|
|
||||||
holidays = self.env['resource.calendar.leaves'].sudo().search([
|
holidays = self.env['resource.calendar.leaves'].sudo().search([
|
||||||
('resource_id', '=', False),
|
('resource_id', '=', False),
|
||||||
('date_from', '<=', day_end),
|
('date_from', '<=', day_end),
|
||||||
('date_to', '>=', day_start),
|
('date_to', '>=', day_start),
|
||||||
])
|
])
|
||||||
if holidays:
|
if holidays:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
att_count = self.sudo().search_count([
|
att_count = self.sudo().search_count([
|
||||||
('employee_id', '=', emp.id),
|
('employee_id', '=', emp.id),
|
||||||
('check_in', '>=', day_start),
|
('check_in', '>=', day_start),
|
||||||
('check_in', '<', day_end),
|
('check_in', '<', day_end),
|
||||||
])
|
])
|
||||||
if att_count > 0:
|
if att_count > 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
leave = LeaveRequest.search([
|
leave = LeaveRequest.search([
|
||||||
('employee_id', '=', emp.id),
|
('employee_id', '=', emp.id),
|
||||||
('leave_date', '=', yesterday),
|
('leave_date', '=', yesterday),
|
||||||
], limit=1)
|
], limit=1)
|
||||||
if leave:
|
if leave:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ActivityLog.create({
|
ActivityLog.create({
|
||||||
'employee_id': emp.id,
|
'employee_id': emp.id,
|
||||||
'log_type': 'absent',
|
'log_type': 'absent',
|
||||||
'log_date': day_start,
|
'log_date': day_start,
|
||||||
'description': f"No attendance recorded for {yesterday}",
|
'description': f"No attendance recorded for {yesterday}",
|
||||||
'source': 'system',
|
'source': 'system',
|
||||||
})
|
})
|
||||||
|
|
||||||
emp.sudo().write({'x_fclk_pending_reason': True})
|
emp.sudo().write({'x_fclk_pending_reason': True})
|
||||||
|
|
||||||
month_start = yesterday.replace(day=1)
|
month_start = yesterday.replace(day=1)
|
||||||
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
|
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
|
||||||
absence_count = ActivityLog.search_count([
|
absence_count = ActivityLog.search_count([
|
||||||
('employee_id', '=', emp.id),
|
('employee_id', '=', emp.id),
|
||||||
('log_type', '=', 'absent'),
|
('log_type', '=', 'absent'),
|
||||||
('log_date', '>=', month_boundary_start),
|
('log_date', '>=', month_boundary_start),
|
||||||
])
|
])
|
||||||
|
|
||||||
if absence_count >= max_absences:
|
if absence_count >= max_absences:
|
||||||
self._fclk_notify_office(
|
self._fclk_notify_office(
|
||||||
office_user_id,
|
office_user_id,
|
||||||
f"Excessive Absences: {emp.name}",
|
f"Excessive Absences: {emp.name}",
|
||||||
f"{emp.name} has {absence_count} absences this month "
|
f"{emp.name} has {absence_count} absences this month "
|
||||||
f"(threshold: {max_absences}). Please review.",
|
f"(threshold: {max_absences}). Please review.",
|
||||||
'hr.employee',
|
'hr.employee',
|
||||||
emp.id,
|
emp.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
_logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday)
|
_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
|
@api.model
|
||||||
def _cron_fusion_employee_reminders(self):
|
def _cron_fusion_employee_reminders(self):
|
||||||
"""Cron job: send clock-in/out reminders to employees."""
|
"""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()
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
if ICP.get_param('fusion_clock.enable_employee_notifications', 'True') != 'True':
|
if ICP.get_param('fusion_clock.enable_employee_notifications', 'True') != 'True':
|
||||||
return
|
return
|
||||||
|
|
||||||
reminder_in_min = float(ICP.get_param('fusion_clock.reminder_before_shift_minutes', '30'))
|
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'))
|
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()
|
now = fields.Datetime.now()
|
||||||
|
|
||||||
employees = self.env['hr.employee'].sudo().search([
|
employees = self.env['hr.employee'].sudo().search([
|
||||||
('x_fclk_enable_clock', '=', True),
|
('x_fclk_enable_clock', '=', True),
|
||||||
])
|
])
|
||||||
|
|
||||||
for emp in employees:
|
for emp in employees:
|
||||||
today = get_local_today(self.env, emp)
|
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
|
||||||
|
|
||||||
if today.weekday() >= 5:
|
is_checked_in = emp.attendance_state == 'checked_in'
|
||||||
continue
|
|
||||||
day_plan = emp._get_fclk_day_plan(today)
|
|
||||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if emp.x_fclk_last_reminder_date == today:
|
if not is_checked_in:
|
||||||
continue
|
# 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)
|
scheduled_in, _scheduled_out = emp._get_fclk_scheduled_times(today)
|
||||||
is_checked_in = emp.attendance_state == 'checked_in'
|
if now <= scheduled_in + timedelta(minutes=reminder_in_min):
|
||||||
|
continue
|
||||||
# Missed clock-in reminder
|
today_start, _ = get_local_day_boundaries(self.env, today, emp)
|
||||||
reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min)
|
has_attendance = self.sudo().search_count([
|
||||||
if not is_checked_in and now > reminder_deadline:
|
('employee_id', '=', emp.id),
|
||||||
today_start, _ = get_local_day_boundaries(self.env, today, emp)
|
('check_in', '>=', today_start),
|
||||||
has_attendance = self.sudo().search_count([
|
])
|
||||||
('employee_id', '=', emp.id),
|
if has_attendance == 0:
|
||||||
('check_in', '>=', today_start),
|
self._fclk_send_employee_reminder(
|
||||||
])
|
emp,
|
||||||
if has_attendance == 0:
|
"Clock-In Reminder",
|
||||||
self._fclk_send_employee_reminder(
|
f"Hi {emp.name}, you haven't clocked in yet today. "
|
||||||
emp,
|
f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
|
||||||
"Clock-In Reminder",
|
)
|
||||||
f"Hi {emp.name}, you haven't clocked in yet today. "
|
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||||
f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
|
else:
|
||||||
)
|
# Still-clocked-in nudge (OT-aware): only as the max-shift
|
||||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
# safety cap approaches, never at the scheduled end.
|
||||||
|
open_att = self.sudo().search([
|
||||||
# Clock-out reminder
|
('employee_id', '=', emp.id),
|
||||||
reminder_before_end = scheduled_out - timedelta(minutes=reminder_out_min)
|
('check_out', '=', False),
|
||||||
if is_checked_in and now > reminder_before_end and now < scheduled_out:
|
], order='check_in desc', limit=1)
|
||||||
self._fclk_send_employee_reminder(
|
if not open_att or not open_att.check_in:
|
||||||
emp,
|
continue
|
||||||
"Clock-Out Reminder",
|
cap = open_att.check_in + timedelta(hours=max_shift)
|
||||||
f"Hi {emp.name}, your shift ends at {_fclk_utc_to_local_str(scheduled_out, emp)}. "
|
if cap - timedelta(minutes=reminder_out_min) < now < cap:
|
||||||
f"Don't forget to clock out.",
|
self._fclk_send_employee_reminder(
|
||||||
)
|
emp,
|
||||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
"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
|
@api.model
|
||||||
def _cron_fusion_weekly_summary(self):
|
def _cron_fusion_weekly_summary(self):
|
||||||
|
|||||||
@@ -132,18 +132,25 @@ class HrEmployee(models.Model):
|
|||||||
], limit=1)
|
], limit=1)
|
||||||
|
|
||||||
def _get_fclk_day_plan(self, date):
|
def _get_fclk_day_plan(self, date):
|
||||||
"""Return the effective plan for a local date.
|
"""Return the effective plan for a local date, with an explicit
|
||||||
|
``scheduled`` flag that ALL attendance automation keys off.
|
||||||
|
|
||||||
Dated schedules are the source of truth. If none exists, the legacy
|
Resolution order:
|
||||||
employee shift/global settings remain the fallback.
|
1. POSTED planner entry (``fusion.clock.schedule`` state='posted').
|
||||||
|
Draft entries are ignored, so the recurring baseline still applies
|
||||||
|
until the team lead posts the schedule.
|
||||||
|
2. The employee's recurring shift, IF it covers this weekday.
|
||||||
|
3. Otherwise: not scheduled. The global default times are returned
|
||||||
|
only as a display hint; ``scheduled`` stays False so nothing fires.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
Schedule = self.env['fusion.clock.schedule'].sudo()
|
Schedule = self.env['fusion.clock.schedule'].sudo()
|
||||||
schedule = self._get_fclk_schedule_for_date(date)
|
schedule = self._get_fclk_schedule_for_date(date)
|
||||||
if schedule:
|
if schedule and schedule.state == 'posted':
|
||||||
return {
|
return {
|
||||||
'source': 'schedule',
|
'source': 'schedule',
|
||||||
'schedule_id': schedule.id,
|
'schedule_id': schedule.id,
|
||||||
|
'scheduled': not schedule.is_off,
|
||||||
'is_off': schedule.is_off,
|
'is_off': schedule.is_off,
|
||||||
'start_time': schedule.start_time,
|
'start_time': schedule.start_time,
|
||||||
'end_time': schedule.end_time,
|
'end_time': schedule.end_time,
|
||||||
@@ -151,12 +158,14 @@ class HrEmployee(models.Model):
|
|||||||
'hours': schedule.planned_hours,
|
'hours': schedule.planned_hours,
|
||||||
'label': schedule.fclk_display_value(),
|
'label': schedule.fclk_display_value(),
|
||||||
}
|
}
|
||||||
if self.x_fclk_shift_id:
|
|
||||||
shift = self.x_fclk_shift_id
|
shift = self.x_fclk_shift_id
|
||||||
|
if shift and shift.covers_weekday(date):
|
||||||
hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0)
|
hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0)
|
||||||
return {
|
return {
|
||||||
'source': 'fallback',
|
'source': 'shift',
|
||||||
'schedule_id': False,
|
'schedule_id': False,
|
||||||
|
'scheduled': True,
|
||||||
'is_off': False,
|
'is_off': False,
|
||||||
'start_time': shift.start_time,
|
'start_time': shift.start_time,
|
||||||
'end_time': shift.end_time,
|
'end_time': shift.end_time,
|
||||||
@@ -168,23 +177,21 @@ class HrEmployee(models.Model):
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Not scheduled — global default times are a display hint only.
|
||||||
ICP = self.env['ir.config_parameter'].sudo()
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
start_time = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
start_time = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||||
end_time = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
end_time = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||||
break_minutes = float(ICP.get_param('fusion_clock.default_break_minutes', '30'))
|
break_minutes = float(ICP.get_param('fusion_clock.default_break_minutes', '30'))
|
||||||
hours = max((end_time - start_time) - (break_minutes / 60.0), 0.0)
|
|
||||||
return {
|
return {
|
||||||
'source': 'fallback',
|
'source': 'none',
|
||||||
'schedule_id': False,
|
'schedule_id': False,
|
||||||
|
'scheduled': False,
|
||||||
'is_off': False,
|
'is_off': False,
|
||||||
'start_time': start_time,
|
'start_time': start_time,
|
||||||
'end_time': end_time,
|
'end_time': end_time,
|
||||||
'break_minutes': break_minutes,
|
'break_minutes': break_minutes,
|
||||||
'hours': hours,
|
'hours': 0.0,
|
||||||
'label': '%s - %s' % (
|
'label': '',
|
||||||
Schedule.fclk_float_to_display(start_time),
|
|
||||||
Schedule.fclk_float_to_display(end_time),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_fclk_break_minutes(self, date=None):
|
def _get_fclk_break_minutes(self, date=None):
|
||||||
|
|||||||
@@ -56,8 +56,11 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
fclk_max_shift_hours = fields.Float(
|
fclk_max_shift_hours = fields.Float(
|
||||||
string='Max Shift Length (hours)',
|
string='Max Shift Length (hours)',
|
||||||
config_parameter='fusion_clock.max_shift_hours',
|
config_parameter='fusion_clock.max_shift_hours',
|
||||||
default=12.0,
|
default=16.0,
|
||||||
help="Maximum shift length before auto clock-out (safety net).",
|
help="Safety-net cap: an attendance left open longer than this is "
|
||||||
|
"auto-clocked-out (assumed forgot-to-clock-out). Overtime up to this "
|
||||||
|
"cap is never cut off, so set it comfortably above your longest real "
|
||||||
|
"shift + overtime.",
|
||||||
)
|
)
|
||||||
fclk_enable_penalties = fields.Boolean(
|
fclk_enable_penalties = fields.Boolean(
|
||||||
string='Enable Penalty Tracking',
|
string='Enable Penalty Tracking',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export class FusionClockShiftPlanner extends Component {
|
|||||||
error: "",
|
error: "",
|
||||||
dirtyCount: 0,
|
dirtyCount: 0,
|
||||||
invalidCount: 0,
|
invalidCount: 0,
|
||||||
|
draftCount: 0,
|
||||||
collapsed: {},
|
collapsed: {},
|
||||||
editor: {
|
editor: {
|
||||||
open: false,
|
open: false,
|
||||||
@@ -89,6 +90,15 @@ export class FusionClockShiftPlanner extends Component {
|
|||||||
this.state.shifts = data.shifts || [];
|
this.state.shifts = data.shifts || [];
|
||||||
this.state.dirtyCount = 0;
|
this.state.dirtyCount = 0;
|
||||||
this.state.invalidCount = 0;
|
this.state.invalidCount = 0;
|
||||||
|
let draft = 0;
|
||||||
|
for (const emp of this.state.employees) {
|
||||||
|
for (const key in emp.cells || {}) {
|
||||||
|
if (emp.cells[key] && emp.cells[key].state === "draft") {
|
||||||
|
draft += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.state.draftCount = draft;
|
||||||
this.state.error = "";
|
this.state.error = "";
|
||||||
this.closeCellEditor();
|
this.closeCellEditor();
|
||||||
}
|
}
|
||||||
@@ -194,6 +204,34 @@ export class FusionClockShiftPlanner extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async postWeek() {
|
||||||
|
if (this.state.dirtyCount) {
|
||||||
|
this.notification.add("Save your changes before posting.", { type: "warning" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm("Post this week's schedule? Employees will be emailed their shifts, and reminders/absence checks will start using it.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.saving = true;
|
||||||
|
try {
|
||||||
|
const result = await rpc("/fusion_clock/shift_planner/post_week", {
|
||||||
|
week_start: this.state.weekStart,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
this.notification.add(result.error, { type: "danger" });
|
||||||
|
} else {
|
||||||
|
this._applyData(result.data);
|
||||||
|
this.notification.add(
|
||||||
|
`Posted ${result.posted || 0} shift(s); notified ${result.notified || 0} employee(s).`,
|
||||||
|
{ type: "success" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.notification.add(error.message || "Could not post the schedule.", { type: "danger" });
|
||||||
|
}
|
||||||
|
this.state.saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
openCellEditor(employee, day, ev) {
|
openCellEditor(employee, day, ev) {
|
||||||
if (this.state.loading || this.state.saving) {
|
if (this.state.loading || this.state.saving) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
Save
|
Save
|
||||||
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
|
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-success" t-on-click="() => this.postWeek()" t-att-disabled="state.loading or state.saving or state.dirtyCount" t-att-title="state.dirtyCount ? 'Save your changes before posting' : 'Publish this week and email employees their shifts'">
|
||||||
|
<i class="fa fa-paper-plane me-1"/> Post Schedule
|
||||||
|
<t t-if="state.draftCount">(<t t-esc="state.draftCount"/> draft)</t>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,7 +104,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<t t-foreach="state.days" t-as="day" t-key="employee.id + '_' + day.date">
|
<t t-foreach="state.days" t-as="day" t-key="employee.id + '_' + day.date">
|
||||||
<t t-set="cell" t-value="employee.cells[day.date]"/>
|
<t t-set="cell" t-value="employee.cells[day.date]"/>
|
||||||
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source === 'fallback' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
|
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source !== 'schedule' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
|
||||||
t-on-click="(ev) => this.openCellEditor(employee, day, ev)">
|
t-on-click="(ev) => this.openCellEditor(employee, day, ev)">
|
||||||
<input class="fclk-planner__shift-input"
|
<input class="fclk-planner__shift-input"
|
||||||
t-att-value="cell.input"
|
t-att-value="cell.input"
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ from . import test_nfc_models
|
|||||||
from . import test_clock_nfc_kiosk
|
from . import test_clock_nfc_kiosk
|
||||||
from . import test_shift_planner
|
from . import test_shift_planner
|
||||||
from . import test_photo_retention
|
from . import test_photo_retention
|
||||||
|
from . import test_schedule_driven
|
||||||
|
|||||||
170
fusion_clock/tests/test_schedule_driven.py
Normal file
170
fusion_clock/tests/test_schedule_driven.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from odoo import fields
|
||||||
|
from odoo.tests import tagged
|
||||||
|
from odoo.tests.common import TransactionCase
|
||||||
|
|
||||||
|
try:
|
||||||
|
from freezegun import freeze_time
|
||||||
|
except ImportError: # freezegun may not be present on the runtime image
|
||||||
|
freeze_time = None
|
||||||
|
|
||||||
|
# 2026-06-01 is a Monday, 2026-06-02 a Tuesday, 2026-06-06 a Saturday.
|
||||||
|
MON = date(2026, 6, 1)
|
||||||
|
TUE = date(2026, 6, 2)
|
||||||
|
SAT = date(2026, 6, 6)
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
class TestScheduleDriven(TransactionCase):
|
||||||
|
"""Attendance automation must be driven by the employee's real schedule
|
||||||
|
(posted planner entry -> recurring shift), never the global default."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.Employee = cls.env['hr.employee']
|
||||||
|
cls.Schedule = cls.env['fusion.clock.schedule']
|
||||||
|
cls.Shift = cls.env['fusion.clock.shift']
|
||||||
|
cls.Attendance = cls.env['hr.attendance']
|
||||||
|
cls.Log = cls.env['fusion.clock.activity.log']
|
||||||
|
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
|
cls.emp = cls.Employee.create({
|
||||||
|
'name': 'Schedule Test',
|
||||||
|
'x_fclk_enable_clock': True,
|
||||||
|
'work_email': 'sched.test@example.com',
|
||||||
|
'tz': 'UTC',
|
||||||
|
})
|
||||||
|
# Mon-Fri 09:00-17:00 recurring baseline (assigned per-test where needed).
|
||||||
|
cls.shift = cls.Shift.create({
|
||||||
|
'name': 'Test Day Shift',
|
||||||
|
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
|
||||||
|
'day_mon': True, 'day_tue': True, 'day_wed': True,
|
||||||
|
'day_thu': True, 'day_fri': True, 'day_sat': False, 'day_sun': False,
|
||||||
|
})
|
||||||
|
cls.ICP.set_param('fusion_clock.enable_employee_notifications', 'True')
|
||||||
|
cls.ICP.set_param('fusion_clock.max_shift_hours', '16')
|
||||||
|
|
||||||
|
def _post(self, day, **vals):
|
||||||
|
v = {
|
||||||
|
'employee_id': self.emp.id, 'schedule_date': day, 'state': 'posted',
|
||||||
|
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
|
||||||
|
}
|
||||||
|
v.update(vals)
|
||||||
|
return self.Schedule.create(v)
|
||||||
|
|
||||||
|
# ----- resolver matrix (time-independent) -----
|
||||||
|
|
||||||
|
def test_posted_working_is_scheduled(self):
|
||||||
|
self._post(MON)
|
||||||
|
plan = self.emp._get_fclk_day_plan(MON)
|
||||||
|
self.assertTrue(plan['scheduled'])
|
||||||
|
self.assertEqual(plan['source'], 'schedule')
|
||||||
|
|
||||||
|
def test_posted_off_is_not_scheduled(self):
|
||||||
|
self._post(MON, is_off=True)
|
||||||
|
plan = self.emp._get_fclk_day_plan(MON)
|
||||||
|
self.assertFalse(plan['scheduled'])
|
||||||
|
self.assertTrue(plan['is_off'])
|
||||||
|
self.assertEqual(plan['source'], 'schedule')
|
||||||
|
|
||||||
|
def test_draft_entry_is_ignored(self):
|
||||||
|
self.emp.x_fclk_shift_id = self.shift
|
||||||
|
self._post(MON, state='draft') # draft on a Monday the shift covers
|
||||||
|
plan = self.emp._get_fclk_day_plan(MON)
|
||||||
|
# Draft ignored -> falls through to the recurring baseline.
|
||||||
|
self.assertTrue(plan['scheduled'])
|
||||||
|
self.assertEqual(plan['source'], 'shift')
|
||||||
|
|
||||||
|
def test_recurring_shift_covers_weekday(self):
|
||||||
|
self.emp.x_fclk_shift_id = self.shift
|
||||||
|
plan = self.emp._get_fclk_day_plan(MON)
|
||||||
|
self.assertTrue(plan['scheduled'])
|
||||||
|
self.assertEqual(plan['source'], 'shift')
|
||||||
|
|
||||||
|
def test_recurring_shift_skips_uncovered_weekday(self):
|
||||||
|
self.emp.x_fclk_shift_id = self.shift
|
||||||
|
plan = self.emp._get_fclk_day_plan(SAT) # Saturday not in the pattern
|
||||||
|
self.assertFalse(plan['scheduled'])
|
||||||
|
self.assertEqual(plan['source'], 'none')
|
||||||
|
|
||||||
|
def test_nothing_scheduled(self):
|
||||||
|
plan = self.emp._get_fclk_day_plan(MON) # no posted entry, no shift
|
||||||
|
self.assertFalse(plan['scheduled'])
|
||||||
|
self.assertEqual(plan['source'], 'none')
|
||||||
|
self.assertEqual(plan['label'], '') # portal card -> "Not scheduled"
|
||||||
|
|
||||||
|
def test_planner_edit_resets_to_draft(self):
|
||||||
|
posted = self._post(MON)
|
||||||
|
self.assertEqual(posted.state, 'posted')
|
||||||
|
# Re-applying the cell via the planner path must drop it back to draft.
|
||||||
|
self.Schedule.fclk_apply_planner_cell(self.emp, MON, {'input': '8:00 - 16:00'})
|
||||||
|
self.assertEqual(posted.state, 'draft')
|
||||||
|
|
||||||
|
# ----- reminder cron -----
|
||||||
|
|
||||||
|
def test_no_reminder_when_not_scheduled(self):
|
||||||
|
# Not scheduled today -> the cron must stay completely silent.
|
||||||
|
self.Attendance._cron_fusion_employee_reminders()
|
||||||
|
self.assertNotEqual(self.emp.x_fclk_last_reminder_date, fields.Date.context_today(self.emp))
|
||||||
|
|
||||||
|
def test_reminder_fires_for_scheduled_late(self):
|
||||||
|
if freeze_time is None:
|
||||||
|
self.skipTest("freezegun not available")
|
||||||
|
with freeze_time("2026-06-01 12:00:00"): # Monday noon, shift started 09:00
|
||||||
|
self._post(MON, start_time=9.0)
|
||||||
|
self.Attendance._cron_fusion_employee_reminders()
|
||||||
|
self.assertEqual(self.emp.x_fclk_last_reminder_date, MON)
|
||||||
|
|
||||||
|
def test_no_early_reminder_for_late_shift(self):
|
||||||
|
if freeze_time is None:
|
||||||
|
self.skipTest("freezegun not available")
|
||||||
|
with freeze_time("2026-06-01 12:00:00"): # noon, but shift starts 14:00
|
||||||
|
self._post(MON, start_time=14.0, end_time=22.0)
|
||||||
|
self.Attendance._cron_fusion_employee_reminders()
|
||||||
|
self.assertFalse(self.emp.x_fclk_last_reminder_date)
|
||||||
|
|
||||||
|
# ----- absence cron -----
|
||||||
|
|
||||||
|
def test_absence_for_scheduled_noshow(self):
|
||||||
|
if freeze_time is None:
|
||||||
|
self.skipTest("freezegun not available")
|
||||||
|
with freeze_time("2026-06-02 09:00:00"): # Tuesday -> yesterday = Monday
|
||||||
|
self._post(MON) # scheduled Monday, no attendance
|
||||||
|
self.Attendance._cron_fusion_check_absences()
|
||||||
|
self.assertEqual(self.Log.search_count([
|
||||||
|
('employee_id', '=', self.emp.id), ('log_type', '=', 'absent'),
|
||||||
|
]), 1)
|
||||||
|
|
||||||
|
def test_no_absence_when_not_scheduled(self):
|
||||||
|
if freeze_time is None:
|
||||||
|
self.skipTest("freezegun not available")
|
||||||
|
with freeze_time("2026-06-02 09:00:00"): # yesterday Monday, nothing scheduled
|
||||||
|
self.Attendance._cron_fusion_check_absences()
|
||||||
|
self.assertEqual(self.Log.search_count([
|
||||||
|
('employee_id', '=', self.emp.id), ('log_type', '=', 'absent'),
|
||||||
|
]), 0)
|
||||||
|
|
||||||
|
# ----- auto clock-out (OT-aware safety cap) -----
|
||||||
|
|
||||||
|
def test_auto_clockout_only_past_cap(self):
|
||||||
|
now = fields.Datetime.now()
|
||||||
|
recent = self.Attendance.create({
|
||||||
|
'employee_id': self.emp.id,
|
||||||
|
'check_in': now - timedelta(hours=2),
|
||||||
|
})
|
||||||
|
emp2 = self.Employee.create({
|
||||||
|
'name': 'Schedule Test 2', 'x_fclk_enable_clock': True, 'tz': 'UTC',
|
||||||
|
})
|
||||||
|
stale = self.Attendance.create({
|
||||||
|
'employee_id': emp2.id,
|
||||||
|
'check_in': now - timedelta(hours=17),
|
||||||
|
})
|
||||||
|
self.Attendance._cron_fusion_auto_clock_out()
|
||||||
|
self.assertFalse(recent.check_out, "Under-cap shift must stay open (overtime).")
|
||||||
|
self.assertTrue(stale.check_out, "Over-cap shift must be auto-closed.")
|
||||||
@@ -102,15 +102,18 @@ class TestShiftPlannerModels(TransactionCase):
|
|||||||
'start_time': 10.0,
|
'start_time': 10.0,
|
||||||
'end_time': 18.0,
|
'end_time': 18.0,
|
||||||
'break_minutes': 60,
|
'break_minutes': 60,
|
||||||
|
'state': 'posted',
|
||||||
})
|
})
|
||||||
|
|
||||||
planned = self.employee._get_fclk_day_plan(planned_date)
|
planned = self.employee._get_fclk_day_plan(planned_date)
|
||||||
fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1))
|
fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1))
|
||||||
|
|
||||||
|
# Posted dated entry wins; the next day (no entry) falls back to the
|
||||||
|
# employee's recurring shift, which now reports source 'shift'.
|
||||||
self.assertEqual(planned['source'], 'schedule')
|
self.assertEqual(planned['source'], 'schedule')
|
||||||
self.assertEqual(planned['start_time'], 10.0)
|
self.assertEqual(planned['start_time'], 10.0)
|
||||||
self.assertEqual(planned['hours'], 7.0)
|
self.assertEqual(planned['hours'], 7.0)
|
||||||
self.assertEqual(fallback['source'], 'fallback')
|
self.assertEqual(fallback['source'], 'shift')
|
||||||
self.assertEqual(fallback['start_time'], 8.0)
|
self.assertEqual(fallback['start_time'], 8.0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<field name="end_time" widget="float_time"/>
|
<field name="end_time" widget="float_time"/>
|
||||||
<field name="break_minutes"/>
|
<field name="break_minutes"/>
|
||||||
<field name="planned_hours"/>
|
<field name="planned_hours"/>
|
||||||
|
<field name="state" widget="badge" decoration-success="state == 'posted'" decoration-warning="state == 'draft'"/>
|
||||||
<field name="company_id" groups="base.group_multi_company"/>
|
<field name="company_id" groups="base.group_multi_company"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
@@ -47,6 +48,8 @@
|
|||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
<field name="note"/>
|
<field name="note"/>
|
||||||
|
<field name="state"/>
|
||||||
|
<field name="posted_date" readonly="1"/>
|
||||||
<field name="department_id" readonly="1"/>
|
<field name="department_id" readonly="1"/>
|
||||||
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
|
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
|
||||||
</group>
|
</group>
|
||||||
@@ -65,6 +68,9 @@
|
|||||||
<field name="schedule_date"/>
|
<field name="schedule_date"/>
|
||||||
<filter name="off" string="OFF" domain="[('is_off', '=', True)]"/>
|
<filter name="off" string="OFF" domain="[('is_off', '=', True)]"/>
|
||||||
<filter name="working" string="Working" domain="[('is_off', '=', False)]"/>
|
<filter name="working" string="Working" domain="[('is_off', '=', False)]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter name="posted" string="Posted" domain="[('state', '=', 'posted')]"/>
|
||||||
|
<filter name="draft" string="Draft" domain="[('state', '=', 'draft')]"/>
|
||||||
<filter name="group_department" string="Department" context="{'group_by': 'department_id'}"/>
|
<filter name="group_department" string="Department" context="{'group_by': 'department_id'}"/>
|
||||||
<filter name="group_date" string="Date" context="{'group_by': 'schedule_date'}"/>
|
<filter name="group_date" string="Date" context="{'group_by': 'schedule_date'}"/>
|
||||||
</search>
|
</search>
|
||||||
|
|||||||
@@ -39,6 +39,15 @@
|
|||||||
<field name="company_id" groups="base.group_multi_company"/>
|
<field name="company_id" groups="base.group_multi_company"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
<group string="Working Days" col="7">
|
||||||
|
<field name="day_mon"/>
|
||||||
|
<field name="day_tue"/>
|
||||||
|
<field name="day_wed"/>
|
||||||
|
<field name="day_thu"/>
|
||||||
|
<field name="day_fri"/>
|
||||||
|
<field name="day_sat"/>
|
||||||
|
<field name="day_sun"/>
|
||||||
|
</group>
|
||||||
<group string="Assigned Employees">
|
<group string="Assigned Employees">
|
||||||
<field name="employee_ids" nolabel="1" colspan="2">
|
<field name="employee_ids" nolabel="1" colspan="2">
|
||||||
<list>
|
<list>
|
||||||
|
|||||||
Reference in New Issue
Block a user