docs(fusion_clock): design spec — schedule-driven attendance automation
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>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
# 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)
|
||||
|
||||
1. **"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."
|
||||
2. **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).
|
||||
3. **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.
|
||||
4. **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 `/my` portal shell.** A concurrent session
|
||||
("Internal employee portal design", `fusion_plating`) owns `/my` + `/my/home`
|
||||
routing and the `/my/clock` bottom-nav tabs (it is adding a Payslips tab).
|
||||
This feature makes **zero** edits to `controllers/portal_clock.py` routing,
|
||||
`views/portal_clock_templates.xml`, or `/my` routing. The existing "Today's
|
||||
Shift" card already renders `today_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 `/my` portal 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:
|
||||
```python
|
||||
{
|
||||
'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:
|
||||
1. **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'`.
|
||||
2. Else **recurring shift** `x_fclk_shift_id` **and** the shift covers
|
||||
`date`'s weekday → `scheduled=True`, times from shift, `source='shift'`.
|
||||
3. Else → `scheduled=False`, `source='none'`, `is_off=False`, `label=''`,
|
||||
`hours=0`. (Global default may fill `start_time`/`end_time` as a display
|
||||
hint only; it never sets `scheduled=True`.)
|
||||
|
||||
`_get_fclk_scheduled_times()` and `_get_fclk_break_minutes()` keep working off
|
||||
this structure unchanged.
|
||||
|
||||
### 2. Data model changes
|
||||
|
||||
- **`fusion.clock.schedule`**: add
|
||||
- `state = 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
|
||||
helper `covers_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_week` in
|
||||
`controllers/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=now` on 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 `/my` portal.)
|
||||
|
||||
### 4. Reminder cron — `hr.attendance._cron_fusion_employee_reminders`
|
||||
|
||||
- Remove the `weekday() >= 5` hardcode.
|
||||
- Per enabled employee: `plan = emp._get_fclk_day_plan(today)`; **if not
|
||||
`plan['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() >= 5` hardcode.
|
||||
- Per enabled employee: `plan = emp._get_fclk_day_plan(yesterday)`; **only flag
|
||||
absent if `plan['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 cap
|
||||
`check_in + max_shift_hours`. Everything between the scheduled end and the cap
|
||||
is captured as overtime by the existing fields.
|
||||
- Bump default `max_shift_hours` **12 → 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_off` checks keep
|
||||
working for posted-OFF days. Extend the "unscheduled shift" log + penalty-skip
|
||||
to also cover `source == 'none'` (clocked in on a day with no schedule) so a
|
||||
not-scheduled clock-in is logged as `unscheduled_shift` and creates no penalty.
|
||||
|
||||
### 9. Settings
|
||||
|
||||
- `res_config_settings`: change `fclk_max_shift_hours` default 12 → 16 (and the
|
||||
resolver/cron `get_param` fallback). 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, not `cr.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.mail` with `auto_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, and `label`.
|
||||
- **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 for `source == 'none'`.
|
||||
- `controllers/shift_planner.py` — `post_week` route + 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_hours` default 16; optional weekday-pattern surfacing.
|
||||
- `views/clock_shift_views.xml` — weekday checkboxes on the shift form.
|
||||
- `views/clock_schedule_views.xml` — show `state`.
|
||||
- `tests/test_schedule_driven.py` (+ `tests/__init__.py`).
|
||||
- **Not touched:** `controllers/portal_clock.py` routing,
|
||||
`views/portal_clock_templates.xml`, `/my` routing (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.
|
||||
Reference in New Issue
Block a user