Files
Odoo-Modules/docs/superpowers/specs/2026-05-30-schedule-driven-attendance-design.md
gsinghpal dd908c3861 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>
2026-05-30 21:17:56 -04:00

248 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 95 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 95 default with
`is_off = False`**. So everyone is treated as a 95 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 95 — 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 MonFri True, SatSun 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. "MonFri Day", "SatSun 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.