docs(fusion_clock): spec for province-aware automatic unpaid break

2-tier statutory break deduction: new fusion.clock.break.rule per-province table (seed Ontario 5h/30 + 10h/30); x_fclk_break_minutes becomes an idempotent stored compute (statutory + penalties) firing on every path incl. manual backend entry; collapses the 4 duplicated break-write sites into one calculator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-31 23:35:58 -04:00
parent 493f01827e
commit aa9b95bd5d

View File

@@ -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).