Files
Odoo-Modules/fusion_clock/docs/superpowers/plans/2026-06-04-remove-planning-dependency.md
2026-06-04 20:34:52 -04:00

301 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<a href="/my/clock/schedule">` 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 0812 and 1317 → plan window 0817, 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/<id>` (open→assign me) and `/unassign/<id>` (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 (A1A3), recurrence (A4A5), send (A6), portal (A7), migration (A8), multi/overnight/open/self-assign/bulk (B1B5), 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).