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>
435 lines
15 KiB
Python
435 lines
15 KiB
Python
# -*- 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`.')
|