20 KiB
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.rolefusion_clock/models/clock_recurrence.py—fusion.clock.schedule.recurrence+ generation enginefusion_clock/views/clock_role_views.xml— role list/form/action/menu + Employee Roles editorfusion_clock/views/clock_recurrence_views.xml— recurrence list/form/action/menufusion_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_planningfusion_clock/data/clock_recurrence_cron.xml— recurrence generation cronfusion_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.pyfusion_clock/migrations/19.0.5.0.0/post-migrate.py— planning→native data migration
Modify:
fusion_clock/models/__init__.py— registerclock_role,clock_recurrencefusion_clock/models/hr_employee.py—x_fclk_default_role_id,x_fclk_role_ids;_get_fclk_day_planmulti-windowfusion_clock/models/clock_shift.py—role_idfusion_clock/models/clock_schedule.py—role_id,recurrence_id,is_open,crosses_midnight; overnight math; constraint relax;fclk_email_posted_range; recurrence helpersfusion_clock/models/res_config_settings.py+res_company.py—fclk_planning_generation_months,fclk_self_unassign_days_beforefusion_clock/controllers/shift_planner.py— recurrence, publish-range, open-shift, bulk-apply endpointsfusion_clock/static/src/js/fusion_clock_shift_planner.js+.xml— role chip, Repeat dialog, Publish&Notify, open lane, bulk applyfusion_clock/views/clock_shift_views.xml,clock_schedule_views.xml— role fields; recurrence/open columnsfusion_clock/views/portal_*_templates.xml(clock, timesheets, reports, payslip list+detail) — inline Schedule nav buttonfusion_clock/data/mail_template_data.xml— schedule publish emailfusion_clock/security/ir.model.access.csv— role + recurrence ACLsfusion_clock/views/clock_menus.xml— Roles + Recurrences config menusfusion_clock/__manifest__.py— version19.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(addfrom . import clock_rolebeforeclock_shift). - ACL rows:
model_fusion_clock_role→ user read; manager RWCU; portal read. - Run
test_roleon 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 withx_fclk_default_role_id+x_fclk_role_ids, copied fromfusion_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 infclk_apply_planner_cellvals fromshift.role_idoremployee.x_fclk_default_role_id; includerole_id+role_colorinfclk_cell_payload.- Add
role_idto 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;untilcaps at date;x_timescaps at N;_stopdeletes 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 approvedfusion.clock.leave.requestcovers date — read existing leave model first).- Cron
data/clock_recurrence_cron.xml: daily,model._cron_generate(), nonumbercall(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'); methodfclk_attach_recurrence(schedule, repeat_vals)creating the rule, linking the seed, calling_generate().- Controller endpoint
/fusion_clock/shift_planner/set_recurrence(manager-gated) → callsfclk_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/extendfclk_email_posted_week→fclk_email_posted_range(employee, start, end)(keep a thin_weekwrapper). Addfclk_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; keeppost_weekcalling it for the visible week. - Mail template
mail_template_fclk_schedule_published(copy/reword from planningdata/mail_template_data.xml; obey Odoo-19 mail rules: nourl_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 theplanning.slotbranch; read onlyfusion.clock.schedule; role colour fromrole_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-barstructure 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).
- roles:
- 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_idrequired=False; add@api.constrainsrequiring employee unlessis_open. - Replace
_employee_date_uniqueConstraint withmodels.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. Writetest_open_shift.pyfirst (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: ifend<=start→(24-start)+end-break/60; setcrosses_midnight._check_schedule_times: allowend<=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 — replacesx_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→ version19.0.5.0.0; add data files (clock_role_views.xml,clock_recurrence_views.xml,clock_recurrence_cron.xml,portal_schedule_templates.xml), assetportal_schedule.css; mail template already indata/.res_config_settings.py+ view: exposefclk_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_clockinto an isolated_testaddons dir shadowing prod;-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0on the clone; assert exit 0 + "Modules loaded". - Run
--test-tags /fusion_clockon the clone; assert green. odoo shellon clone: assert 144 schedule rows intact, 8 slots + 1 role migrated, portal/my/clock/schedulerenders;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 pushinto/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/%';thensystemctl start odoo.
Task C4: retire fusion_planning (+ optional planning)
- After prod
-uhealthy + migration verified: uninstallfusion_planning(Apps → uninstall, orenv['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).