Files
Odoo-Modules/fusion_plating/scripts/fp_demo_seed.py
gsinghpal e07002d550 feat(shopfloor): rich Tablet Station dashboard + full shop-floor demo data
Tablet Station rebuilt as a live dashboard (not just a QR scanner):

  * KPI strip — WOs Ready/Progress, Awaiting/Missed bakes,
    First-Piece pending, Quality Holds (each tinted by state)
  * Active WO banner with pulsing indicator when a WO is running
  * My Queue panel (left) — priority-badged operator next-up list,
    clickable rows that jump to the WO/bake/gate form
  * Baths tile grid (right) — last-log status chips, MTO count,
    hover jump to chemistry log
  * Bake Windows list — inline Start/End/Open actions, colour-coded
    by state (awaiting / in-progress / missed)
  * First-Piece Gates — Pass/Fail buttons for pending inspections
  * Quality Holds — Review jump when any open holds exist
  * Station picker + scan drawer (collapsed by default)
  * 30s auto-refresh, persists picked station in localStorage

New controller endpoints: /fp/shopfloor/tablet_overview,
/fp/shopfloor/pair_station, /fp/shopfloor/mark_gate.

Demo seeder (Phase 12.5) now populates:
  * 5 shop-floor stations (Plating, Bake, Inspection, Shipping, Receiving)
  * +3 bake windows (awaiting / in-progress / near-due)
  * 4 first-piece gates (1 pending, 1 passed+released, 1 passed-holding, 1 failed)
  * 2 quality holds on active MOs (one on_hold, one under_review)

All four Shop Floor menu pages (Plant Overview, Tablet Station, Bake
Windows, First-Piece Gates) now have meaningful content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 07:43:10 -04:00

