docs(fusion_clock): implementation plan for province-aware auto break
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,854 @@
|
||||
# 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`:
|
||||
|
||||
Change the version:
|
||||
```python
|
||||
'version': '19.0.4.1.0',
|
||||
```
|
||||
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',
|
||||
```
|
||||
|
||||
- [ ] **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 2–3 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: Create the migration** — `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 (migration runs — note nexa/entech run `log_level=warn`, but modsdev shows INFO), `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
|
||||
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 1–3. §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). ✓
|
||||
Reference in New Issue
Block a user