feat(fusion_clock): planning -> native data migration [A8]

post-migrate(19.0.5.0.0) -> fusion.clock.schedule._fclk_port_planning_data:
planning.role -> fusion.clock.role, employee default/allowed roles, and
planning.slot -> fusion.clock.schedule (local date+float, role map, posted
if published, open if unassigned). Guarded (no-op on Community), idempotent
(marker), per-row savepoints. Integration test runs on Enterprise clones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-04 20:58:13 -04:00
parent 3376a32143
commit d35d5f4b34
3 changed files with 159 additions and 0 deletions

View File

@@ -516,6 +516,83 @@ class FusionClockSchedule(models.Model):
notified += 1
return posted, notified
@api.model
def _fclk_port_planning_data(self):
"""Port Odoo Planning data (roles, employee roles, slots) into the
native models. Safe no-op when planning is not installed. Returns a
dict of counts. Called by the 19.0.5.0.0 migration and by tests."""
import pytz
counts = {'roles': 0, 'employees': 0, 'slots': 0, 'skipped': 0}
env = self.env
has_roles = 'planning.role' in env
has_slots = 'planning.slot' in env
if not has_roles and not has_slots:
return counts
Role = env['fusion.clock.role'].sudo()
role_map = {}
if has_roles:
for prole in env['planning.role'].sudo().with_context(active_test=False).search([]):
target = Role.with_context(active_test=False).search(
[('name', '=ilike', prole.name)], limit=1) or Role.create({
'name': prole.name, 'color': prole.color or 1, 'active': prole.active})
role_map[prole.id] = target.id
counts['roles'] = len(role_map)
Employee = env['hr.employee'].sudo().with_context(active_test=False)
for emp in Employee.search([]):
vals = {}
if emp._fields.get('default_planning_role_id') and emp.default_planning_role_id:
mapped = role_map.get(emp.default_planning_role_id.id)
if mapped:
vals['x_fclk_default_role_id'] = mapped
if emp._fields.get('planning_role_ids') and emp.planning_role_ids:
mapped_ids = [role_map[r.id] for r in emp.planning_role_ids if r.id in role_map]
if mapped_ids:
vals['x_fclk_role_ids'] = [(6, 0, mapped_ids)]
if vals:
emp.write(vals)
counts['employees'] += 1
if has_slots:
Schedule = self.sudo()
for slot in env['planning.slot'].sudo().search([], order='start_datetime'):
if not slot.start_datetime or not slot.end_datetime:
counts['skipped'] += 1
continue
employee = slot.employee_id if 'employee_id' in slot._fields else False
tz_name = ((employee.tz if employee else False)
or (slot.resource_id.tz if slot.resource_id else False)
or env.company.partner_id.tz or 'UTC')
try:
tz = pytz.timezone(tz_name)
except Exception:
tz = pytz.UTC
local_start = pytz.utc.localize(slot.start_datetime).astimezone(tz)
local_end = pytz.utc.localize(slot.end_datetime).astimezone(tz)
span_hours = (slot.end_datetime - slot.start_datetime).total_seconds() / 3600.0
allocated = slot.allocated_hours if 'allocated_hours' in slot._fields else span_hours
vals = {
'employee_id': employee.id if employee else False,
'is_open': not bool(employee),
'schedule_date': local_start.date(),
'start_time': round(local_start.hour + local_start.minute / 60.0, 2),
'end_time': round(local_end.hour + local_end.minute / 60.0, 2),
'break_minutes': round(max(0.0, span_hours - (allocated or span_hours)) * 60.0, 0),
'role_id': role_map.get(slot.role_id.id) if slot.role_id else False,
'note': slot.name or False,
'state': 'posted' if slot.state == 'published' else 'draft',
}
with env.cr.savepoint():
try:
Schedule.create(vals)
counts['slots'] += 1
except Exception as exc:
counts['skipped'] += 1
_logger.warning("Fusion Clock: skip planning.slot %s (%s).", slot.id, exc)
return counts
class FusionClockScheduleAudit(models.Model):
_name = 'fusion.clock.schedule.audit'