diff --git a/fusion_plating/scripts/fp_demo_stage_filler.py b/fusion_plating/scripts/fp_demo_stage_filler.py new file mode 100644 index 00000000..db9b0a9c --- /dev/null +++ b/fusion_plating/scripts/fp_demo_stage_filler.py @@ -0,0 +1,546 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""Demo stage-filler — fills the gaps left after fp_demo_seed.py. + +The base seeder gives us customers, SOs, MOs, WOs, deliveries, invoices +and payments. After the team-skills + timer-audit + presence-aware +Manager Desk work landed (commit 0d12902) the demo needs: + + 1. Timer audit fields (x_fc_started_*, x_fc_finished_*) backfilled + on done WOs so the new "Timer Audit" group has values + 2. WO role tags filled in on any leftover WOs (auto-promotion needs + a role on the WO to credit the operator) + 3. A diverse team — Marie / James / Priya / Diego / Aisha / Carlos + covering different role combinations including lead hands + 4. Proficiency records seeded from completed WOs so the "Task + Proficiency" list on the employee form has rich data and a few + auto-promotions are already on record + 5. Three employees clocked in right now via hr.attendance so the + Manager Desk "Present X / Y" chip has data and the worker + dropdown shows the bucket cues working + 6. Two extra quality holds + one paused WO so every stage of the + workflow is represented somewhere in the demo + +Run via odoo-shell: + + su - odoo -s /bin/bash -c "odoo shell -c /etc/odoo/odoo.conf -d admin \\ + --no-http --stop-after-init < fp_demo_stage_filler.py" + +Idempotent — re-runs are safe. +""" +import logging +from datetime import timedelta + +from odoo import fields + +_logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# 1. Backfill x_fc_work_role_id on WOs that don't have one yet +# --------------------------------------------------------------------------- +# Simple keyword map: WO/workcenter name → role code. Anything that +# doesn't match falls back to plating_op (the most common role). +ROLE_KEYWORDS = [ + ('mask', 'masking'), + ('rack', 'racking'), + ('demask', 'demask'), + ('derack', 'derack'), + ('plat', 'plating_op'), + ('bake', 'oven'), + ('oven', 'oven'), + ('inspect','inspection'), + ('rework', 'rework'), +] + + +def _fill_wo_roles(env): + Role = env['fp.work.role'] + role_by_code = {r.code: r for r in Role.search([])} + fallback = role_by_code.get('plating_op') + fixed = 0 + wos = env['mrp.workorder'].search([('x_fc_work_role_id', '=', False)]) + for wo in wos: + haystack = ((wo.name or '') + ' ' + (wo.workcenter_id.name or '')).lower() + match = next( + (role_by_code[code] for kw, code in ROLE_KEYWORDS + if kw in haystack and code in role_by_code), + fallback, + ) + if match: + wo.x_fc_work_role_id = match.id + fixed += 1 + print(f"[1] Filled work_role on {fixed} WOs (fallback = plating_op)") + + +# --------------------------------------------------------------------------- +# 2. Backfill timer audit on done WOs +# --------------------------------------------------------------------------- +def _backfill_timer_audit(env): + """Stamp started_at/finished_at on done WOs from their time_ids. + + Where time_ids isn't populated (some demo WOs were created without + going through the productivity flow) we synthesise the timestamps + from create_date + duration so the WO header isn't empty. + """ + fixed = 0 + wos = env['mrp.workorder'].search([ + ('state', '=', 'done'), + ('x_fc_started_at', '=', False), + ]) + for wo in wos: + # Prefer real time_ids data — that's what the live override would + # have captured if the WO had been finished after the upgrade. + if wo.time_ids: + first = wo.time_ids.sorted('date_start')[:1] + last = wo.time_ids.sorted('date_end', reverse=True)[:1] + start_dt = first.date_start if first else False + end_dt = last.date_end if last else False + start_uid = first.user_id.id if first and first.user_id else False + end_uid = last.user_id.id if last and last.user_id else False + else: + start_dt = wo.create_date + dur = wo.duration or 0 + end_dt = ( + wo.create_date + timedelta(minutes=dur) + if wo.create_date else False + ) + uid = ( + wo.x_fc_assigned_user_id.id + if wo.x_fc_assigned_user_id else env.uid + ) + start_uid = end_uid = uid + # Always falls back to the assigned worker if we couldn't pull a + # user from time_ids — that's the operator who SHOULD get credit. + if not start_uid: + start_uid = ( + wo.x_fc_assigned_user_id.id + if wo.x_fc_assigned_user_id else env.uid + ) + if not end_uid: + end_uid = start_uid + wo.write({ + 'x_fc_started_at': start_dt, + 'x_fc_started_by_user_id': start_uid, + 'x_fc_finished_at': end_dt, + 'x_fc_finished_by_user_id': end_uid, + }) + fixed += 1 + print(f"[2] Backfilled timer audit on {fixed} done WOs") + + +# --------------------------------------------------------------------------- +# 3. Add five diverse operators + a senior lead hand +# --------------------------------------------------------------------------- +TEAM = [ + { + 'name': 'Marie Dubois', + 'work_email': 'marie.dubois@entech.demo', + 'job_title': 'Masking Specialist', + 'role_codes': ['masking', 'racking'], + 'lead_codes': ['masking'], + }, + { + 'name': "James O'Connor", + 'work_email': 'james.oconnor@entech.demo', + 'job_title': 'Senior Plating Operator', + 'role_codes': ['plating_op', 'demask'], + 'lead_codes': ['plating_op'], + }, + { + 'name': 'Priya Sharma', + 'work_email': 'priya.sharma@entech.demo', + 'job_title': 'Quality Inspector', + 'role_codes': ['oven', 'inspection'], + 'lead_codes': ['oven', 'inspection'], + }, + { + # Diego is the "still in training" employee — racks and plates + # but isn't qualified for masking yet. The proficiency tracker + # will promote him once he's finished N masking WOs. + 'name': 'Diego Ramirez', + 'work_email': 'diego.ramirez@entech.demo', + 'job_title': 'Plating Operator', + 'role_codes': ['racking', 'plating_op'], + 'lead_codes': [], + }, + { + 'name': 'Aisha Khan', + 'work_email': 'aisha.khan@entech.demo', + 'job_title': 'Inspection & Rework', + 'role_codes': ['inspection', 'rework'], + 'lead_codes': [], + }, + { + # Carlos is the senior — lead hand for everything, can cover + # any shift. Manager Desk surfaces him on every dropdown. + 'name': 'Carlos Silva', + 'work_email': 'carlos.silva@entech.demo', + 'job_title': 'Shift Supervisor', + 'role_codes': ['masking', 'racking', 'plating_op', 'demask', + 'oven', 'derack', 'inspection', 'rework'], + 'lead_codes': ['masking', 'racking', 'plating_op', 'demask', + 'oven', 'derack', 'inspection', 'rework'], + }, +] + + +def _seed_team(env): + Role = env['fp.work.role'] + Emp = env['hr.employee'] + Users = env['res.users'] + role_by_code = {r.code: r for r in Role.search([])} + operator_group = env.ref( + 'fusion_plating.group_fusion_plating_operator', + raise_if_not_found=False, + ) + created = 0 + updated = 0 + for spec in TEAM: + emp = Emp.search([('name', '=', spec['name'])], limit=1) + role_ids = [ + role_by_code[c].id for c in spec['role_codes'] + if c in role_by_code + ] + lead_ids = [ + role_by_code[c].id for c in spec['lead_codes'] + if c in role_by_code + ] + vals = { + 'name': spec['name'], + 'work_email': spec['work_email'], + 'job_title': spec['job_title'], + 'x_fc_work_role_ids': [(6, 0, role_ids)], + 'x_fc_lead_hand_role_ids': [(6, 0, lead_ids)], + } + if emp: + emp.write(vals) + updated += 1 + else: + emp = Emp.create(vals) + created += 1 + + # Make sure each employee has a backing res.users so the Manager + # Desk dropdown can pick them. Without a user the WO can't be + # assigned (x_fc_assigned_user_id targets res.users). + if not emp.user_id: + user = Users.search([('login', '=', spec['work_email'])], limit=1) + if not user: + # Odoo 19 renamed groups_id → group_ids on res.users. + user_vals = { + 'name': spec['name'], + 'login': spec['work_email'], + 'email': spec['work_email'], + } + if operator_group: + user_vals['group_ids'] = [(4, operator_group.id)] + user = Users.create(user_vals) + emp.user_id = user.id + print(f"[3] Seeded team: {created} new, {updated} updated") + + +# --------------------------------------------------------------------------- +# 4. Backfill proficiency records from completed WOs +# --------------------------------------------------------------------------- +def _redistribute_completed_wos(env): + """Re-assign a slice of historical done WOs across the new team. + + Without this, all the existing 82 done WOs stay credited to the + two original users (Administrator + Andrew) and the new team + members (Marie/James/Priya/etc.) look like blank slates with no + completion history. Spread the credit so the demo shows realistic + proficiency variance — Marie has 8 masking jobs done, James has + 12 plating_op, Carlos has touched everything, Diego is mid-training. + """ + Emp = env['hr.employee'] + Role = env['fp.work.role'] + role_by_code = {r.code: r for r in Role.search([])} + + # Plan: each (operator, role) gets a target completion count. + # Diego stays at 2 masking on purpose (still in training). + plan = { + 'Marie Dubois': {'masking': 8, 'racking': 5}, + "James O'Connor": {'plating_op': 12, 'demask': 4}, + 'Priya Sharma': {'oven': 6, 'inspection': 9}, + 'Aisha Khan': {'inspection': 4, 'rework': 3}, + 'Carlos Silva': {'masking': 3, 'racking': 3, 'plating_op': 4, 'oven': 2, + 'demask': 2, 'derack': 2, 'inspection': 3, 'rework': 1}, + } + + moved = 0 + for emp_name, role_targets in plan.items(): + emp = Emp.search([('name', '=', emp_name)], limit=1) + if not emp or not emp.user_id: + continue + for role_code, target_count in role_targets.items(): + role = role_by_code.get(role_code) + if not role: + continue + # Find done WOs of this role that aren't already on this user + wos = env['mrp.workorder'].search([ + ('state', '=', 'done'), + ('x_fc_work_role_id', '=', role.id), + ('x_fc_assigned_user_id', '!=', emp.user_id.id), + ], limit=target_count) + for wo in wos: + wo.x_fc_assigned_user_id = emp.user_id.id + # Re-stamp the audit so finished_by reflects the new owner. + wo.x_fc_started_by_user_id = emp.user_id.id + wo.x_fc_finished_by_user_id = emp.user_id.id + moved += 1 + print(f"[3b] Re-credited {moved} historical WOs to the new team") + + +def _seed_proficiency(env): + """Walk every done WO and credit the assigned worker for the role. + + This rebuilds the proficiency tally as if every historical WO had + gone through the new button_finish override. Auto-promotion fires + naturally on the way: if Marie has 8 completed masking WOs and + masking.mastery_required is 3, she'll get promoted (which is a + no-op since she's already qualified) but the proficiency row will + show promoted=True with a real promoted_at timestamp. + """ + Prof = env['fp.operator.proficiency'] + Prof.search([]).unlink() # reset so re-runs are deterministic + + counts = {} # (employee_id, role_id) → count + first_seen = {} + last_seen = {} + done_wos = env['mrp.workorder'].search([ + ('state', '=', 'done'), + ('x_fc_assigned_user_id', '!=', False), + ('x_fc_work_role_id', '!=', False), + ]) + for wo in done_wos: + emp = wo.x_fc_assigned_user_id.employee_id + if not emp: + continue + key = (emp.id, wo.x_fc_work_role_id.id) + counts[key] = counts.get(key, 0) + 1 + ts = wo.x_fc_finished_at or wo.write_date + if key not in first_seen or ts < first_seen[key]: + first_seen[key] = ts + if key not in last_seen or ts > last_seen[key]: + last_seen[key] = ts + + created = 0 + promoted = 0 + for (emp_id, role_id), count in counts.items(): + rec = Prof.create({ + 'employee_id': emp_id, + 'role_id': role_id, + 'completed_count': count, + 'first_completed_at': first_seen[(emp_id, role_id)], + 'last_completed_at': last_seen[(emp_id, role_id)], + }) + rec._maybe_promote() # fires promotion + chatter when applicable + created += 1 + if rec.promoted: + promoted += 1 + + # Give Diego a partial-mastery row on masking so we can SEE the + # progress label "2 / 3" and the not-yet-promoted state — that's + # the most interesting demo case. + diego = env['hr.employee'].search([('name', '=', 'Diego Ramirez')], limit=1) + masking = env['fp.work.role'].search([('code', '=', 'masking')], limit=1) + if diego and masking and not Prof.search_count([ + ('employee_id', '=', diego.id), ('role_id', '=', masking.id) + ]): + Prof.create({ + 'employee_id': diego.id, + 'role_id': masking.id, + 'completed_count': max(masking.mastery_required - 1, 1), + 'first_completed_at': fields.Datetime.now() - timedelta(days=10), + 'last_completed_at': fields.Datetime.now() - timedelta(days=1), + }) + print(f" + Diego seeded with {max(masking.mastery_required - 1, 1)} " + f"masking completions (one more away from promotion)") + print(f"[4] Seeded {created} proficiency rows ({promoted} auto-promoted)") + + +# --------------------------------------------------------------------------- +# 5. Clock three employees in right now (hr.attendance open record) +# --------------------------------------------------------------------------- +def _clock_in_team(env): + Att = env['hr.attendance'] + # Wipe any stale open records before creating fresh ones. We unlink + # rather than close because earlier script runs may have left + # check_in timestamps in the future (a .replace(hour=12) bug); the + # validator then refuses any check_out and the close fails. + Att.search([('check_out', '=', False)]).sudo().unlink() + + targets = ['Marie Dubois', 'James O\'Connor', 'Carlos Silva'] + started = [] + # Always 4 hours ago — guaranteed before "now", regardless of when + # the close-stale-records step ran. The earlier .replace() approach + # could land in the future if the script ran after noon UTC, which + # the validator rejected. + check_in = fields.Datetime.now() - timedelta(hours=4) + for name in targets: + emp = env['hr.employee'].search([('name', '=', name)], limit=1) + if not emp: + continue + Att.create({ + 'employee_id': emp.id, + 'check_in': check_in, + }) + started.append(name) + print(f"[5] Clocked in: {', '.join(started) or '(no targets found)'}") + + +# --------------------------------------------------------------------------- +# 6. Top up extra demo records — quality holds + paused WO + RFQs +# --------------------------------------------------------------------------- +def _add_quality_holds(env): + Hold = env['fusion.plating.quality.hold'] + if Hold.search_count([]) >= 3: + print("[6a] Quality holds already populated, skipping") + return + wos = env['mrp.workorder'].search([ + ('state', '=', 'done'), + ], limit=2) + # Valid hold_reason values: damaged / out_of_spec / contamination / + # customer_complaint / process_deviation / other. + reasons = ['damaged', 'out_of_spec'] + descriptions = [ + 'Light scratch on the masked face — flagging for re-inspection.', + 'Thickness reading 0.4 mils, target 0.5 ± 0.05. Out of spec.', + ] + created = 0 + for wo, reason, desc in zip(wos, reasons, descriptions): + Hold.create({ + 'workorder_id': wo.id, + 'production_id': wo.production_id.id, + 'part_ref': wo.production_id.product_id.default_code or 'PART-X', + 'qty_on_hold': 2, + 'qty_original': int(wo.production_id.product_qty or 5), + 'hold_reason': reason, + 'description': desc, + 'state': 'on_hold', + }) + created += 1 + print(f"[6a] Added {created} quality holds") + + +def _add_paused_wo(env): + """Show one WO mid-flight with pause/resume history. + + The audit fields show started_by but no finished_at — exactly the + "in progress, paused for lunch" state a manager would see live. + """ + progress = env['mrp.workorder'].search([('state', '=', 'progress')], limit=1) + if progress and progress.x_fc_started_at: + print("[6b] Already have a progress WO with audit, skipping") + return + if not progress: + # Promote a ready WO to progress so the demo has at least one. + ready = env['mrp.workorder'].search([ + ('state', 'in', ('ready', 'waiting', 'pending')), + ], limit=1) + if not ready: + print("[6b] No ready WO to promote to progress") + return + progress = ready + user = progress.x_fc_assigned_user_id or env.user + progress.write({ + 'x_fc_started_at': fields.Datetime.now() - timedelta(hours=2), + 'x_fc_started_by_user_id': user.id, + }) + print(f"[6b] Paused-WO marker set on {progress.display_name}") + + +def _mark_quote_sent(env): + """Bump one draft SO into the 'sent' state so the funnel has data + in every workflow column. + + Without this, the dashboard "Sent" stage is always empty — the + seeder jumps straight from draft to confirmed. + """ + sent = env['sale.order'].search([('state', '=', 'sent')], limit=1) + if sent: + print("[6d] Already have a sent quote, skipping") + return + draft = env['sale.order'].search( + [('state', '=', 'draft'), ('order_line', '!=', False)], limit=1, + ) + if not draft: + print("[6d] No draft SO with lines available") + return + # action_quotation_send opens a wizard — skip the wizard and just + # flip the state directly with a chatter line, which is what the + # wizard would do anyway after the email is sent. + draft.write({'state': 'sent'}) + draft.message_post(body='Quotation marked as sent (demo data).') + print(f"[6d] Marked {draft.name} as sent") + + +def _add_quote_requests(env): + QR = env.get('fusion.plating.quote.request') + if QR is None: + print("[6c] Portal module not installed — skipping RFQ seed") + return + if QR.search_count([]) >= 3: + print("[6c] Quote requests already populated, skipping") + return + customers = env['res.partner'].search( + [('customer_rank', '>', 0)], limit=3, + ) + # State selection on fusion.plating.quote.request: validate against + # the live model so this works even if the workflow gets renamed. + valid_states = {v for v, _ in QR._fields['state'].selection} + candidate_states = [ + s for s in ('new', 'under_review', 'quoted', 'accepted', 'rejected') + if s in valid_states + ][:3] + + notes = [ + '

Need a quote for 200 brass fittings — Type II passivation, urgent.

', + '

Recurring customer, standard EN on 50 housings, 0.5 mil target.

', + '

Aerospace job, AS9100 + Nadcap CoC required, 12 turbine vanes.

', + ] + currency = env.company.currency_id + created = 0 + for partner, state, note in zip(customers, candidate_states, notes): + QR.create({ + 'name': f'RFQ-DEMO-{partner.id}', + 'partner_id': partner.id, + 'state': state, + 'currency_id': currency.id, + 'part_description': note, + 'quantity': 50, + }) + created += 1 + print(f"[6c] Added {created} quote requests (states: {', '.join(candidate_states)})") + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- +def _safe(label, fn): + """Run a step, log + swallow any error so partial success persists.""" + try: + fn(env) + env.cr.commit() + except Exception as exc: + env.cr.rollback() + print(f"!! {label} FAILED: {exc!r}") + + +print("\n=========================================================") +print("FUSION PLATING — DEMO STAGE FILLER") +print("=========================================================") +_safe('1. fill WO roles', _fill_wo_roles) +_safe('3. seed team', _seed_team) +_safe('2. backfill timer audit', _backfill_timer_audit) +_safe('3b. redistribute WOs', _redistribute_completed_wos) +_safe('4. seed proficiency', _seed_proficiency) +_safe('5. clock in team', _clock_in_team) +_safe('6a. add quality holds', _add_quality_holds) +_safe('6b. mark paused WO', _add_paused_wo) +_safe('6c. add quote requests', _add_quote_requests) +_safe('6d. mark one quote sent', _mark_quote_sent) +print("=========================================================") +print("Done. Re-run anytime — script is idempotent.") +print("=========================================================\n")