diff --git a/fusion_plating/fusion_plating_jobs/scripts/cleanup_demo_data.py b/fusion_plating/fusion_plating_jobs/scripts/cleanup_demo_data.py new file mode 100644 index 00000000..c3f5a9b8 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/scripts/cleanup_demo_data.py @@ -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`.') diff --git a/fusion_plating/fusion_plating_jobs/scripts/seed_demo_data.py b/fusion_plating/fusion_plating_jobs/scripts/seed_demo_data.py new file mode 100644 index 00000000..5c6691cf --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/scripts/seed_demo_data.py @@ -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`.')