# -*- 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`.')