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:
@@ -2,11 +2,15 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockSchedule(models.Model):
|
||||
_name = 'fusion.clock.schedule'
|
||||
@@ -72,6 +76,15 @@ class FusionClockSchedule(models.Model):
|
||||
compute='_compute_display_name',
|
||||
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(
|
||||
'UNIQUE(employee_id, schedule_date)',
|
||||
@@ -288,6 +301,10 @@ class FusionClockSchedule(models.Model):
|
||||
'end_time': parsed.get('end_time') or 0.0,
|
||||
'break_minutes': parsed.get('break_minutes') or 0.0,
|
||||
'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:
|
||||
existing.write(vals)
|
||||
@@ -321,6 +338,7 @@ class FusionClockSchedule(models.Model):
|
||||
return {
|
||||
'schedule_id': schedule.id,
|
||||
'source': 'schedule',
|
||||
'state': schedule.state,
|
||||
'input': schedule.fclk_display_value(),
|
||||
'label': schedule.fclk_display_value(),
|
||||
'is_off': schedule.is_off,
|
||||
@@ -336,7 +354,8 @@ class FusionClockSchedule(models.Model):
|
||||
plan = employee._get_fclk_day_plan(date_obj)
|
||||
return {
|
||||
'schedule_id': False,
|
||||
'source': plan.get('source') or 'fallback',
|
||||
'source': plan.get('source') or 'none',
|
||||
'state': False,
|
||||
'input': plan.get('label') or '',
|
||||
'label': plan.get('label') or '',
|
||||
'is_off': plan.get('is_off', False),
|
||||
@@ -349,6 +368,57 @@ class FusionClockSchedule(models.Model):
|
||||
'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):
|
||||
_name = 'fusion.clock.schedule.audit'
|
||||
|
||||
Reference in New Issue
Block a user