From 9574fa0ae4e136bd3d6950df72a6fb1d4b320404 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 20:26:28 -0400 Subject: [PATCH 01/12] =?UTF-8?q?docs(fusion=5Fclock):=20design=20spec=20?= =?UTF-8?q?=E2=80=94=20remove=20Odoo=20Planning=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-fit Planning's role + recurrence + send onto the native per-day fusion.clock.schedule model; retire fusion_planning into fusion_clock; make the family Community-installable. Full feature-parity matrix, data migration, and gated Entech rollout included. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...06-04-remove-planning-dependency-design.md | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 fusion_clock/docs/superpowers/specs/2026-06-04-remove-planning-dependency-design.md diff --git a/fusion_clock/docs/superpowers/specs/2026-06-04-remove-planning-dependency-design.md b/fusion_clock/docs/superpowers/specs/2026-06-04-remove-planning-dependency-design.md new file mode 100644 index 00000000..9c52df75 --- /dev/null +++ b/fusion_clock/docs/superpowers/specs/2026-06-04-remove-planning-dependency-design.md @@ -0,0 +1,366 @@ +# Remove the Odoo Planning dependency from the Fusion Clock family + +**Date:** 2026-06-04 +**Module:** `fusion_clock` (absorbs `fusion_planning`, which is retired) +**Status:** Design — awaiting spec review + +--- + +## 1. Goal + +Make the Fusion Clock product family **fully Community-installable** (no Odoo +Enterprise `planning` dependency) **and** simplify the Entech deployment, while +**preserving every scheduling capability** currently delivered through Odoo +Planning. No feature is removed; the work is sequenced, not trimmed. + +Driver (confirmed): **Both** — ship to clients without Enterprise *and* cut the +barely-used Planning Gantt out of Entech. + +## 2. Current state (verified) + +`fusion_clock` itself does **not** depend on `planning`. Its deps are clean: +`hr_attendance, hr, portal, mail, resource`. The entire Odoo-`planning` coupling +lives in **one bridge module, `fusion_planning`**: + +| Coupling point | Where | +|---|---| +| `depends: ['planning']` | `fusion_planning/__manifest__.py` | +| `_inherit = 'planning.slot'` (+ `x_fc_additional_resource_ids`, auto-publish `create()`) | `fusion_planning/models/planning_slot.py` | +| inherits `planning.planning_view_form`, uses `planning.group_planning_manager` | `fusion_planning/views/planning_slot_views.xml` | +| uses `hr.employee.default_planning_role_id` / `planning_role_ids`, menu under `planning.planning_menu_settings` | `fusion_planning/views/hr_employee_role_views.xml` | +| reads `planning.slot` for the portal Schedule tab (already merges with native schedule) | `fusion_planning/controllers/portal_schedule.py` | +| the Planning **Gantt** backend UI (`web_gantt`, Enterprise) | Odoo Planning | + +**Live Entech data (LXC 111, DB `admin`, Enterprise):** + +| Table | Rows | +|---|---| +| `planning.slot` | **8** (7 published) | +| `planning.role` | **1** | +| `fusion.clock.schedule` (native per-day planner) | **144** | +| `fusion.clock.shift` (native templates) | 6 | + +The **native** per-day planner (`fusion.clock.schedule` + the OWL shift planner) +is the real workhorse. Odoo Planning is essentially vestigial here. + +No other module in the repo references `planning` or `fusion_planning` (the one +grep hit in `fusion_plating` is the English word "Planning" in a selection — noise). + +## 3. Decision & rationale + +**Chosen approach: re-fit Planning's *logic* onto the native per-day model +(`fusion.clock.schedule`), extended to full feature parity. Retire +`fusion_planning` by folding everything into `fusion_clock`.** + +Rejected alternative: *vendor `planning.slot` + `planning.recurrency` + +`planning.planning` wholesale*. Why rejected: + +1. **The Gantt can't come to Community anyway.** `web_gantt` is Enterprise. + Vendoring `planning.slot`'s datetime model still leaves us building a + non-Gantt UI — so wholesale vendoring buys the heavy data model but not the UI. +2. **Bugs live in the attendance pipeline, not the recurrence engine.** + fusion_clock's penalties (money), overtime, absence detection, reminders, + portal and dashboard all read one contract: `hr.employee._get_fclk_day_plan()` + off `fusion.clock.schedule`. Option 1 leaves that pipeline's data source + **untouched** and confines new code to isolated, testable features. Wholesale + vendoring forces either a dual schedule model or a rewire of that + money-critical pipeline onto Planning's datetime model **plus** a migration of + the 144 live rows — the highest-risk change possible, in exactly the code we + most need to keep correct. +3. **Reuse is still honoured.** We copy the parts that copy cleanly + (`planning.role` near-verbatim, the recurrence **field design + repeat + semantics**, the **mail templates**) and re-fit only the generation loop — + which is *less* code on the per-day model because it drops the + resource-interval/DST math. + +**Two honest deltas vs Odoo Planning (only these):** + +- **The Gantt drag-drop board** → replaced by the native weekly OWL planner. + Capability preserved, UX differs. (Accepted by owner.) Drag-drop is a possible + future enhancement, out of scope here. +- **Full resource-calendar-aware generation.** Planning's recurrence consults + resource work-intervals, flexible-resource flags and contract-end dates when + generating. The native re-fit uses the employee weekday pattern and skips + approved-leave days. This covers the real case; the heavy resource-calendar + engine is overkill at Entech's scale (8 slots). Documented simplification. + +## 4. End-state architecture + +- **`fusion_clock`** becomes self-contained and Community-installable. It owns: + native scheduling (existing), **roles**, **recurrence**, **publish/notify + (send)**, the **portal Schedule tab**, and **open / multi / overnight shift** + support. Manifest deps unchanged (`hr_attendance, hr, portal, mail, resource`) + — crucially **no `planning`**. +- **`fusion_planning`** is **retired** — its functionality is folded into + `fusion_clock`, then it is uninstalled on Entech. +- The attendance automation contract (`_get_fclk_day_plan`) is **unchanged** in + shape; new schedule capabilities resolve into the same single per-day + work-window it already returns (see §5.4). + +## 5. Detailed design + +### 5.1 New model: `fusion.clock.role` (copied from `planning.role`) + +Near-verbatim copy of `planning/models/planning_role.py`: + +- `name` — Char, required, translate +- `color` — Integer, default random 1–11 +- `active` — Boolean, default True +- `sequence` — Integer +- `company_id` — Many2one `res.company` (added for fusion multi-company + consistency; Planning's role had none) +- Copy `_get_color_from_code(is_open_shift)` → returns the fullcalendar-compatible + hex used to colour shifts on the portal Schedule tab. +- Drop `resource_ids` m2m and `slot_properties_definition` (unused here). + +### 5.2 `hr.employee` — native role fields + +- `x_fclk_default_role_id` — Many2one `fusion.clock.role` (fills new shifts) +- `x_fclk_role_ids` — Many2many `fusion.clock.role` (allowed roles) + +(Migrated from `default_planning_role_id` / `planning_role_ids`.) + +**Employee Roles editor** — port `fusion_planning/views/hr_employee_role_views.xml` +to the native fields; reparent the menu from `planning.planning_menu_settings` +to a fusion_clock config menu; gate with `group_fusion_clock_manager`. + +### 5.3 `fusion.clock.shift` (existing template) — additions + +- `role_id` — Many2one `fusion.clock.role` (a template can carry a default role) + +### 5.4 `fusion.clock.schedule` (existing per-day) — additions + +**Part A additions** (ship with the core dependency removal; the +`UNIQUE(employee_id, schedule_date)` one-shift/day constraint is **kept**): + +- `role_id` — Many2one `fusion.clock.role`; default from `shift_id.role_id` or + `employee_id.x_fclk_default_role_id`. Drives portal colour/label. +- `recurrence_id` — Many2one `fusion.clock.schedule.recurrence` (set when a rule + generated the row); `ondelete='set null'`. + +In Part A the attendance contract `_get_fclk_day_plan` is **completely +unchanged** (one posted row per employee per day, exactly as today). + +**Part B additions** (parity for currently-unused Planning capabilities; built +after A — see §10): + +- `is_open` — Boolean; an **open / unassigned** shift available for self-assign. +- `crosses_midnight` — Boolean (overnight support). +- **Constraint changes:** replace the hard `UNIQUE(employee_id, schedule_date)` + with a **partial unique** that still forbids accidental duplicate assigned + rows while allowing intentional multiple shifts/day. Exact predicate finalised + in the plan; use `models.Constraint` / `models.UniqueIndex` per Odoo-19 rules. + `employee_id` becomes **not required** *only* when `is_open = True` (enforced by + a Python `@api.constrains`). +- **Overnight:** relax `_check_schedule_times` to permit `end_time <= start_time` + as crossing midnight (set `crosses_midnight`); update `_compute_planned_hours` + (`(24 - start) + end - break`) and `_get_fclk_scheduled_times` (out datetime is + next day). + +**The attendance contract stays single-window in Part B too.** +`_get_fclk_day_plan(date)` still returns one plan per employee per day: + +- 0 assigned rows → not scheduled (unchanged). +- 1 assigned row → that row (unchanged). +- N assigned rows for the day → resolve to one work-window = earliest start → + latest end across that day's assigned shifts; break = sum of breaks. This keeps + penalties/overtime/absence math **unchanged in shape** while letting managers + schedule split shifts and employees see each shift on the portal. +- `is_open` rows never feed any employee's plan until self-assigned. + +This is the key safety property: **multi-shift / overnight / open-shift live in +the scheduling + UI + portal layers; the money-critical attendance layer keeps +its existing one-window contract.** + +### 5.5 New model: `fusion.clock.schedule.recurrence` (design copied from `planning.recurrency`) + +Fields (copied semantics): + +- `repeat_interval` — Integer, default 1, `CHECK(repeat_interval >= 1)` +- `repeat_unit` — Selection day/week/month/year, default week +- `repeat_type` — Selection forever/until/x_times, default forever +- `repeat_until` — Date (required when `repeat_type='until'`) +- `repeat_number` — Integer (`>= 0`) +- `last_generated_date` — Date, readonly +- `company_id` — Many2one res.company +- `schedule_ids` — One2many `fusion.clock.schedule` + +**Generation** (`_generate(stop_date=False)`), re-fit of `_repeat_slot` onto the +per-day model — much simpler (no resource-interval/DST math): + +- Seed = the schedule entry the rule was created from (employee, weekday, + start/end/break/role). +- Emit per-day `fusion.clock.schedule` rows at the cadence (`repeat_interval` × + `repeat_unit`) up to a horizon = `min(repeat_until, today + company.fclk_planning_generation_months, repeat_number cap)`. +- **Skip** dates the employee has an approved `fusion.clock.leave.request` + (the "resource-calendar-aware" simplification). +- Generated rows are created in **draft** (must be posted/published to drive + automation), carrying `recurrence_id`. +- Idempotent via `last_generated_date` (never regenerate past rows). +- `_stop(from_date)` deletes future **draft** rows of the rule (copy of + Planning's `_delete_slot`); posted rows are kept. + +**Cron** `_cron_generate_recurring_schedules` (copy of Planning's +`_cron_schedule_next` shape) rolls the horizon forward. Odoo-19: no `numbercall`; +`active=True` recurring cron. + +### 5.6 Manager UI — native OWL planner extensions + +The existing weekly planner (`fusion_clock_shift_planner.js/xml` + controller +`shift_planner.py`) gains, alongside its current toolbar (Prev/This/Next week, +Copy Previous Week, Export XLSX, Save, Post Schedule): + +- **Role** shown/edited per cell (colour chip from `role_id._get_color_from_code`). +- **Repeat…** control in the cell editor → creates a `fusion.clock.schedule.recurrence` + for that cell and generates rows; a backend list view manages/stops recurrences. +- **Publish & Notify** (generalises the existing `post_week`): pick a date range + (default current week) + optional employee subset + optional message → posts + matching draft rows and emails each affected employee their posted shifts for + the range (see §5.8). +- **Open shifts lane** + **bulk apply** ("Apply Also To" replacement): create an + open shift, or apply one cell's shift to several selected employees in one go. + +All planner endpoints stay gated by `group_fusion_clock_manager` (unchanged). + +### 5.7 Portal — fold the Schedule tab into `fusion_clock` + +- Move the controller `/my/clock/schedule` into `fusion_clock` + (`controllers/portal_clock.py` or a new `portal_schedule.py`), reading **only** + `fusion.clock.schedule` (drop the `planning.slot` branch). Role colour/label + come from `role_id`. +- Move the template `fusion_planning.portal_schedule_page` → + `fusion_clock.portal_schedule_page`. +- Add the **Schedule** nav button **inline** in each fusion_clock portal page's + `.fclk-nav-bar` (clock, timesheets, reports, payslips list, payslip detail), + replacing `fusion_planning`'s cross-module xpath inherits. Keep the + `.fclk-nav-bar` structure stable (no shared-template refactor — see the known + Odoo-19 xpath-inheritor gotcha). +- **Self-assign / unassign** of open shifts on the portal (respect a company + "days before shift" setting, mirroring Planning's `allow_self_unassign`). + +### 5.8 Mail templates (copied, reworded) + +Port Planning's "send schedule" templates (`planning/data/mail_template_data.xml`) +as the basis for fusion_clock's publish/notify email; reword for the Fusion +portal link. The native `fusion.clock.schedule.fclk_email_posted_week()` is +generalised to `fclk_email_posted_range(employee, start, end)`. +Follow Odoo-19 mail.template rules (no `url_encode` in QWeb; `ctx` is +`env.context`). + +### 5.9 Security & menus + +- `fusion.clock.role` + `fusion.clock.schedule.recurrence` → `ir.model.access.csv` + (manager write, user read as needed) + appropriate `ir.rule`s. +- Employee Roles editor menu + a Recurrences menu under the fusion_clock + configuration menu, gated `group_fusion_clock_manager`. + +## 6. Data migration & retirement + +### 6.1 Migration (in `fusion_clock`, guarded, idempotent) + +A post-migration step (new module version) that runs only where Planning data +exists (`if 'planning.role' in env` / `'planning.slot' in env`): + +1. **Roles:** each `planning.role` → find-or-create `fusion.clock.role` + (name + color). Build an id map. +2. **Employee roles:** `default_planning_role_id` → `x_fclk_default_role_id`; + `planning_role_ids` → `x_fclk_role_ids` (via the map). +3. **Slots:** each `planning.slot` → `fusion.clock.schedule`: + `resource_id`→employee, local date + local float start/end (employee tz), + break derived from span vs `allocated_hours`, `role_id` via map, + `state = posted` if published else draft. Unusual slots (overnight / multi / + open) handled by the §5.4 rules; anything unexpected is logged, not dropped. + Idempotent via a one-time `ir.config_parameter` marker. + +Volume is tiny (8 slots, 1 role) — fast and low-risk. + +### 6.2 Retire `fusion_planning` + +After `fusion_clock` provides the Schedule tab + roles + migration, **uninstall +`fusion_planning`** (its portal templates, nav xpath-inherits and the +`x_fc_additional_resource_ids` m2m are removed). fusion_clock now owns the +Schedule tab inline. **Optionally** uninstall `planning` / `web_gantt` afterwards +(separate, gated cleanup — destructive, so done last and only on sign-off). + +### 6.3 Community-install guarantee + +After the change, `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_clock` +must install on **Community** with no `planning` present (the migration is +guarded; no runtime code references `planning.*`). Add this as a smoke check. + +## 7. Entech rollout (gated, revert-on-failure) + +1. **Backup** DB + module dir (outside the addons path). +2. **Clone-verify**: clone `admin` → upgrade `fusion_clock` (+migration) on the + clone → assert: 144 native rows intact, 8 slots + 1 role migrated, roles + + recurrence + portal Schedule render, attendance/penalty tests green. +3. **Prod upgrade** `fusion_clock` (stop → `-u` → start **only if RC==0 + + "Modules loaded"**, else restore backup, no restart). Clear asset bundle + attachments; restart. +4. **Uninstall `fusion_planning`**. +5. **Optional**: uninstall `planning` / `web_gantt` (final, on sign-off). + +## 8. Feature-parity matrix + +| Planning feature | Preserved as | +|---|---| +| Assign shifts, weekly board | Native OWL planner (extended) | +| Gantt drag-drop timeline | ❗→ native weekly planner (Gantt can't be Community) | +| Shift templates | `fusion.clock.shift` (exists) + `role_id` | +| Roles + colour | `fusion.clock.role` (copied) + portal colour | +| Employee default/allowed roles | `x_fclk_default_role_id` / `x_fclk_role_ids` + editor | +| Recurrence (N day/week/month/year; forever/until/N-times) + cron | `fusion.clock.schedule.recurrence` (copied design) | +| Send / publish + email | Publish & Notify over a range (copied templates) | +| Multiple shifts/day | per-day model + single work-window contract (§5.4) | +| Overnight shifts | `crosses_midnight` (§5.4) | +| Open shifts + self-assign/unassign | `is_open` + portal self-assign | +| Auto-publish on create | native option (kept) | +| "Apply Also To" multi-employee | native bulk-apply | +| Allocated hours, portal My Schedule | `planned_hours`; Schedule tab folded in | +| Attendance/penalty/overtime/absence | **UNCHANGED** (per-day contract preserved) | +| Resource-calendar-aware generation | simplified: weekday pattern + skip leave | + +## 9. Testing strategy + +- **Unit:** role + colour; recurrence generation across each repeat_type/unit; + `_stop` deletes future drafts only; publish-range posts + emails; migration maps + roles/slots/employee-roles; overnight `planned_hours` + scheduled_times; + open-shift self-assign; multi-shift day-plan → correct single work-window. +- **Regression:** existing attendance / penalty / overtime / absence / dashboard + tests stay green (data source unchanged). +- **Community smoke:** install `fusion_clock` on Community `modsdev` (no planning). +- Odoo-19 test runner: `--http-port=0 --gevent-port=0`, `--test-tags /fusion_clock`. + +## 10. Sequencing + +**Decision: Part A and Part B ship together in one release** (full Planning +parity at once). The A/B labels below are **internal build phases** for the +implementation plan (so each gets its own review checkpoint), not separate +deployments. + +- **Phase A:** drop the planning dep with parity for everything in real use — + roles, recurrence, publish/notify, portal fold-in, migration, retire + `fusion_planning`. (Per-day model keeps one-shift/day here.) +- **Phase B:** the remaining Planning capabilities — multi-shift/day, overnight, + open shifts + self-assign, "Apply Also To" bulk — using the safe single-window + attendance contract; isolated from the attendance engine. + +Both phases are validated together before the single Entech rollout in §7. + +## 11. Risks & open questions + +- **Recurrence correctness** is new code — mitigated by isolation + unit tests + across every repeat_type/unit and the idempotent `last_generated_date` guard. +- **Multi-shift day-plan resolution** (§5.4) is the subtlest change; covered by a + dedicated test asserting the work-window and that penalties are unaffected. +- **Licensing:** the role model is generic, the recurrence loop is re-fit/original, + and mail templates are reworded — so near-verbatim Enterprise code is minimal. + Flag for the resale build; owner's call. +- **Resolved:** Parts A and B ship together in one release (§10). + +## 12. Out of scope + +- Drag-and-drop on the native planner (future enhancement). +- Full resource working-interval / flexible-resource / contract-end recurrence + math (deliberate simplification, §3). +- Uninstalling `planning` from Entech is optional and gated separately. From 023fc95acddaed6fc0c254aa78fdbd7de99b4651 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 20:34:52 -0400 Subject: [PATCH 02/12] =?UTF-8?q?docs(fusion=5Fclock):=20implementation=20?= =?UTF-8?q?plan=20=E2=80=94=20remove=20Planning=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-04-remove-planning-dependency.md | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 fusion_clock/docs/superpowers/plans/2026-06-04-remove-planning-dependency.md diff --git a/fusion_clock/docs/superpowers/plans/2026-06-04-remove-planning-dependency.md b/fusion_clock/docs/superpowers/plans/2026-06-04-remove-planning-dependency.md new file mode 100644 index 00000000..e580cc8f --- /dev/null +++ b/fusion_clock/docs/superpowers/plans/2026-06-04-remove-planning-dependency.md @@ -0,0 +1,300 @@ +# Remove Odoo Planning Dependency — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use `- [ ]`. + +**Goal:** Make `fusion_clock` fully Community-installable by re-fitting Odoo Planning's role + recurrence + send onto the native per-day `fusion.clock.schedule`, folding `fusion_planning` in, and retiring it — with full Planning feature parity. + +**Architecture:** All new code lands in `fusion_clock` (deps stay `hr_attendance, hr, portal, mail, resource` — no `planning`). New native models `fusion.clock.role` and `fusion.clock.schedule.recurrence`; additive fields on `fusion.clock.schedule` / `fusion.clock.shift` / `hr.employee`. The attendance contract `_get_fclk_day_plan` keeps its one-window-per-day shape (multi/overnight/open resolve into it). A guarded, idempotent post-migration ports the live planning data, then `fusion_planning` is uninstalled. + +**Tech Stack:** Odoo 19, Python, OWL, QWeb, xlsxwriter. Verify on Entech LXC 111 clone; deploy gated revert-on-failure. + +**Reference (read before coding each piece):** Enterprise Planning source on Entech at `/mnt/extra-addons/_dependencies/planning/` (`models/planning_role.py`, `models/planning_recurrency.py`, `models/planning_planning.py`, `data/mail_template_data.xml`). Spec: `fusion_clock/docs/superpowers/specs/2026-06-04-remove-planning-dependency-design.md`. + +--- + +## File map + +**Create:** +- `fusion_clock/models/clock_role.py` — `fusion.clock.role` +- `fusion_clock/models/clock_recurrence.py` — `fusion.clock.schedule.recurrence` + generation engine +- `fusion_clock/views/clock_role_views.xml` — role list/form/action/menu + Employee Roles editor +- `fusion_clock/views/clock_recurrence_views.xml` — recurrence list/form/action/menu +- `fusion_clock/views/portal_schedule_templates.xml` — portal Schedule tab (folded from fusion_planning) +- `fusion_clock/controllers/portal_schedule.py` — `/my/clock/schedule` (+ self-assign endpoints) +- `fusion_clock/static/src/css/portal_schedule.css` — folded from fusion_planning +- `fusion_clock/data/clock_recurrence_cron.xml` — recurrence generation cron +- `fusion_clock/tests/test_role.py`, `test_recurrence.py`, `test_publish_range.py`, `test_open_shift.py`, `test_overnight.py`, `test_multishift_window.py`, `test_planning_migration.py` +- `fusion_clock/migrations/19.0.5.0.0/post-migrate.py` — planning→native data migration + +**Modify:** +- `fusion_clock/models/__init__.py` — register `clock_role`, `clock_recurrence` +- `fusion_clock/models/hr_employee.py` — `x_fclk_default_role_id`, `x_fclk_role_ids`; `_get_fclk_day_plan` multi-window +- `fusion_clock/models/clock_shift.py` — `role_id` +- `fusion_clock/models/clock_schedule.py` — `role_id`, `recurrence_id`, `is_open`, `crosses_midnight`; overnight math; constraint relax; `fclk_email_posted_range`; recurrence helpers +- `fusion_clock/models/res_config_settings.py` + `res_company.py` — `fclk_planning_generation_months`, `fclk_self_unassign_days_before` +- `fusion_clock/controllers/shift_planner.py` — recurrence, publish-range, open-shift, bulk-apply endpoints +- `fusion_clock/static/src/js/fusion_clock_shift_planner.js` + `.xml` — role chip, Repeat dialog, Publish&Notify, open lane, bulk apply +- `fusion_clock/views/clock_shift_views.xml`, `clock_schedule_views.xml` — role fields; recurrence/open columns +- `fusion_clock/views/portal_*_templates.xml` (clock, timesheets, reports, payslip list+detail) — inline Schedule nav button +- `fusion_clock/data/mail_template_data.xml` — schedule publish email +- `fusion_clock/security/ir.model.access.csv` — role + recurrence ACLs +- `fusion_clock/views/clock_menus.xml` — Roles + Recurrences config menus +- `fusion_clock/__manifest__.py` — version `19.0.5.0.0`; new data/asset files + +**Retire (on deploy):** uninstall `fusion_planning`; optional uninstall `planning`. + +--- + +## PHASE A — Core parity (roles, recurrence, send, portal, migration) + +### Task A1: `fusion.clock.role` model +**Files:** Create `models/clock_role.py`; Modify `models/__init__.py`, `security/ir.model.access.csv`. + +- [ ] Write `test_role.py`: create role, default color in 1..11, `_get_color_from_code(False)` returns `#`-hex. +- [ ] Implement (copied from `planning_role.py`, trimmed): +```python +from random import randint +from odoo import fields, models + +class FusionClockRole(models.Model): + _name = 'fusion.clock.role' + _description = 'Clock Shift Role' + _order = 'sequence, name' + + def _get_default_color(self): + return randint(1, 11) + + name = fields.Char(required=True, translate=True) + color = fields.Integer(default=_get_default_color) + active = fields.Boolean(default=True) + sequence = fields.Integer(default=10) + company_id = fields.Many2one('res.company', default=lambda self: self.env.company) + + _COLOR_HEX = {0:'#008784',1:'#EE4B39',2:'#F29648',3:'#F4C609',4:'#55B7EA', + 5:'#71405B',6:'#E86869',7:'#008784',8:'#267283',9:'#BF1255', + 10:'#2BAF73',11:'#8754B0'} + + def _get_color_from_code(self, is_open_shift=False): + self.ensure_one() + hexv = self._COLOR_HEX.get(self.color, '#008784') + return hexv + ('80' if is_open_shift else '') +``` +- [ ] Register in `__init__.py` (add `from . import clock_role` before `clock_shift`). +- [ ] ACL rows: `model_fusion_clock_role` → user read; manager RWCU; portal read. +- [ ] Run `test_role` on Entech clone; commit. + +### Task A2: Employee role fields + Roles editor view/menu +**Files:** Modify `models/hr_employee.py`, `views/clock_role_views.xml` (create), `views/clock_menus.xml`, `security/ir.model.access.csv`. + +- [ ] Add to `hr.employee`: +```python +x_fclk_default_role_id = fields.Many2one('fusion.clock.role', string='Default Shift Role') +x_fclk_role_ids = fields.Many2many('fusion.clock.role', 'fclk_employee_role_rel', + 'employee_id', 'role_id', string='Allowed Shift Roles') +``` +- [ ] `clock_role_views.xml`: role list/form/action + `action_fclk_employee_role_editor` (editable hr.employee list with `x_fclk_default_role_id` + `x_fclk_role_ids`, copied from `fusion_planning/views/hr_employee_role_views.xml`, native fields, `multi_edit="1"`). +- [ ] Menus under fusion_clock config: "Roles" (role action) + "Employee Roles" (editor), `groups="group_fusion_clock_manager"`. +- [ ] Commit. + +### Task A3: `role_id` on shift template + schedule + default +**Files:** Modify `models/clock_shift.py`, `models/clock_schedule.py`, `views/clock_shift_views.xml`, `views/clock_schedule_views.xml`. + +- [ ] `clock_shift.py`: `role_id = fields.Many2one('fusion.clock.role')`. +- [ ] `clock_schedule.py`: `role_id = fields.Many2one('fusion.clock.role')`; default in `fclk_apply_planner_cell` vals from `shift.role_id` or `employee.x_fclk_default_role_id`; include `role_id`+`role_color` in `fclk_cell_payload`. +- [ ] Add `role_id` to shift form/list + schedule list/form. +- [ ] Commit. + +### Task A4: `fusion.clock.schedule.recurrence` model + engine + cron +**Files:** Create `models/clock_recurrence.py`, `data/clock_recurrence_cron.xml`; Modify `models/__init__.py`, `models/clock_schedule.py`, `models/res_company.py`, ACL csv, manifest. + +- [ ] Write `test_recurrence.py`: weekly interval=1 forever generates rows on same weekday up to horizon; `until` caps at date; `x_times` caps at N; `_stop` deletes future drafts only, keeps posted; leave days skipped. +- [ ] `res_company.py`: `fclk_planning_generation_months = fields.Integer(default=6)`. +- [ ] Implement model (design copied from `planning_recurrency.py`, re-fit to per-day): +```python +from dateutil.relativedelta import relativedelta +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + +class FusionClockScheduleRecurrence(models.Model): + _name = 'fusion.clock.schedule.recurrence' + _description = 'Clock Schedule Recurrence' + + schedule_ids = fields.One2many('fusion.clock.schedule', 'recurrence_id') + repeat_interval = fields.Integer('Repeat Every', default=1, required=True) + repeat_unit = fields.Selection([('day','Days'),('week','Weeks'),('month','Months'),('year','Years')], + default='week', required=True) + repeat_type = fields.Selection([('forever','Forever'),('until','Until'),('x_times','Number of Repetitions')], + default='forever', required=True) + repeat_until = fields.Date('Repeat Until') + repeat_number = fields.Integer('Repetitions') + last_generated_date = fields.Date(readonly=True) + company_id = fields.Many2one('res.company', required=True, default=lambda self: self.env.company) + + _check_interval_pos = models.Constraint('CHECK(repeat_interval >= 1)', 'Repeat every must be >= 1.') + + @api.constrains('repeat_type', 'repeat_until') + def _check_until(self): + for r in self: + if r.repeat_type == 'until' and not r.repeat_until: + raise ValidationError(_('Set an end date for "Until" recurrences.')) + + def _delta(self, n): + unit = {'day':'days','week':'weeks','month':'months','year':'years'}[self.repeat_unit] + return relativedelta(**{unit: self.repeat_interval * n}) + + def _horizon(self): + months = int(self.env['ir.config_parameter'].sudo().get_param('fusion_clock.generation_months') + or self.company_id.fclk_planning_generation_months or 6) + return fields.Date.today() + relativedelta(months=months) + + def _generate(self, stop_date=False): + Schedule = self.env['fusion.clock.schedule'].sudo() + for r in self: + seed = Schedule.search([('recurrence_id','=',r.id)], order='schedule_date desc', limit=1) + if not seed: + continue + limit = min([d for d in [r.repeat_until, + stop_date or r._horizon()] if d]) + existing = Schedule.search_count([('recurrence_id','=',r.id)]) + vals_list, last = [], seed.schedule_date + i = 1 + while True: + nxt = seed.schedule_date + r._delta(i); i += 1 + if nxt > limit: + break + if r.repeat_type == 'x_times' and existing + len(vals_list) >= r.repeat_number: + break + if Schedule.search_count([('recurrence_id','=',r.id),('schedule_date','=',nxt)]): + continue + if seed.employee_id and seed.employee_id._fclk_on_leave(nxt): + continue + vals_list.append({ + 'employee_id': seed.employee_id.id, 'schedule_date': nxt, + 'shift_id': seed.shift_id.id or False, 'is_off': seed.is_off, + 'start_time': seed.start_time, 'end_time': seed.end_time, + 'break_minutes': seed.break_minutes, 'role_id': seed.role_id.id or False, + 'recurrence_id': r.id, 'state': 'draft', + }) + last = nxt + if vals_list: + Schedule.create(vals_list) + r.last_generated_date = last + + def _stop(self, from_date): + self.env['fusion.clock.schedule'].sudo().search([ + ('recurrence_id','in', self.ids), ('schedule_date','>=', from_date), + ('state','=','draft')]).unlink() + + @api.model + def _cron_generate(self): + self.search([])._generate() +``` +- [ ] `hr_employee.py`: add `_fclk_on_leave(date)` (True if an approved `fusion.clock.leave.request` covers date — read existing leave model first). +- [ ] Cron `data/clock_recurrence_cron.xml`: daily, `model._cron_generate()`, no `numbercall` (Odoo 19). +- [ ] ACL: recurrence manager RWCU, user read. +- [ ] Run `test_recurrence`; commit. + +### Task A5: Recurrence on schedule + planner "Repeat…" wiring +**Files:** Modify `models/clock_schedule.py`, `controllers/shift_planner.py`, planner `.js`/`.xml`. + +- [ ] `clock_schedule.py`: `recurrence_id = fields.Many2one('fusion.clock.schedule.recurrence', ondelete='set null')`; method `fclk_attach_recurrence(schedule, repeat_vals)` creating the rule, linking the seed, calling `_generate()`. +- [ ] Controller endpoint `/fusion_clock/shift_planner/set_recurrence` (manager-gated) → calls `fclk_attach_recurrence`; `/clear_recurrence` → `_stop(today)` + unlink rule. +- [ ] Planner cell editor: "Repeat…" button → small dialog (interval/unit/type/until/number) → POST; show a recurrence badge on recurring cells. +- [ ] Commit. + +### Task A6: Publish & Notify over a range (generalise post_week) +**Files:** Modify `models/clock_schedule.py`, `controllers/shift_planner.py`, planner `.js`/`.xml`, `data/mail_template_data.xml`. + +- [ ] `clock_schedule.py`: rename/extend `fclk_email_posted_week` → `fclk_email_posted_range(employee, start, end)` (keep a thin `_week` wrapper). Add `fclk_publish_range(employees, start, end, message=None)` posting drafts + emailing. +- [ ] Controller `/fusion_clock/shift_planner/publish_range` (range + optional employee_ids + message) → `fclk_publish_range`; keep `post_week` calling it for the visible week. +- [ ] Mail template `mail_template_fclk_schedule_published` (copy/reword from planning `data/mail_template_data.xml`; obey Odoo-19 mail rules: no `url_encode`). +- [ ] Planner: "Publish & Notify…" dialog (date range + message). Commit. + +### Task A7: Portal Schedule tab folded into fusion_clock +**Files:** Create `controllers/portal_schedule.py`, `views/portal_schedule_templates.xml`, `static/src/css/portal_schedule.css`; Modify `controllers/__init__.py`, portal nav templates, manifest. + +- [ ] Move controller from `fusion_planning/controllers/portal_schedule.py`; **delete the `planning.slot` branch**; read only `fusion.clock.schedule`; role colour from `role_id._get_color_from_code()`. +- [ ] Move template → `fusion_clock.portal_schedule_page`; move css. +- [ ] Inline a "Schedule" nav `` into each `.fclk-nav-bar` (clock, timesheets, reports, payslip list, payslip detail) between Timesheets and Reports. Keep `.fclk-nav-bar` structure stable. +- [ ] Manifest: add template + css asset. Commit. + +### Task A8: planning → native data migration +**Files:** Create `migrations/19.0.5.0.0/post-migrate.py`. + +- [ ] Guarded + idempotent (marker `fusion_clock.planning_migrated`): + - roles: `planning.role` → `fusion.clock.role` (name, color); build id map. + - employees: `default_planning_role_id`→`x_fclk_default_role_id`; `planning_role_ids`→`x_fclk_role_ids`. + - slots: `planning.slot` → `fusion.clock.schedule` (resource→employee, local date+float times, role via map, posted if published). + - log anything unusual (overnight/open/multi handled by Phase B rules). +- [ ] Write `test_planning_migration.py` (stub planning models or skip if absent — guard with `'planning.slot' in env`). +- [ ] Commit. + +--- + +## PHASE B — Multi-shift / overnight / open shifts / self-assign / bulk + +### Task B1: schedule fields + constraint relax +**Files:** Modify `models/clock_schedule.py`. +- [ ] Add `is_open = fields.Boolean()`, `crosses_midnight = fields.Boolean(compute store)`. +- [ ] Make `employee_id` `required=False`; add `@api.constrains` requiring employee unless `is_open`. +- [ ] Replace `_employee_date_unique` Constraint with `models.UniqueIndex('(employee_id, schedule_date) WHERE employee_id IS NOT NULL AND recurrence_id IS NULL AND is_open = false')` — allow intentional multi via recurrence/open; finalise predicate so existing 144 single rows pass. Write `test_open_shift.py` first (open row needs no employee; two open rows same day allowed). +- [ ] Commit. + +### Task B2: overnight math +**Files:** Modify `models/clock_schedule.py`, `models/hr_employee.py`. +- [ ] `test_overnight.py`: 22:00→06:00 with 30m break → 7.5h; scheduled out is next-day. +- [ ] `_compute_planned_hours`: if `end<=start` → `(24-start)+end-break/60`; set `crosses_midnight`. +- [ ] `_check_schedule_times`: allow `end<=start` (remove the overnight block) but keep break < shift length. +- [ ] `hr_employee._get_fclk_scheduled_times`: when crossing midnight, out datetime += 1 day. +- [ ] Commit. + +### Task B3: multi-shift day-plan work-window +**Files:** Modify `models/hr_employee.py`. +- [ ] `test_multishift_window.py`: two posted shifts 08–12 and 13–17 → plan window 08–17, hours = sum worked; penalties unaffected (one window). +- [ ] `_get_fclk_day_plan`: search ALL posted assigned rows for the date; if >1, earliest start / latest end, summed breaks, summed hours; single row + none unchanged. +- [ ] Commit. + +### Task B4: open shifts in planner +**Files:** Modify `controllers/shift_planner.py`, planner `.js`/`.xml`. +- [ ] `_load_week_data`: include an "Open Shifts" pseudo-row (is_open rows by day). +- [ ] Endpoints `/create_open_shift`, `/bulk_apply` (apply a cell to many employee_ids — replaces `x_fc_additional_resource_ids`). +- [ ] Planner UI: open-shift lane + "Apply to…" multi-select. Commit. + +### Task B5: portal self-assign / unassign +**Files:** Modify `controllers/portal_schedule.py`, `views/portal_schedule_templates.xml`, `res_company.py`/`res_config_settings.py`. +- [ ] `res_company`: `fclk_self_unassign_days_before = fields.Integer(default=1)`. +- [ ] Portal endpoints `/my/clock/schedule/claim/` (open→assign me) and `/unassign/` (respect days-before). +- [ ] Template: show open shifts + Claim button; show Unassign on own upcoming shifts when allowed. Commit. + +--- + +## PHASE C — Manifest, verify, deploy + +### Task C1: manifest + config settings UI +- [ ] `__manifest__.py` → version `19.0.5.0.0`; add data files (`clock_role_views.xml`, `clock_recurrence_views.xml`, `clock_recurrence_cron.xml`, `portal_schedule_templates.xml`), asset `portal_schedule.css`; mail template already in `data/`. +- [ ] `res_config_settings.py` + view: expose `fclk_planning_generation_months`, `fclk_self_unassign_days_before` (Integer — config_parameter ok). +- [ ] Commit. + +### Task C2: clone-verify on Entech +- [ ] Clone `admin` → `admin_fctest` (pg_dump|psql inside LXC 111). +- [ ] Stage branch `fusion_clock` into an isolated `_test` addons dir shadowing prod; `-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0` on the clone; assert exit 0 + "Modules loaded". +- [ ] Run `--test-tags /fusion_clock` on the clone; assert green. +- [ ] `odoo shell` on clone: assert 144 schedule rows intact, 8 slots + 1 role migrated, portal `/my/clock/schedule` renders; `env.cr.rollback()`. + +### Task C3: deploy to Entech prod (gated) +- [ ] Backup DB (`pg_dump -Fc`) + module dir copy OUTSIDE addons path. +- [ ] scp branch `fusion_clock` → pve-worker5 → `pct push` into `/mnt/extra-addons/custom/fusion_clock` (swap; keep backup). +- [ ] `systemctl stop odoo; runuser -u odoo -- odoo -c ... -d admin -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 --logfile=/tmp/up.log`. **Restart only if RC==0 + "Modules loaded"**, else restore backup, no restart. +- [ ] `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';` then `systemctl start odoo`. + +### Task C4: retire fusion_planning (+ optional planning) +- [ ] After prod `-u` healthy + migration verified: uninstall `fusion_planning` (Apps → uninstall, or `env['ir.module.module'].search([('name','=','fusion_planning')]).button_immediate_uninstall()` via shell with backup). +- [ ] Verify portal Schedule tab still present (now served by fusion_clock); attendance/penalty crons intact. +- [ ] Optional, last, gated: uninstall `planning`/`web_gantt` (destructive — only after migration confirmed). Leave if any doubt. + +--- + +## Self-review notes +- Spec coverage: roles (A1–A3), recurrence (A4–A5), send (A6), portal (A7), migration (A8), multi/overnight/open/self-assign/bulk (B1–B5), Community manifest + deploy (C). All §5–§7 items mapped. +- Risk: recurrence engine + multi-window are new — covered by `test_recurrence`, `test_multishift_window`, `test_overnight`. +- Verification is batch (Entech clone), not per-step (no local docker from Mac). From b4ca85e2919d389047efe9272e71991db80ad983 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 20:42:04 -0400 Subject: [PATCH 03/12] feat(fusion_clock): native shift roles (fusion.clock.role) [A1-A3] Replaces Odoo Planning's planning.role: name+colour model with the same 1-11 palette, employee default/allowed role fields, Employee Roles editor, role_id on shift template + schedule with default resolution, ACLs, menus. Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_clock/__manifest__.py | 7 +- fusion_clock/models/__init__.py | 1 + fusion_clock/models/clock_role.py | 46 ++++++++++ fusion_clock/models/clock_schedule.py | 31 +++++++ fusion_clock/models/clock_shift.py | 6 ++ fusion_clock/models/hr_employee.py | 15 ++++ fusion_clock/security/ir.model.access.csv | 5 ++ fusion_clock/tests/__init__.py | 7 ++ fusion_clock/tests/test_role.py | 49 +++++++++++ fusion_clock/views/clock_menus.xml | 21 +++++ fusion_clock/views/clock_role_views.xml | 94 +++++++++++++++++++++ fusion_clock/views/clock_schedule_views.xml | 4 + fusion_clock/views/clock_shift_views.xml | 1 + 13 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 fusion_clock/models/clock_role.py create mode 100644 fusion_clock/tests/test_role.py create mode 100644 fusion_clock/views/clock_role_views.xml diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index 5e027958..7d92de95 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Clock', - 'version': '19.0.4.2.0', + 'version': '19.0.5.0.0', 'category': 'Human Resources/Attendances', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'description': """ @@ -54,6 +54,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil 'data/ir_config_parameter_data.xml', 'data/clock_break_rule_data.xml', 'data/ir_cron_data.xml', + 'data/clock_recurrence_cron.xml', # Reports (must load before mail templates that reference them) 'report/clock_report_template.xml', 'report/clock_employee_report.xml', @@ -72,6 +73,8 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil 'views/clock_dashboard_views.xml', 'views/hr_employee_views.xml', 'views/clock_schedule_views.xml', + 'views/clock_role_views.xml', + 'views/clock_recurrence_views.xml', 'views/clock_break_rule_views.xml', # Wizards (must load before clock_menus.xml since menu references wizard action) 'wizard/clock_nfc_enrollment_views.xml', @@ -82,12 +85,14 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil 'views/portal_timesheet_templates.xml', 'views/portal_report_templates.xml', 'views/portal_payslip_templates.xml', + 'views/portal_schedule_templates.xml', 'views/kiosk_templates.xml', 'views/kiosk_nfc_templates.xml', ], 'assets': { 'web.assets_frontend': [ 'fusion_clock/static/src/css/portal_clock.css', + 'fusion_clock/static/src/css/portal_schedule.css', 'fusion_clock/static/src/scss/nfc_kiosk.scss', 'fusion_clock/static/src/scss/pin_kiosk.scss', 'fusion_clock/static/src/js/fusion_clock_portal.js', diff --git a/fusion_clock/models/__init__.py b/fusion_clock/models/__init__.py index c51939ad..b0797190 100644 --- a/fusion_clock/models/__init__.py +++ b/fusion_clock/models/__init__.py @@ -10,6 +10,7 @@ from . import clock_report from . import res_config_settings from . import clock_activity_log from . import clock_leave_request +from . import clock_role from . import clock_shift from . import clock_schedule from . import clock_correction diff --git a/fusion_clock/models/clock_role.py b/fusion_clock/models/clock_role.py new file mode 100644 index 00000000..b68a4bfb --- /dev/null +++ b/fusion_clock/models/clock_role.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# Native shift role. Re-implements the small, useful subset of Odoo +# Enterprise ``planning.role`` (name + colour) so Fusion Clock can colour and +# label shifts on the portal without depending on the Enterprise Planning app. + +from random import randint + +from odoo import fields, models + + +class FusionClockRole(models.Model): + _name = 'fusion.clock.role' + _description = 'Clock Shift Role' + _order = 'sequence, name' + _rec_name = 'name' + + def _get_default_color(self): + return randint(1, 11) + + name = fields.Char(required=True, translate=True) + color = fields.Integer(default=_get_default_color) + active = fields.Boolean(default=True) + sequence = fields.Integer(default=10) + company_id = fields.Many2one( + 'res.company', + string='Company', + default=lambda self: self.env.company, + ) + + # Kanban colour code (1-11) -> hex, mirroring planning.role._get_color_from_code + # so the portal Schedule tab shows the same palette Planning used. + _COLOR_HEX = { + 0: '#008784', 1: '#EE4B39', 2: '#F29648', 3: '#F4C609', + 4: '#55B7EA', 5: '#71405B', 6: '#E86869', 7: '#008784', + 8: '#267283', 9: '#BF1255', 10: '#2BAF73', 11: '#8754B0', + } + + def _get_color_from_code(self, is_open_shift=False): + """Return a hex colour for this role. Open shifts get an '80' alpha + suffix (matching Planning's open-shift transparency convention).""" + self.ensure_one() + hex_value = self._COLOR_HEX.get(self.color, '#008784') + return hex_value + ('80' if is_open_shift else '') diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index e734cf6f..3756fb0f 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -58,6 +58,20 @@ class FusionClockSchedule(models.Model): store=True, ) note = fields.Char(string='Note') + role_id = fields.Many2one( + 'fusion.clock.role', + string='Role', + help="Shift role — drives the colour/label shown on the employee's " + "portal schedule. Defaults from the shift template or the " + "employee's Default Shift Role.", + ) + recurrence_id = fields.Many2one( + 'fusion.clock.schedule.recurrence', + string='Recurrence', + ondelete='set null', + index=True, + help="Set when this entry was generated by a recurring rule.", + ) company_id = fields.Many2one( 'res.company', string='Company', @@ -292,10 +306,21 @@ class FusionClockSchedule(models.Model): new_schedule = self.browse() new_value = '' else: + # Resolve the role: explicit payload role wins, then the shift + # template's role, then the employee's default role. + role_id = payload.get('role_id') + if not role_id: + shift_id = parsed.get('shift_id') + shift = self.env['fusion.clock.shift'].browse(shift_id) if shift_id else None + if shift and shift.role_id: + role_id = shift.role_id.id + elif employee.x_fclk_default_role_id: + role_id = employee.x_fclk_default_role_id.id vals = { 'employee_id': employee.id, 'schedule_date': date_obj, 'shift_id': parsed.get('shift_id') or False, + 'role_id': int(role_id) if role_id else False, 'is_off': bool(parsed.get('is_off')), 'start_time': parsed.get('start_time') or 0.0, 'end_time': parsed.get('end_time') or 0.0, @@ -349,6 +374,9 @@ class FusionClockSchedule(models.Model): 'hours': schedule.planned_hours, 'hours_display': Schedule.fclk_hours_display(schedule.planned_hours), 'note': schedule.note or '', + 'role_id': schedule.role_id.id or False, + 'role_name': schedule.role_id.name or '', + 'role_color': schedule.role_id._get_color_from_code() if schedule.role_id else '', } plan = employee._get_fclk_day_plan(date_obj) @@ -366,6 +394,9 @@ class FusionClockSchedule(models.Model): 'hours': plan.get('hours') or 0.0, 'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0), 'note': '', + 'role_id': False, + 'role_name': '', + 'role_color': '', } @api.model diff --git a/fusion_clock/models/clock_shift.py b/fusion_clock/models/clock_shift.py index c86ad164..b5bb2dd7 100644 --- a/fusion_clock/models/clock_shift.py +++ b/fusion_clock/models/clock_shift.py @@ -42,6 +42,12 @@ class FusionClockShift(models.Model): ) active = fields.Boolean(default=True) color = fields.Char(string='Color', default='#3B82F6') + role_id = fields.Many2one( + 'fusion.clock.role', + string='Default Role', + help="Role assigned to shifts created from this template " + "(drives the colour/label on the employee's portal schedule).", + ) # Weekday pattern — which days this recurring shift applies as the baseline # when there is no posted planner entry for the day. Default Mon-Fri. diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py index 1b840320..1c67fe4b 100644 --- a/fusion_clock/models/hr_employee.py +++ b/fusion_clock/models/hr_employee.py @@ -33,6 +33,21 @@ class HrEmployee(models.Model): help="Assigned shift schedule. Leave empty to use global defaults.", ) + # Shift roles (native replacement for Odoo Planning's employee role fields) + x_fclk_default_role_id = fields.Many2one( + 'fusion.clock.role', + string='Default Shift Role', + help="Pre-fills the role on every new shift created for this employee.", + ) + x_fclk_role_ids = fields.Many2many( + 'fusion.clock.role', + 'fclk_employee_role_rel', + 'employee_id', + 'role_id', + string='Allowed Shift Roles', + help="Roles this employee is allowed to be scheduled for.", + ) + # Pending reason enforcement x_fclk_pending_reason = fields.Boolean( string='Pending Reason Required', diff --git a/fusion_clock/security/ir.model.access.csv b/fusion_clock/security/ir.model.access.csv index 644cecff..240c705d 100644 --- a/fusion_clock/security/ir.model.access.csv +++ b/fusion_clock/security/ir.model.access.csv @@ -28,3 +28,8 @@ access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_sh access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0 access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1 access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1 +access_fusion_clock_role_user,fusion.clock.role.user,model_fusion_clock_role,group_fusion_clock_user,1,0,0,0 +access_fusion_clock_role_manager,fusion.clock.role.manager,model_fusion_clock_role,group_fusion_clock_manager,1,1,1,1 +access_fusion_clock_role_portal,fusion.clock.role.portal,model_fusion_clock_role,base.group_portal,1,0,0,0 +access_fusion_clock_recurrence_user,fusion.clock.schedule.recurrence.user,model_fusion_clock_schedule_recurrence,group_fusion_clock_user,1,0,0,0 +access_fusion_clock_recurrence_manager,fusion.clock.schedule.recurrence.manager,model_fusion_clock_schedule_recurrence,group_fusion_clock_manager,1,1,1,1 diff --git a/fusion_clock/tests/__init__.py b/fusion_clock/tests/__init__.py index 49f71366..2af2e0a8 100644 --- a/fusion_clock/tests/__init__.py +++ b/fusion_clock/tests/__init__.py @@ -11,3 +11,10 @@ from . import test_settings from . import test_clock_kiosk from . import test_break_rules from . import test_pending_reason_exempt +from . import test_role +from . import test_recurrence +from . import test_publish_range +from . import test_open_shift +from . import test_overnight +from . import test_multishift_window +from . import test_planning_migration diff --git a/fusion_clock/tests/test_role.py b/fusion_clock/tests/test_role.py new file mode 100644 index 00000000..781fcffd --- /dev/null +++ b/fusion_clock/tests/test_role.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import fields +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestFusionClockRole(TransactionCase): + + def test_default_color_in_range(self): + role = self.env['fusion.clock.role'].create({'name': 'Cashier'}) + self.assertTrue(1 <= role.color <= 11, "Default colour should be 1..11") + + def test_color_hex_and_open_alpha(self): + role = self.env['fusion.clock.role'].create({'name': 'Red', 'color': 1}) + self.assertEqual(role._get_color_from_code(), '#EE4B39') + self.assertEqual(role._get_color_from_code(True), '#EE4B3980') + + def test_employee_default_and_allowed_roles(self): + lead = self.env['fusion.clock.role'].create({'name': 'Lead', 'color': 3}) + cashier = self.env['fusion.clock.role'].create({'name': 'Cashier', 'color': 4}) + emp = self.env['hr.employee'].create({ + 'name': 'Bob', + 'x_fclk_default_role_id': lead.id, + 'x_fclk_role_ids': [(6, 0, [lead.id, cashier.id])], + }) + self.assertEqual(emp.x_fclk_default_role_id, lead) + self.assertIn(cashier, emp.x_fclk_role_ids) + + def test_schedule_inherits_employee_default_role(self): + lead = self.env['fusion.clock.role'].create({'name': 'Lead', 'color': 3}) + emp = self.env['hr.employee'].create({'name': 'Cara', 'x_fclk_default_role_id': lead.id}) + sch = self.env['fusion.clock.schedule'].fclk_apply_planner_cell( + emp, fields.Date.today(), {'input': '9-5'}) + self.assertEqual(sch.role_id, lead, + "A new shift should inherit the employee's default role") + + def test_schedule_role_from_shift_template(self): + stock = self.env['fusion.clock.role'].create({'name': 'Stock', 'color': 5}) + shift = self.env['fusion.clock.shift'].create({ + 'name': 'Morning', 'start_time': 8.0, 'end_time': 16.0, 'role_id': stock.id}) + emp = self.env['hr.employee'].create({'name': 'Dan'}) + sch = self.env['fusion.clock.schedule'].fclk_apply_planner_cell( + emp, fields.Date.today(), {'shift_id': shift.id}) + self.assertEqual(sch.role_id, stock, + "Shift-template role should win when employee has no default") diff --git a/fusion_clock/views/clock_menus.xml b/fusion_clock/views/clock_menus.xml index 672ed6b7..3ddfcfa8 100644 --- a/fusion_clock/views/clock_menus.xml +++ b/fusion_clock/views/clock_menus.xml @@ -121,6 +121,20 @@ sequence="15" groups="group_fusion_clock_manager"/> + + + + + + + + + + + fusion.clock.role.list + fusion.clock.role + + + + + + + + + + + + + fusion.clock.role.form + fusion.clock.role + +
+ + + + + + + + + + + + + +
+
+
+ + + Shift Roles + fusion.clock.role + list,form + +

