feat(jobs): cleanup + seed scripts for demo data reset

cleanup_demo_data.py: deletes ALL fp.job, fp.job.step, timelogs,
mrp.production, mrp.workorder, and dependent records (deliveries,
certs, holds, portal jobs, racking inspections, uninvoiced SOs).
Resets the fp.job sequence. Preserves masters. Force-cancels MOs/SOs
via SQL UPDATE before unlink to bypass Odoo's _unlink_except_done
and _unlink_except_draft_or_cancel guards.

seed_demo_data.py: creates 31 fp.job rows distributed across all
6 states (draft=5, confirmed=6, in_progress=8, on_hold=3, done=6,
cancelled=3). In_progress jobs have mixed step states with real
timelogs to simulate a live shop floor. Falls back to direct
fp.job creation when a customer's parts have no coating/recipe,
ensuring customer variety even with sparse coating data.

Both scripts: idempotent (safe to re-run), commit at end, walk
dependents bottom-up to avoid FK violations. Used to reset entech
demo data after the migration trial.

Part of: native job model migration (spec 2026-04-25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-25 05:43:48 -04:00
parent 3ca0f7a719
commit 8f458017c9
2 changed files with 582 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# DESTRUCTIVE: deletes ALL fp.job, fp.job.step, fp.job.step.timelog,
# mrp.production, mrp.workorder records and their dependent data
# (deliveries, certs, thickness readings, holds, portal jobs, racking
# inspections). Preserves masters (partners, parts, recipes, coating
# configs, baths, tanks, work centres, users, groups, settings).
#
# Use only on demo/dev environments. Take a Proxmox snapshot first.
def run(env):
print('=== Cleanup starting ===')
# Walk dependents bottom-up so FK cascades don't bite us.
# 1. Time logs (cascades on step delete, but be explicit)
n = env['fp.job.step.timelog'].search_count([])
env['fp.job.step.timelog'].sudo().search([]).unlink()
print(' Deleted %d fp.job.step.timelog rows' % n)
# 2. fp.job.node.override (cascades on job delete)
n = env['fp.job.node.override'].search_count([])
env['fp.job.node.override'].sudo().search([]).unlink()
print(' Deleted %d fp.job.node.override rows' % n)
# 3. Deliveries linked to jobs OR with job_ref set OR linked to a SO that
# we will delete. Delete ALL deliveries — they're test data.
if 'fusion.plating.delivery' in env:
deliveries = env['fusion.plating.delivery'].sudo().search([])
n = len(deliveries)
deliveries.unlink()
print(' Deleted %d fusion.plating.delivery rows' % n)
# 4. Certificates linked to jobs/MOs
if 'fp.certificate' in env:
certs = env['fp.certificate'].sudo().search([])
n = len(certs)
certs.unlink()
print(' Deleted %d fp.certificate rows' % n)
# 5. Thickness readings
if 'fp.thickness.reading' in env:
tr = env['fp.thickness.reading'].sudo().search([])
n = len(tr)
tr.unlink()
print(' Deleted %d fp.thickness.reading rows' % n)
# 6. Quality holds linked to jobs/MOs
if 'fusion.plating.quality.hold' in env:
holds = env['fusion.plating.quality.hold'].sudo().search([])
n = len(holds)
holds.unlink()
print(' Deleted %d fusion.plating.quality.hold rows' % n)
# 7. Portal jobs (linked to jobs OR legacy production)
if 'fusion.plating.portal.job' in env:
portals = env['fusion.plating.portal.job'].sudo().search([])
n = len(portals)
portals.unlink()
print(' Deleted %d fusion.plating.portal.job rows' % n)
# 8. Racking inspections — required FK to mrp.production, so delete
# BEFORE we kill the productions.
if 'fp.racking.inspection' in env:
insps = env['fp.racking.inspection'].sudo().search([])
n = len(insps)
insps.unlink()
print(' Deleted %d fp.racking.inspection rows' % n)
# 9. Receiving records (required FK to sale.order — delete before SOs)
if 'fp.receiving' in env:
recs = env['fp.receiving'].sudo().search([])
n = len(recs)
recs.unlink()
print(' Deleted %d fp.receiving rows' % n)
# 10. fp.job.step (cascade-safe via job_id, but be explicit)
n = env['fp.job.step'].search_count([])
env['fp.job.step'].sudo().search([]).unlink()
print(' Deleted %d fp.job.step rows' % n)
# 11. fp.job
n = env['fp.job'].search_count([])
env['fp.job'].sudo().search([]).unlink()
print(' Deleted %d fp.job rows' % n)
# 12. mrp.workorder (legacy)
n = env['mrp.workorder'].search_count([])
env['mrp.workorder'].sudo().search([]).unlink()
print(' Deleted %d mrp.workorder rows' % n)
# 13. mrp.production (legacy) — force state via SQL so unlink() bypasses
# Odoo's _unlink_except_done guard (which forbids deleting done MOs)
# and the action_cancel guard (which forbids cancelling done MOs).
# Demo data only.
n = env['mrp.production'].search_count([])
if n:
# 'cancel' state is the only state mrp.production._unlink_except_done
# explicitly permits.
env.cr.execute("UPDATE mrp_production SET state='cancel'")
# Also clear stock moves' state so cascaded checks pass
env.cr.execute(
"UPDATE stock_move SET state='cancel' "
"WHERE raw_material_production_id IN (SELECT id FROM mrp_production) "
"OR production_id IN (SELECT id FROM mrp_production)"
)
env.invalidate_all()
env['mrp.production'].sudo().search([]).unlink()
print(' Deleted %d mrp.production rows' % n)
# 14. Test SOs — delete ALL non-invoiced sale orders. Force state to
# 'cancel' via SQL because Odoo's _unlink_except_draft_or_cancel
# guard otherwise blocks the unlink. Demo data only.
so_ids = env['sale.order'].sudo().search([
('invoice_ids', '=', False),
]).ids
n = len(so_ids)
if so_ids:
env.cr.execute(
"UPDATE sale_order SET state='cancel' WHERE id = ANY(%s)",
(so_ids,),
)
# Also cancel stock pickings the SOs may have created (forces
# Odoo's cascade-aware unlink to pass)
env.cr.execute(
"UPDATE stock_picking SET state='cancel' "
"WHERE sale_id = ANY(%s)",
(so_ids,),
)
env.invalidate_all()
env['sale.order'].sudo().browse(so_ids).unlink()
print(' Deleted %d sale.order rows (uninvoiced)' % n)
# 15. Reset fp.job sequence so new ones start from JOB/00001
seq = env['ir.sequence'].sudo().search([('code', '=', 'fp.job')], limit=1)
if seq:
seq.number_next = 1
print(' Reset fp.job sequence to start at 1')
env.cr.commit()
print('=== Cleanup complete ===')
try:
run(env)
except NameError:
print('Run inside `odoo shell`.')

View File

@@ -0,0 +1,434 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Seeds 5-8 fp.job rows in each lifecycle state to simulate a live
# shop floor. Run after cleanup_demo_data.py.
#
# Strategy:
# 1. Find seedable customer/part combos. Prefer parts with a coating
# (so the SO-confirm flow runs end-to-end), but fall back to
# direct fp.job creation with the only available recipe so we get
# customer variety.
# 2. For each target state, create N jobs and manipulate their
# lifecycle state + step state to simulate a live shop.
#
# Usage: load this file from inside `odoo shell` via the standard
# pattern documented in scripts/README.md.
from datetime import datetime, timedelta
import random
random.seed(42) # reproducible
def _build_combos(env):
"""Return two lists:
- via_so: (partner, part, coating) - for SO-confirm flow
- direct: (partner, part_or_None, recipe) - for direct fp.job create
`via_so` requires a part with x_fc_default_coating_config_id whose
recipe_id is set. `direct` covers all other customers/parts.
"""
via_so = []
direct = []
# Prefer the canonical recipe; fall back to any recipe with operations.
recipe = env['fusion.plating.process.node'].search([
('node_type', '=', 'recipe'),
('name', '=', 'ENP-ALUM-BASIC'),
], limit=1)
if not recipe:
recipe = env['fusion.plating.process.node'].search([
('node_type', '=', 'recipe'),
], limit=1)
if not recipe:
print('ERROR: no recipes found. Cannot seed.')
return via_so, direct, None
parts = env['fp.part.catalog'].search([])
for p in parts:
if not p.partner_id:
continue
if p.x_fc_default_coating_config_id and p.x_fc_default_coating_config_id.recipe_id:
via_so.append((p.partner_id, p, p.x_fc_default_coating_config_id))
else:
direct.append((p.partner_id, p, recipe))
return via_so, direct, recipe
def _create_so(env, partner, part, coating, qty, deadline_offset_days):
"""Create + confirm a SO with one plating line. Returns (so, job)."""
# fp.part.catalog has no product_id field — use a generic product
# for the SO line. Plating-specific fields (x_fc_part_catalog_id,
# x_fc_coating_config_id) carry the real linkage.
fallback_product = env['product.product'].search(
[('sale_ok', '=', True)], limit=1)
if not fallback_product:
fallback_product = env['product.product'].search([], limit=1)
line_vals = {
'product_id': fallback_product.id,
'product_uom_qty': qty,
'price_unit': 50.0 + qty * 2,
}
SOL_fields = env['sale.order.line']._fields
if 'x_fc_part_catalog_id' in SOL_fields:
line_vals['x_fc_part_catalog_id'] = part.id
if 'x_fc_coating_config_id' in SOL_fields:
line_vals['x_fc_coating_config_id'] = coating.id
so = env['sale.order'].sudo().create({
'partner_id': partner.id,
'client_order_ref': 'SEED-%s' % datetime.now().strftime('%H%M%S%f')[:10],
'commitment_date': datetime.now() + timedelta(days=deadline_offset_days),
'order_line': [(0, 0, line_vals)],
})
try:
so.action_confirm()
except Exception as e:
print(' WARN: SO confirm failed for %s (%s) - %s' % (so.name, partner.name, e))
return so, env['fp.job']
job = env['fp.job'].sudo().search([('sale_order_id', '=', so.id)], limit=1)
return so, job
def _create_job_direct(env, partner, part, recipe, qty, deadline_offset_days):
"""Direct fp.job create (skips the SO-confirm hook)."""
Job = env['fp.job'].sudo()
vals = {
'partner_id': partner.id,
'qty': qty,
'date_deadline': datetime.now() + timedelta(days=deadline_offset_days),
'recipe_id': recipe.id,
'priority': random.choice(['low', 'normal', 'normal', 'high']),
'quoted_revenue': 50.0 + qty * 2,
}
if part:
vals['part_catalog_id'] = part.id
# fp.part.catalog has no product_id field — leave fp.job.product_id
# null. It's an optional field used as a "Reference Product".
return Job.create(vals)
def _operators(env):
g = env.ref('fusion_plating.group_fusion_plating_operator',
raise_if_not_found=False)
if not g:
return env['res.users']
# Odoo 19: group <-> users m2m field on res.users is `all_group_ids`
return env['res.users'].search([('all_group_ids', 'in', g.id)])
def _confirm_and_steps(env, job):
"""Drive a draft job through action_confirm + step generation."""
if not job:
return
if job.state == 'draft':
try:
job.action_confirm()
except Exception as e:
print(' WARN: job %s action_confirm failed: %s' % (job.name, e))
return
if job.recipe_id and not job.step_ids:
try:
job._generate_steps_from_recipe()
except Exception as e:
print(' WARN: job %s step gen failed: %s' % (job.name, e))
def run(env):
print('=== Seeding fresh demo data ===')
via_so, direct, recipe = _build_combos(env)
print(' via_so combos: %d' % len(via_so))
print(' direct combos: %d' % len(direct))
print(' recipe: %s' % (recipe.name if recipe else 'NONE'))
if not recipe:
return
if not direct and not via_so:
print('ERROR: no combos available. Cannot seed.')
return
operators = _operators(env)
print(' operators: %d' % len(operators))
counts = {
'draft': 5,
'confirmed': 6,
'in_progress': 8,
'on_hold': 3,
'done': 6,
'cancelled': 3,
}
via_so_idx = 0
direct_idx = 0
def _next_via_so():
nonlocal via_so_idx
if not via_so:
return None
c = via_so[via_so_idx % len(via_so)]
via_so_idx += 1
return c
def _next_direct():
nonlocal direct_idx
if not direct:
return None
c = direct[direct_idx % len(direct)]
direct_idx += 1
return c
def _next_combo(prefer_so=False):
if prefer_so and via_so:
return ('so', _next_via_so())
if direct:
return ('direct', _next_direct())
if via_so:
return ('so', _next_via_so())
return (None, None)
created = {state: [] for state in counts}
# 1. DRAFT - direct create, do NOT confirm
print('-- Creating draft jobs --')
for i in range(counts['draft']):
kind, combo = _next_combo()
if not combo:
break
partner, part, coating_or_recipe = combo
if kind == 'so':
job = _create_job_direct(
env, partner, part, coating_or_recipe.recipe_id,
qty=random.choice([1, 5, 10, 25, 50]),
deadline_offset_days=random.randint(7, 30),
)
if part.x_fc_default_coating_config_id:
job.coating_config_id = part.x_fc_default_coating_config_id.id
else:
job = _create_job_direct(
env, partner, part, coating_or_recipe,
qty=random.choice([1, 5, 10, 25, 50]),
deadline_offset_days=random.randint(7, 30),
)
created['draft'].append(job)
print(' draft: %s (%s)' % (job.name, partner.name))
# 2. CONFIRMED
print('-- Creating confirmed jobs --')
for i in range(counts['confirmed']):
prefer_so = (i % 2 == 0)
kind, combo = _next_combo(prefer_so=prefer_so)
if not combo:
break
partner, part, coating_or_recipe = combo
if kind == 'so':
so, job = _create_so(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10, 25, 50, 100]),
deadline_offset_days=random.randint(5, 25),
)
_confirm_and_steps(env, job)
else:
job = _create_job_direct(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10, 25, 50, 100]),
deadline_offset_days=random.randint(5, 25),
)
_confirm_and_steps(env, job)
if job:
created['confirmed'].append(job)
print(' confirmed: %s (%s, %d steps)' % (
job.name, partner.name, len(job.step_ids)))
# 3. IN_PROGRESS
print('-- Creating in_progress jobs --')
for i in range(counts['in_progress']):
kind, combo = _next_combo()
if not combo:
break
partner, part, coating_or_recipe = combo
if kind == 'so':
so, job = _create_so(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10, 25, 50]),
deadline_offset_days=random.randint(3, 15),
)
else:
job = _create_job_direct(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10, 25, 50]),
deadline_offset_days=random.randint(3, 15),
)
if not job:
continue
_confirm_and_steps(env, job)
job.state = 'in_progress'
job.date_started = datetime.now() - timedelta(days=random.randint(1, 5))
steps = job.step_ids.sorted('sequence')
if not steps:
print(' WARN: in_progress job %s has no steps' % job.name)
created['in_progress'].append(job)
continue
for s in steps:
if operators:
s.assigned_user_id = operators[
random.randrange(len(operators))
]
n_done = max(1, int(len(steps) * random.uniform(0.3, 0.6)))
for s in steps[:n_done]:
s.state = 'done'
s.date_started = datetime.now() - timedelta(
hours=random.randint(2, 48))
s.date_finished = s.date_started + timedelta(
minutes=random.randint(15, 240))
s.duration_actual = (
s.date_finished - s.date_started).total_seconds() / 60.0
s.started_by_user_id = s.assigned_user_id or env.user
s.finished_by_user_id = s.assigned_user_id or env.user
if n_done < len(steps):
cur = steps[n_done]
cur.state = 'in_progress'
cur.date_started = datetime.now() - timedelta(
minutes=random.randint(5, 90))
cur.started_by_user_id = cur.assigned_user_id or env.user
env['fp.job.step.timelog'].sudo().create({
'step_id': cur.id,
'user_id': (cur.assigned_user_id.id
if cur.assigned_user_id else env.user.id),
'date_started': cur.date_started,
})
if n_done + 1 < len(steps):
steps[n_done + 1].state = 'ready'
created['in_progress'].append(job)
print(' in_progress: %s (%s, %d/%d done)' % (
job.name, partner.name, n_done, len(steps)))
# 4. ON_HOLD
print('-- Creating on_hold jobs --')
for i in range(counts['on_hold']):
kind, combo = _next_combo()
if not combo:
break
partner, part, coating_or_recipe = combo
if kind == 'so':
so, job = _create_so(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10, 25]),
deadline_offset_days=random.randint(5, 20),
)
else:
job = _create_job_direct(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10, 25]),
deadline_offset_days=random.randint(5, 20),
)
if not job:
continue
_confirm_and_steps(env, job)
steps = job.step_ids.sorted('sequence')
for s in steps[:2]:
s.state = 'done'
s.date_finished = datetime.now() - timedelta(days=1)
s.date_started = s.date_finished - timedelta(minutes=60)
s.duration_actual = 60.0
if len(steps) > 2:
steps[2].state = 'paused'
steps[2].date_started = datetime.now() - timedelta(hours=4)
job.state = 'on_hold'
created['on_hold'].append(job)
print(' on_hold: %s (%s)' % (job.name, partner.name))
# 5. DONE
print('-- Creating done jobs --')
for i in range(counts['done']):
kind, combo = _next_combo()
if not combo:
break
partner, part, coating_or_recipe = combo
if kind == 'so':
so, job = _create_so(
env, partner, part, coating_or_recipe,
qty=random.choice([1, 5, 10, 25]),
deadline_offset_days=random.randint(-5, 5),
)
else:
job = _create_job_direct(
env, partner, part, coating_or_recipe,
qty=random.choice([1, 5, 10, 25]),
deadline_offset_days=random.randint(-5, 5),
)
if not job:
continue
_confirm_and_steps(env, job)
steps = job.step_ids.sorted('sequence')
for j, s in enumerate(steps):
s.state = 'done'
offset = (len(steps) - j) * 30
s.date_started = datetime.now() - timedelta(minutes=offset + 30)
s.date_finished = datetime.now() - timedelta(minutes=offset)
s.duration_actual = 30.0
if operators:
op = operators[random.randrange(len(operators))]
s.assigned_user_id = op
s.started_by_user_id = op
s.finished_by_user_id = op
# Set state directly to avoid downstream side effects (delivery
# + cert auto-create) on demo data.
job.state = 'done'
job.date_finished = datetime.now() - timedelta(
hours=random.randint(1, 48))
job.date_started = datetime.now() - timedelta(days=2)
created['done'].append(job)
print(' done: %s (%s)' % (job.name, partner.name))
# 6. CANCELLED
print('-- Creating cancelled jobs --')
for i in range(counts['cancelled']):
kind, combo = _next_combo()
if not combo:
break
partner, part, coating_or_recipe = combo
if kind == 'so':
so, job = _create_so(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10]),
deadline_offset_days=random.randint(10, 30),
)
else:
job = _create_job_direct(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10]),
deadline_offset_days=random.randint(10, 30),
)
if not job:
continue
_confirm_and_steps(env, job)
try:
job.action_cancel()
except Exception:
job.state = 'cancelled'
created['cancelled'].append(job)
print(' cancelled: %s (%s)' % (job.name, partner.name))
env.cr.commit()
print()
print('=== Seed summary ===')
for state, jobs in created.items():
print(' %s: %d jobs' % (state, len(jobs)))
print()
print('=== Verification ===')
Job = env['fp.job']
for state in counts:
print(' fp.job state=%s: actual=%d' % (
state, Job.search_count([('state', '=', state)])))
try:
run(env)
except NameError:
print('Run inside `odoo shell`.')