# 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).