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

20 KiB
Raw Blame History

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.pyfusion.clock.role
  • fusion_clock/models/clock_recurrence.pyfusion.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.pyx_fclk_default_role_id, x_fclk_role_ids; _get_fclk_day_plan multi-window
  • fusion_clock/models/clock_shift.pyrole_id
  • fusion_clock/models/clock_schedule.pyrole_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.pyfclk_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):
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:
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):
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_weekfclk_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.rolefusion.clock.role (name, color); build id map.
    • employees: default_planning_role_idx_fclk_default_role_id; planning_role_idsx_fclk_role_ids.
    • slots: planning.slotfusion.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 adminadmin_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).