1393 lines
55 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Fusion Plating — Demo Seeder
# ================================
# Story-driven demo data: six customers, every workflow stage populated,
# historical depth for dashboards/trends, exception cases for enforcement.
#
# Run: odoo shell -c /etc/odoo/odoo.conf -d admin < fp_demo_seed.py
# Safe to run multiple times (idempotent on the records it creates).
import base64
import logging
import random
from datetime import datetime, timedelta
from odoo import Command, fields
_log = logging.getLogger('fp_demo')
def LOG(msg):
print(f"[FP-DEMO] {msg}")
# ============================================================
# Helpers
# ============================================================
def soc(model, key_field, key_value, vals):
"""Search-or-create a singleton by a unique key field."""
rec = env[model].search([(key_field, '=', key_value)], limit=1)
if rec:
rec.write({k: v for k, v in vals.items() if k != key_field})
return rec
return env[model].create(vals)
def backdate(rec, days_ago):
"""Force create_date back in time, so history tabs look real."""
if not rec or not rec.ids:
return
d = datetime.now() - timedelta(days=days_ago)
env.cr.execute(
f"UPDATE {rec._table} SET create_date = %s, write_date = %s WHERE id IN %s",
(d, d, tuple(rec.ids)),
)
def set_date_field(rec, field, days_ago):
d = datetime.now() - timedelta(days=days_ago)
rec.write({field: d})
# ============================================================
# Phase 0.5: Company CoC settings (accreditation badges + signature)
# ============================================================
# We generate clean PIL-based badge PNGs for Nadcap / AS9100 / CGP
# so the CoC PDF renders complete without the client having to upload
# anything. They can still replace them with the real trademarked logos
# via Settings → Fusion Plating → Accreditation Logos whenever they want.
def _make_badge(lines, width=420, height=220, bg='#0066A1', fg='white',
border_color='#003d66', border_px=6, font_size=42,
subtitle=None, subtitle_color='white', subtitle_size=18):
"""Render a rectangular badge with centred stacked text."""
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
LOG(" PIL not available — skipping badge generation")
return None
import io
img = Image.new('RGB', (width, height), bg)
draw = ImageDraw.Draw(img)
# Border
draw.rectangle([(border_px // 2, border_px // 2),
(width - border_px, height - border_px)],
outline=border_color, width=border_px)
# Pick a sans-serif bold font
font = None
for candidate in (
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
'/System/Library/Fonts/Helvetica.ttc',
'DejaVuSans-Bold.ttf',
):
try:
font = ImageFont.truetype(candidate, font_size)
break
except Exception:
continue
if font is None:
font = ImageFont.load_default()
sub_font = None
if subtitle:
for candidate in (
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
):
try:
sub_font = ImageFont.truetype(candidate, subtitle_size)
break
except Exception:
continue
if sub_font is None:
sub_font = font
# Stack lines vertically, centred
line_gap = int(font_size * 1.2)
total_h = line_gap * len(lines) + (subtitle_size + 10 if subtitle else 0)
y = (height - total_h) // 2
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
tw = bbox[2] - bbox[0]
draw.text(((width - tw) / 2, y), line, fill=fg, font=font)
y += line_gap
if subtitle:
y += 4
bbox = draw.textbbox((0, 0), subtitle, font=sub_font)
tw = bbox[2] - bbox[0]
draw.text(((width - tw) / 2, y), subtitle, fill=subtitle_color, font=sub_font)
buf = io.BytesIO()
img.save(buf, format='PNG', optimize=True)
return base64.b64encode(buf.getvalue())
def _make_signature(name, width=700, height=180, color='#00338D'):
"""Render a plausible handwritten-looking signature from a name."""
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
return None
import io
img = Image.new('RGBA', (width, height), (255, 255, 255, 0))
draw = ImageDraw.Draw(img)
# Prefer an italic / oblique font for the script look
font = None
for candidate in (
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Italic.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed-BoldOblique.ttf',
):
try:
font = ImageFont.truetype(candidate, 88)
break
except Exception:
continue
if font is None:
font = ImageFont.load_default()
draw.text((30, 30), name, fill=color, font=font)
# Underline flourish
bbox = draw.textbbox((30, 30), name, font=font)
draw.line(
[(30, bbox[3] + 10), (bbox[2] + 80, bbox[3] + 10)],
fill=color, width=3,
)
buf = io.BytesIO()
img.save(buf, format='PNG', optimize=True)
return base64.b64encode(buf.getvalue())
LOG("Phase 0.5: Company CoC settings (badges + signature)")
_company = env.company
# Build accreditation badges (only if not already set to avoid clobbering
# real logos the client uploaded via Settings)
if not _company.x_fc_nadcap_logo:
_company.x_fc_nadcap_logo = _make_badge(
['NADCAP', 'ACCREDITED'],
bg='#0066A1', border_color='#003d66',
subtitle='Administered by PRI',
)
if not _company.x_fc_as9100_logo:
_company.x_fc_as9100_logo = _make_badge(
['AS9100D', 'CERTIFIED'],
bg='#2B6CB0', border_color='#1a4d80',
subtitle='ISO 9001',
)
if not _company.x_fc_cgp_logo:
_company.x_fc_cgp_logo = _make_badge(
['CGP', 'REGISTERED'],
bg='#C8102E', border_color='#8B0A1F',
subtitle="Canada's Controlled Goods Program",
subtitle_size=15,
)
_company.x_fc_nadcap_active = True
_company.x_fc_as9100_active = True
_company.x_fc_cgp_active = True
# Designate a demo owner: a user named "Kris Pathinather" so the
# Certified By / Name line on the CoC matches the signature image.
_kris_user = env['res.users'].search([('login', '=', 'kris.pathinather')], limit=1)
if not _kris_user:
_kris_user = env['res.users'].with_context(no_reset_password=True).create({
'name': 'Kris Pathinather',
'login': 'kris.pathinather',
'email': 'kris@enplating.ca',
})
_company.x_fc_owner_user_id = _kris_user.id
# Always refresh signature (cheap, looks clean)
_company.x_fc_coc_signature_override = _make_signature('Kris Pathinather')
LOG(f" Accreditation badges + signature generated — owner: {_kris_user.name}")
# ============================================================
# Phase 1: Customers (6 stories)
# ============================================================
LOG("Phase 1: Customers")
def ensure_partner(ref, vals):
p = env['res.partner'].search([('ref', '=', ref)], limit=1)
if p:
p.write(vals)
return p
vals = dict(vals)
vals['ref'] = ref
vals.setdefault('customer_rank', 1)
return env['res.partner'].create(vals)
amphenol = ensure_partner('FPD-AMPHENOL', {
'name': 'Amphenol Canada Corp.',
'email': 'jimip@amphenolcanada.com',
'phone': '+1 (647) 577-3880',
'street': '605 Milner Ave',
'city': 'Toronto', 'zip': 'M1B 5X6',
'country_id': env.ref('base.ca').id,
'state_id': env.ref('base.state_ca_on').id,
'website': 'amphenolcanada.com',
})
# Give Amphenol their trademark blue-block logo
if not amphenol.image_1920:
amphenol.image_1920 = _make_badge(
['Amphenol'], width=320, height=200,
bg='#005EB8', border_color='#003c75',
font_size=46,
subtitle='Canada Corp.', subtitle_size=20,
)
magellan = ensure_partner('FPD-MAGELLAN', {
'name': 'Magellan Aerospace Ltd',
'email': 'quality@magellan.aero',
'phone': '+1 (204) 231-8000',
'street': '3160 Derry Rd East',
'city': 'Mississauga', 'zip': 'L4T 1A9',
'country_id': env.ref('base.ca').id,
'state_id': env.ref('base.state_ca_on').id,
})
cyclone = ensure_partner('FPD-CYCLONE', {
'name': 'Cyclone Manufacturing Inc.',
'email': 'buyer@cyclone-mfg.com',
'phone': '+1 (905) 677-4441',
'street': '380 Sheldon Dr',
'city': 'Cambridge', 'zip': 'N1T 2B6',
'country_id': env.ref('base.ca').id,
'state_id': env.ref('base.state_ca_on').id,
})
honeywell = ensure_partner('FPD-HONEYWELL', {
'name': 'Honeywell Aerospace Toronto',
'email': 'procurement.toronto@honeywell.com',
'phone': '+1 (416) 798-2100',
'street': '3333 Unity Dr',
'city': 'Mississauga', 'zip': 'L5L 3S6',
'country_id': env.ref('base.ca').id,
'state_id': env.ref('base.state_ca_on').id,
})
westin = ensure_partner('FPD-WESTIN', {
'name': 'Westin Manufacturing Ltd',
'email': 'shop@westinmfg.ca',
'phone': '+1 (905) 791-8300',
'street': '200 Rexdale Blvd',
'city': 'Toronto', 'zip': 'M9W 1R1',
'country_id': env.ref('base.ca').id,
'state_id': env.ref('base.state_ca_on').id,
})
delinquent = ensure_partner('FPD-DELINQUENT', {
'name': 'Delinquent Industries (DEMO — On Hold)',
'email': 'ap@delinquent-demo.com',
'phone': '+1 (416) 555-0199',
'street': '1 Overdue Lane',
'city': 'Toronto', 'zip': 'M1M 1M1',
'country_id': env.ref('base.ca').id,
'state_id': env.ref('base.state_ca_on').id,
})
# Put this one on account hold
if 'x_fc_account_hold' in delinquent._fields:
delinquent.write({
'x_fc_account_hold': True,
'x_fc_account_hold_reason': '$45,320 past due > 90 days. Cleared manager override only.',
})
# Child contacts for Amphenol (used by contact_partner_id on CoC)
jimi = env['res.partner'].search([
('parent_id', '=', amphenol.id), ('name', '=', 'Jimi Patel'),
], limit=1)
if not jimi:
jimi = env['res.partner'].create({
'name': 'Jimi Patel', 'parent_id': amphenol.id,
'function': 'Quality Manager',
'email': 'jimip@amphenolcanada.com', 'phone': '+1 (647) 577-3880',
'type': 'contact',
})
LOG(f" 6 customers ready: {amphenol.name}, {magellan.name}, {cyclone.name}, "
f"{honeywell.name}, {westin.name}, {delinquent.name}")
# ============================================================
# Phase 2: Coating Configurations + Recipes
# ============================================================
LOG("Phase 2: Coating configs")
def ensure_process_type(code, name, family='plating'):
pt = env['fusion.plating.process.type'].search([('code', '=', code)], limit=1)
if pt:
return pt
cat = env['fusion.plating.process.category'].search([], limit=1)
return env['fusion.plating.process.type'].create({
'code': code, 'name': name,
'category_id': cat.id if cat else False,
'process_family': family,
})
en_midphos = ensure_process_type('EN_MID', 'Electroless Nickel — Mid Phos', 'plating')
en_lowphos = ensure_process_type('EN_LOW', 'Electroless Nickel — Low Phos', 'plating')
passivation = ensure_process_type('PASS_AMS2700', 'Passivation AMS 2700', 'passivation')
hard_chrome = ensure_process_type('HARD_CR', 'Hard Chrome', 'plating')
alk_clean = ensure_process_type('ALK_CLEAN', 'Alkaline Clean', 'pre_treatment')
def ensure_coating(name, process_type, **extras):
cfg = env['fp.coating.config'].search([('name', '=', name)], limit=1)
vals = {
'name': name, 'process_type_id': process_type.id,
}
vals.update(extras)
if cfg:
cfg.write(vals)
return cfg
return env['fp.coating.config'].create(vals)
coating_en_mid = ensure_coating(
'EN Mid-Phos AMS 2404', en_midphos,
phosphorus_level='mid_phos',
thickness_min=0.5, thickness_max=1.0, thickness_uom='mils',
spec_reference='AMS 2404',
certification_level='nadcap',
requires_bake_relief=True, bake_window_hours=4.0,
bake_temperature=375.0, bake_duration_hours=23.0,
)
coating_en_low = ensure_coating(
'EN Low-Phos MIL-C-26074', en_lowphos,
phosphorus_level='low_phos',
thickness_min=0.3, thickness_max=0.6, thickness_uom='mils',
spec_reference='MIL-C-26074',
certification_level='mil_spec',
)
coating_pass = ensure_coating(
'Passivation AMS 2700 Rev G', passivation,
thickness_min=0, thickness_max=0, thickness_uom='mils',
spec_reference='AMS 2700 Rev G Method 1 Type 2 Class 2',
certification_level='nadcap',
)
coating_chrome = ensure_coating(
'Hard Chrome AMS 2406', hard_chrome,
thickness_min=1.0, thickness_max=3.0, thickness_uom='mils',
spec_reference='AMS 2406',
certification_level='mil_spec',
requires_bake_relief=True, bake_window_hours=4.0,
bake_temperature=375.0, bake_duration_hours=23.0,
)
LOG(f" 4 coating configs: {coating_en_mid.name}, {coating_en_low.name}, "
f"{coating_pass.name}, {coating_chrome.name}")
# ============================================================
# Phase 3: Parts Catalog
# ============================================================
LOG("Phase 3: Part catalog")
PARTS = [
# (partner, pn, name, substrate, complexity, surface_area, revisions)
(amphenol, 'VS-ESMC6H00801P01', 'Shell, Connector Backshell', 'stainless', 'moderate', 4.2, 2),
(amphenol, 'VS-PQR8440', 'Pin Block Assembly', 'stainless', 'complex', 6.8, 3),
(amphenol, 'VS-HSA201-B', 'Heat Sink Arm', 'aluminium', 'moderate', 12.4, 1),
(amphenol, 'VS-COV-2401', 'RF Shield Cover', 'aluminium', 'simple', 8.9, 1),
(magellan, 'MG-WG-8801', 'Engine Mount Bracket', 'steel', 'complex', 18.2, 2),
(magellan, 'MG-CYL-550', 'Hydraulic Cylinder Housing', 'steel', 'complex', 22.1, 1),
(magellan, 'MG-BRK-112', 'Bell Crank Lever', 'steel', 'moderate', 9.7, 1),
(cyclone, 'CY-STR-101', 'Structural Strut Fitting', 'stainless', 'moderate', 11.5, 1),
(cyclone, 'CY-VLV-44', 'Valve Body Assembly', 'stainless', 'complex', 14.8, 2),
(cyclone, 'CY-PIN-880', 'Clevis Pin — Port Side', 'steel', 'simple', 2.4, 1),
(cyclone, 'CY-PIN-881', 'Clevis Pin — Starboard', 'steel', 'simple', 2.4, 1),
(honeywell, 'HW-TBO-7001', 'Turbine Output Shaft', 'stainless', 'complex', 28.6, 1),
(honeywell, 'HW-GRB-22', 'Grommet Retainer', 'aluminium', 'simple', 1.8, 1),
(honeywell, 'HW-VNG-114', 'Vane Ring Segment', 'stainless', 'complex', 16.9, 1),
(westin, 'WM-HSG-201', 'Sensor Housing', 'aluminium', 'moderate', 5.3, 1),
(westin, 'WM-PLT-308', 'Mounting Plate', 'aluminium', 'simple', 6.1, 1),
(delinquent, 'DL-TEST-01', 'Demo Hold-Blocked Part', 'steel', 'simple', 3.0, 1),
]
created_parts = {}
for partner, pn, name, sub, cx, sa, revs in PARTS:
existing = env['fp.part.catalog'].search([
('partner_id', '=', partner.id),
('part_number', '=', pn),
], limit=1)
if existing:
created_parts[pn] = existing
continue
part = env['fp.part.catalog'].create({
'partner_id': partner.id,
'part_number': pn,
'name': name,
'substrate_material': sub,
'complexity': cx,
'surface_area': sa,
'surface_area_uom': 'sq_in',
'revision': 'Rev 1',
'revision_number': 1,
'is_latest_revision': True,
'geometry_source': 'pdf_drawing',
})
# Create extra revisions (Rev 2, Rev 3)
for r in range(2, revs + 1):
# Mark previous latest false
env['fp.part.catalog'].search([
'|', ('id', '=', part.id), ('parent_part_id', '=', part.id),
('is_latest_revision', '=', True),
]).write({'is_latest_revision': False})
env['fp.part.catalog'].create({
'partner_id': partner.id,
'part_number': pn,
'name': name,
'substrate_material': sub,
'complexity': cx,
'surface_area': sa * (1.0 + 0.05 * (r - 1)),
'surface_area_uom': 'sq_in',
'revision': f'Rev {r}',
'revision_number': r,
'parent_part_id': part.id,
'is_latest_revision': (r == revs),
'geometry_source': 'pdf_drawing',
'revision_note': f'Revision {r} — minor geometry update',
})
created_parts[pn] = part
LOG(f" {len(created_parts)} parts with revisions")
# ============================================================
# Phase 4: Customer price lists
# ============================================================
LOG("Phase 4: Customer price lists")
PRICE_LISTS = [
(amphenol, coating_en_mid, 45.00, 'per_part', 1),
(amphenol, coating_en_mid, 38.00, 'per_part', 50),
(amphenol, coating_en_mid, 32.00, 'per_part', 200),
(amphenol, coating_pass, 12.00, 'per_part', 1),
(magellan, coating_en_mid, 65.00, 'per_part', 1),
(magellan, coating_chrome, 120.00, 'per_part', 1),
(cyclone, coating_en_mid, 52.00, 'per_part', 1),
(cyclone, coating_pass, 15.00, 'per_part', 1),
(honeywell, coating_en_low, 72.00, 'per_part', 1),
(westin, coating_en_low, 48.00, 'per_part', 1),
]
for partner, cfg, price, uom, minq in PRICE_LISTS:
existing = env['fp.customer.price.list'].search([
('partner_id', '=', partner.id),
('coating_config_id', '=', cfg.id),
('min_quantity', '=', minq),
], limit=1)
if not existing:
env['fp.customer.price.list'].create({
'partner_id': partner.id,
'coating_config_id': cfg.id,
'unit_price': price,
'price_uom': uom,
'min_quantity': minq,
'effective_from': (datetime.now() - timedelta(days=365)).date(),
})
LOG(f" {len(PRICE_LISTS)} price list entries")
# ============================================================
# Phase 5: Invoice strategy defaults per customer
# ============================================================
LOG("Phase 5: Invoice strategy defaults")
def ensure_strategy(partner, strategy, **extras):
rec = env['fp.invoice.strategy.default'].search([('partner_id', '=', partner.id)], limit=1)
vals = {'partner_id': partner.id, 'default_strategy': strategy}
vals.update(extras)
if rec:
rec.write(vals)
else:
env['fp.invoice.strategy.default'].create(vals)
ensure_strategy(amphenol, 'net_terms')
ensure_strategy(magellan, 'progress', default_deposit_percent=40.0)
ensure_strategy(cyclone, 'deposit', default_deposit_percent=50.0)
ensure_strategy(honeywell, 'net_terms')
ensure_strategy(westin, 'cod_prepay')
LOG(" 5 customer strategy defaults")
# ============================================================
# Phase 6: Baths, bath logs, replenishment suggestions
# ============================================================
LOG("Phase 6: Baths + chemistry logs")
facility = env['fusion.plating.facility'].search([], limit=1)
if not facility:
facility = env['fusion.plating.facility'].create({
'name': 'Main Shop — 36 Taber Rd',
'code': 'MAIN',
'company_id': env.company.id,
})
# Tanks
def ensure_tank(code, facility_, proc_type):
tk = env['fusion.plating.tank'].search([('code', '=', code)], limit=1)
if tk:
return tk
return env['fusion.plating.tank'].create({
'name': f'Tank {code}',
'code': code,
'facility_id': facility_.id,
'volume': 1000.0,
})
tank_en = ensure_tank('TK-EN-01', facility, en_midphos)
tank_pass = ensure_tank('TK-PASS-01', facility, passivation)
def ensure_bath(name, tank, proc_type, state='operational'):
b = env['fusion.plating.bath'].search([('name', '=', name)], limit=1)
if b:
return b
return env['fusion.plating.bath'].create({
'name': name,
'tank_id': tank.id,
'process_type_id': proc_type.id,
'state': state,
'makeup_date': datetime.now() - timedelta(days=45),
'volume': 950.0,
})
bath_en = ensure_bath('EN-BATH-A', tank_en, en_midphos)
bath_pass = ensure_bath('PASS-BATH-A', tank_pass, passivation)
# Ensure there are bath parameters
def ensure_param(name, code, uom, target_min, target_max, param_type='concentration'):
p = env['fusion.plating.bath.parameter'].search([('code', '=', code)], limit=1)
if p:
return p
return env['fusion.plating.bath.parameter'].create({
'name': name, 'code': code, 'uom': uom,
'target_min': target_min, 'target_max': target_max,
'parameter_type': param_type, 'warning_tolerance': 10.0,
})
p_nickel = ensure_param('Nickel Concentration', 'Ni', 'g/L', 4.5, 6.5)
p_ph = ensure_param('pH', 'PH', 'pH', 4.6, 5.2, 'ph')
p_temp = ensure_param('Temperature', 'TEMP', '°C', 87.0, 91.0, 'temperature')
# Attach parameters to process type if not already
for param in (p_nickel, p_ph, p_temp):
if param not in en_midphos.parameter_ids:
en_midphos.parameter_ids = [Command.link(param.id)]
# Per-bath target ranges (override) — use bath.target_line_ids
for bath, params in [(bath_en, [p_nickel, p_ph, p_temp])]:
for p in params:
existing = env['fusion.plating.bath.target'].search([
('bath_id', '=', bath.id), ('parameter_id', '=', p.id),
], limit=1)
if not existing:
env['fusion.plating.bath.target'].create({
'bath_id': bath.id, 'parameter_id': p.id,
'target_min': p.target_min, 'target_max': p.target_max,
})
# Replenishment rule — Ni drop → add NiP replenisher
existing_rule = env['fusion.plating.bath.replenishment.rule'].search([
('bath_id', '=', bath_en.id), ('parameter_id', '=', p_nickel.id),
], limit=1)
if not existing_rule:
env['fusion.plating.bath.replenishment.rule'].create({
'name': 'EN Bath — Nickel Low Replenishment',
'bath_id': bath_en.id,
'parameter_id': p_nickel.id,
'trigger': 'below_min',
'product_name': 'Nickel Sulfamate 30% (Elnic 101)',
'dose_rate': 0.25,
'dose_uom': 'ml',
'min_dose': 50.0,
})
# Historical bath logs — 15 readings over 30 days, last 2 out-of-spec (to trigger suggestions)
existing_log_count = env['fusion.plating.bath.log'].search_count([('bath_id', '=', bath_en.id)])
if existing_log_count < 12:
for days_ago in range(30, 0, -2): # 15 readings
# Most readings in-spec, last 2 out-of-spec on nickel
if days_ago <= 3:
ni_val = 4.0 # Below min 4.5 → triggers replenishment suggestion
ph_val = 5.0
temp_val = 89.0
else:
ni_val = random.uniform(5.0, 6.0)
ph_val = random.uniform(4.8, 5.1)
temp_val = random.uniform(88.0, 90.0)
log_date = datetime.now() - timedelta(days=days_ago)
log = env['fusion.plating.bath.log'].create({
'bath_id': bath_en.id,
'log_date': log_date,
'operator_id': env.user.id,
'shift': random.choice(['day', 'evening']),
'line_ids': [
Command.create({'parameter_id': p_nickel.id, 'value': ni_val}),
Command.create({'parameter_id': p_ph.id, 'value': ph_val}),
Command.create({'parameter_id': p_temp.id, 'value': temp_val}),
],
})
backdate(log, days_ago)
LOG(f" 2 baths, 3 parameters, 1 replenishment rule, historical logs seeded")
# ============================================================
# Phase 7: Racks (1 needs strip, 3 active)
# ============================================================
LOG("Phase 7: Racks")
def ensure_rack(name, state='active', mto=0.0):
r = env['fusion.plating.rack'].search([
('facility_id', '=', facility.id), ('name', '=', name),
], limit=1)
if r:
return r
return env['fusion.plating.rack'].create({
'name': name, 'facility_id': facility.id,
'rack_type': 'rack', 'capacity': 24,
'contact_points': 8, 'mto_count': mto,
'strip_interval_mto': 3.0,
'state': state,
'last_stripped_date': datetime.now() - timedelta(days=random.randint(30, 90)),
'strips_count': random.randint(2, 8),
})
rack_1 = ensure_rack('RACK-001', 'active', 1.2)
rack_2 = ensure_rack('RACK-002', 'active', 2.1)
rack_3 = ensure_rack('RACK-003', 'active', 0.4)
rack_old = ensure_rack('RACK-004', 'active', 3.2)
# Force the needs_strip compute to run by rewriting mto_count
rack_old.write({'mto_count': 3.2})
barrel_1 = ensure_rack('BARREL-001', 'active', 1.8)
LOG(" 5 racks seeded (1 will show 'Needs Strip')")
# ============================================================
# Phase 8: Generic Plating Service product (used by SOs)
# ============================================================
LOG("Phase 8: Ensure FP-SERVICE product")
product = env['product.product'].search([('default_code', '=', 'FP-SERVICE')], limit=1)
if not product:
product = env['product.product'].create({
'name': 'Plating Service',
'default_code': 'FP-SERVICE',
'type': 'service',
'sale_ok': True, 'purchase_ok': False,
'list_price': 0,
})
# Also a storable product for manufacturing (MOs need a BOM-routed product)
widget = env['product.product'].search([('default_code', '=', 'FP-WIDGET')], limit=1)
if not widget:
widget = env['product.product'].create({
'name': 'Plated Widget (Generic)',
'default_code': 'FP-WIDGET',
'type': 'consu',
'is_storable': True,
'sale_ok': True, 'purchase_ok': False,
'list_price': 0,
})
# ============================================================
# Phase 9: Historical closed orders (3 months of activity)
# ============================================================
LOG("Phase 9: Historical closed orders")
def create_so_line(so_vals, desc, qty, price):
so_vals.setdefault('order_line', [])
so_vals['order_line'].append(Command.create({
'product_id': product.id,
'name': desc,
'product_uom_qty': qty,
'price_unit': price,
}))
def register_payment(invoice):
"""Post + fully pay an invoice."""
if invoice.state == 'draft':
invoice.action_post()
try:
wiz = env['account.payment.register'].with_context(
active_model='account.move', active_ids=invoice.ids, active_id=invoice.id,
).create({'payment_date': invoice.invoice_date or fields.Date.today()})
return wiz._create_payments()
except Exception as e:
print(f" [register_payment] {invoice.name} failed: {e}")
return False
# Historical job factory — creates full SO→MO→Delivery→Invoice→Payment chain
# then backdates everything to simulate months of history
def make_closed_job(partner, coating, part_cat, qty, unit_price, days_ago,
strategy='net_terms', deposit_pct=0.0):
# 1. SO
so_vals = {
'partner_id': partner.id,
'x_fc_invoice_strategy': strategy,
'x_fc_deposit_percent': deposit_pct,
'x_fc_coating_config_id': coating.id,
'x_fc_part_catalog_id': part_cat.id if part_cat else False,
'x_fc_po_number': f'PO-{random.randint(10000, 99999)}',
'x_fc_po_received': True,
'order_line': [Command.create({
'product_id': product.id,
'name': f'{coating.name}{part_cat.name if part_cat else "custom"} (x{qty})',
'product_uom_qty': qty, 'price_unit': unit_price,
})],
}
# Bypass account hold for the seeder
so = env['sale.order'].create(so_vals)
try:
so.action_confirm()
except Exception as e:
print(f" closed_job confirm failed: {e}")
return None
# 2. MO (manual create since FP-SERVICE has no BOM)
mo = env['mrp.production'].create({
'product_id': widget.id, 'product_qty': qty, 'origin': so.name,
})
try:
mo.action_confirm()
except Exception as e:
print(f" mo confirm: {e}")
# 3. MO done (auto-creates delivery + cert)
try:
mo.button_mark_done()
except Exception:
mo.write({'state': 'done'})
# 4. Deliver
delivery = env['fusion.plating.delivery'].search([
('job_ref', '=', mo.x_fc_portal_job_id.name if mo.x_fc_portal_job_id else mo.name),
], limit=1)
if delivery:
try:
delivery.action_schedule(); delivery.action_start_route(); delivery.action_mark_delivered()
except Exception as e:
print(f" delivery: {e}")
# 5. Post + pay all invoices on the SO
for inv in so.invoice_ids:
if inv.state == 'draft':
try:
inv.action_post()
except Exception:
pass
if inv.state == 'posted':
register_payment(inv)
# 6. Certificate
cert = env['fp.certificate'].search([('production_id', '=', mo.id)], limit=1)
if cert:
# Add thickness readings for some (SPC data)
if random.random() > 0.3 and coating.thickness_min and coating.thickness_max:
target = (coating.thickness_min + coating.thickness_max) / 2
for n in range(1, random.randint(5, 9) + 1):
env['fp.thickness.reading'].create({
'certificate_id': cert.id,
'reading_number': n,
'nip_mils': random.uniform(
target * 0.95, target * 1.05,
),
'ni_percent': random.uniform(93.5, 94.5),
'p_percent': random.uniform(6.0, 8.0),
})
try:
cert.action_issue()
except Exception:
cert.state = 'issued'
# 7. Backdate everything
backdate(so, days_ago)
backdate(mo, days_ago)
if delivery:
backdate(delivery, days_ago - 2)
set_date_field(delivery, 'delivered_at', days_ago - 2)
for inv in so.invoice_ids:
backdate(inv, days_ago - 1)
inv.write({'invoice_date': (datetime.now() - timedelta(days=days_ago - 1)).date()})
if cert:
backdate(cert, days_ago - 1)
cert.write({'issue_date': (datetime.now() - timedelta(days=days_ago - 1)).date()})
return so
# Only build history if we don't already have lots of closed orders
closed_count = env['sale.order'].search_count([
('partner_id', 'in', [amphenol.id, magellan.id, cyclone.id, honeywell.id, westin.id]),
('state', '=', 'sale'),
])
if closed_count < 10:
# 8 closed jobs over 4 months, mostly Amphenol
history_jobs = [
(amphenol, coating_en_mid, created_parts.get('VS-ESMC6H00801P01'), 2010, 32.00, 120, 'net_terms', 0),
(amphenol, coating_en_mid, created_parts.get('VS-PQR8440'), 500, 38.00, 95, 'net_terms', 0),
(amphenol, coating_pass, created_parts.get('VS-COV-2401'), 150, 12.00, 75, 'net_terms', 0),
(amphenol, coating_en_mid, created_parts.get('VS-HSA201-B'), 300, 38.00, 55, 'net_terms', 0),
(magellan, coating_chrome, created_parts.get('MG-WG-8801'), 45, 120.00, 90, 'progress', 40),
(cyclone, coating_en_mid, created_parts.get('CY-STR-101'), 80, 52.00, 65, 'deposit', 50),
(honeywell, coating_en_low, created_parts.get('HW-TBO-7001'), 25, 72.00, 40, 'net_terms', 0),
(westin, coating_en_low, created_parts.get('WM-HSG-201'), 60, 48.00, 30, 'cod_prepay', 0),
]
for row in history_jobs:
try:
make_closed_job(*row)
except Exception as e:
print(f" ! history job failed: {e}")
LOG(f" Created 8 historical closed jobs")
else:
LOG(f" Already has {closed_count} closed jobs — skipping history phase")
# ============================================================
# Phase 10: Active orders in progress (showcase stages)
# ============================================================
LOG("Phase 10: Active orders at various stages")
def quick_so(partner, coating, part_cat, qty, price, strategy, deposit_pct=0.0,
po_number=None, confirm=True):
so = env['sale.order'].create({
'partner_id': partner.id,
'x_fc_invoice_strategy': strategy,
'x_fc_deposit_percent': deposit_pct,
'x_fc_coating_config_id': coating.id,
'x_fc_part_catalog_id': part_cat.id if part_cat else False,
'x_fc_po_number': po_number or f'PO-{random.randint(10000, 99999)}',
'x_fc_po_received': True,
'order_line': [Command.create({
'product_id': product.id,
'name': f'{coating.name}{part_cat.name if part_cat else "part"} (x{qty})',
'product_uom_qty': qty, 'price_unit': price,
})],
})
if confirm:
try:
so.action_confirm()
except Exception as e:
print(f" confirm: {e}")
return so
# Only seed active orders once
active_marker = env['sale.order'].search_count([
('x_fc_po_number', '=', 'FPDEMO-ACTIVE-MAGELLAN'),
])
if active_marker == 0:
# Magellan progress billing — SO confirmed, 40% invoice already posted
so_mag = quick_so(magellan, coating_chrome, created_parts.get('MG-CYL-550'),
30, 120.00, 'progress', 40.0, 'FPDEMO-ACTIVE-MAGELLAN')
for inv in so_mag.invoice_ids:
try: inv.action_post()
except: pass
# Cyclone deposit — SO confirmed, 50% deposit invoice posted & paid,
# MO in progress at WO stage
so_cyc = quick_so(cyclone, coating_en_mid, created_parts.get('CY-VLV-44'),
40, 52.00, 'deposit', 50.0, 'FPDEMO-ACTIVE-CYCLONE')
for inv in so_cyc.invoice_ids:
try:
inv.action_post()
register_payment(inv)
except: pass
mo_cyc = env['mrp.production'].create({
'product_id': widget.id, 'product_qty': 40, 'origin': so_cyc.name,
})
try: mo_cyc.action_confirm()
except: pass
# Honeywell net_terms — SO just confirmed, receiving pending
so_hw = quick_so(honeywell, coating_en_low, created_parts.get('HW-TBO-7001'),
25, 72.00, 'net_terms', 0, 'FPDEMO-ACTIVE-HONEYWELL')
# Westin COD re-order from Direct Order path
so_wn = quick_so(westin, coating_en_low, created_parts.get('WM-HSG-201'),
20, 48.00, 'cod_prepay', 0, 'FPDEMO-WESTIN-DIRECT')
# Amphenol — MO done, ready to ship (for live delivery demo)
so_am = quick_so(amphenol, coating_en_mid, created_parts.get('VS-ESMC6H00801P01'),
500, 38.00, 'net_terms', 0, 'FPDEMO-AMPHENOL-READY')
mo_am = env['mrp.production'].create({
'product_id': widget.id, 'product_qty': 500, 'origin': so_am.name,
})
try:
mo_am.action_confirm()
mo_am.button_mark_done()
except Exception as e:
print(f" amphenol ready mo: {e}")
mo_am.write({'state': 'done'})
LOG(" 5 active orders across workflow stages")
else:
LOG(" Active demo orders already present — skipping")
# ============================================================
# Phase 11: Exception case — Delinquent Industries
# ============================================================
LOG("Phase 11: Exception — account hold blocked SO")
block_marker = env['sale.order'].search_count([
('partner_id', '=', delinquent.id), ('x_fc_po_number', '=', 'FPDEMO-HOLD-TEST'),
])
if block_marker == 0:
# Create but leave in DRAFT — it will raise UserError when they click confirm
env['sale.order'].create({
'partner_id': delinquent.id,
'x_fc_invoice_strategy': 'net_terms',
'x_fc_po_number': 'FPDEMO-HOLD-TEST',
'order_line': [Command.create({
'product_id': product.id,
'name': 'Test order (will raise account hold UserError)',
'product_uom_qty': 10, 'price_unit': 100.0,
})],
})
LOG(" Draft SO for Delinquent Industries — click Confirm to demo the hold")
# ============================================================
# Phase 12: Bake window states (awaiting / missed / baked)
# ============================================================
LOG("Phase 12: Bake window variety")
bw_count = env['fusion.plating.bake.window'].search_count([])
if bw_count < 3:
# Awaiting — recent plate exit, still within window
env['fusion.plating.bake.window'].create({
'bath_id': bath_en.id,
'part_ref': 'MG-WG-8801', 'lot_ref': 'LOT-BW-001',
'customer_ref': 'Magellan Aerospace',
'quantity': 45, 'window_hours': 4.0,
'plate_exit_time': datetime.now() - timedelta(hours=1),
})
# Missed (for alert demo)
bw_missed = env['fusion.plating.bake.window'].create({
'bath_id': bath_en.id,
'part_ref': 'CY-STR-101', 'lot_ref': 'LOT-BW-MISSED',
'customer_ref': 'Cyclone Manufacturing',
'quantity': 80, 'window_hours': 4.0,
'plate_exit_time': datetime.now() - timedelta(hours=8),
'state': 'missed_window',
})
# Completed historical
bw_done = env['fusion.plating.bake.window'].create({
'bath_id': bath_en.id,
'part_ref': 'VS-PQR8440', 'lot_ref': 'LOT-BW-DONE',
'customer_ref': 'Amphenol Canada', 'quantity': 500,
'window_hours': 4.0,
'plate_exit_time': datetime.now() - timedelta(days=14, hours=2),
'bake_start_time': datetime.now() - timedelta(days=14, hours=1),
'bake_end_time': datetime.now() - timedelta(days=13, hours=2),
'bake_temp': 375.0, 'bake_duration_hours': 23.0,
'state': 'baked',
})
backdate(bw_done, 14)
LOG(" 3 bake windows: 1 awaiting, 1 missed, 1 baked")
else:
LOG(f" Already has {bw_count} bake windows — skipping")
# ============================================================
# Phase 12.5: Shop-floor stations + first-piece gates + variety
# Feeds the Tablet Station + First-Piece Gates + bake Kanban demos.
# ============================================================
LOG("Phase 12.5: Shop-floor stations + first-piece gates")
Station = env['fusion.plating.shopfloor.station']
stn_count = Station.search_count([])
if stn_count < 5:
station_defs = [
('Plating Room Tablet 1', 'TAB-PL-01', 'tablet', 'Plating'),
('Bake Oven Tablet', 'TAB-BK-01', 'tablet', 'Bake Oven'),
('Inspection Kiosk', 'TAB-QA-01', 'kiosk', 'Inspection'),
('Shipping Desktop', 'TAB-SH-01', 'desktop', 'Shipping'),
('Receiving Mobile', 'TAB-RC-01', 'mobile', 'Receiving'),
]
for sname, scode, stype, wc_name in station_defs:
if Station.search_count([('code', '=', scode)]):
continue
fp_wc = env['fusion.plating.work.center'].search(
[('name', '=', wc_name)], limit=1,
) if wc_name else False
Station.create({
'name': sname,
'code': scode,
'station_type': stype,
'facility_id': facility.id,
'work_center_id': fp_wc.id if fp_wc else False,
'last_ping': datetime.now() - timedelta(minutes=random.randint(0, 45)),
})
LOG(f" 5 shop-floor stations created")
else:
LOG(f" Already has {stn_count} stations — skipping")
# More bake windows — add a couple active ones for realism
if env['fusion.plating.bake.window'].search_count([]) < 6:
env['fusion.plating.bake.window'].create({
'bath_id': bath_en.id,
'part_ref': 'HW-TOR-5501', 'lot_ref': 'LOT-BW-HW-01',
'customer_ref': 'Honeywell Toronto',
'quantity': 120, 'window_hours': 4.0,
'plate_exit_time': datetime.now() - timedelta(hours=2, minutes=15),
})
env['fusion.plating.bake.window'].create({
'bath_id': bath_en.id,
'part_ref': 'AP-WGL-7200', 'lot_ref': 'LOT-BW-AP-01',
'customer_ref': 'Amphenol Canada',
'quantity': 300, 'window_hours': 4.0,
'plate_exit_time': datetime.now() - timedelta(hours=3, minutes=30),
'bake_start_time': datetime.now() - timedelta(minutes=40),
'state': 'bake_in_progress',
})
env['fusion.plating.bake.window'].create({
'bath_id': bath_en.id,
'part_ref': 'CY-STR-240', 'lot_ref': 'LOT-BW-CY-02',
'customer_ref': 'Cyclone Manufacturing',
'quantity': 60, 'window_hours': 4.0,
'plate_exit_time': datetime.now() - timedelta(minutes=45),
})
LOG(" +3 additional bake windows (awaiting / in-progress)")
# First-piece inspection gates — seed 4 variants
Gate = env['fusion.plating.first.piece.gate']
if Gate.search_count([]) < 4:
Gate.create({
'bath_id': bath_en.id,
'part_ref': 'HW-TOR-5501',
'customer_ref': 'Honeywell Toronto',
'routing_first_run': True,
'first_piece_produced': datetime.now() - timedelta(minutes=35),
'result': 'pending',
})
Gate.create({
'bath_id': bath_en.id,
'part_ref': 'AP-WGL-7200',
'customer_ref': 'Amphenol Canada',
'routing_first_run': False,
'first_piece_produced': datetime.now() - timedelta(hours=2),
'first_piece_inspected': datetime.now() - timedelta(hours=1, minutes=40),
'inspector_id': env.user.id,
'result': 'pass',
'rest_of_lot_released': True,
})
Gate.create({
'bath_id': bath_en.id,
'part_ref': 'MG-WG-8801',
'customer_ref': 'Magellan Aerospace',
'routing_first_run': True,
'first_piece_produced': datetime.now() - timedelta(hours=4),
'first_piece_inspected': datetime.now() - timedelta(hours=3, minutes=30),
'inspector_id': env.user.id,
'result': 'pass',
'rest_of_lot_released': False, # passed but awaiting release
'notes': '<p>Thickness 1.95 mils — within tolerance. Lot released pending planner signoff.</p>',
})
Gate.create({
'bath_id': bath_en.id,
'part_ref': 'CY-STR-240',
'customer_ref': 'Cyclone Manufacturing',
'routing_first_run': True,
'first_piece_produced': datetime.now() - timedelta(hours=6),
'first_piece_inspected': datetime.now() - timedelta(hours=5, minutes=30),
'inspector_id': env.user.id,
'result': 'fail',
'notes': '<p>Thickness 0.8 mils — below spec (min 1.2). Rework required.</p>',
})
LOG(" 4 first-piece gates: 1 pending / 1 passed+released / 1 passed-holding / 1 failed")
else:
LOG(f" Already has {Gate.search_count([])} first-piece gates — skipping")
# Quality holds on active MOs — gives the Shop Floor quality-holds panel content
Hold = env['fusion.plating.quality.hold']
if Hold.search_count([]) < 2:
active_mos = env['mrp.production'].search(
[('state', 'in', ('progress', 'confirmed'))], limit=3,
)
hold_reasons = ['out_of_spec', 'damaged', 'contamination']
for i, mo in enumerate(active_mos[:2]):
wo = mo.workorder_ids[:1]
Hold.create({
'part_ref': mo.product_id.default_code or mo.product_id.name,
'qty_on_hold': random.randint(3, 8),
'qty_original': int(mo.product_qty or 10),
'hold_reason': hold_reasons[i % len(hold_reasons)],
'description': f'Demo hold — flagged during in-process inspection on {mo.name}.',
'production_id': mo.id,
'workorder_id': wo.id if wo else False,
'portal_job_id': mo.x_fc_portal_job_id.id if mo.x_fc_portal_job_id else False,
'facility_id': facility.id,
'operator_id': env.user.id,
'state': 'on_hold' if i == 0 else 'under_review',
})
LOG(f" {Hold.search_count([])} quality holds seeded")
else:
LOG(f" Already has {Hold.search_count([])} quality holds — skipping")
# ============================================================
# Phase 13: Quote configurator + win/loss variety
# ============================================================
LOG("Phase 13: Quote configurator sessions (won/lost/expired/draft)")
quote_marker = env['fp.quote.configurator'].search_count([('name', 'like', 'FP-QC-%')])
if quote_marker < 5:
import math
def make_quote(partner, part_cat, coating, qty, state='draft',
lost_reason=None, days_ago=0):
q = env['fp.quote.configurator'].create({
'partner_id': partner.id,
'part_catalog_id': part_cat.id if part_cat else False,
'coating_config_id': coating.id,
'quantity': qty,
'substrate_material': 'steel',
'surface_area': (part_cat.surface_area if part_cat else 10.0),
'surface_area_uom': 'sq_in',
'calculated_price': qty * 42.50,
})
if state == 'lost':
q.write({
'lost_reason': lost_reason or 'price',
'lost_details': 'Competitor came in 18% lower on unit price.',
'state': 'lost',
'lost_date': (datetime.now() - timedelta(days=days_ago)).date(),
})
elif state == 'expired':
q.write({'state': 'expired',
'lost_date': (datetime.now() - timedelta(days=days_ago)).date()})
if days_ago:
backdate(q, days_ago)
return q
make_quote(magellan, created_parts.get('MG-BRK-112'), coating_en_mid, 120, 'draft')
make_quote(cyclone, created_parts.get('CY-PIN-880'), coating_en_mid, 500, 'draft')
make_quote(honeywell, created_parts.get('HW-GRB-22'), coating_en_low, 80, 'lost', 'price', 22)
make_quote(westin, created_parts.get('WM-PLT-308'), coating_en_low, 40, 'lost', 'lead_time', 35)
make_quote(amphenol, created_parts.get('VS-HSA201-B'), coating_en_mid, 200, 'expired', None, 55)
make_quote(magellan, created_parts.get('MG-CYL-550'), coating_chrome, 25, 'lost', 'competitor', 18)
LOG(" 6 quote configurator sessions (2 draft, 3 lost, 1 expired)")
# ============================================================
# Phase 14: Operator certifications
# ============================================================
LOG("Phase 14: Operator certifications")
employee = env['hr.employee'].search([], limit=1)
if not employee:
employee = env['hr.employee'].create({'name': 'Kris Pathinather'})
for proc_type in (en_midphos, en_lowphos, passivation):
existing = env['fp.operator.certification'].search([
('employee_id', '=', employee.id),
('process_type_id', '=', proc_type.id),
], limit=1)
if not existing:
env['fp.operator.certification'].create({
'employee_id': employee.id,
'process_type_id': proc_type.id,
'issued_date': (datetime.now() - timedelta(days=120)).date(),
'expires_date': (datetime.now() + timedelta(days=245)).date(),
})
# Operator who has only 1 cert (for the "gap" demo)
try:
emp2 = env['hr.employee'].search([], offset=1, limit=1)
if emp2 and emp2 != employee:
existing = env['fp.operator.certification'].search([
('employee_id', '=', emp2.id),
('process_type_id', '=', en_midphos.id),
], limit=1)
if not existing:
env['fp.operator.certification'].create({
'employee_id': emp2.id,
'process_type_id': en_midphos.id,
'issued_date': (datetime.now() - timedelta(days=60)).date(),
'expires_date': (datetime.now() + timedelta(days=305)).date(),
})
except Exception:
pass
LOG(f" Certifications seeded for {employee.name}")
# ============================================================
# Phase 14.5: Work centres + recipe wiring + WO backfill
# ============================================================
LOG("Phase 14.5: Work centres + recipe wiring")
# Create MRP work centres matching the ENP-ALUM-BASIC recipe operations
# (names must match the node names for the auto-mapping to find them)
WC_DEFS = [
('Ready for processing', 'FP-QUEUE', 25.0),
('Racking', 'FP-RACK', 35.0),
('Masking', 'FP-MASK', 35.0),
('E-Nickel Plating', 'FP-EN', 55.0),
('Oven baking', 'FP-BAKE', 30.0),
('Post-plate Inspection', 'FP-INSP', 45.0),
('De-racking', 'FP-DERACK', 30.0),
('De-Masking', 'FP-DEMASK', 30.0),
('Oven bake (Post de-rack)', 'FP-POSTBAKE', 30.0),
]
# Ensure one FP facility for mapping
if not facility:
facility = env['fusion.plating.facility'].search([], limit=1)
# Create MRP work centres first
mrp_wc_by_code = {}
for name, code, rate in WC_DEFS:
mrp_wc = env['mrp.workcenter'].search([('code', '=', code)], limit=1)
if not mrp_wc:
mrp_wc = env['mrp.workcenter'].create({
'name': name, 'code': code,
'costs_hour': rate, 'time_efficiency': 100.0,
})
mrp_wc_by_code[code] = mrp_wc
# Create matching FP work centres, linked to their MRP counterparts
fp_wc_by_name = {}
for name, code, _rate in WC_DEFS:
fp_wc = env['fusion.plating.work.center'].search([('code', '=', code)], limit=1)
if not fp_wc:
fp_wc = env['fusion.plating.work.center'].create({
'name': name, 'code': code,
'facility_id': facility.id,
'x_fc_mrp_workcenter_id': mrp_wc_by_code[code].id,
})
else:
fp_wc.x_fc_mrp_workcenter_id = mrp_wc_by_code[code].id
fp_wc_by_name[name] = fp_wc
LOG(f" {len(WC_DEFS)} MRP + FP work centres (paired)")
# Wire recipe operation nodes to FP work centres by matching names
recipe_main = env['fusion.plating.process.node'].search(
[('node_type', '=', 'recipe')], limit=1,
)
wired = 0
for op in env['fusion.plating.process.node'].search([('node_type', '=', 'operation')]):
if op.work_center_id:
continue
wc = fp_wc_by_name.get(op.name)
if wc:
op.work_center_id = wc.id
wired += 1
LOG(f" Wired {wired} recipe operations to work centres (recipe: {recipe_main.name if recipe_main else 'none'})")
# Link every coating config to the main recipe
if recipe_main:
for cfg in env['fp.coating.config'].search([('recipe_id', '=', False)]):
cfg.recipe_id = recipe_main.id
LOG(f" Linked coating configs → recipe")
# ============================================================
# Phase 14.6: Backfill work orders on existing demo MOs
# ============================================================
LOG("Phase 14.6: Backfill work orders on existing MOs")
def _populate_workorders(mo):
"""Assign recipe from SO coating config + generate WOs."""
if not mo.x_fc_recipe_id and mo.origin:
so = env['sale.order'].search([('name', '=', mo.origin)], limit=1)
if so and so.x_fc_coating_config_id and so.x_fc_coating_config_id.recipe_id:
mo.x_fc_recipe_id = so.x_fc_coating_config_id.recipe_id.id
if mo.x_fc_recipe_id and not mo.workorder_ids:
mo._generate_workorders_from_recipe()
wo_created = 0
for mo in env['mrp.production'].search([('state', 'in', ['confirmed', 'progress', 'done'])]):
before = len(mo.workorder_ids)
_populate_workorders(mo)
wo_created += len(mo.workorder_ids) - before
LOG(f" Generated {wo_created} work orders across existing MOs")
# Drive work orders into a mix of states for the demo:
# - For done MOs: mark all WOs done with backdated durations
# - For one active MO (Cyclone): first WO done, second in progress, rest ready
# - Others: leave in pending
import random as _r
for mo in env['mrp.production'].search([('state', '=', 'done')]):
for wo in mo.workorder_ids:
if wo.state != 'done':
wo.write({
'state': 'done',
'duration': _r.uniform(25, 90),
'duration_expected': 45.0,
})
cyc_mo = env['mrp.production'].search(
[('origin', 'in', env['sale.order'].search(
[('x_fc_po_number', '=', 'FPDEMO-ACTIVE-CYCLONE')]
).mapped('name'))], limit=1,
)
if cyc_mo and cyc_mo.workorder_ids:
wos = cyc_mo.workorder_ids.sorted('sequence')
for i, wo in enumerate(wos):
if i == 0:
wo.write({'state': 'done', 'duration': 42.0})
elif i == 1:
wo.write({'state': 'progress', 'duration': 18.0,
'x_fc_priority': '1'})
elif i == 2:
wo.write({'state': 'ready'})
else:
wo.write({'state': 'ready'})
LOG(f" Cyclone MO {cyc_mo.name}: WO progression set (done / in progress / ready / pending)")
# Give all work orders reasonable expected durations if blank
env.cr.execute(
"UPDATE mrp_workorder SET duration_expected = 45 WHERE duration_expected IS NULL OR duration_expected = 0"
)
final_wo_count = env['mrp.workorder'].search_count([])
LOG(f" TOTAL work orders now in DB: {final_wo_count}")
# ============================================================
# Phase 15: Final commit
# ============================================================
env.cr.commit()
LOG("=" * 60)
LOG("DEMO SEED COMPLETE")
LOG("=" * 60)
LOG(f" Customers: 6")
LOG(f" Parts: {env['fp.part.catalog'].search_count([])}")
LOG(f" Price lists: {env['fp.customer.price.list'].search_count([])}")
LOG(f" Quotes: {env['fp.quote.configurator'].search_count([])}")
LOG(f" Sale orders: {env['sale.order'].search_count([('partner_id', 'in', [amphenol.id, magellan.id, cyclone.id, honeywell.id, westin.id, delinquent.id])])}")
LOG(f" MOs: {env['mrp.production'].search_count([])}")
LOG(f" Deliveries: {env['fusion.plating.delivery'].search_count([])}")
LOG(f" Certificates: {env['fp.certificate'].search_count([])}")
LOG(f" Invoices: {env['account.move'].search_count([('move_type', '=', 'out_invoice')])}")
LOG(f" Payments: {env['account.payment'].search_count([('payment_type', '=', 'inbound')])}")
LOG(f" Bath logs: {env['fusion.plating.bath.log'].search_count([])}")
LOG(f" Replenishments: {env['fusion.plating.bath.replenishment.suggestion'].search_count([])}")
LOG(f" Bake windows: {env['fusion.plating.bake.window'].search_count([])}")
LOG(f" Stations: {env['fusion.plating.shopfloor.station'].search_count([])}")
LOG(f" First-piece: {env['fusion.plating.first.piece.gate'].search_count([])}")
LOG(f" Quality holds: {env['fusion.plating.quality.hold'].search_count([])}")
LOG(f" Racks: {env['fusion.plating.rack'].search_count([])}")
LOG(f" Operator certs: {env['fp.operator.certification'].search_count([])}")
LOG("")
LOG("Key demo stops:")
LOG(" 1. Amphenol Canada → 8+ closed jobs, paid invoices, issued certs w/ SPC data")
LOG(" 2. Magellan → active progress-billing order, 40% invoice posted")
LOG(" 3. Cyclone → active deposit order, 50% paid, MO in progress")
LOG(" 4. Honeywell → just-confirmed net-terms SO, receiving pending")
LOG(" 5. Westin → direct-order path (COD)")
LOG(" 6. Delinquent Industries → click Confirm to demo account hold block")
LOG(" 7. Bake windows → 1 awaiting / 1 missed / 1 baked")
LOG(" 8. EN-BATH-A → pending replenishment suggestions (nickel low)")
LOG(" 9. RACK-004 → shows Needs Strip (MTO 3.2 / 3.0)")
LOG("")