From 023fc95acddaed6fc0c254aa78fdbd7de99b4651 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 4 Jun 2026 20:34:52 -0400 Subject: [PATCH] =?UTF-8?q?docs(fusion=5Fclock):=20implementation=20plan?= =?UTF-8?q?=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).