Files
Odoo-Modules/docs/superpowers/plans/2026-05-31-fusion-clock-statutory-break.md
2026-05-31 23:50:08 -04:00

865 lines
41 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.
# Fusion Clock — Province-Aware Automatic Unpaid Break Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the unpaid meal break deduct automatically from worked hours on every path (portal, kiosk, NFC, cron, **and manual backend entry**), using a 2-tier per-province rule table (Ontario: 5h→30min, 10h→+30min), with no duplicated logic.
**Architecture:** A new `fusion.clock.break.rule` table holds the per-province thresholds. `hr.employee._get_fclk_break_rule()` resolves an employee's rule from its company's province (global default fallback). `hr.attendance.x_fclk_break_minutes` becomes a single stored **computed** field — `statutory_break(worked_hours) + Σ penalty_minutes` — that recomputes on every save and replaces the four scattered write sites (controller `_apply_break_deduction` ×3 call sites, the auto-clock-out cron, and the penalty code's manual write).
**Tech Stack:** Odoo 19, Python, QWeb/XML views, Odoo test framework (`TransactionCase`).
**Spec:** `docs/superpowers/specs/2026-05-31-fusion-clock-statutory-break-design.md`
---
## Dev environment & sync (READ FIRST — applies to every task)
**Two working copies (per project memory `feedback_dual_path_fusion_clock`):**
- **Git/source tree (edit + commit here):** `K:\Github\Odoo-Modules\fusion_clock`
- **Docker/active tree (what the container loads):** `K:\Github\odoo-modsdev\addons\fusion_clock`
Edit in the **git tree**, then **mirror to the Docker tree before every test run**:
```powershell
robocopy "K:\Github\Odoo-Modules\fusion_clock" "K:\Github\odoo-modsdev\addons\fusion_clock" /MIR /XD ".git" "__pycache__" /XF "*.pyc" /NFL /NDL /NJH /NJS; if ($LASTEXITCODE -lt 8) { "sync ok" } else { "sync FAILED" }
```
(robocopy exit codes < 8 = success.) **Preflight:** if `K:\Github\odoo-modsdev\addons\fusion_clock` does not exist, the dual-tree setup changed — STOP and confirm the active copy with the user before continuing.
**Container/DB:** `odoo-modsdev-app` / db `modsdev` (per memory `reference_docker_env_names`).
**Canonical commands** (note the ephemeral ports — `--test-enable` forces `http_spawn()` so 8069/8072 collide without them; per repo CLAUDE.md):
- Run this module's tests:
```bash
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 -100
```
- Plain upgrade (no tests):
```bash
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -50
```
- Pyflakes a changed Python file (catches undefined names instantly):
```bash
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/<relpath>.py
```
**Commit:** only from the git tree (`git -C "K:/Github/Odoo-Modules" ...`). Per memory `feedback_always_push_to_main`, push after each commit on `main`.
---
## File Structure
**Created:**
- `fusion_clock/models/clock_break_rule.py` — the `fusion.clock.break.rule` model + tier engine + constraints.
- `fusion_clock/data/clock_break_rule_data.xml` — seed Ontario rule (`is_default`).
- `fusion_clock/views/clock_break_rule_views.xml` — list/form/action for the rule.
- `fusion_clock/migrations/19.0.4.1.0/post-migrate.py` — drop retired param + recompute break.
- `fusion_clock/tests/test_break_rules.py` — all new tests.
**Modified:**
- `fusion_clock/models/__init__.py` — import the new model.
- `fusion_clock/models/hr_employee.py` — add `_get_fclk_break_rule()`.
- `fusion_clock/models/hr_attendance.py` — `x_fclk_break_minutes` → stored compute; drop cron break-write.
- `fusion_clock/controllers/clock_api.py` — delete `_apply_break_deduction`, its clock-out call, and the penalty break-write.
- `fusion_clock/controllers/clock_kiosk.py` — delete the `_apply_break_deduction` call.
- `fusion_clock/controllers/clock_nfc_kiosk.py` — delete the `_apply_break_deduction` call.
- `fusion_clock/models/res_config_settings.py` — remove `fclk_break_threshold_hours`.
- `fusion_clock/views/res_config_settings_views.xml` — remove threshold row; relabel default-break as scheduling-only; point to Break Rules.
- `fusion_clock/data/ir_config_parameter_data.xml` — remove the `break_threshold_hours` seed record.
- `fusion_clock/security/ir.model.access.csv` — manager access for the new model.
- `fusion_clock/views/clock_menus.xml` — "Break Rules" config menu.
- `fusion_clock/__manifest__.py` — version bump + new data/view files.
- `fusion_clock/tests/__init__.py` — import the new test module.
- `fusion_clock/tests/test_settings.py` — assert the retired field is gone.
- `fusion_clock/CLAUDE.md` — model map, settings keys, break gotcha (Task 5).
**Behaviour-change note (intentional, approved by spec §4.3):** today a *late-in* penalty written at clock-in (e.g. +15) is silently swallowed at clock-out because `_apply_break_deduction` does `max(break, current)`. The new compute makes **all** penalty minutes strictly additive (`statutory + Σ penalties`), so a late-in penalty on a long shift is no longer lost. Net hours for such shifts will be correctly lower than before.
---
## Task 1: New model `fusion.clock.break.rule`
**Files:**
- Create: `fusion_clock/models/clock_break_rule.py`
- Create: `fusion_clock/data/clock_break_rule_data.xml`
- Create: `fusion_clock/views/clock_break_rule_views.xml`
- Create: `fusion_clock/tests/test_break_rules.py`
- Modify: `fusion_clock/models/__init__.py`
- Modify: `fusion_clock/tests/__init__.py`
- Modify: `fusion_clock/security/ir.model.access.csv`
- Modify: `fusion_clock/views/clock_menus.xml`
- Modify: `fusion_clock/__manifest__.py`
- [ ] **Step 1: Write the failing tests** — create `fusion_clock/tests/test_break_rules.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import datetime, timedelta
from odoo.tests import tagged, TransactionCase
from odoo.exceptions import ValidationError
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestBreakRules(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.auto_deduct_break', 'True')
cls.Rule = cls.env['fusion.clock.break.rule']
cls.default_rule = cls.Rule.search([('is_default', '=', True)], limit=1)
cls.employee = cls.env['hr.employee'].create({'name': 'FCLK Break Test'})
def _mk_att(self, hours):
check_in = datetime(2026, 1, 5, 9, 0, 0)
return self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': check_in,
'check_out': check_in + timedelta(hours=hours),
})
# ---- Task 1: tier engine + constraints ----
def test_break_minutes_for_tiers(self):
rule = self.Rule.create({
'name': 'Tier Test', 'is_default': False,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
})
self.assertEqual(rule.break_minutes_for(4.99), 0.0)
self.assertEqual(rule.break_minutes_for(5.0), 30.0)
self.assertEqual(rule.break_minutes_for(9.99), 30.0)
self.assertEqual(rule.break_minutes_for(10.0), 60.0)
self.assertEqual(rule.break_minutes_for(12.0), 60.0)
def test_second_tier_must_exceed_first(self):
with self.assertRaises(ValidationError):
self.Rule.create({
'name': 'Bad', 'is_default': False,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 5.0, 'break2_minutes': 30.0,
})
def test_single_default_enforced(self):
self.assertTrue(self.default_rule, "seed default rule must exist")
with self.assertRaises(ValidationError):
self.Rule.create({
'name': 'Another Default', 'is_default': True, 'active': True,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
})
```
Append the import to `fusion_clock/tests/__init__.py` (add the line if not already present):
```python
from . import test_break_rules
```
- [ ] **Step 2: Create the model** — `fusion_clock/models/clock_break_rule.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
class FusionClockBreakRule(models.Model):
_name = 'fusion.clock.break.rule'
_description = 'Statutory Break Rule'
_order = 'sequence, name'
name = fields.Char(string='Name', required=True)
country_id = fields.Many2one('res.country', string='Country')
state_id = fields.Many2one(
'res.country.state',
string='Province / State',
help="Employees whose company is in this province use this rule.",
)
is_default = fields.Boolean(
string='Default Rule',
help="Used when an employee's company province matches no other rule. "
"Only one active rule may be the default.",
)
break1_after_hours = fields.Float(
string='First Break After (h)', default=5.0,
help="Worked hours at or above this trigger the first unpaid break.",
)
break1_minutes = fields.Float(
string='First Break (min)', default=30.0,
help="Length of the first unpaid break. 0 disables it.",
)
break2_after_hours = fields.Float(
string='Second Break After (h)', default=10.0,
help="Worked hours at or above this add the second unpaid break.",
)
break2_minutes = fields.Float(
string='Second Break (min)', default=30.0,
help="Length of the second unpaid break. 0 disables it.",
)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
def break_minutes_for(self, worked_hours):
"""Total statutory unpaid break (minutes) for the given worked hours.
Tiers are inclusive (``>=``): a break applies when worked hours are
equal to or greater than the threshold. The second tier adds on top of
the first.
"""
self.ensure_one()
worked = worked_hours or 0.0
total = 0.0
if self.break1_minutes and worked >= self.break1_after_hours:
total += self.break1_minutes
if self.break2_minutes and worked >= self.break2_after_hours:
total += self.break2_minutes
return total
@api.constrains('break1_after_hours', 'break1_minutes',
'break2_after_hours', 'break2_minutes')
def _check_tiers(self):
for rule in self:
if min(rule.break1_after_hours, rule.break1_minutes,
rule.break2_after_hours, rule.break2_minutes) < 0:
raise ValidationError(_("Break hours and minutes cannot be negative."))
if rule.break2_minutes and rule.break2_after_hours <= rule.break1_after_hours:
raise ValidationError(_(
"The second break threshold (%(n2)s h) must be greater than "
"the first (%(n1)s h).",
n2=rule.break2_after_hours, n1=rule.break1_after_hours))
@api.constrains('is_default', 'active')
def _check_single_default(self):
for rule in self:
if rule.is_default and rule.active:
dupe = self.search([
('is_default', '=', True), ('active', '=', True),
('id', '!=', rule.id),
], limit=1)
if dupe:
raise ValidationError(_(
"Only one active break rule can be the default "
"(currently: %s).", dupe.name))
```
- [ ] **Step 3: Register the model** — add to `fusion_clock/models/__init__.py` after the `clock_penalty` import:
```python
from . import clock_break_rule
```
- [ ] **Step 4: Grant access** — append one row to `fusion_clock/security/ir.model.access.csv`:
```
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
```
(No user/portal grant needed — the resolver reads the table via `sudo()`.)
- [ ] **Step 5: Seed the Ontario rule** — create `fusion_clock/data/clock_break_rule_data.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="break_rule_ontario" model="fusion.clock.break.rule">
<field name="name">Ontario</field>
<field name="country_id" ref="base.ca"/>
<field name="state_id" ref="base.state_ca_on"/>
<field name="is_default" eval="True"/>
<field name="break1_after_hours">5.0</field>
<field name="break1_minutes">30.0</field>
<field name="break2_after_hours">10.0</field>
<field name="break2_minutes">30.0</field>
</record>
</odoo>
```
- [ ] **Step 6: Views + action** — create `fusion_clock/views/clock_break_rule_views.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_clock_break_rule_list" model="ir.ui.view">
<field name="name">fusion.clock.break.rule.list</field>
<field name="model">fusion.clock.break.rule</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="state_id"/>
<field name="country_id" optional="hide"/>
<field name="break1_after_hours" widget="float_time"/>
<field name="break1_minutes"/>
<field name="break2_after_hours" widget="float_time"/>
<field name="break2_minutes"/>
<field name="is_default"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fusion_clock_break_rule_form" model="ir.ui.view">
<field name="name">fusion.clock.break.rule.form</field>
<field name="model">fusion.clock.break.rule</field>
<field name="arch" type="xml">
<form>
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
invisible="active"/>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Ontario"/></h1>
</div>
<group>
<group string="Jurisdiction">
<field name="country_id"/>
<field name="state_id"
domain="[('country_id', '=', country_id)]"/>
<field name="is_default"/>
<field name="active"/>
</group>
<group string="Unpaid Break Tiers">
<label for="break1_after_hours" string="First break after"/>
<div class="o_row">
<field name="break1_after_hours" widget="float_time"/>
<span>h →</span>
<field name="break1_minutes"/>
<span>min</span>
</div>
<label for="break2_after_hours" string="Second break after"/>
<div class="o_row">
<field name="break2_after_hours" widget="float_time"/>
<span>h →</span>
<field name="break2_minutes"/>
<span>min</span>
</div>
</group>
</group>
<p class="text-muted">
Breaks are unpaid and deducted from actual worked hours. A tier with
0 minutes is disabled. Triggers are inclusive — a break applies when
worked hours are equal to or above the threshold.
</p>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_clock_break_rule" model="ir.actions.act_window">
<field name="name">Break Rules</field>
<field name="res_model">fusion.clock.break.rule</field>
<field name="view_mode">list,form</field>
<field name="context">{'active_test': False}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">Create a statutory break rule</p>
<p>Define unpaid meal-break thresholds per province/country. Employees inherit
the rule matching their company's province, or the default rule.</p>
</field>
</record>
</odoo>
```
- [ ] **Step 7: Add the menu** — in `fusion_clock/views/clock_menus.xml`, insert after the `menu_fusion_clock_locations_config` menuitem (the Locations config item) and before `menu_fusion_clock_nfc_enrollment`:
```xml
<menuitem id="menu_fusion_clock_break_rules"
name="Break Rules"
parent="menu_fusion_clock_config"
action="action_fusion_clock_break_rule"
sequence="25"
groups="group_fusion_clock_manager"/>
```
- [ ] **Step 8: Wire the manifest** — in `fusion_clock/__manifest__.py`:
**Do NOT bump the version yet** — it stays `19.0.4.0.3` until Task 4, so the
`19.0.4.1.0` migration actually fires in dev (Odoo only runs a version's migration
when the installed version is *lower* than the manifest version).
Add the seed data file after `'data/ir_config_parameter_data.xml',`:
```python
'data/clock_break_rule_data.xml',
```
Add the view file after `'views/clock_schedule_views.xml',`:
```python
'views/clock_break_rule_views.xml',
```
(Data and view files reload on every `-u` regardless of the version number, so the
new model/menu install without a bump. No assets change in this plan, so the bump's
only purpose is the migration trigger — deferred to Task 4.)
- [ ] **Step 9: Sync, upgrade, run tests**
Sync (see preamble), then:
```bash
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 -100
```
Expected: module upgrades cleanly; `test_break_minutes_for_tiers`, `test_second_tier_must_exceed_first`, `test_single_default_enforced` PASS. (Other tests in the class will error until Tasks 23 add their dependencies — that's expected if you scoped the run; otherwise the not-yet-added methods simply don't exist yet.)
- [ ] **Step 10: Commit**
```bash
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/clock_break_rule.py fusion_clock/models/__init__.py fusion_clock/data/clock_break_rule_data.xml fusion_clock/views/clock_break_rule_views.xml fusion_clock/views/clock_menus.xml fusion_clock/security/ir.model.access.csv fusion_clock/__manifest__.py fusion_clock/tests/test_break_rules.py fusion_clock/tests/__init__.py
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): add fusion.clock.break.rule per-province break table" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
```
---
## Task 2: Jurisdiction resolver on `hr.employee`
**Files:**
- Modify: `fusion_clock/models/hr_employee.py`
- Modify: `fusion_clock/tests/test_break_rules.py`
- [ ] **Step 1: Add the resolver tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`:
```python
# ---- Task 2: jurisdiction resolver ----
def test_resolver_matches_company_province(self):
bc = self.env.ref('base.state_ca_bc')
bc_rule = self.Rule.create({
'name': 'British Columbia', 'state_id': bc.id, 'is_default': False,
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
})
self.employee.company_id.state_id = bc.id
self.assertEqual(self.employee._get_fclk_break_rule(), bc_rule)
def test_resolver_falls_back_to_default(self):
self.assertTrue(self.default_rule, "seed default rule must exist")
alberta = self.env.ref('base.state_ca_ab') # no rule for AB
self.employee.company_id.state_id = alberta.id
self.assertEqual(self.employee._get_fclk_break_rule(), self.default_rule)
```
- [ ] **Step 2: Run to verify they fail**
Sync, then:
```bash
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
```
Expected: FAIL — `AttributeError: 'hr.employee' object has no attribute '_get_fclk_break_rule'`.
- [ ] **Step 3: Implement the resolver** — in `fusion_clock/models/hr_employee.py`, add this method immediately after the `_get_fclk_break_minutes` method (after its `return float(...)` block, before `_get_fclk_scheduled_times`):
```python
def _get_fclk_break_rule(self):
"""Return the statutory break rule for this employee.
Resolution: company's province → matching rule; else the global default
rule; else an empty recordset (caller treats as zero break). Read via
sudo so the portal net-hours compute can resolve it without a direct ACL.
"""
self.ensure_one()
Rule = self.env['fusion.clock.break.rule'].sudo()
rule = Rule.browse()
state = self.company_id.state_id
if state:
rule = Rule.search([('state_id', '=', state.id)], limit=1)
if not rule:
rule = Rule.search([('is_default', '=', True)], limit=1)
return rule
```
- [ ] **Step 4: Run to verify they pass**
Sync, then re-run the Step 2 command. Expected: `test_resolver_matches_company_province` and `test_resolver_falls_back_to_default` PASS.
- [ ] **Step 5: Commit**
```bash
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_employee.py fusion_clock/tests/test_break_rules.py
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): resolve employee break rule from company province" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
```
---
## Task 3: `x_fclk_break_minutes` → stored compute; remove all manual writes
This task is atomic: once the field is computed (no inverse), any remaining `write({'x_fclk_break_minutes': ...})` raises at runtime, so the field conversion and the removal of all four write sites must land together.
**Files:**
- Modify: `fusion_clock/models/hr_attendance.py`
- Modify: `fusion_clock/controllers/clock_api.py`
- Modify: `fusion_clock/controllers/clock_kiosk.py`
- Modify: `fusion_clock/controllers/clock_nfc_kiosk.py`
- Modify: `fusion_clock/tests/test_break_rules.py`
- [ ] **Step 1: Add the attendance tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`:
```python
# ---- Task 3: automatic deduction on every path ----
def test_manual_attendance_applies_statutory_break(self):
att = self._mk_att(6) # 6h >= 5 -> first break
self.assertEqual(att.x_fclk_break_minutes, 30.0)
self.assertAlmostEqual(att.x_fclk_net_hours, 5.5, places=2)
def test_manual_edit_extends_break(self):
att = self._mk_att(6)
self.assertEqual(att.x_fclk_break_minutes, 30.0)
att.check_out = att.check_in + timedelta(hours=10) # now >= 10
self.assertEqual(att.x_fclk_break_minutes, 60.0)
self.assertAlmostEqual(att.x_fclk_net_hours, 9.0, places=2)
def test_under_first_threshold_no_break(self):
att = self._mk_att(4) # 4h < 5 -> nothing
self.assertEqual(att.x_fclk_break_minutes, 0.0)
self.assertAlmostEqual(att.x_fclk_net_hours, 4.0, places=2)
def test_penalty_minutes_are_additive(self):
att = self._mk_att(6) # statutory 30
self.env['fusion.clock.penalty'].create({
'attendance_id': att.id,
'employee_id': self.employee.id,
'penalty_type': 'early_out',
'penalty_minutes': 15.0,
'date': att.check_in.date(),
})
self.assertEqual(att.x_fclk_break_minutes, 45.0)
def test_master_toggle_off_zero_statutory(self):
self.ICP.set_param('fusion_clock.auto_deduct_break', 'False')
att = self._mk_att(6)
self.assertEqual(att.x_fclk_break_minutes, 0.0)
def test_open_attendance_zero_break(self):
att = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2026, 1, 5, 9, 0, 0),
})
self.assertEqual(att.x_fclk_break_minutes, 0.0)
```
- [ ] **Step 2: Run to verify they fail**
Sync, then run the module tests. Expected: the new tests FAIL — e.g. `test_manual_attendance_applies_statutory_break` asserts 30 but gets 0 (no write override exists yet).
- [ ] **Step 3: Convert the field to a stored compute** — in `fusion_clock/models/hr_attendance.py`, replace the field definition:
OLD:
```python
x_fclk_break_minutes = fields.Float(
string='Break (min)',
default=0.0,
tracking=True,
help="Break duration in minutes to deduct from worked hours.",
)
```
NEW:
```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 (per the "
"employee's province rule, from actual hours worked) plus any penalty "
"minutes. Computed automatically on every save.",
)
```
- [ ] **Step 4: Add the compute method** — in the same file, insert this method immediately before the `_compute_net_hours` method (just above its `@api.depends('worked_hours', 'x_fclk_break_minutes')` decorator):
```python
@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
```
- [ ] **Step 5: Remove the cron's break write** — in the same file, inside `_cron_fusion_auto_clock_out`:
Remove the now-unused threshold read (the line near the top of the method):
```python
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
```
Remove the two now-unused locals in the per-attendance loop:
```python
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
```
Remove the break-write block (the compute now applies the break when `check_out` is set):
```python
if (att.worked_hours or 0) >= threshold:
att.sudo().write(
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
)
```
(Leave the surrounding `employee = att.employee_id` and `clock_out_time = effective_deadline` lines intact.)
- [ ] **Step 6: Delete the controller helper and its call sites** — in `fusion_clock/controllers/clock_api.py`:
Delete the entire `_apply_break_deduction` method:
```python
def _apply_break_deduction(self, attendance, employee):
"""Apply automatic break deduction if configured."""
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
return
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
worked = attendance.worked_hours or 0.0
if worked >= threshold:
local_date = get_local_today(request.env, employee)
if attendance.check_in:
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
break_min = employee._get_fclk_break_minutes(local_date)
current = attendance.x_fclk_break_minutes or 0.0
# Set to whichever is higher: configured break or existing (penalty-inflated) value
new_val = max(break_min, current)
if new_val != current:
attendance.sudo().write({'x_fclk_break_minutes': new_val})
```
Delete its clock-out call (in the CLOCK OUT branch):
```python
# Apply break deduction
self._apply_break_deduction(attendance, employee)
```
Delete the penalty break-write in `_check_and_create_penalty` (keep the penalty-record `create` above it and the activity log below it):
```python
# Deduct penalty minutes from attendance (adds to break deduction)
current_break = attendance.x_fclk_break_minutes or 0.0
attendance.sudo().write({
'x_fclk_break_minutes': current_break + deduction,
})
```
- [ ] **Step 7: Delete the kiosk call sites**
In `fusion_clock/controllers/clock_kiosk.py`, delete the line:
```python
api._apply_break_deduction(attendance, employee)
```
In `fusion_clock/controllers/clock_nfc_kiosk.py`, delete the line:
```python
api._apply_break_deduction(attendance, employee)
```
- [ ] **Step 8: Pyflakes the touched controllers/models** (catches a missed `pytz`/var reference instantly)
```bash
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/controllers/clock_api.py /mnt/extra-addons/fusion_clock/controllers/clock_kiosk.py /mnt/extra-addons/fusion_clock/controllers/clock_nfc_kiosk.py /mnt/extra-addons/fusion_clock/models/hr_attendance.py
```
Expected: no output (clean). If it flags `pytz` as unused in `hr_attendance.py`, that's fine only if no other code uses it — verify before removing the import (the absence/overtime crons still use `pytz`, so leave the import).
- [ ] **Step 9: Run to verify all Task 3 tests pass**
Sync, then run the module tests. Expected: all `test_manual_*`, `test_under_first_threshold_no_break`, `test_penalty_minutes_are_additive`, `test_master_toggle_off_zero_statutory`, `test_open_attendance_zero_break` PASS, and the existing NFC/kiosk/dashboard tests still PASS.
- [ ] **Step 10: Commit**
```bash
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_attendance.py fusion_clock/controllers/clock_api.py fusion_clock/controllers/clock_kiosk.py fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_break_rules.py
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): auto-apply statutory break via one stored compute" -m "x_fclk_break_minutes is now statutory(worked_hours) + penalties, recomputed on every path including manual backend entry. Removes the four duplicated write sites (controller _apply_break_deduction + 3 call sites, auto-clock-out cron, penalty write)." -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
```
---
## Task 4: Retire `break_threshold_hours`; clean settings & migrate
**Files:**
- Modify: `fusion_clock/models/res_config_settings.py`
- Modify: `fusion_clock/views/res_config_settings_views.xml`
- Modify: `fusion_clock/data/ir_config_parameter_data.xml`
- Create: `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`
- Modify: `fusion_clock/tests/test_settings.py`
- [ ] **Step 1: Add the dead-setting assertion** — in `fusion_clock/tests/test_settings.py`, add one line to `test_dead_settings_removed`:
```python
self.assertNotIn('fclk_break_threshold_hours', fields)
```
- [ ] **Step 2: Remove the settings field** — in `fusion_clock/models/res_config_settings.py`, delete:
```python
fclk_break_threshold_hours = fields.Float(
string='Break Threshold (hours)',
config_parameter='fusion_clock.break_threshold_hours',
default=4.0,
help="Only deduct break if shift is longer than this many hours.",
)
```
- [ ] **Step 3: Fix the settings view** — in `fusion_clock/views/res_config_settings_views.xml`, replace the whole `fclk_auto_break` setting block:
OLD:
```xml
<setting id="fclk_auto_break" string="Auto-Deduct Break"
help="Automatically deduct unpaid break from worked hours on clock-out.">
<field name="fclk_auto_deduct_break"/>
<div class="content-group" invisible="not fclk_auto_deduct_break">
<div class="row mt16">
<label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_default_break_minutes"/>
</div>
<div class="row mt8">
<label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/>
<field name="fclk_break_threshold_hours" widget="float_time"/>
</div>
</div>
</setting>
```
NEW:
```xml
<setting id="fclk_auto_break" string="Auto-Deduct Break"
help="Automatically deduct the statutory unpaid break from worked hours. Break lengths and thresholds are configured per province under Configuration → Break Rules.">
<field name="fclk_auto_deduct_break"/>
<div class="content-group" invisible="not fclk_auto_deduct_break">
<div class="row mt16">
<label for="fclk_default_break_minutes" string="Default scheduling break (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_default_break_minutes"/>
</div>
<div class="text-muted small mt4">
Used as the default break when building shifts/schedules
(planned hours). Actual deductions follow the province Break Rules.
</div>
</div>
</setting>
```
- [ ] **Step 4: Remove the seed param** — in `fusion_clock/data/ir_config_parameter_data.xml`, delete:
```xml
<record id="config_break_threshold_hours" model="ir.config_parameter">
<field name="key">fusion_clock.break_threshold_hours</field>
<field name="value">4.0</field>
</record>
```
- [ ] **Step 5: Bump the version + create the migration**
First bump the manifest so the migration fires (installed `19.0.4.0.3` < manifest
`19.0.4.1.0`). In `fusion_clock/__manifest__.py`:
```python
'version': '19.0.4.1.0',
```
Then create `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, SUPERUSER_ID
def migrate(cr, version):
"""Retire the single-threshold break param (superseded by per-rule
break1_after_hours), and force-recompute the now-computed break field so
existing closed attendances reflect the province rule + their penalties."""
cr.execute(
"DELETE FROM ir_config_parameter WHERE key = %s",
('fusion_clock.break_threshold_hours',),
)
env = api.Environment(cr, SUPERUSER_ID, {})
Attendance = env['hr.attendance']
field = Attendance._fields['x_fclk_break_minutes']
closed = Attendance.search([('check_out', '!=', False)])
if closed:
env.add_to_compute(field, closed)
closed.flush_recordset(['x_fclk_break_minutes'])
```
- [ ] **Step 6: Sync, upgrade, run tests**
Sync, then run the module tests. Expected: module upgrades cleanly and the `19.0.4.1.0` migration executes (installed `19.0.4.0.3` < manifest `19.0.4.1.0`; modsdev shows the INFO line, nexa/entech run `log_level=warn`), `test_dead_settings_removed` PASS, full `fusion_clock` suite green.
- [ ] **Step 7: Verify the param is gone and historical rows recomputed** (sanity)
```bash
docker exec odoo-modsdev-app odoo shell -d modsdev --no-http 2>/dev/null <<'PY'
ICP = env['ir.config_parameter'].sudo()
print('threshold param:', ICP.get_param('fusion_clock.break_threshold_hours', 'ABSENT'))
print('default rule:', env['fusion.clock.break.rule'].search([('is_default','=',True)]).mapped('name'))
PY
```
Expected: `threshold param: ABSENT`; `default rule: ['Ontario']`.
- [ ] **Step 8: Commit**
```bash
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml fusion_clock/data/ir_config_parameter_data.xml fusion_clock/migrations/19.0.4.1.0/post-migrate.py fusion_clock/tests/test_settings.py fusion_clock/__manifest__.py
git -C "K:/Github/Odoo-Modules" commit -m "refactor(fusion_clock): retire break_threshold_hours; breaks now driven by Break Rules" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
```
---
## Task 5: Full verification, docs, manual smoke
**Files:**
- Modify: `fusion_clock/CLAUDE.md`
- [ ] **Step 1: Full test run (whole module)**
Sync, then:
```bash
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 -120
```
Expected: all `fusion_clock` tests PASS, zero tracebacks. If anything fails, fix before continuing.
- [ ] **Step 2: Manual smoke (manager UI)** at http://localhost:8082
- Configuration → **Break Rules** exists; the **Ontario** row shows 5h→30 / 10h→30, Default ticked.
- Attendances → create a manual attendance, check-in 09:00 check-out 15:00 (6h) → **Break = 30**, Net = 5.5h, with no clock action.
- Edit that record's check-out to 19:00 (10h) → **Break = 60**, Net = 9.0h.
- Create a 4h attendance → **Break = 0**.
- Settings → the old "Min. Shift" threshold field is gone; the Auto-Deduct Break help points to Break Rules.
- [ ] **Step 3: Update the module CLAUDE.md** — in `fusion_clock/CLAUDE.md`:
- §4 Model Map: add a row — `fusion.clock.break.rule | models/clock_break_rule.py | Per-province statutory unpaid-break thresholds (2-tier).`
- §5 Clocking Flow: note that the break deduction is no longer a controller step — `x_fclk_break_minutes` is a stored compute (`statutory(worked_hours) + Σ penalties`) that fires on every path including manual backend entry; resolved rule via `hr.employee._get_fclk_break_rule()` (company province → default).
- §11 Settings Keys: remove `fusion_clock.break_threshold_hours`.
- §13 Gotchas: add — "Unpaid break is computed, not written: never `write({'x_fclk_break_minutes': ...})`; change the province rule (`fusion.clock.break.rule`) or `auto_deduct_break` instead. Penalty minutes are now strictly additive (the old `max()` that swallowed late-in penalties is gone)."
- Bump the version line in §1 to `19.0.4.1.0`.
- [ ] **Step 4: Commit the docs**
```bash
git -C "K:/Github/Odoo-Modules" add fusion_clock/CLAUDE.md
git -C "K:/Github/Odoo-Modules" commit -m "docs(fusion_clock): document province break rules + computed break field" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git -C "K:/Github/Odoo-Modules" push
```
- [ ] **Step 5: Report** — summarize what changed, the behaviour-change note (penalties now additive), and that live deployment to entech (`odoo-entech`) is a separate step pending user sign-off.
---
## Self-Review (performed against the spec)
**1. Spec coverage**
- §4.1 model → Task 1. §4.2 resolver → Task 2. §4.3 stored compute → Task 3. §4.4 removals → Task 3 (writes) + Task 4 (setting/param/view). §4.5 UI/security/data → Task 1 (+ settings view in Task 4). §5 edge cases → tests in Tasks 1 & 3. §6 migration → Task 4. §7 tests → all six+ cases present across Tasks 13. §8 rollout → preamble + Task 5. ✓ No gaps.
**2. Placeholder scan** — every step has full code/commands; no TBD/TODO/"similar to". ✓
**3. Type/name consistency** — `break_minutes_for`, `_get_fclk_break_rule`, `_compute_fclk_break_minutes`, fields `break1_after_hours/break1_minutes/break2_after_hours/break2_minutes/is_default`, model `fusion.clock.break.rule`, access id `model_fusion_clock_break_rule`, action `action_fusion_clock_break_rule`, menu `menu_fusion_clock_break_rules` — all used identically across tasks. The compute folds `Σ penalty_minutes` (field `penalty_minutes` on `fusion.clock.penalty`, confirmed). ✓