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>
1393 lines
55 KiB
Python
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("")
|