diff --git a/fusion_plating/fusion_plating_reports/report/report_fp_invoice.xml b/fusion_plating/fusion_plating_reports/report/report_fp_invoice.xml index 7e4cf7b5..463837cd 100644 --- a/fusion_plating/fusion_plating_reports/report/report_fp_invoice.xml +++ b/fusion_plating/fusion_plating_reports/report/report_fp_invoice.xml @@ -173,7 +173,7 @@ - +
@@ -364,7 +364,7 @@ - +
@@ -378,7 +378,7 @@ - ✓ PAYMENT DETAILS — PAID IN FULL + ✓ PAYMENT DETAILS — PAID IN FULL PAYMENT DETAILS — PARTIALLY PAID PAYMENT DETAILS diff --git a/fusion_plating/scripts/fp_demo_seed.py b/fusion_plating/scripts/fp_demo_seed.py new file mode 100644 index 00000000..c1d86596 --- /dev/null +++ b/fusion_plating/scripts/fp_demo_seed.py @@ -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("")