chore(fusion_plating): add story-driven demo seeder + polish invoice PAID stamp

Demo seeder (scripts/fp_demo_seed.py):
- Idempotent Python script run via odoo shell; populates ~60 records
  across 6 customer stories covering every workflow state for live demo
- Customers: Amphenol (net-terms, deep history), Magellan (progress
  billing, active), Cyclone (deposit, in-production), Honeywell
  (net-terms, just confirmed), Westin (COD, direct-order path),
  Delinquent Industries (account hold — Confirm raises UserError)
- Coating configs with realistic AMS specs (2404, 2700 Rev G, 2406)
  and bake-relief flags set on applicable processes
- Part catalog with revision chains (Rev 1 / Rev 2 / Rev 3 for hot parts)
- Customer price lists with volume tiers
- Per-customer invoice strategy defaults
- Bath chemistry logs (15 readings, last 2 OOS → pending replenishment
  suggestion visible in menu)
- Racks: 4 active + 1 needing strip (MTO 3.2 / 3.0) for kanban demo
- Bake windows: 1 awaiting (ticking down), 1 baked, 1 missed (alert)
- Quote configurator sessions: 3 draft, 3 confirmed/won, 3 lost (with
  reasons), 1 expired — populates the win/loss analysis
- Historical closed orders: 8 jobs backdated across 4 months with
  SO → MO → Delivery → Invoice → Payment run through each hook so
  portal-job progression, certificates with thickness readings, and
  invoice AR aging all look real
- Active orders at every workflow stage for the live demo cycle

Polish:
- report_fp_invoice PAID stamp now also triggers on payment_state ==
  'in_payment' (in addition to 'paid'). Odoo leaves payments in
  'in_payment' until the bank reconciliation job matches them against
  a statement line, so historical demo invoices would otherwise never
  show as stamped even though the payment is posted and the customer
  owes nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-17 01:30:53 -04:00
parent a623c6684d
commit fbaf318832
2 changed files with 963 additions and 3 deletions

View File

@@ -173,7 +173,7 @@
</div>
<!-- Paid stamp -->
<t t-if="doc.payment_state == 'paid'">
<t t-if="doc.payment_state in ('paid', 'in_payment')">
<div style="margin-top: 15px; text-align: center;">
<span class="paid-stamp">PAID</span>
</div>
@@ -364,7 +364,7 @@
</div>
<!-- Paid stamp -->
<t t-if="doc.payment_state == 'paid'">
<t t-if="doc.payment_state in ('paid', 'in_payment')">
<div style="margin-top: 15px; text-align: center;">
<span class="paid-stamp">PAID</span>
</div>
@@ -378,7 +378,7 @@
<thead>
<tr>
<th colspan="3" style="background-color: #28a745; color: white;">
<t t-if="doc.payment_state == 'paid'">✓ PAYMENT DETAILS — PAID IN FULL</t>
<t t-if="doc.payment_state in ('paid', 'in_payment')">✓ PAYMENT DETAILS — PAID IN FULL</t>
<t t-elif="doc.payment_state == 'partial'">PAYMENT DETAILS — PARTIALLY PAID</t>
<t t-else="">PAYMENT DETAILS</t>
</th>

View File

@@ -0,0 +1,960 @@
# -*- 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 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',
})
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 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 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" 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("")