Posted-schedule/recurring-shift drives reminders, absence, penalties, and auto-clock-out (never the global 9-5 default); overtime never cut (auto-close only at a safety cap); team-lead draft->post workflow with employee notify. Frontend footprint kept at zero to avoid colliding with the concurrent fusion_plating employee-portal session. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
Schedule-Driven Attendance Automation — Design
Date: 2026-05-30
Module: fusion_clock
Status: Approved design → ready for implementation plan
Goal
Drive every attendance automation (clock-in/out reminders, absence detection, late/early penalties, auto-clock-out) from each employee's real schedule — the team lead's posted planner entry first, then the employee's recurring shift — never the global 9–5 default. Employees who aren't scheduled get no reminders or absence flags. Overtime past the scheduled end is normal and is never cut off.
Problem & root cause
The machinery already exists: fusion.clock.shift (recurring templates,
assigned via hr.employee.x_fclk_shift_id), fusion.clock.schedule (dated
per-employee entries built in the backend shift planner client action), and
hr.employee._get_fclk_day_plan(date) which resolves per-day times. The crons
already call these.
The bug: in _get_fclk_day_plan(), when an employee has no dated entry and no
assigned shift, it silently falls back to the global 9–5 default with
is_off = False. So everyone is treated as a 9–5 worker, and the reminder /
absence crons fire off that global time. The crons also hardcode-skip Sat/Sun
(weekday() >= 5), which is wrong for a production floor that runs weekends.
Net effect: reminders are not actually schedule-driven for anyone who isn't on a
fixed weekday 9–5 — exactly the spurious-email problem reported.
Decisions (from brainstorming)
- "Expected to work" source: posted planner entry → else recurring shift (if it covers that weekday) → else not scheduled (silent). The global default never makes someone "expected."
- Overtime: time past the scheduled end is overtime and is never cut off. Auto-clock-out fires only at a generous safety cap (forgot-to-clock-out).
- Posting: draft → post gate. Team leads build the week in draft; automation ignores draft days. "Post" publishes the week and emails each employee their shifts. Only posted entries drive automation.
- Employee schedule view: reuse the existing "Today's Shift" card on
/my/clock— no new portal view. (See Coordination.)
Non-goals / constraints
- No edits to the employee
/myportal shell. A concurrent session ("Internal employee portal design",fusion_plating) owns/my+/my/homerouting and the/my/clockbottom-nav tabs (it is adding a Payslips tab). This feature makes zero edits tocontrollers/portal_clock.pyrouting,views/portal_clock_templates.xml, or/myrouting. The existing "Today's Shift" card already renderstoday_schedule.get('label') or 'Not scheduled', so once the resolver is schedule-driven the card updates itself. Employees get their full posted week via the Post notification email. A dedicated "My Schedule" nav tab, if ever wanted, belongs to the portal-shell session. - The backend shift planner client action (manager/team-lead facing) is
not the
/myportal and is in scope to edit (Post button, draft/posted visuals). - No change to how attendance hours / overtime are computed.
Architecture
1. Schedule resolver — hr.employee._get_fclk_day_plan(date)
Rewrite to return an explicit scheduled flag and a precise source, keeping
all existing keys for backward compatibility (is_off, label, hours,
start_time, end_time, break_minutes).
Return shape:
{
'scheduled': bool, # is the employee expected to work this day?
'source': 'schedule' | 'shift' | 'none',
'is_off': bool,
'start_time': float, 'end_time': float, 'break_minutes': float,
'hours': float,
'label': str, # '' when not scheduled → card shows 'Not scheduled'
'schedule_id': int | False,
}
Resolution order:
- Posted planner entry (
fusion.clock.schedule,state == 'posted') for (employee, date) — draft entries are ignored, treated as absent:is_off→scheduled=False,is_off=True,source='schedule',hours=0,label='OFF'.- else →
scheduled=True, times from entry,source='schedule'.
- Else recurring shift
x_fclk_shift_idand the shift coversdate's weekday →scheduled=True, times from shift,source='shift'. - Else →
scheduled=False,source='none',is_off=False,label='',hours=0. (Global default may fillstart_time/end_timeas a display hint only; it never setsscheduled=True.)
_get_fclk_scheduled_times() and _get_fclk_break_minutes() keep working off
this structure unchanged.
2. Data model changes
fusion.clock.schedule: addstate = Selection([('draft','Draft'),('posted','Posted')], default='draft')posted_date = Datetime- Automation reads only
state == 'posted'.
fusion.clock.shift: add a weekday pattern —day_mon … day_sun = Boolean(default Mon–Fri True, Sat–Sun False) plus a helpercovers_weekday(date) -> bool. This replaces the hardcoded weekend skip and lets weekend shifts exist. (Judgment call: pattern lives on the shared shift template, e.g. "Mon–Fri Day", "Sat–Sun Weekend"; unique patterns → own template or a posted planner override.)
3. Posting workflow
- New jsonrpc route
POST /fusion_clock/shift_planner/post_weekincontrollers/shift_planner.py:- Gate: manager OR team lead.
- Scope: managers → all in-scope employees for the viewed week; team leads →
their direct reports (
parent_id== the team lead's employee). Reuse the existing dashboard scoping helper. - Set
state='posted',posted_date=nowon those week entries. - Queue one email per affected employee summarizing their posted shifts
for the week (reuse
_fclk_email_wrap). Failures logged, never block the post.
- New planner entries default to
draft. Re-posting after edits re-publishes (and re-notifies, flagged as an update). - Planner client action (
static/src/js/fusion_clock_shift_planner.js+ its template) gains a Post button and a draft-vs-posted visual cue. (Backend client action — not the/myportal.)
4. Reminder cron — hr.attendance._cron_fusion_employee_reminders
- Remove the
weekday() >= 5hardcode. - Per enabled employee:
plan = emp._get_fclk_day_plan(today); if notplan['scheduled']→ skip (silent). - Missed clock-in: if scheduled, not checked in, no attendance today, and
now > scheduled_in + reminder_before_shift_minutes→ remind. Uses the employee's real start, so a 14:00 shift is never pinged at 09:30. - Clock-out reminder: reframed (judgment call). Drop the "your shift ends at
X" nudge (noise when OT is the norm). Instead, if still checked in and
approaching the safety cap (
check_in + max_shift_hours - reminder_before_end_minutes), send "you're still clocked in — remember to clock out."
5. Absence cron — hr.attendance._cron_fusion_check_absences
- Remove the
weekday() >= 5hardcode. - Per enabled employee:
plan = emp._get_fclk_day_plan(yesterday); only flag absent ifplan['scheduled']AND no attendance AND no leave request AND no global holiday. Off/unscheduled → never flagged.
6. Auto-clock-out — hr.attendance._cron_fusion_auto_clock_out
- Stop closing at
scheduled_out + grace. Close only at the safety capcheck_in + max_shift_hours. Everything between the scheduled end and the cap is captured as overtime by the existing fields. - Bump default
max_shift_hours12 → 16 (still configurable). - Keep
x_fclk_pending_reason=True, break deduction, and office notify on auto-close.
7. Penalties — controllers/clock_api.py::_check_and_create_penalty
- Skip when the day is not scheduled (
not plan['scheduled']), in addition to the existing posted-OFF skip. Late-in / early-out stay keyed off the resolved scheduled start/end. Overtime is never penalized.
8. Kiosk callers — clock_kiosk.py, clock_nfc_kiosk.py
- The existing
is_scheduled_off = source == 'schedule' and is_offchecks keep working for posted-OFF days. Extend the "unscheduled shift" log + penalty-skip to also coversource == 'none'(clocked in on a day with no schedule) so a not-scheduled clock-in is logged asunscheduled_shiftand creates no penalty.
9. Settings
res_config_settings: changefclk_max_shift_hoursdefault 12 → 16 (and the resolver/cronget_paramfallback). Optionally surface the shift weekday pattern on the shift form. No other new settings required.
10. Frontend
- No file edits. The existing "Today's Shift" card auto-reflects the new
resolver: scheduled → times + hours; posted OFF → "OFF"; not scheduled →
"Not scheduled" (already coded as
label or 'Not scheduled').
Data flow
posted planner entry / recurring shift → _get_fclk_day_plan(date) →
scheduled flag → consumed by: reminder cron, absence cron, penalty helper,
kiosk unscheduled-log, and (read-only) the portal "Today's Shift" card. Posting
flips state to posted (making entries visible to the resolver) and emails
employees.
Error handling
- Crons: wrap each employee's body in
with self.env.cr.savepoint():so one bad record can't abort the batch (savepoints, notcr.commit()— works in prod and tests). - Posting: state writes + email queueing in one transaction; email creation in try/except with logging so a bad address never blocks the post.
- Notifications:
mail.mailwithauto_delete=True; send failures logged.
Testing (tests/test_schedule_driven.py, post_install)
- Resolver matrix: posted-working / posted-off / draft-ignored /
recurring-covers-weekday / recurring-skips-weekday / nothing → not-scheduled.
Assert
scheduled, times, andlabel. - Reminder cron: scheduled + late + no attendance → reminder; not scheduled → none; 14:00 shift not pinged at 09:30; already clocked in → no clock-in reminder.
- Absence cron: scheduled no-show → absent logged; not scheduled → not flagged; leave/holiday → not flagged.
- Auto-clock-out: open past scheduled end but under cap → stays open; past
cap → closed +
x_fclk_pending_reason. - Posting: draft entry → resolver
scheduled=False(ignored by crons); post →state='posted', resolver picks it up, email queued; team lead can post only direct reports. - Penalties: not-scheduled clock-in → no penalty; scheduled late →
late_in.
Files expected to change (for the plan)
models/hr_employee.py— resolver refactor.models/clock_shift.py— weekday booleans +covers_weekday.models/clock_schedule.py—state+posted_date.models/hr_attendance.py— reminders, absences, auto-clock-out + savepoints.controllers/clock_api.py— penalty skip when not scheduled.controllers/clock_kiosk.py,controllers/clock_nfc_kiosk.py— unscheduled log/penalty forsource == 'none'.controllers/shift_planner.py—post_weekroute + scope + notifications; default new entries to draft.static/src/js/fusion_clock_shift_planner.js+ planner template — Post button, draft/posted visuals.models/res_config_settings.py+views/res_config_settings_views.xml—max_shift_hoursdefault 16; optional weekday-pattern surfacing.views/clock_shift_views.xml— weekday checkboxes on the shift form.views/clock_schedule_views.xml— showstate.tests/test_schedule_driven.py(+tests/__init__.py).- Not touched:
controllers/portal_clock.pyrouting,views/portal_clock_templates.xml,/myrouting (owned by the concurrent portal-shell session).
Coordination
Concurrent session "Internal employee portal design" (fusion_plating) owns the
employee /my portal shell: /my + /my/home redirect to the clock page and
new bottom-nav tabs (Payslips). This feature is backend-only on the frontend
side — it edits no /my portal files — so the two land without conflict
regardless of order. Shared touchpoint to watch: both evolve the employee
experience; if a "My Schedule" nav tab is desired, it is the portal-shell
session's responsibility, fed by this feature's resolver.