Create your first shift role

+

Roles colour and label shifts on each employee's portal schedule + (e.g. "Cashier", "Stockroom", "Shift Lead").

+
+
+ + + + hr.employee.list.fclk.role.editor + hr.employee + + + + + + + + + + + + + + Employee Roles + hr.employee + list + + [('active', '=', True)] + +

+ Set the Default Role and allowed Roles for each employee +

+

Click any cell under Default Role or All Allowed Roles + and start typing. The Default Role pre-fills every new shift you + create for that employee.

+
+
+ +
diff --git a/fusion_clock/views/clock_schedule_views.xml b/fusion_clock/views/clock_schedule_views.xml index 585c3ccd..ef7260e0 100644 --- a/fusion_clock/views/clock_schedule_views.xml +++ b/fusion_clock/views/clock_schedule_views.xml @@ -16,10 +16,12 @@ + + @@ -38,6 +40,8 @@ + + diff --git a/fusion_clock/views/clock_shift_views.xml b/fusion_clock/views/clock_shift_views.xml index 2e86984f..876b9a34 100644 --- a/fusion_clock/views/clock_shift_views.xml +++ b/fusion_clock/views/clock_shift_views.xml @@ -36,6 +36,7 @@ + From 734b3b94fd2d3341bda042bfffadc0ad7c0d06da Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 20:49:26 -0400 Subject: [PATCH 04/12] feat(fusion_clock): native recurring shifts engine [A4-A5] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fusion.clock.schedule.recurrence (repeat every N day/week/month/year; forever/until/N-times) re-fit from planning.recurrency onto per-day rows; daily generation cron; _fclk_on_leave skip; planner Repeat…/Stop-repeat UI + endpoints; recurrence + role indicators on cells. Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_clock/controllers/shift_planner.py | 32 ++++ fusion_clock/data/clock_recurrence_cron.xml | 17 ++ fusion_clock/models/__init__.py | 1 + fusion_clock/models/clock_recurrence.py | 154 ++++++++++++++++++ fusion_clock/models/clock_schedule.py | 32 ++++ fusion_clock/models/hr_employee.py | 13 ++ fusion_clock/models/res_company.py | 11 ++ .../src/js/fusion_clock_shift_planner.js | 66 ++++++++ .../src/scss/fusion_clock_shift_planner.scss | 46 ++++++ .../src/xml/fusion_clock_shift_planner.xml | 52 ++++++ fusion_clock/tests/test_recurrence.py | 95 +++++++++++ fusion_clock/views/clock_recurrence_views.xml | 72 ++++++++ 12 files changed, 591 insertions(+) create mode 100644 fusion_clock/data/clock_recurrence_cron.xml create mode 100644 fusion_clock/models/clock_recurrence.py create mode 100644 fusion_clock/tests/test_recurrence.py create mode 100644 fusion_clock/views/clock_recurrence_views.xml diff --git a/fusion_clock/controllers/shift_planner.py b/fusion_clock/controllers/shift_planner.py index 6f3f6a3b..1ffcb1b5 100644 --- a/fusion_clock/controllers/shift_planner.py +++ b/fusion_clock/controllers/shift_planner.py @@ -237,6 +237,38 @@ class FusionClockShiftPlanner(http.Controller): 'data': self._load_week_data(start), } + @http.route('/fusion_clock/shift_planner/set_recurrence', type='jsonrpc', auth='user', methods=['POST']) + def set_recurrence(self, employee_id=None, date=None, repeat=None, week_start=None, **kw): + """Make the shift at (employee, date) recurring and generate it forward.""" + if not self._check_manager(): + return {'error': 'Access denied.'} + Schedule = request.env['fusion.clock.schedule'].sudo() + schedule = Schedule.search([ + ('employee_id', '=', int(employee_id or 0)), + ('schedule_date', '=', date), + ], limit=1) + if not schedule: + return {'success': False, 'message': 'Save this shift before repeating it.'} + try: + Schedule.fclk_attach_recurrence(schedule, repeat or {}) + except ValidationError as exc: + return {'success': False, 'message': str(exc.args[0] if exc.args else exc)} + return {'success': True, 'data': self._load_week_data(week_start)} + + @http.route('/fusion_clock/shift_planner/clear_recurrence', type='jsonrpc', auth='user', methods=['POST']) + def clear_recurrence(self, employee_id=None, date=None, week_start=None, **kw): + """Stop the recurrence seeded at (employee, date); keep posted rows.""" + if not self._check_manager(): + return {'error': 'Access denied.'} + Schedule = request.env['fusion.clock.schedule'].sudo() + schedule = Schedule.search([ + ('employee_id', '=', int(employee_id or 0)), + ('schedule_date', '=', date), + ], limit=1) + if schedule: + Schedule.fclk_clear_recurrence(schedule) + return {'success': True, 'data': self._load_week_data(week_start)} + @http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST']) def export_xlsx(self, week_start=None, **kw): if not self._check_manager(): diff --git a/fusion_clock/data/clock_recurrence_cron.xml b/fusion_clock/data/clock_recurrence_cron.xml new file mode 100644 index 00000000..8cf8b892 --- /dev/null +++ b/fusion_clock/data/clock_recurrence_cron.xml @@ -0,0 +1,17 @@ + + + + + + Fusion Clock: Generate Recurring Shifts + + code + model._cron_generate() + 1 + days + True + 75 + + + diff --git a/fusion_clock/models/__init__.py b/fusion_clock/models/__init__.py index b0797190..aeb5efe8 100644 --- a/fusion_clock/models/__init__.py +++ b/fusion_clock/models/__init__.py @@ -12,6 +12,7 @@ from . import clock_activity_log from . import clock_leave_request from . import clock_role from . import clock_shift +from . import clock_recurrence from . import clock_schedule from . import clock_correction from . import res_company diff --git a/fusion_clock/models/clock_recurrence.py b/fusion_clock/models/clock_recurrence.py new file mode 100644 index 00000000..caf8767a --- /dev/null +++ b/fusion_clock/models/clock_recurrence.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# Native recurring-shift engine. The field design and repeat semantics are +# adapted from Odoo Enterprise ``planning.recurrency`` (repeat every N +# days/weeks/months/years; forever / until / N-times), but the generation loop +# targets Fusion Clock's per-day ``fusion.clock.schedule`` rows instead of +# datetime ``planning.slot`` records — so there is no resource-calendar / DST +# machinery to carry. Generated rows are born ``draft`` and must be posted +# (published) before any attendance automation acts on them. + +import logging + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + +# Hard safety cap on iterations when projecting a recurrence forward, so a +# misconfigured rule can never loop unbounded (5 years of daily shifts). +_MAX_OCCURRENCES = 365 * 5 + + +class FusionClockScheduleRecurrence(models.Model): + _name = 'fusion.clock.schedule.recurrence' + _description = 'Clock Schedule Recurrence' + _rec_name = 'display_name' + + schedule_ids = fields.One2many( + 'fusion.clock.schedule', 'recurrence_id', string='Generated Shifts') + repeat_interval = fields.Integer('Repeat Every', default=1, required=True) + repeat_unit = fields.Selection( + [('day', 'Days'), ('week', 'Weeks'), ('month', 'Months'), ('year', 'Years')], + string='Repeat Unit', default='week', required=True) + repeat_type = fields.Selection( + [('forever', 'Forever'), ('until', 'Until'), ('x_times', 'Number of Repetitions')], + string='Until', default='forever', required=True) + repeat_until = fields.Date('Repeat Until') + repeat_number = fields.Integer('Repetitions', default=1) + last_generated_date = fields.Date(readonly=True) + company_id = fields.Many2one( + 'res.company', string='Company', required=True, + default=lambda self: self.env.company) + display_name = fields.Char(compute='_compute_display_name') + + _check_interval_positive = models.Constraint( + 'CHECK(repeat_interval >= 1)', 'The repeat interval must be at least 1.') + + @api.constrains('repeat_type', 'repeat_until') + def _check_until(self): + for rec in self: + if rec.repeat_type == 'until' and not rec.repeat_until: + raise ValidationError(_('Set an end date for an "Until" recurrence.')) + + @api.constrains('repeat_type', 'repeat_number') + def _check_number(self): + for rec in self: + if rec.repeat_type == 'x_times' and rec.repeat_number < 1: + raise ValidationError(_('The number of repetitions must be at least 1.')) + + @api.depends('repeat_type', 'repeat_interval', 'repeat_unit', 'repeat_until', 'repeat_number') + def _compute_display_name(self): + units = dict(self._fields['repeat_unit'].selection) + for rec in self: + unit = units.get(rec.repeat_unit, rec.repeat_unit) + if rec.repeat_type == 'forever': + rec.display_name = _('Every %(n)s %(u)s, forever', n=rec.repeat_interval, u=unit) + elif rec.repeat_type == 'until': + rec.display_name = _('Every %(n)s %(u)s until %(d)s', + n=rec.repeat_interval, u=unit, d=rec.repeat_until) + else: + rec.display_name = _('Every %(n)s %(u)s, %(c)s times', + n=rec.repeat_interval, u=unit, c=rec.repeat_number) + + def _delta(self, n): + """relativedelta for the n-th occurrence after the seed.""" + self.ensure_one() + key = {'day': 'days', 'week': 'weeks', 'month': 'months', 'year': 'years'}[self.repeat_unit] + return relativedelta(**{key: self.repeat_interval * n}) + + def _horizon(self): + """Furthest date we pre-generate to when the recurrence has no end.""" + self.ensure_one() + months = self.company_id.fclk_planning_generation_months or 6 + return fields.Date.today() + relativedelta(months=months) + + def _generate(self, stop_date=False): + """Materialise per-day schedule rows for each recurrence up to its + horizon. Idempotent: dates already covered for the rule are skipped and + ``last_generated_date`` advances.""" + Schedule = self.env['fusion.clock.schedule'].sudo() + for rec in self: + seed = Schedule.search( + [('recurrence_id', '=', rec.id)], order='schedule_date desc', limit=1) + if not seed: + # No anchor row -> nothing to repeat; drop the empty rule. + rec.unlink() + continue + anchor = Schedule.search( + [('recurrence_id', '=', rec.id)], order='schedule_date asc', limit=1) + bounds = [stop_date or rec._horizon()] + if rec.repeat_until: + bounds.append(rec.repeat_until) + limit = min(bounds) + + existing = Schedule.search_count([('recurrence_id', '=', rec.id)]) + vals_list, last = [], rec.last_generated_date + for i in range(1, _MAX_OCCURRENCES + 1): + nxt = anchor.schedule_date + rec._delta(i) + if nxt > limit: + break + if rec.repeat_type == 'x_times' and existing + len(vals_list) >= rec.repeat_number: + break + if Schedule.search_count( + [('recurrence_id', '=', rec.id), ('schedule_date', '=', nxt)]): + continue + if anchor.employee_id and anchor.employee_id._fclk_on_leave(nxt): + continue + vals_list.append({ + 'employee_id': anchor.employee_id.id or False, + 'schedule_date': nxt, + 'shift_id': anchor.shift_id.id or False, + 'role_id': anchor.role_id.id or False, + 'is_off': anchor.is_off, + # is_open is added in the Phase B schedule extension; guard so + # the engine works whether or not that field exists yet. + 'is_open': bool(getattr(anchor, 'is_open', False)), + 'start_time': anchor.start_time, + 'end_time': anchor.end_time, + 'break_minutes': anchor.break_minutes, + 'note': anchor.note or False, + 'recurrence_id': rec.id, + 'state': 'draft', + }) + last = nxt + if vals_list: + Schedule.create(vals_list) + rec.last_generated_date = last + + def _stop(self, from_date): + """Delete future DRAFT rows of these rules (posted rows are kept).""" + self.env['fusion.clock.schedule'].sudo().search([ + ('recurrence_id', 'in', self.ids), + ('schedule_date', '>=', from_date), + ('state', '=', 'draft'), + ]).unlink() + + @api.model + def _cron_generate(self): + """Roll every recurrence's horizon forward (called daily).""" + self.search([])._generate() diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index 3756fb0f..ac183f7e 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -377,6 +377,7 @@ class FusionClockSchedule(models.Model): 'role_id': schedule.role_id.id or False, 'role_name': schedule.role_id.name or '', 'role_color': schedule.role_id._get_color_from_code() if schedule.role_id else '', + 'recurring': bool(schedule.recurrence_id), } plan = employee._get_fclk_day_plan(date_obj) @@ -399,6 +400,37 @@ class FusionClockSchedule(models.Model): 'role_color': '', } + @api.model + def fclk_attach_recurrence(self, schedule, repeat_vals): + """Attach a recurrence rule to a seed schedule cell and generate it + forward. ``repeat_vals`` mirrors the recurrence fields.""" + schedule = schedule.sudo() + if not schedule: + raise ValidationError(_("Pick a shift to repeat first.")) + rule = self.env['fusion.clock.schedule.recurrence'].sudo().create({ + 'repeat_interval': int(repeat_vals.get('repeat_interval') or 1), + 'repeat_unit': repeat_vals.get('repeat_unit') or 'week', + 'repeat_type': repeat_vals.get('repeat_type') or 'forever', + 'repeat_until': repeat_vals.get('repeat_until') or False, + 'repeat_number': int(repeat_vals.get('repeat_number') or 1), + 'company_id': schedule.company_id.id or self.env.company.id, + }) + schedule.recurrence_id = rule.id + rule._generate() + return rule + + @api.model + def fclk_clear_recurrence(self, schedule): + """Detach + stop the recurrence on a seed cell (keeps posted rows).""" + schedule = schedule.sudo() + rule = schedule.recurrence_id + if rule: + rule._stop(fields.Date.today()) + schedule.recurrence_id = False + if not rule.schedule_ids: + rule.unlink() + return True + @api.model def fclk_email_posted_week(self, employee, week_start, week_end): """Email one employee a summary of their POSTED shifts for the week.""" diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py index 1c67fe4b..51c7b833 100644 --- a/fusion_clock/models/hr_employee.py +++ b/fusion_clock/models/hr_employee.py @@ -173,6 +173,19 @@ class HrEmployee(models.Model): ('schedule_date', '=', date_obj), ], limit=1) + def _fclk_on_leave(self, date): + """True if an approved leave request covers ``date`` for this employee. + Used by the recurrence engine to skip generating shifts on days off.""" + self.ensure_one() + date_obj = fields.Date.to_date(date) + if not date_obj: + return False + return bool(self.env['fusion.clock.leave.request'].sudo().search_count([ + ('employee_id', '=', self.id), + ('leave_date', '<=', date_obj), + ('date_to', '>=', date_obj), + ])) + def _get_fclk_day_plan(self, date): """Return the effective plan for a local date, with an explicit ``scheduled`` flag that ALL attendance automation keys off. diff --git a/fusion_clock/models/res_company.py b/fusion_clock/models/res_company.py index 7daa8453..c6914193 100644 --- a/fusion_clock/models/res_company.py +++ b/fusion_clock/models/res_company.py @@ -14,3 +14,14 @@ class ResCompany(models.Model): domain="[('company_id', '=', id)]", help="Clock location bound to the on-site kiosk (NFC and PIN) for this company.", ) + fclk_planning_generation_months = fields.Integer( + string='Schedule Generation Horizon (months)', + default=6, + help="How many months ahead recurring shifts are pre-generated.", + ) + fclk_self_unassign_days_before = fields.Integer( + string='Self-Unassign Cutoff (days before shift)', + default=1, + help="Employees may release an open shift they claimed up to this many " + "days before it starts.", + ) diff --git a/fusion_clock/static/src/js/fusion_clock_shift_planner.js b/fusion_clock/static/src/js/fusion_clock_shift_planner.js index f725dbe6..ea739541 100644 --- a/fusion_clock/static/src/js/fusion_clock_shift_planner.js +++ b/fusion_clock/static/src/js/fusion_clock_shift_planner.js @@ -45,6 +45,9 @@ export class FusionClockShiftPlanner extends Component { error: "", top: 0, left: 0, + recurring: false, + showRepeat: false, + repeat: { interval: 1, unit: "week", type: "forever", until: "", number: 4 }, }, }); @@ -258,9 +261,72 @@ export class FusionClockShiftPlanner extends Component { this.state.editor.breakMinutes = breakMinutes; this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours); this.state.editor.error = cell.error || ""; + this.state.editor.recurring = !!cell.recurring; + this.state.editor.showRepeat = false; this._positionActiveEditor(anchor); } + toggleRepeatPanel() { + this.state.editor.showRepeat = !this.state.editor.showRepeat; + } + + onRepeatField(field, ev) { + const value = ev.target.value; + this.state.editor.repeat[field] = + field === "interval" || field === "number" ? Number(value) : value; + } + + async setRecurrence() { + const editor = this.state.editor; + this.state.saving = true; + try { + const result = await rpc("/fusion_clock/shift_planner/set_recurrence", { + employee_id: editor.employeeId, + date: editor.date, + week_start: this.state.weekStart, + repeat: { + repeat_interval: editor.repeat.interval, + repeat_unit: editor.repeat.unit, + repeat_type: editor.repeat.type, + repeat_until: editor.repeat.until || false, + repeat_number: editor.repeat.number, + }, + }); + if (result.error || result.success === false) { + this.notification.add(result.error || result.message || "Could not repeat shift.", { + type: "danger", + }); + } else { + this._applyData(result.data); + this.notification.add("Recurring shift created.", { type: "success" }); + } + } catch (error) { + this.notification.add(error.message || "Could not repeat shift.", { type: "danger" }); + } + this.state.saving = false; + } + + async clearRecurrence() { + const editor = this.state.editor; + this.state.saving = true; + try { + const result = await rpc("/fusion_clock/shift_planner/clear_recurrence", { + employee_id: editor.employeeId, + date: editor.date, + week_start: this.state.weekStart, + }); + if (result.error) { + this.notification.add(result.error, { type: "danger" }); + } else { + this._applyData(result.data); + this.notification.add("Recurrence stopped.", { type: "success" }); + } + } catch (error) { + this.notification.add(error.message || "Could not stop recurrence.", { type: "danger" }); + } + this.state.saving = false; + } + closeCellEditor() { this.state.editor.open = false; this.activeCellAnchor = null; diff --git a/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss index 735c45c0..3732a3da 100644 --- a/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss +++ b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss @@ -217,6 +217,52 @@ padding: 4px; vertical-align: top; background: var(--fclk-planner-card, #ffffff); + position: relative; +} + +.fclk-planner__cell-recur { + position: absolute; + top: 2px; + right: 4px; + font-size: 9px; + opacity: 0.6; + pointer-events: none; +} + +.fclk-planner__cell-role { + position: absolute; + bottom: 3px; + right: 4px; + width: 8px; + height: 8px; + border-radius: 50%; + pointer-events: none; +} + +.fclk-planner__repeat-panel { + border-top: 1px solid var(--fclk-planner-border, #d8dadd); + margin-top: 6px; + padding-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; + + .fclk-planner__repeat-row { + display: flex; + align-items: center; + gap: 6px; + + select, + input { + flex: 1; + min-width: 0; + } + } + + .fclk-planner__repeat-int { + max-width: 64px; + flex: 0 0 auto; + } } .fclk-planner__shift-cell--fallback { diff --git a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml index e3a96537..fc26481d 100644 --- a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml +++ b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml @@ -115,6 +115,13 @@
+ + + + @@ -182,12 +189,57 @@ +
+
+ Every + + +
+
+ + + +
+ +
+
+ + +
+
+ + + + + +
+
diff --git a/fusion_clock/tests/test_publish_range.py b/fusion_clock/tests/test_publish_range.py new file mode 100644 index 00000000..c901f2f3 --- /dev/null +++ b/fusion_clock/tests/test_publish_range.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from datetime import date + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestPublishRange(TransactionCase): + + def setUp(self): + super().setUp() + self.Schedule = self.env['fusion.clock.schedule'] + self.emp = self.env['hr.employee'].create({ + 'name': 'Pat', 'work_email': 'pat@example.com'}) + + def _draft(self, day): + return self.Schedule.create({ + 'employee_id': self.emp.id, 'schedule_date': day, + 'start_time': 9.0, 'end_time': 17.0, 'state': 'draft'}) + + def test_publish_range_posts_drafts(self): + d1, d2 = date(2026, 6, 1), date(2026, 6, 3) + self._draft(d1) + self._draft(d2) + posted, _notified = self.Schedule.fclk_publish_range(self.emp, d1, d2) + self.assertEqual(posted, 2) + rows = self.Schedule.search([('employee_id', '=', self.emp.id)]) + self.assertTrue(all(r.state == 'posted' for r in rows)) + self.assertTrue(all(r.posted_date for r in rows)) + + def test_publish_range_skips_already_posted(self): + d = date(2026, 6, 1) + self.Schedule.create({ + 'employee_id': self.emp.id, 'schedule_date': d, + 'start_time': 9.0, 'end_time': 17.0, 'state': 'posted'}) + posted, _notified = self.Schedule.fclk_publish_range(self.emp, d, d) + self.assertEqual(posted, 0, "Already-posted rows are not re-posted") + + def test_publish_range_respects_bounds(self): + inside = self._draft(date(2026, 6, 5)) + outside = self._draft(date(2026, 6, 20)) + posted, _notified = self.Schedule.fclk_publish_range( + self.emp, date(2026, 6, 1), date(2026, 6, 7)) + self.assertEqual(posted, 1) + self.assertEqual(inside.state, 'posted') + self.assertEqual(outside.state, 'draft') + + def test_email_posted_range_no_email_returns_false(self): + emp2 = self.env['hr.employee'].create({'name': 'NoEmail'}) + self.assertFalse( + self.Schedule.fclk_email_posted_range(emp2, date(2026, 6, 1), date(2026, 6, 2))) diff --git a/fusion_clock/views/portal_clock_templates.xml b/fusion_clock/views/portal_clock_templates.xml index 54ded7ba..b4ce6b0b 100644 --- a/fusion_clock/views/portal_clock_templates.xml +++ b/fusion_clock/views/portal_clock_templates.xml @@ -303,6 +303,19 @@ Timesheets + + + + + + + + + + + + Schedule + diff --git a/fusion_clock/views/portal_payslip_templates.xml b/fusion_clock/views/portal_payslip_templates.xml index 02807d5b..69146716 100644 --- a/fusion_clock/views/portal_payslip_templates.xml +++ b/fusion_clock/views/portal_payslip_templates.xml @@ -64,6 +64,19 @@ Timesheets + + + + + + + + + + + + Schedule + @@ -166,6 +179,19 @@ Timesheets + + + + + + + + + + + + Schedule + diff --git a/fusion_clock/views/portal_report_templates.xml b/fusion_clock/views/portal_report_templates.xml index e8e5063c..7ee325b3 100644 --- a/fusion_clock/views/portal_report_templates.xml +++ b/fusion_clock/views/portal_report_templates.xml @@ -77,6 +77,19 @@ Timesheets + + + + + + + + + + + + Schedule + diff --git a/fusion_clock/views/portal_schedule_templates.xml b/fusion_clock/views/portal_schedule_templates.xml new file mode 100644 index 00000000..8d155b25 --- /dev/null +++ b/fusion_clock/views/portal_schedule_templates.xml @@ -0,0 +1,174 @@ + + + + + + + + diff --git a/fusion_clock/views/portal_timesheet_templates.xml b/fusion_clock/views/portal_timesheet_templates.xml index 9d9da1ca..95ba7740 100644 --- a/fusion_clock/views/portal_timesheet_templates.xml +++ b/fusion_clock/views/portal_timesheet_templates.xml @@ -128,6 +128,19 @@ Timesheets + + + + + + + + + + + + Schedule + From d35d5f4b34b2e16177231a5f65cb12b2cf1ee95f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 20:58:13 -0400 Subject: [PATCH 06/12] feat(fusion_clock): planning -> native data migration [A8] post-migrate(19.0.5.0.0) -> fusion.clock.schedule._fclk_port_planning_data: planning.role -> fusion.clock.role, employee default/allowed roles, and planning.slot -> fusion.clock.schedule (local date+float, role map, posted if published, open if unassigned). Guarded (no-op on Community), idempotent (marker), per-row savepoints. Integration test runs on Enterprise clones. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migrations/19.0.5.0.0/post-migrate.py | 33 ++++++++ fusion_clock/models/clock_schedule.py | 77 +++++++++++++++++++ fusion_clock/tests/test_planning_migration.py | 49 ++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 fusion_clock/migrations/19.0.5.0.0/post-migrate.py create mode 100644 fusion_clock/tests/test_planning_migration.py diff --git a/fusion_clock/migrations/19.0.5.0.0/post-migrate.py b/fusion_clock/migrations/19.0.5.0.0/post-migrate.py new file mode 100644 index 00000000..c5005e5a --- /dev/null +++ b/fusion_clock/migrations/19.0.5.0.0/post-migrate.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# One-time port of Odoo Enterprise Planning data into the native Fusion Clock +# models, so a deployment that previously used the `planning` bridge keeps all +# its roles, employee role assignments and shifts after `planning` / +# `fusion_planning` are removed. +# +# Guarded: a no-op on Community / fresh installs where planning data is absent. +# Idempotent: a marker param stops it re-running. + +import logging + +from odoo import api, SUPERUSER_ID + +_logger = logging.getLogger(__name__) + +_MARKER = 'fusion_clock.planning_migrated' + + +def migrate(cr, version): + """Port Odoo Planning data into the native models, once. The heavy lifting + lives in fusion.clock.schedule._fclk_port_planning_data so it can be unit + tested on an Enterprise clone where planning is installed.""" + env = api.Environment(cr, SUPERUSER_ID, {}) + ICP = env['ir.config_parameter'].sudo() + if ICP.get_param(_MARKER): + _logger.info("Fusion Clock: planning data already migrated; skipping.") + return + counts = env['fusion.clock.schedule'].sudo()._fclk_port_planning_data() + ICP.set_param(_MARKER, '1') + _logger.info("Fusion Clock: planning -> native migration done: %s", counts) diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index 65d6b67f..5e15e7c0 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -516,6 +516,83 @@ class FusionClockSchedule(models.Model): notified += 1 return posted, notified + @api.model + def _fclk_port_planning_data(self): + """Port Odoo Planning data (roles, employee roles, slots) into the + native models. Safe no-op when planning is not installed. Returns a + dict of counts. Called by the 19.0.5.0.0 migration and by tests.""" + import pytz + + counts = {'roles': 0, 'employees': 0, 'slots': 0, 'skipped': 0} + env = self.env + has_roles = 'planning.role' in env + has_slots = 'planning.slot' in env + if not has_roles and not has_slots: + return counts + + Role = env['fusion.clock.role'].sudo() + role_map = {} + if has_roles: + for prole in env['planning.role'].sudo().with_context(active_test=False).search([]): + target = Role.with_context(active_test=False).search( + [('name', '=ilike', prole.name)], limit=1) or Role.create({ + 'name': prole.name, 'color': prole.color or 1, 'active': prole.active}) + role_map[prole.id] = target.id + counts['roles'] = len(role_map) + + Employee = env['hr.employee'].sudo().with_context(active_test=False) + for emp in Employee.search([]): + vals = {} + if emp._fields.get('default_planning_role_id') and emp.default_planning_role_id: + mapped = role_map.get(emp.default_planning_role_id.id) + if mapped: + vals['x_fclk_default_role_id'] = mapped + if emp._fields.get('planning_role_ids') and emp.planning_role_ids: + mapped_ids = [role_map[r.id] for r in emp.planning_role_ids if r.id in role_map] + if mapped_ids: + vals['x_fclk_role_ids'] = [(6, 0, mapped_ids)] + if vals: + emp.write(vals) + counts['employees'] += 1 + + if has_slots: + Schedule = self.sudo() + for slot in env['planning.slot'].sudo().search([], order='start_datetime'): + if not slot.start_datetime or not slot.end_datetime: + counts['skipped'] += 1 + continue + employee = slot.employee_id if 'employee_id' in slot._fields else False + tz_name = ((employee.tz if employee else False) + or (slot.resource_id.tz if slot.resource_id else False) + or env.company.partner_id.tz or 'UTC') + try: + tz = pytz.timezone(tz_name) + except Exception: + tz = pytz.UTC + local_start = pytz.utc.localize(slot.start_datetime).astimezone(tz) + local_end = pytz.utc.localize(slot.end_datetime).astimezone(tz) + span_hours = (slot.end_datetime - slot.start_datetime).total_seconds() / 3600.0 + allocated = slot.allocated_hours if 'allocated_hours' in slot._fields else span_hours + vals = { + 'employee_id': employee.id if employee else False, + 'is_open': not bool(employee), + 'schedule_date': local_start.date(), + 'start_time': round(local_start.hour + local_start.minute / 60.0, 2), + 'end_time': round(local_end.hour + local_end.minute / 60.0, 2), + 'break_minutes': round(max(0.0, span_hours - (allocated or span_hours)) * 60.0, 0), + 'role_id': role_map.get(slot.role_id.id) if slot.role_id else False, + 'note': slot.name or False, + 'state': 'posted' if slot.state == 'published' else 'draft', + } + with env.cr.savepoint(): + try: + Schedule.create(vals) + counts['slots'] += 1 + except Exception as exc: + counts['skipped'] += 1 + _logger.warning("Fusion Clock: skip planning.slot %s (%s).", slot.id, exc) + return counts + class FusionClockScheduleAudit(models.Model): _name = 'fusion.clock.schedule.audit' diff --git a/fusion_clock/tests/test_planning_migration.py b/fusion_clock/tests/test_planning_migration.py new file mode 100644 index 00000000..e8fba4b4 --- /dev/null +++ b/fusion_clock/tests/test_planning_migration.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# Integration test for the planning -> native port. Runs only where Odoo +# Planning is installed (Enterprise); a no-op skip on Community / local dev. + +from datetime import datetime + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestPlanningMigration(TransactionCase): + + def test_port_planning_data(self): + if 'planning.slot' not in self.env: + self.skipTest('planning not installed (Community / local dev)') + + prole = self.env['planning.role'].create({'name': 'PortLead', 'color': 5}) + emp = self.env['hr.employee'].create({'name': 'Porty McPort'}) + if 'default_planning_role_id' in emp._fields: + emp.default_planning_role_id = prole.id + self.env['planning.slot'].create({ + 'resource_id': emp.resource_id.id, + 'company_id': emp.company_id.id, + 'start_datetime': datetime(2026, 6, 1, 14, 0, 0), + 'end_datetime': datetime(2026, 6, 1, 22, 0, 0), + 'role_id': prole.id, + 'state': 'published', + }) + + counts = self.env['fusion.clock.schedule']._fclk_port_planning_data() + + self.assertGreaterEqual(counts['roles'], 1) + self.assertTrue( + self.env['fusion.clock.role'].search([('name', '=ilike', 'PortLead')]), + "planning.role should be ported to a native fusion.clock.role") + + emp.invalidate_recordset() + if 'default_planning_role_id' in emp._fields: + self.assertTrue(emp.x_fclk_default_role_id, + "employee default planning role should be ported") + + sched = self.env['fusion.clock.schedule'].search([('employee_id', '=', emp.id)]) + self.assertTrue(sched, "published planning.slot should become a native schedule row") + self.assertEqual(sched[0].state, 'posted', + "published slots port as posted schedule entries") From 68aaa132ee9b7abf1992036b3c867f7ae52bdede Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 21:04:58 -0400 Subject: [PATCH 07/12] =?UTF-8?q?feat(fusion=5Fclock):=20schedule=20parity?= =?UTF-8?q?=20=E2=80=94=20overnight,=20split=20shifts,=20open=20shifts=20[?= =?UTF-8?q?B1-B3]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_open + crosses_midnight fields; employee_id optional (open shifts); company_id computed w/ env.company fallback; drop hard one-per-day UNIQUE (allow split + open). Overnight math in planned_hours/_check_schedule_times/ scheduled_times. _get_fclk_day_plan resolves multiple posted rows into ONE work-window so penalties/overtime/absence stay correct. Migration drops the old constraint defensively. Tests for overnight, window, open shifts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migrations/19.0.5.0.0/post-migrate.py | 8 +++ fusion_clock/models/clock_schedule.py | 63 ++++++++++++++---- fusion_clock/models/hr_employee.py | 64 +++++++++++++++---- fusion_clock/tests/test_multishift_window.py | 54 ++++++++++++++++ fusion_clock/tests/test_open_shift.py | 46 +++++++++++++ fusion_clock/tests/test_overnight.py | 40 ++++++++++++ 6 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 fusion_clock/tests/test_multishift_window.py create mode 100644 fusion_clock/tests/test_open_shift.py create mode 100644 fusion_clock/tests/test_overnight.py diff --git a/fusion_clock/migrations/19.0.5.0.0/post-migrate.py b/fusion_clock/migrations/19.0.5.0.0/post-migrate.py index c5005e5a..9a9c9e2e 100644 --- a/fusion_clock/migrations/19.0.5.0.0/post-migrate.py +++ b/fusion_clock/migrations/19.0.5.0.0/post-migrate.py @@ -24,6 +24,14 @@ def migrate(cr, version): lives in fusion.clock.schedule._fclk_port_planning_data so it can be unit tested on an Enterprise clone where planning is installed.""" env = api.Environment(cr, SUPERUSER_ID, {}) + + # Phase B drops the hard one-shift-per-day uniqueness so split/open shifts + # are allowed. Odoo drops removed declarative constraints on upgrade, but be + # explicit so the upgrade can never leave the old constraint behind. + cr.execute( + "ALTER TABLE fusion_clock_schedule " + "DROP CONSTRAINT IF EXISTS fusion_clock_schedule_employee_date_unique") + ICP = env['ir.config_parameter'].sudo() if ICP.get_param(_MARKER): _logger.info("Fusion Clock: planning data already migrated; skipping.") diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index 5e15e7c0..73dd1069 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -21,10 +21,16 @@ class FusionClockSchedule(models.Model): employee_id = fields.Many2one( 'hr.employee', string='Employee', - required=True, + required=False, # open (unassigned) shifts have no employee until claimed index=True, ondelete='cascade', ) + is_open = fields.Boolean( + string='Open Shift', + default=False, + index=True, + help="An unassigned shift any eligible employee can claim from the portal.", + ) schedule_date = fields.Date( string='Date', required=True, @@ -57,6 +63,13 @@ class FusionClockSchedule(models.Model): compute='_compute_planned_hours', store=True, ) + crosses_midnight = fields.Boolean( + string='Overnight', + compute='_compute_planned_hours', + store=True, + help="Set automatically when the shift ends on the next day " + "(end time on or before start time).", + ) note = fields.Char(string='Note') role_id = fields.Many2one( 'fusion.clock.role', @@ -75,9 +88,10 @@ class FusionClockSchedule(models.Model): company_id = fields.Many2one( 'res.company', string='Company', - related='employee_id.company_id', + compute='_compute_fclk_company', store=True, - readonly=True, + readonly=False, + index=True, ) department_id = fields.Many2one( 'hr.department', @@ -100,18 +114,41 @@ class FusionClockSchedule(models.Model): ) posted_date = fields.Datetime(string='Posted On', readonly=True) - _employee_date_unique = models.Constraint( - 'UNIQUE(employee_id, schedule_date)', - 'Only one shift schedule is allowed per employee per day.', - ) + # No hard UNIQUE(employee, date): the per-day model now allows split shifts + # and open (unassigned) shifts. The shift planner still manages one cell per + # day in place; the attendance contract (_get_fclk_day_plan) resolves + # multiple posted rows into a single work-window. + + @api.depends('employee_id') + def _compute_fclk_company(self): + for rec in self: + if rec.employee_id: + rec.company_id = rec.employee_id.company_id + elif not rec.company_id: + rec.company_id = self.env.company + + @api.constrains('employee_id', 'is_open') + def _check_employee_or_open(self): + for rec in self: + if not rec.employee_id and not rec.is_open: + raise ValidationError( + _("A shift must have an employee unless it is an open shift.")) @api.depends('is_off', 'start_time', 'end_time', 'break_minutes') def _compute_planned_hours(self): for rec in self: + rec.crosses_midnight = False if rec.is_off: rec.planned_hours = 0.0 continue - raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0) + start = rec.start_time or 0.0 + end = rec.end_time or 0.0 + if end <= start: + # Overnight: the shift ends on the following day. + rec.crosses_midnight = True + raw_hours = (24.0 - start) + end + else: + raw_hours = end - start rec.planned_hours = round(max(raw_hours - ((rec.break_minutes or 0.0) / 60.0), 0.0), 2) @api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time') @@ -130,11 +167,13 @@ class FusionClockSchedule(models.Model): continue if rec.start_time < 0 or rec.start_time >= 24: raise ValidationError(_("Start time must be between 00:00 and 23:59.")) - if rec.end_time <= 0 or rec.end_time > 24: - raise ValidationError(_("End time must be between 00:01 and 24:00.")) + if rec.end_time < 0 or rec.end_time > 24: + raise ValidationError(_("End time must be between 00:00 and 24:00.")) + # Overnight shifts (end on/before start) are allowed and span midnight. if rec.end_time <= rec.start_time: - raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet.")) - shift_minutes = (rec.end_time - rec.start_time) * 60.0 + shift_minutes = ((24.0 - rec.start_time) + rec.end_time) * 60.0 + else: + shift_minutes = (rec.end_time - rec.start_time) * 60.0 if rec.break_minutes >= shift_minutes: raise ValidationError(_("Break duration must be shorter than the scheduled shift.")) diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py index 51c7b833..63e5d70d 100644 --- a/fusion_clock/models/hr_employee.py +++ b/fusion_clock/models/hr_employee.py @@ -200,23 +200,60 @@ class HrEmployee(models.Model): """ self.ensure_one() Schedule = self.env['fusion.clock.schedule'].sudo() - schedule = self._get_fclk_schedule_for_date(date) - if schedule and schedule.state == 'posted': + date_obj = fields.Date.to_date(date) + + # All POSTED, assigned (non-open) rows for the day. The model now allows + # split shifts, so resolve several rows into one work-window that the + # whole attendance pipeline keys off — earliest start to latest end. + posted = Schedule.search([ + ('employee_id', '=', self.id), + ('schedule_date', '=', date_obj), + ('state', '=', 'posted'), + ('is_open', '=', False), + ]) if date_obj else Schedule.browse() + working = posted.filtered(lambda s: not s.is_off) + if working: + start = min(working.mapped('start_time')) + + def _eff_end(s): + return (s.end_time + 24.0) if s.crosses_midnight else s.end_time + win_end_eff = max(_eff_end(s) for s in working) + crosses = win_end_eff > 24.0 + end = win_end_eff - 24.0 if crosses else win_end_eff return { 'source': 'schedule', - 'schedule_id': schedule.id, - 'scheduled': not schedule.is_off, - 'is_off': schedule.is_off, - 'start_time': schedule.start_time, - 'end_time': schedule.end_time, - 'break_minutes': schedule.break_minutes, - 'hours': schedule.planned_hours, - 'label': schedule.fclk_display_value(), + 'schedule_id': working[0].id, + 'scheduled': True, + 'is_off': False, + 'start_time': start, + 'end_time': end, + 'break_minutes': sum(working.mapped('break_minutes')), + 'hours': sum(working.mapped('planned_hours')), + 'crosses_midnight': crosses, + 'label': '%s - %s' % ( + Schedule.fclk_float_to_display(start), + Schedule.fclk_float_to_display(end), + ), + } + if posted: # every posted row for the day is OFF + return { + 'source': 'schedule', + 'schedule_id': posted[0].id, + 'scheduled': False, + 'is_off': True, + 'start_time': 0.0, + 'end_time': 0.0, + 'break_minutes': 0.0, + 'hours': 0.0, + 'crosses_midnight': False, + 'label': 'OFF', } shift = self.x_fclk_shift_id if shift and shift.covers_weekday(date): - hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0) + crosses = shift.end_time <= shift.start_time + raw = ((24.0 - shift.start_time) + shift.end_time) if crosses else (shift.end_time - shift.start_time) + hours = max(raw - (shift.break_minutes / 60.0), 0.0) return { 'source': 'shift', 'schedule_id': False, @@ -226,6 +263,7 @@ class HrEmployee(models.Model): 'end_time': shift.end_time, 'break_minutes': shift.break_minutes, 'hours': hours, + 'crosses_midnight': crosses, 'label': '%s - %s' % ( Schedule.fclk_float_to_display(shift.start_time), Schedule.fclk_float_to_display(shift.end_time), @@ -242,6 +280,7 @@ class HrEmployee(models.Model): 'schedule_id': False, 'scheduled': False, 'is_off': False, + 'crosses_midnight': False, 'start_time': start_time, 'end_time': end_time, 'break_minutes': break_minutes, @@ -320,6 +359,9 @@ class HrEmployee(models.Model): local_out = local_tz.localize( datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m)) ) + # Overnight shift: scheduled clock-out lands on the following day. + if plan.get('crosses_midnight'): + local_out = local_out + timedelta(days=1) scheduled_in = local_in.astimezone(utc).replace(tzinfo=None) scheduled_out = local_out.astimezone(utc).replace(tzinfo=None) diff --git a/fusion_clock/tests/test_multishift_window.py b/fusion_clock/tests/test_multishift_window.py new file mode 100644 index 00000000..e774f28d --- /dev/null +++ b/fusion_clock/tests/test_multishift_window.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# The per-day model now allows split shifts. The attendance contract +# (_get_fclk_day_plan) MUST still hand the rest of the pipeline a single +# work-window so penalties / overtime / absence stay correct. + +from datetime import date + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestMultiShiftWindow(TransactionCase): + + def setUp(self): + super().setUp() + self.S = self.env['fusion.clock.schedule'] + self.emp = self.env['hr.employee'].create({'name': 'Sam Split'}) + + def test_split_shift_resolves_to_single_window(self): + self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1), + 'start_time': 8.0, 'end_time': 12.0, 'break_minutes': 0.0, 'state': 'posted'}) + self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1), + 'start_time': 13.0, 'end_time': 17.0, 'break_minutes': 0.0, 'state': 'posted'}) + plan = self.emp._get_fclk_day_plan(date(2026, 6, 1)) + self.assertTrue(plan['scheduled']) + self.assertEqual(plan['start_time'], 8.0, "window starts at earliest shift") + self.assertEqual(plan['end_time'], 17.0, "window ends at latest shift") + self.assertAlmostEqual(plan['hours'], 8.0, places=2, msg="worked hours = sum of shifts") + + def test_draft_shift_excluded_from_window(self): + self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1), + 'start_time': 8.0, 'end_time': 12.0, 'break_minutes': 0.0, 'state': 'posted'}) + self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1), + 'start_time': 13.0, 'end_time': 17.0, 'break_minutes': 0.0, 'state': 'draft'}) + plan = self.emp._get_fclk_day_plan(date(2026, 6, 1)) + self.assertEqual(plan['end_time'], 12.0, "draft shift must not widen the window") + + def test_all_off_rows_resolve_to_off(self): + self.S.create({'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1), + 'is_off': True, 'state': 'posted'}) + plan = self.emp._get_fclk_day_plan(date(2026, 6, 1)) + self.assertTrue(plan['is_off']) + self.assertFalse(plan['scheduled']) + + def test_open_shift_does_not_feed_employee_plan(self): + # An open shift (no employee) on the same day must not affect anyone. + self.S.create({'is_open': True, 'schedule_date': date(2026, 6, 1), + 'start_time': 9.0, 'end_time': 17.0, 'state': 'posted'}) + plan = self.emp._get_fclk_day_plan(date(2026, 6, 1)) + self.assertFalse(plan['scheduled'], "open shift is not assigned to this employee") diff --git a/fusion_clock/tests/test_open_shift.py b/fusion_clock/tests/test_open_shift.py new file mode 100644 index 00000000..7c9b05dd --- /dev/null +++ b/fusion_clock/tests/test_open_shift.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from datetime import date + +from odoo.exceptions import ValidationError +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestOpenShift(TransactionCase): + + def setUp(self): + super().setUp() + self.S = self.env['fusion.clock.schedule'] + + def test_open_shift_needs_no_employee_and_gets_company(self): + sch = self.S.create({ + 'is_open': True, 'schedule_date': date(2026, 6, 1), + 'start_time': 9.0, 'end_time': 17.0, 'state': 'posted'}) + self.assertFalse(sch.employee_id) + self.assertTrue(sch.company_id, "open shift falls back to the active company") + + def test_assigned_shift_requires_employee(self): + with self.assertRaises(ValidationError): + self.S.create({ + 'schedule_date': date(2026, 6, 1), + 'start_time': 9.0, 'end_time': 17.0}) + + def test_two_open_shifts_same_day_allowed(self): + d = date(2026, 6, 1) + self.S.create({'is_open': True, 'schedule_date': d, 'start_time': 8.0, 'end_time': 12.0}) + self.S.create({'is_open': True, 'schedule_date': d, 'start_time': 13.0, 'end_time': 17.0}) + self.assertEqual( + self.S.search_count([('is_open', '=', True), ('schedule_date', '=', d)]), 2) + + def test_split_shift_for_same_employee_allowed(self): + emp = self.env['hr.employee'].create({'name': 'Splitter'}) + d = date(2026, 6, 1) + self.S.create({'employee_id': emp.id, 'schedule_date': d, 'start_time': 8.0, 'end_time': 12.0}) + self.S.create({'employee_id': emp.id, 'schedule_date': d, 'start_time': 13.0, 'end_time': 17.0}) + self.assertEqual( + self.S.search_count([('employee_id', '=', emp.id), ('schedule_date', '=', d)]), 2, + "the hard one-shift-per-day uniqueness is gone") diff --git a/fusion_clock/tests/test_overnight.py b/fusion_clock/tests/test_overnight.py new file mode 100644 index 00000000..2827b9fb --- /dev/null +++ b/fusion_clock/tests/test_overnight.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from datetime import date + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestOvernight(TransactionCase): + + def setUp(self): + super().setUp() + self.Schedule = self.env['fusion.clock.schedule'] + self.emp = self.env['hr.employee'].create({'name': 'Nox'}) + + def test_overnight_hours_and_flag(self): + sch = self.Schedule.create({ + 'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1), + 'start_time': 22.0, 'end_time': 6.0, 'break_minutes': 30.0, 'state': 'posted'}) + self.assertTrue(sch.crosses_midnight) + # 22:00 -> 06:00 = 8h, minus 30m break = 7.5h + self.assertAlmostEqual(sch.planned_hours, 7.5, places=2) + + def test_overnight_scheduled_out_is_next_day(self): + self.Schedule.create({ + 'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 1), + 'start_time': 22.0, 'end_time': 6.0, 'break_minutes': 0.0, 'state': 'posted'}) + sin, sout = self.emp._get_fclk_scheduled_times(date(2026, 6, 1)) + self.assertGreater(sout, sin) + self.assertAlmostEqual((sout - sin).total_seconds() / 3600.0, 8.0, places=1) + + def test_overnight_is_allowed_by_constraint(self): + # Must not raise now that overnight is supported. + sch = self.Schedule.create({ + 'employee_id': self.emp.id, 'schedule_date': date(2026, 6, 2), + 'start_time': 20.0, 'end_time': 4.0, 'break_minutes': 60.0, 'state': 'posted'}) + self.assertAlmostEqual(sch.planned_hours, 7.0, places=2) # 8h - 1h break From 2ad94070c7ca31f025a1784a882600a4a4510b49 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 21:12:10 -0400 Subject: [PATCH 08/12] feat(fusion_clock): open shifts + self-assign + bulk apply [B4-B5] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model: fclk_create_open_shifts/claim_open_shift/release_shift (days-before cutoff + role eligibility)/bulk_apply. Planner: Open Shift… panel, open-shifts strip with delete, Apply-to-dept; load includes open shifts. Portal: claim open shifts + release own upcoming shifts with feedback banners. Tests for claim/role-gate/release/bulk. Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_clock/controllers/portal_schedule.py | 62 +++++++++++ fusion_clock/controllers/shift_planner.py | 60 ++++++++++ fusion_clock/models/clock_schedule.py | 62 +++++++++++ .../static/src/css/portal_schedule.css | 47 ++++++++ .../src/js/fusion_clock_shift_planner.js | 103 ++++++++++++++++++ .../src/scss/fusion_clock_shift_planner.scss | 62 +++++++++++ .../src/xml/fusion_clock_shift_planner.xml | 38 +++++++ fusion_clock/tests/test_open_shift.py | 40 ++++++- .../views/portal_schedule_templates.xml | 41 +++++++ 9 files changed, 514 insertions(+), 1 deletion(-) diff --git a/fusion_clock/controllers/portal_schedule.py b/fusion_clock/controllers/portal_schedule.py index 8a11529f..1b9eb5b1 100644 --- a/fusion_clock/controllers/portal_schedule.py +++ b/fusion_clock/controllers/portal_schedule.py @@ -9,8 +9,10 @@ import logging from collections import OrderedDict from datetime import timedelta +from urllib.parse import quote from odoo import http, fields +from odoo.exceptions import ValidationError from odoo.http import request _logger = logging.getLogger(__name__) @@ -30,6 +32,7 @@ class FusionClockSchedulePortal(http.Controller): horizon_local = today_local + timedelta(days=60) Schedule = request.env['fusion.clock.schedule'].sudo() + cutoff = employee.company_id.fclk_self_unassign_days_before or 0 entries = [] for sch in Schedule.search([ ('employee_id', '=', employee.id), @@ -54,11 +57,37 @@ class FusionClockSchedulePortal(http.Controller): 'role_name': sch.role_id.name if sch.role_id else '', 'role_color': sch.role_id._get_color_from_code() if sch.role_id else '', 'note': sch.note or '', + 'schedule_id': sch.id, + 'releasable': (day - today_local).days >= cutoff, }, )) entries.sort(key=lambda e: e[0]) + # Open shifts the employee may claim: company-scoped, future, and either + # role-eligible (allowed-role list contains the shift role) or roleless. + open_shifts = [] + for row in Schedule.search([ + ('is_open', '=', True), + ('state', '=', 'posted'), + ('company_id', '=', employee.company_id.id), + ('schedule_date', '>=', today_local), + ('schedule_date', '<=', horizon_local), + ], order='schedule_date asc, start_time asc', limit=100): + if row.role_id and employee.x_fclk_role_ids and row.role_id not in employee.x_fclk_role_ids: + continue + d = row.schedule_date + open_shifts.append({ + 'id': row.id, + 'date_full': d.strftime('%a, %b %d'), + 'time_range': '%s - %s' % ( + Schedule.fclk_float_to_display(row.start_time), + Schedule.fclk_float_to_display(row.end_time), + ), + 'role_name': row.role_id.name if row.role_id else '', + 'duration_hours': round(row.planned_hours or 0.0, 1), + }) + groups = OrderedDict() for _key, day, item in entries: delta_days = (day - today_local).days @@ -86,7 +115,40 @@ class FusionClockSchedulePortal(http.Controller): 'groups': groups, 'slot_count': len(entries), 'next_slot': next_slot_data, + 'open_shifts': open_shifts, + 'error': kw.get('err'), + 'success': kw.get('ok'), 'page_name': 'fusion_clock_schedule', 'show_payslips': 'hr.payslip' in request.env, } return request.render('fusion_clock.portal_schedule_page', values) + + @http.route('/my/clock/schedule/claim', type='http', auth='user', + methods=['POST'], website=True) + def claim_open_shift(self, schedule_id=None, **kw): + employee = request.env.user.employee_id + if not employee or not schedule_id: + return request.redirect('/my/clock/schedule') + Schedule = request.env['fusion.clock.schedule'].sudo() + sch = Schedule.browse(int(schedule_id)) + try: + Schedule.fclk_claim_open_shift(sch, employee) + return request.redirect('/my/clock/schedule?ok=claimed') + except ValidationError as exc: + return request.redirect( + '/my/clock/schedule?err=' + quote(str(exc.args[0] if exc.args else exc))) + + @http.route('/my/clock/schedule/release', type='http', auth='user', + methods=['POST'], website=True) + def release_shift(self, schedule_id=None, **kw): + employee = request.env.user.employee_id + if not employee or not schedule_id: + return request.redirect('/my/clock/schedule') + Schedule = request.env['fusion.clock.schedule'].sudo() + sch = Schedule.browse(int(schedule_id)) + try: + Schedule.fclk_release_shift(sch, employee) + return request.redirect('/my/clock/schedule?ok=released') + except ValidationError as exc: + return request.redirect( + '/my/clock/schedule?err=' + quote(str(exc.args[0] if exc.args else exc))) diff --git a/fusion_clock/controllers/shift_planner.py b/fusion_clock/controllers/shift_planner.py index c762c70d..03f81618 100644 --- a/fusion_clock/controllers/shift_planner.py +++ b/fusion_clock/controllers/shift_planner.py @@ -79,9 +79,26 @@ class FusionClockShiftPlanner(http.Controller): ('company_id', 'in', request.env.user.company_ids.ids), ], order='sequence, name') + open_rows = Schedule.search([ + ('is_open', '=', True), + ('company_id', 'in', request.env.user.company_ids.ids), + ('schedule_date', '>=', start), + ('schedule_date', '<=', days[-1]), + ], order='schedule_date, start_time') + open_by_day = {} + for row in open_rows: + open_by_day.setdefault(str(row.schedule_date), []).append({ + 'id': row.id, + 'label': row.fclk_display_value(), + 'role_name': row.role_id.name or '', + 'role_color': row.role_id._get_color_from_code(True) if row.role_id else '', + 'hours_display': Schedule.fclk_hours_display(row.planned_hours), + }) + return { 'week_start': str(start), 'week_end': str(days[-1]), + 'open_shifts': open_by_day, 'days': [{ 'date': str(day), 'weekday': day.strftime('%a').upper(), @@ -277,6 +294,49 @@ class FusionClockShiftPlanner(http.Controller): Schedule.fclk_clear_recurrence(schedule) return {'success': True, 'data': self._load_week_data(week_start)} + @http.route('/fusion_clock/shift_planner/create_open_shift', type='jsonrpc', auth='user', methods=['POST']) + def create_open_shift(self, date=None, start_time=None, end_time=None, role_id=None, + count=1, break_minutes=0.0, week_start=None, **kw): + """Create one or more open (unassignable) shifts for a day.""" + if not self._check_manager(): + return {'error': 'Access denied.'} + Schedule = request.env['fusion.clock.schedule'].sudo() + company = request.env.company + try: + Schedule.fclk_create_open_shifts( + company, date, start_time, end_time, + role_id=role_id, count=count, break_minutes=break_minutes) + except ValidationError as exc: + return {'success': False, 'message': str(exc.args[0] if exc.args else exc)} + return {'success': True, 'data': self._load_week_data(week_start)} + + @http.route('/fusion_clock/shift_planner/delete_open_shift', type='jsonrpc', auth='user', methods=['POST']) + def delete_open_shift(self, schedule_id=None, week_start=None, **kw): + if not self._check_manager(): + return {'error': 'Access denied.'} + Schedule = request.env['fusion.clock.schedule'].sudo() + row = Schedule.browse(int(schedule_id or 0)) + if row.exists() and row.is_open: + row.unlink() + return {'success': True, 'data': self._load_week_data(week_start)} + + @http.route('/fusion_clock/shift_planner/bulk_apply', type='jsonrpc', auth='user', methods=['POST']) + def bulk_apply(self, employee_ids=None, date=None, payload=None, week_start=None, **kw): + """Apply one shift to several employees at once (Apply Also To).""" + if not self._check_manager(): + return {'error': 'Access denied.'} + employees = self._manager_employees() + wanted = {int(eid) for eid in (employee_ids or [])} + employees = employees.filtered(lambda e: e.id in wanted) + if not employees: + return {'success': False, 'message': 'Pick at least one employee.'} + Schedule = request.env['fusion.clock.schedule'].sudo() + try: + Schedule.fclk_bulk_apply(employees, date, payload or {}, request.env.user) + except ValidationError as exc: + return {'success': False, 'message': str(exc.args[0] if exc.args else exc)} + return {'success': True, 'data': self._load_week_data(week_start)} + @http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST']) def export_xlsx(self, week_start=None, **kw): if not self._check_manager(): diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index 73dd1069..1460e7ac 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -470,6 +470,68 @@ class FusionClockSchedule(models.Model): rule.unlink() return True + # ----- Open shifts + bulk apply (native "Apply Also To" / self-assign) ----- + + @api.model + def fclk_create_open_shifts(self, company, date_obj, start, end, + role_id=False, count=1, break_minutes=0.0, note=None): + """Create N open (unassigned) shifts for a day, available to claim.""" + date_obj = fields.Date.to_date(date_obj) + if not date_obj: + raise ValidationError(_("Pick a date for the open shift.")) + company_id = (company.id if company else False) or self.env.company.id + vals_list = [{ + 'is_open': True, + 'schedule_date': date_obj, + 'start_time': float(start or 0.0), + 'end_time': float(end or 0.0), + 'break_minutes': float(break_minutes or 0.0), + 'role_id': int(role_id) if role_id else False, + 'company_id': company_id, + 'note': note or False, + 'state': 'posted', + } for _i in range(max(1, int(count or 1)))] + return self.sudo().create(vals_list) + + @api.model + def fclk_claim_open_shift(self, schedule, employee): + """Assign an open shift to an employee (portal self-assign).""" + schedule = schedule.sudo() + employee = employee.sudo() + if not schedule or not schedule.is_open: + raise ValidationError(_("This shift is no longer available.")) + if not employee: + raise ValidationError(_("No employee to assign this shift to.")) + # If the shift carries a role and the employee has an explicit allowed + # list, enforce eligibility (no list = eligible for anything). + if schedule.role_id and employee.x_fclk_role_ids \ + and schedule.role_id not in employee.x_fclk_role_ids: + raise ValidationError(_("You are not eligible for this shift's role.")) + schedule.write({'employee_id': employee.id, 'is_open': False}) + return schedule + + @api.model + def fclk_release_shift(self, schedule, employee): + """Release a claimed shift back to the open pool (portal self-unassign), + respecting the company's days-before cutoff.""" + schedule = schedule.sudo() + if not schedule or schedule.employee_id != employee.sudo(): + raise ValidationError(_("You can only release your own shift.")) + cutoff = schedule.company_id.fclk_self_unassign_days_before or 0 + if (schedule.schedule_date - fields.Date.today()).days < cutoff: + raise ValidationError(_("It is too late to release this shift.")) + schedule.write({'employee_id': False, 'is_open': True}) + return schedule + + @api.model + def fclk_bulk_apply(self, employees, date_obj, payload, user=None): + """Apply the same shift payload to several employees in one go + (native replacement for Planning's 'Apply Also To').""" + results = self.browse() + for employee in employees: + results |= self.fclk_apply_planner_cell(employee, date_obj, dict(payload or {}), user) + return results + @api.model def fclk_email_posted_range(self, employee, start, end, message=None): """Email one employee a summary of their POSTED shifts between two diff --git a/fusion_clock/static/src/css/portal_schedule.css b/fusion_clock/static/src/css/portal_schedule.css index 3e6b32b5..8b0f93fc 100644 --- a/fusion_clock/static/src/css/portal_schedule.css +++ b/fusion_clock/static/src/css/portal_schedule.css @@ -103,6 +103,53 @@ font-style: italic; } +/* ---- Claim / release feedback + open shifts ---- */ +.fpl-flash { + margin: 0 16px 12px; + padding: 10px 14px; + border-radius: 8px; + font-size: 13px; +} + +.fpl-flash-err { + background: rgba(239, 68, 68, 0.10); + border: 1px solid rgba(239, 68, 68, 0.30); + color: #ef4444; +} + +.fpl-flash-ok { + background: rgba(16, 185, 129, 0.10); + border: 1px solid rgba(16, 185, 129, 0.25); + color: var(--fclk-green); +} + +.fpl-open-item { + align-items: center; + justify-content: space-between; +} + +.fpl-claim-form, +.fpl-release-form { + display: inline-block; + margin: 0; +} + +.fpl-release-btn { + display: block; + margin-top: 4px; + background: transparent; + border: 1px solid rgba(239, 68, 68, 0.35); + color: #ef4444; + font-size: 11px; + border-radius: 6px; + padding: 2px 8px; + cursor: pointer; +} + +.fpl-release-btn:hover { + background: rgba(239, 68, 68, 0.10); +} + /* ---- Bottom padding so nav doesn't cover last shift ---- */ .fclk-container { padding-bottom: 80px; diff --git a/fusion_clock/static/src/js/fusion_clock_shift_planner.js b/fusion_clock/static/src/js/fusion_clock_shift_planner.js index 6ae2f330..9c33e1c2 100644 --- a/fusion_clock/static/src/js/fusion_clock_shift_planner.js +++ b/fusion_clock/static/src/js/fusion_clock_shift_planner.js @@ -50,6 +50,8 @@ export class FusionClockShiftPlanner extends Component { repeat: { interval: 1, unit: "week", type: "forever", until: "", number: 4 }, }, publish: { open: false, from: "", to: "", message: "" }, + openShifts: {}, + openShift: { open: false, date: "", start: "09:00", end: "17:00", count: 1 }, }); onWillStart(async () => { @@ -92,6 +94,7 @@ export class FusionClockShiftPlanner extends Component { this.state.departments = data.departments || []; this.state.employees = data.employees || []; this.state.shifts = data.shifts || []; + this.state.openShifts = data.open_shifts || {}; this.state.dirtyCount = 0; this.state.invalidCount = 0; let draft = 0; @@ -368,6 +371,106 @@ export class FusionClockShiftPlanner extends Component { this.state.saving = false; } + _timeStrToFloat(str) { + const [h, m] = (str || "0:0").split(":").map(Number); + return (h || 0) + (m || 0) / 60; + } + + getOpenShiftsForDay(date) { + return this.state.openShifts[date] || []; + } + + get hasOpenShifts() { + return Object.keys(this.state.openShifts || {}).length > 0; + } + + toggleOpenShiftPanel() { + this.state.openShift.open = !this.state.openShift.open; + if (this.state.openShift.open && !this.state.openShift.date) { + this.state.openShift.date = this.state.weekStart; + } + } + + onOpenShiftField(field, ev) { + this.state.openShift[field] = ev.target.value; + } + + async addOpenShift() { + const os = this.state.openShift; + this.state.saving = true; + try { + const result = await rpc("/fusion_clock/shift_planner/create_open_shift", { + date: os.date || this.state.weekStart, + start_time: this._timeStrToFloat(os.start), + end_time: this._timeStrToFloat(os.end), + count: Number(os.count) || 1, + week_start: this.state.weekStart, + }); + if (result.error || result.success === false) { + this.notification.add(result.error || result.message || "Could not add open shift.", { + type: "danger", + }); + } else { + this._applyData(result.data); + this.state.openShift.open = false; + this.notification.add("Open shift added.", { type: "success" }); + } + } catch (error) { + this.notification.add(error.message || "Could not add open shift.", { type: "danger" }); + } + this.state.saving = false; + } + + async deleteOpenShift(id) { + this.state.saving = true; + try { + const result = await rpc("/fusion_clock/shift_planner/delete_open_shift", { + schedule_id: id, + week_start: this.state.weekStart, + }); + if (!result.error) { + this._applyData(result.data); + } + } catch (error) { + this.notification.add(error.message || "Could not remove open shift.", { type: "danger" }); + } + this.state.saving = false; + } + + async bulkApplyDept() { + const editor = this.state.editor; + const employee = this.state.employees.find((e) => e.id === editor.employeeId); + if (!employee) { + return; + } + const department = this.state.departments.find((d) => d.id === employee.department_id); + const ids = (department && department.employee_ids) || [employee.id]; + this.state.saving = true; + try { + const result = await rpc("/fusion_clock/shift_planner/bulk_apply", { + employee_ids: ids, + date: editor.date, + week_start: this.state.weekStart, + payload: { + start_time: Number(editor.startValue), + end_time: Number(editor.endValue), + break_minutes: editor.breakMinutes || 0, + }, + }); + if (result.error || result.success === false) { + this.notification.add(result.error || result.message || "Could not apply.", { + type: "danger", + }); + } else { + this._applyData(result.data); + this.notification.add(`Applied to ${ids.length} employee(s).`, { type: "success" }); + } + } catch (error) { + this.notification.add(error.message || "Could not apply.", { type: "danger" }); + } + this.state.saving = false; + } + closeCellEditor() { this.state.editor.open = false; this.activeCellAnchor = null; diff --git a/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss index 230eebd0..064769f6 100644 --- a/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss +++ b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss @@ -256,6 +256,68 @@ } } +.fclk-planner__open-strip { + margin: 0 10px 10px; + padding: 8px 12px; + background: var(--fclk-planner-card, #ffffff); + border: 1px dashed var(--fclk-planner-border, #d8dadd); + border-radius: 6px; + + .fclk-planner__open-strip-title { + font-size: 12px; + font-weight: 600; + opacity: 0.75; + margin-bottom: 6px; + } + + .fclk-planner__open-cols { + display: flex; + flex-wrap: wrap; + gap: 12px; + } + + .fclk-planner__open-col { + min-width: 120px; + } + + .fclk-planner__open-day { + font-size: 11px; + font-weight: 600; + opacity: 0.6; + margin-bottom: 4px; + } + + .fclk-planner__open-chip { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + padding: 3px 6px; + margin-bottom: 4px; + background: var(--fclk-planner-fallback, #fff8e5); + border-radius: 4px; + } + + .fclk-planner__open-role { + font-size: 10px; + opacity: 0.7; + } + + .fclk-planner__open-del { + margin-left: auto; + border: none; + background: transparent; + cursor: pointer; + font-size: 14px; + line-height: 1; + opacity: 0.6; + } + + .fclk-planner__open-del:hover { + opacity: 1; + } +} + .fclk-planner__repeat-panel { border-top: 1px solid var(--fclk-planner-border, #d8dadd); margin-top: 6px; diff --git a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml index 7b10e33f..a7f50d01 100644 --- a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml +++ b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml @@ -35,6 +35,9 @@ Publish… + @@ -49,6 +52,35 @@ +
+ + + + + + +
+ +
+
Open Shifts (employees can claim)
+
+ +
+
+ +
+ + + +
+
+
+
+
+
+
@@ -254,6 +286,12 @@ t-on-click="() => this.clearRecurrence()"> Stop repeat + + + + + + + +
@@ -108,6 +143,12 @@
h +
+ + + +
From 0cb30f256d5d5cf5e5fc023a35b52644fba9fd02 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 21:14:03 -0400 Subject: [PATCH 09/12] feat(fusion_clock): settings UI for generation horizon + self-unassign; open-shift in backend views [C1] Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_clock/models/res_config_settings.py | 13 +++++++++++++ fusion_clock/views/clock_schedule_views.xml | 6 +++++- fusion_clock/views/res_config_settings_views.xml | 13 +++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/fusion_clock/models/res_config_settings.py b/fusion_clock/models/res_config_settings.py index d00c53e3..2e854a5a 100644 --- a/fusion_clock/models/res_config_settings.py +++ b/fusion_clock/models/res_config_settings.py @@ -245,6 +245,19 @@ class ResConfigSettings(models.TransientModel): help="Which clock location is bound to the NFC kiosk for this company. " "Required when the kiosk is enabled.", ) + fclk_planning_generation_months = fields.Integer( + related='company_id.fclk_planning_generation_months', + readonly=False, + string='Schedule Generation Horizon (months)', + help="How many months ahead recurring shifts are pre-generated.", + ) + fclk_self_unassign_days_before = fields.Integer( + related='company_id.fclk_self_unassign_days_before', + readonly=False, + string='Self-Unassign Cutoff (days before shift)', + help="Employees may release an open shift they claimed up to this many " + "days before it starts.", + ) fclk_photo_retention_days = fields.Integer( string='Auto-Wipe Photos After (days)', config_parameter='fusion_clock.photo_retention_days', diff --git a/fusion_clock/views/clock_schedule_views.xml b/fusion_clock/views/clock_schedule_views.xml index ef7260e0..06bbde1e 100644 --- a/fusion_clock/views/clock_schedule_views.xml +++ b/fusion_clock/views/clock_schedule_views.xml @@ -15,6 +15,7 @@ + @@ -36,12 +37,14 @@ - + + + @@ -72,6 +75,7 @@ + diff --git a/fusion_clock/views/res_config_settings_views.xml b/fusion_clock/views/res_config_settings_views.xml index cdecb117..6f96a6fb 100644 --- a/fusion_clock/views/res_config_settings_views.xml +++ b/fusion_clock/views/res_config_settings_views.xml @@ -41,6 +41,19 @@ + +
+
+
+
+
+
+
From 498963e83a28ce1b45b35e99955a6db398896299 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 21:24:08 -0400 Subject: [PATCH 10/12] fix(fusion_clock): pre-migrate re-links orphaned config-param external ids Settings saved via set_param() have no ir_model_data; the noupdate config XML then collides on UNIQUE(key) during -u. Pre-migrate links existing params to their XML external id (value-preserving) so upgrades are robust. Found on the Entech clone-verify; affects prod (35 params vs 32 xmlids). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migrations/19.0.5.0.0/pre-migrate.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 fusion_clock/migrations/19.0.5.0.0/pre-migrate.py diff --git a/fusion_clock/migrations/19.0.5.0.0/pre-migrate.py b/fusion_clock/migrations/19.0.5.0.0/pre-migrate.py new file mode 100644 index 00000000..ca8a9901 --- /dev/null +++ b/fusion_clock/migrations/19.0.5.0.0/pre-migrate.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# Defensive pre-migration: re-link orphaned fusion_clock config-parameter +# external IDs. +# +# Booleans/floats saved through the Settings UI go in via set_param(), which +# creates the ir_config_parameter row WITHOUT an ir_model_data external id. If a +# param later also appears in the noupdate data/ir_config_parameter_data.xml, +# a plain `-u` can't match it by external id, treats it as new, and the INSERT +# trips the UNIQUE(key) constraint -> "Failed to load registry". +# +# This runs BEFORE the data files load: for every config record in the XML whose +# param already exists but whose external id is missing, we create the external +# id pointing at the existing param. The noupdate load then matches + skips it, +# so the existing (possibly customised) value is preserved. + +import logging +import os + +from lxml import etree + +from odoo.modules.module import get_module_path + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + module_path = get_module_path('fusion_clock') + if not module_path: + return + xml_path = os.path.join(module_path, 'data', 'ir_config_parameter_data.xml') + if not os.path.exists(xml_path): + return + + tree = etree.parse(xml_path) + fixed = 0 + for rec in tree.findall('.//record[@model="ir.config_parameter"]'): + xmlid = rec.get('id') + key_node = rec.find('./field[@name="key"]') + if not xmlid or key_node is None or not (key_node.text or '').strip(): + continue + key = key_node.text.strip() + + cr.execute("SELECT id FROM ir_config_parameter WHERE key = %s", (key,)) + param = cr.fetchone() + if not param: + continue # not set yet -> the noupdate load will create it cleanly + + cr.execute( + "SELECT id FROM ir_model_data WHERE module = 'fusion_clock' AND name = %s", + (xmlid,)) + if cr.fetchone(): + continue # already linked + + cr.execute(""" + INSERT INTO ir_model_data (module, name, model, res_id, noupdate, create_date, write_date) + VALUES ('fusion_clock', %s, 'ir.config_parameter', %s, true, now(), now()) + """, (xmlid, param[0])) + fixed += 1 + + if fixed: + _logger.info( + "Fusion Clock: re-linked %s orphaned config-parameter external id(s).", fixed) From 1630a2025f1f4d4cd170ed038aebb30ee40c2dfa Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 21:28:34 -0400 Subject: [PATCH 11/12] fix(fusion_clock): planning port defers when planning ORM not loaded during -u fusion_clock doesn't depend on planning, so planning's models load AFTER it during -u and the port saw no data. Now detect planning tables via SQL, defer (no marker) when the ORM isn't loaded, and finish the port from the deploy odoo-shell step (full registry). Marker now owned by the method. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migrations/19.0.5.0.0/post-migrate.py | 21 ++++++----- fusion_clock/models/clock_schedule.py | 35 +++++++++++++++---- fusion_clock/tests/test_planning_migration.py | 4 +++ 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/fusion_clock/migrations/19.0.5.0.0/post-migrate.py b/fusion_clock/migrations/19.0.5.0.0/post-migrate.py index 9a9c9e2e..3d6094cb 100644 --- a/fusion_clock/migrations/19.0.5.0.0/post-migrate.py +++ b/fusion_clock/migrations/19.0.5.0.0/post-migrate.py @@ -16,13 +16,13 @@ from odoo import api, SUPERUSER_ID _logger = logging.getLogger(__name__) -_MARKER = 'fusion_clock.planning_migrated' - def migrate(cr, version): - """Port Odoo Planning data into the native models, once. The heavy lifting - lives in fusion.clock.schedule._fclk_port_planning_data so it can be unit - tested on an Enterprise clone where planning is installed.""" + """Drop the legacy one-shift-per-day constraint and attempt the planning -> + native port. The port (fusion.clock.schedule._fclk_port_planning_data) is + marker-guarded and self-defers: because fusion_clock doesn't depend on + planning, planning's ORM may not be loaded here, in which case the deploy + shell step finishes the port. Lives in the model so it's unit-testable.""" env = api.Environment(cr, SUPERUSER_ID, {}) # Phase B drops the hard one-shift-per-day uniqueness so split/open shifts @@ -32,10 +32,9 @@ def migrate(cr, version): "ALTER TABLE fusion_clock_schedule " "DROP CONSTRAINT IF EXISTS fusion_clock_schedule_employee_date_unique") - ICP = env['ir.config_parameter'].sudo() - if ICP.get_param(_MARKER): - _logger.info("Fusion Clock: planning data already migrated; skipping.") - return counts = env['fusion.clock.schedule'].sudo()._fclk_port_planning_data() - ICP.set_param(_MARKER, '1') - _logger.info("Fusion Clock: planning -> native migration done: %s", counts) + if counts.get('deferred'): + _logger.info("Fusion Clock: planning models not loaded during migration; " + "data will be ported by the deploy shell step.") + else: + _logger.info("Fusion Clock: planning -> native migration: %s", counts) diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index 1460e7ac..6860bebb 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -620,17 +620,38 @@ class FusionClockSchedule(models.Model): @api.model def _fclk_port_planning_data(self): """Port Odoo Planning data (roles, employee roles, slots) into the - native models. Safe no-op when planning is not installed. Returns a - dict of counts. Called by the 19.0.5.0.0 migration and by tests.""" + native models. Idempotent (marker-guarded). Returns a dict of counts. + + Because fusion_clock does NOT depend on planning, during a `-u` planning + may load AFTER us, so its ORM models aren't available in the migration's + registry. When that happens we set ``deferred`` and do nothing; the + deploy then runs this again from `odoo shell`, where the whole registry + (planning included) is loaded. Called by the 19.0.5.0.0 migration, the + deploy shell step, and tests.""" import pytz - counts = {'roles': 0, 'employees': 0, 'slots': 0, 'skipped': 0} + counts = {'roles': 0, 'employees': 0, 'slots': 0, 'skipped': 0, 'deferred': False} env = self.env - has_roles = 'planning.role' in env - has_slots = 'planning.slot' in env - if not has_roles and not has_slots: + ICP = env['ir.config_parameter'].sudo() + if ICP.get_param('fusion_clock.planning_migrated'): return counts + # Do the planning tables exist at all? (raw SQL — independent of whether + # planning's ORM models are loaded in this registry.) + env.cr.execute( + "SELECT to_regclass('public.planning_role'), to_regclass('public.planning_slot')") + role_tbl, slot_tbl = env.cr.fetchone() + if not role_tbl and not slot_tbl: + ICP.set_param('fusion_clock.planning_migrated', '1') # Community / fresh + return counts + + # Tables exist but the ORM models may not be loaded yet -> defer. + if 'planning.slot' not in env or 'planning.role' not in env: + counts['deferred'] = True + return counts + + has_roles = bool(role_tbl) + has_slots = bool(slot_tbl) Role = env['fusion.clock.role'].sudo() role_map = {} if has_roles: @@ -692,6 +713,8 @@ class FusionClockSchedule(models.Model): except Exception as exc: counts['skipped'] += 1 _logger.warning("Fusion Clock: skip planning.slot %s (%s).", slot.id, exc) + + ICP.set_param('fusion_clock.planning_migrated', '1') return counts diff --git a/fusion_clock/tests/test_planning_migration.py b/fusion_clock/tests/test_planning_migration.py index e8fba4b4..32bd2c29 100644 --- a/fusion_clock/tests/test_planning_migration.py +++ b/fusion_clock/tests/test_planning_migration.py @@ -18,6 +18,10 @@ class TestPlanningMigration(TransactionCase): if 'planning.slot' not in self.env: self.skipTest('planning not installed (Community / local dev)') + # Ensure the port actually runs (it is marker-guarded for production). + self.env['ir.config_parameter'].sudo().search( + [('key', '=', 'fusion_clock.planning_migrated')]).unlink() + prole = self.env['planning.role'].create({'name': 'PortLead', 'color': 5}) emp = self.env['hr.employee'].create({'name': 'Porty McPort'}) if 'default_planning_role_id' in emp._fields: From 53c292083f3c25bb9899f5f577313e8a97030204 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 21:38:29 -0400 Subject: [PATCH 12/12] test(fusion_clock): update tests for dropped unique + overnight; fix leave reason test_unique_employee_date_schedule -> test_multiple_shifts_per_day_allowed; test_invalid_same_day_range_is_rejected -> test_overnight_range_is_accepted; add required reason to the recurrence leave-skip test. Co-Authored-By: Claude Opus 4.8 (1M context) --- fusion_clock/tests/test_recurrence.py | 2 +- fusion_clock/tests/test_shift_planner.py | 46 ++++++++++++------------ 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/fusion_clock/tests/test_recurrence.py b/fusion_clock/tests/test_recurrence.py index f7fdba58..d47c79e0 100644 --- a/fusion_clock/tests/test_recurrence.py +++ b/fusion_clock/tests/test_recurrence.py @@ -73,7 +73,7 @@ class TestRecurrence(TransactionCase): def test_leave_day_skipped(self): self.env['fusion.clock.leave.request'].create({ - 'employee_id': self.emp.id, + 'employee_id': self.emp.id, 'reason': 'Vacation', 'leave_date': date(2026, 6, 8), 'date_to': date(2026, 6, 8)}) seed = self._seed(date(2026, 6, 1)) rule = self.Schedule.fclk_attach_recurrence(seed, { diff --git a/fusion_clock/tests/test_shift_planner.py b/fusion_clock/tests/test_shift_planner.py index 60b1299e..112b27f8 100644 --- a/fusion_clock/tests/test_shift_planner.py +++ b/fusion_clock/tests/test_shift_planner.py @@ -3,12 +3,8 @@ import json from datetime import date, timedelta -from psycopg2 import IntegrityError - from odoo import fields -from odoo.exceptions import ValidationError from odoo.tests.common import HttpCase, TransactionCase, tagged -from odoo.tools.misc import mute_logger @tagged('-at_install', 'post_install', 'fusion_clock') @@ -34,19 +30,22 @@ class TestShiftPlannerModels(TransactionCase): cls.employee.x_fclk_shift_id = cls.default_shift.id cls.schedule_date = date(2026, 1, 5) - def test_unique_employee_date_schedule(self): + def test_multiple_shifts_per_day_allowed(self): + # The hard one-shift-per-day UNIQUE was dropped in 19.0.5.0.0 to support + # split shifts; the day-plan resolves several rows into one work-window. self.Schedule.create({ 'employee_id': self.employee.id, 'schedule_date': self.schedule_date, - 'is_off': True, + 'start_time': 8.0, 'end_time': 12.0, }) - with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'): - with self.env.cr.savepoint(): - self.Schedule.create({ - 'employee_id': self.employee.id, - 'schedule_date': self.schedule_date, - 'is_off': True, - }) + self.Schedule.create({ + 'employee_id': self.employee.id, + 'schedule_date': self.schedule_date, + 'start_time': 13.0, 'end_time': 17.0, + }) + self.assertEqual(self.Schedule.search_count([ + ('employee_id', '=', self.employee.id), + ('schedule_date', '=', self.schedule_date)]), 2) def test_off_schedule_has_zero_hours(self): schedule = self.Schedule.create({ @@ -68,15 +67,18 @@ class TestShiftPlannerModels(TransactionCase): self.assertEqual(schedule.planned_hours, 8.0) self.assertEqual(self.Schedule.fclk_hours_display(schedule.planned_hours), '8:00') - def test_invalid_same_day_range_is_rejected(self): - with self.assertRaises(ValidationError): - self.Schedule.create({ - 'employee_id': self.employee.id, - 'schedule_date': date(2026, 1, 8), - 'start_time': 17.0, - 'end_time': 9.0, - 'break_minutes': 30, - }) + def test_overnight_range_is_accepted(self): + # Overnight shifts (end on/before start) are supported as of 19.0.5.0.0. + sch = self.Schedule.create({ + 'employee_id': self.employee.id, + 'schedule_date': date(2026, 1, 8), + 'start_time': 17.0, + 'end_time': 9.0, + 'break_minutes': 30, + }) + self.assertTrue(sch.crosses_midnight) + # 17:00 -> 09:00 = 16h, minus 30m break = 15.5h + self.assertAlmostEqual(sch.planned_hours, 15.5, places=2) def test_apply_planner_cell_creates_audit(self): schedule_date = date(2026, 1, 9)