diff --git a/docs/superpowers/specs/2026-05-31-fusion-clock-statutory-break-design.md b/docs/superpowers/specs/2026-05-31-fusion-clock-statutory-break-design.md new file mode 100644 index 00000000..1a2cb017 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-fusion-clock-statutory-break-design.md @@ -0,0 +1,256 @@ +# Fusion Clock — Province-Aware Automatic Unpaid Break (2-tier) + +- **Date:** 2026-05-31 +- **Module:** `fusion_clock` +- **Version bump:** `19.0.4.0.3` → `19.0.4.1.0` +- **Status:** Approved design, pending implementation plan +- **Author:** Claude Code (brainstormed with user) + +## 1. Problem + +Statutory unpaid meal breaks are jurisdiction-driven: a break is required after N1 +hours of work, and a second break after a higher N2 threshold. Ontario, for example: +a 30-minute eating period after 5 hours of work, and (per the user's policy) another +30 minutes after 10 hours. The deduction must be **automatic** and must apply on **every** +way an attendance is recorded — including a manager manually adding or editing hours. + +### Audit of current behaviour (what exists today) + +The deduction field is `hr.attendance.x_fclk_break_minutes` (minutes). Net hours are +`x_fclk_net_hours = worked_hours − x_fclk_break_minutes/60` (`models/hr_attendance.py:261`). + +Break minutes are written from **four** places, all implementing variations of one rule: + +1. `controllers/clock_api.py::_apply_break_deduction` (line 161) — on **clock-out**; + reused by the PIN kiosk (`controllers/clock_kiosk.py:158`) and NFC kiosk + (`controllers/clock_nfc_kiosk.py:381`). Logic: `if worked_hours >= break_threshold_hours` + (default **4.0h**) → set break to `employee._get_fclk_break_minutes()` (default **30**), + using `max(new, current)` so it doesn't wipe penalty minutes. +2. Auto-clock-out cron (`models/hr_attendance.py:343`) — same single-threshold write. +3. `controllers/clock_api.py::_check_and_create_penalty` (line 140) — **adds** penalty + minutes into the same `x_fclk_break_minutes` field. + +### Gaps vs. requirement + +1. **Single tier only** — one threshold (4h), one break (30m). No second break. +2. **Not applied on manual entry** — there is **no `create`/`write` override** on + `hr.attendance`. A manager-created or manager-edited attendance gets break `= 0`. + This is the central gap. +3. **No province/country awareness** — no jurisdiction field exists anywhere (location + has address/timezone but no province; company has none). Threshold + amount are flat + global config params. +4. **First-break default is 4h, not 5h** (Ontario is 5h). + +## 2. Goals / Non-goals + +**Goals** +- Statutory unpaid break applies automatically based on **actual worked hours**, on every + path (portal, systray, PIN kiosk, NFC kiosk, auto-clock-out cron, **and manual backend + create/edit**). +- Two tiers: first break after N1 hours, second break adds after N2 hours. Trigger is + `worked_hours >= N` (inclusive; nothing under N1). +- Rules are defined **per province/country** in a table; an employee resolves its rule + from its **company's province**, with a single global default fallback. +- **Eliminate the duplicated deduction logic** — one calculator, called everywhere. + +**Non-goals (YAGNI)** +- Per-employee break-rule override (resolver is structured so this is a cheap add later). +- GPS/location-based jurisdiction detection. +- More than two tiers (the table is 2-tier; a 3rd break would be a future schema change). +- Changing the *planned* break concept used for scheduled-hours math. + +## 3. Locked decisions + +| # | Decision | Choice | +|---|---|---| +| 1 | Rule model | **Per-province table**, 2-tier (`fusion.clock.break.rule`) | +| 2 | Jurisdiction source | **Company province** (`company_id.state_id`) + global default fallback | +| 3 | Override behaviour | **Fully automatic** — idempotent stored compute, recomputes on every save | +| 4 | Planned-vs-statute | **Statutory only** — the planned/scheduled break never affects the actual deduction | + +## 4. Design + +### 4.1 New model `fusion.clock.break.rule` + +`models/clock_break_rule.py`, `_name = 'fusion.clock.break.rule'`, +`_description = 'Statutory Break Rule'`, `_order = 'sequence, name'`. + +| Field | Type | Default | Notes | +|---|---|---|---| +| `name` | Char (required) | — | e.g. "Ontario" | +| `country_id` | Many2one `res.country` | — | scopes the province picker | +| `state_id` | Many2one `res.country.state` | — | the province; `domain` on `country_id` | +| `is_default` | Boolean | False | global fallback when no province matches | +| `break1_after_hours` | Float | 5.0 | first break trigger N1 | +| `break1_minutes` | Float | 30.0 | first break amount M1 (0 = disabled) | +| `break2_after_hours` | Float | 10.0 | second break trigger N2 | +| `break2_minutes` | Float | 30.0 | second break amount M2 (0 = disabled) | +| `sequence` | Integer | 10 | | +| `active` | Boolean | True | | + +**Constraints** (`models.Constraint`, per repo Odoo-19 rule 9): +- `break1_after_hours >= 0`, `break2_after_hours >= 0`, minutes `>= 0`. +- When `break2_minutes > 0`: `break2_after_hours > break1_after_hours` + (a misordered second tier is a config error). +- (Soft) at most one `is_default = True` — enforced in a Python `@api.constrains` + rather than a partial unique index, to give a friendly message. + +**Method** — `break_minutes_for(self, worked_hours)`: +``` +self.ensure_one() +total = 0.0 +if self.break1_minutes and worked_hours >= self.break1_after_hours: + total += self.break1_minutes +if self.break2_minutes and worked_hours >= self.break2_after_hours: + total += self.break2_minutes +return total +``` +`>=` is intentional and matches the requirement ("equal to or more than N1"). + +**Seed** (`data/clock_break_rule_data.xml`, `noupdate="1"`): one row — +`name="Ontario"`, `state_id=base.state_ca_on`, `is_default=True`, +`break1_after_hours=5.0`, `break1_minutes=30.0`, +`break2_after_hours=10.0`, `break2_minutes=30.0`. +(Acting as both the Ontario match and the global fallback for this deployment. +Other provinces can be added as rows.) + +### 4.2 Jurisdiction resolver — `hr.employee._get_fclk_break_rule()` + +``` +self.ensure_one() +Rule = self.env['fusion.clock.break.rule'].sudo() +state = self.company_id.state_id +rule = Rule.browse() +if state: + rule = Rule.search([('state_id', '=', state.id)], limit=1) +if not rule: + rule = Rule.search([('is_default', '=', True)], limit=1) +return rule # may be empty recordset → caller treats as 0 break +``` +`sudo()` so the portal net-hours compute (run as the employee) can read the rule table +without a direct ACL grant. Resolver is a single method → adding a per-employee override +(`x_fclk_break_rule_id`) later is a two-line change. + +### 4.3 `hr.attendance` — `x_fclk_break_minutes` becomes a stored compute + +The field changes from a plain editable Float to a **stored computed** field — this is the +single calculator that replaces all four write sites. + +```python +x_fclk_break_minutes = fields.Float( + string='Break (min)', + compute='_compute_fclk_break_minutes', + store=True, + tracking=True, + help="Unpaid break deducted from worked hours: statutory break (by province " + "rule, from actual hours worked) plus any penalty minutes.", +) + +@api.depends('worked_hours', 'check_out', + 'x_fclk_penalty_ids.penalty_minutes', 'employee_id') +def _compute_fclk_break_minutes(self): + ICP = self.env['ir.config_parameter'].sudo() + auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True' + for att in self: + statutory = 0.0 + if auto and att.check_out and att.employee_id: + rule = att.employee_id._get_fclk_break_rule() + if rule: + statutory = rule.break_minutes_for(att.worked_hours or 0.0) + penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes')) + att.x_fclk_break_minutes = statutory + penalties +``` + +Properties: +- **Idempotent** — same hours + same penalties always yield the same value; no drift, + nothing to wipe. +- **Fires on every path** — `worked_hours` recomputes whenever `check_in`/`check_out` + change, so portal, kiosk, NFC, cron, **and manual backend create/edit** all recompute + automatically. This is what fixes the manual-entry gap. +- **Mid-shift = 0** — `check_out` empty → statutory 0 (penalties, if any, still counted). +- **Master toggle preserved** — `auto_deduct_break` False → statutory 0 (penalties remain). +- `_compute_net_hours` is unchanged (still `worked_hours − break/60`); it now depends on a + computed-stored field, which Odoo chains correctly. + +The attendance form's Break field becomes read-only (consistent with "fully automatic"). +`views/hr_attendance_views.xml` updated accordingly. + +### 4.4 Removals (the de-duplication) + +| Remove | File | Replaced by | +|---|---|---| +| `_apply_break_deduction` method + its 3 call sites | `controllers/clock_api.py:161`, `controllers/clock_kiosk.py:158`, `controllers/clock_nfc_kiosk.py:381` | the compute | +| cron's `x_fclk_break_minutes` write | `models/hr_attendance.py:343-346` | the compute | +| penalty's `current_break + deduction` write | `controllers/clock_api.py:140-144` | the compute's `Σ penalty_minutes` | +| setting `fclk_break_threshold_hours` + `fusion_clock.break_threshold_hours` | `models/res_config_settings.py:39`, seed in `data/ir_config_parameter_data.xml` | per-rule `break1_after_hours` | + +**Kept and untouched:** `hr.employee._get_fclk_break_minutes()`, `fusion_clock.default_break_minutes`, +`fusion.clock.shift.break_minutes`, `fusion.clock.schedule.break_minutes` — these are the +**planned** break (used to compute scheduled `planned_hours`), a separate concept from the +actual worked-hours deduction. Decision #4 keeps them out of the deduction path. + +**Kept:** the `auto_deduct_break` master toggle (now gates the statutory portion only). + +### 4.5 UI / security / data + +- **Menu:** *Fusion Clock → Configuration → Break Rules* (new `ir.actions.act_window` + + list/form views in `views/clock_break_rule_views.xml`), gated to + `group_fusion_clock_manager`. Add the menu item in `views/clock_menus.xml`. +- **Security:** `security/ir.model.access.csv` — `fusion.clock.break.rule`: manager = + full CRUD; team-lead/user = read (or none — the resolver uses sudo, so no direct grant + is strictly required; grant manager full, no portal access). +- **Manifest `data`:** add `data/clock_break_rule_data.xml` (after security, before crons) + and `views/clock_break_rule_views.xml` (with the other config views, before + `clock_menus.xml`). Bump `version` to `19.0.4.1.0`. + +## 5. Edge cases + +- **No rule resolvable** (no province match, no default) → statutory 0. The seeded default + prevents this in practice. +- **Company has no `state_id`** → falls to the default rule. +- **`break2_after_hours <= break1_after_hours`** → blocked by constraint. +- **Penalty created after clock-out** → `x_fclk_penalty_ids` change retriggers the compute; + final break = statutory + penalty (preserves today's combined-field semantics, reported + as one "Break" number). +- **Open attendance** (no checkout) → break 0; recomputed when it's closed. +- **Worked hours exactly at a boundary** (5.0h, 10.0h) → tier fires (`>=`). + +## 6. Migration / upgrade + +- On upgrade, flipping `x_fclk_break_minutes` to `store=True compute` makes Odoo recompute + it for all existing rows. For closed attendances this re-derives break from + `worked_hours` + linked penalties using the seeded Ontario rule — which is the intended + corrected value. Any historical hand-edited break values are replaced (acceptable per + Decision #3, "fully automatic"). Call this out in the change log. +- No `pre`/`post` migration script is required; the recompute is automatic. (If we later + want to *avoid* touching very old periods, a guarded post-migrate could pin them — out of + scope for now.) + +## 7. Testing (`tests/test_break_rules.py`, `@tagged('-at_install','post_install','fusion_clock')`) + +1. `break_minutes_for`: 4.99h→0, 5.0h→30, 9.99h→30, 10.0h→60. +2. Resolver: company in Ontario → Ontario rule; company with unset/other province → default. +3. **Manual backend create** of a closed attendance (check_in/out spanning 6h) → break 30, + net = worked − 0.5. **Manual edit** extending to 10h → break 60. (This is the headline + gap; assert it directly via `env['hr.attendance'].create(...)`, not via a controller.) +4. Penalty additivity: 6h + one 15-min penalty record → break 45. +5. Master toggle off (`auto_deduct_break=False`) → statutory 0 (penalty-only). +6. Constraint: `break2_after_hours <= break1_after_hours` raises. + +Run (note ephemeral ports per repo CLAUDE.md): +``` +docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock \ + -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60 +``` + +## 8. Rollout notes + +- **Dual-path write** during dev: edit files in **both** `K:\Github\odoo-modsdev\addons\fusion_clock` + (Docker-mounted, for tests) **and** `K:\Github\Odoo-Modules\fusion_clock` (git); commit + from the git path only. (Per project memory.) +- Live target is **entech** (`odoo-entech`); deploy after local tests pass and user review. +- Asset/version bump already covered by the manifest `version` change. + +## 9. Open questions + +None — all four design forks resolved (see §3).