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:
@@ -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).
|
||||||
Reference in New Issue
Block a user