feat(plating): demo stage-filler — every workflow step now has data

Companion to fp_demo_seed.py. Bridges the gaps the original seeder
left after the team-skills + timer-audit + presence-aware Manager Desk
work landed (commit 0d12902). Idempotent.

Eight steps, each wrapped in a safe() driver so a failure in one
doesn't abort the rest:

  1. Fill x_fc_work_role_id on any WO that doesn't have one yet.
     Keyword map (mask/rack/plat/bake/oven/inspect/rework) → role
     code, falls back to plating_op. The auto-promotion tracker
     can't credit a worker without a role on the WO.

  2. Backfill the four timer audit fields (started_by/at,
     finished_by/at) on done WOs. Pulls from time_ids when the
     productivity records exist, otherwise synthesises timestamps
     from create_date + duration.

  3. Seed a diverse team of six operators with distinct role
     coverage and lead-hand permissions:
       - Marie Dubois     — masking + racking      (lead: masking)
       - James O'Connor   — plating_op + demask    (lead: plating_op)
       - Priya Sharma     — oven + inspection      (lead: oven, inspection)
       - Diego Ramirez    — racking + plating_op   (TRAINING: 2/3 masking)
       - Aisha Khan       — inspection + rework
       - Carlos Silva     — every role             (lead: every role)
     Each gets a backing res.users so the Manager Desk dropdown
     can assign them.

  3b. Redistribute ~40 historical done WOs across the new team so
      their Task Proficiency lists aren't empty. Plan targets
      realistic per-role counts (Marie 8 masking + 5 racking,
      James 12 plating + 4 demask, etc.) and re-stamps the timer
      audit so finished_by reflects the new owner.

  4. Wipe + rebuild fp.operator.proficiency from completed WOs so
     the per-(employee, role) tally is deterministic. Auto-promotion
     fires naturally during the rebuild — workers who already cleared
     the threshold get promoted=True with timestamps. Diego is
     deliberately seeded at 2/3 on masking so the demo shows the
     "one more job away from promotion" state live.

  5. Clock three operators in via hr.attendance (4-hour shift).
     Wipes any stale open records first because earlier script
     iterations left future-dated check_in timestamps that the
     attendance validator refused to close.

  6a. Two extra quality holds (damaged + out_of_spec).

  6b. Mark the in-progress WO with a started_at but no finished_at
      so the demo has a "paused for lunch" exemplar.

  6c. Three portal RFQs (one per workflow state: new / under_review
      / quoted) so the funnel front-end has data.

  6d. Push one draft SO to "sent" so the quotation pipeline has
      data in every column (was draft → confirmed previously).

Verified on entech: 21 of 21 workflow stages now , including
Diego's 2/3 masking row that shows the auto-promotion mechanic
in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-18 22:22:23 -04:00
parent 0d12902ee7
commit 0315fee988

View File

@@ -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 = [
'<p>Need a quote for 200 brass fittings — Type II passivation, urgent.</p>',
'<p>Recurring customer, standard EN on 50 housings, 0.5 mil target.</p>',
'<p>Aerospace job, AS9100 + Nadcap CoC required, 12 turbine vanes.</p>',
]
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")