feat(fusion_plating): quote-to-cash infra, notifications, wizards, Tier 1 plating features

Quote-to-cash PDF reports (portrait + landscape variants, 16 new actions):
- Quotation / Sales Order, Work Order Traveller, Packing Slip, Bill of Lading,
  Certificate of Conformance (portrait added), Invoice, Payment Receipt
- Shared fp_portrait_styles + fp_landscape_styles base templates

Workflow gap fixes (fusion_plating_bridge_mrp):
- Auto-assign recipe from SO coating config in MrpProduction.action_confirm
- Auto-create draft CoC (fp.certificate) on MrpProduction.button_mark_done

Notifications overhaul (fusion_plating_notifications v2.0):
- Expanded TRIGGER_EVENTS to 7 (added quote_sent, mo_complete, shipped, payment_received)
- Shared _dispatch method replaces three duplicated send helpers
- Auto-attach PDF reports per template config (quote, SO, CoC, invoice, receipt, BoL)
- Rebuilt 7 email templates with fusion_claims accent-bar design
  (info/success color-coded, theme-safe, 600px max-width)
- New hooks: MrpProduction done, FpDelivery mark_delivered, AccountPayment post,
  SaleOrder action_quotation_send

Wizards (fusion_plating_configurator):
- fp.direct.order.wizard — skip quotation for repeat customers with PO in hand;
  optional new-revision drawing upload bumps fp.part.catalog revision and links
  new rev to the SO; creates + confirms the SO in one step
- fp.part.catalog.import.wizard — 3-step CSV import with dry-run preview,
  tolerant parsing (customer by name/email/xmlid, human-readable selections),
  duplicate detection, create-missing-customers option, single transaction commit
- Partner form stat buttons: Direct Order, Import Parts
- CSV template download button

Tier 1 practical plating features:
- T1.1 Hydrogen bake window enforcement (fp.coating.config.requires_bake_relief,
  auto-create fusion.plating.bake.window on plating WO finish, FpDelivery lockout
  when window is open)
- T1.2 Bath replenishment rules + pending suggestion queue
  (fusion.plating.bath.replenishment.rule + .suggestion, hook on bath log line
  create, operator Apply / Dismiss actions)
- T1.3 Rack/fixture library (fusion.plating.rack with MTO counter, strip
  schedule, lifecycle: active → needs_strip → stripping → retired)
- T1.4 Rework / strip-and-replate MOs (x_fc_is_rework, x_fc_original_production_id,
  Create Rework stat button on completed MOs)
- T1.5 Parts location (x_fc_current_location computed on mrp.production —
  "In progress: Alkaline Clean" / "Queued: Bake Oven" / "Ready to Ship")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-16 23:41:12 -04:00
parent 7c7ef06057
commit d3dd6376a6
51 changed files with 5231 additions and 197 deletions

View File

@@ -5,3 +5,4 @@
from . import controllers
from . import models
from . import wizard

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.1.0.0',
'version': '19.0.2.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -39,6 +39,8 @@ Provides:
'security/ir.model.access.csv',
'data/fp_configurator_sequence_data.xml',
'data/fp_treatment_data.xml',
'wizard/fp_direct_order_wizard_views.xml',
'wizard/fp_part_catalog_import_wizard_views.xml',
'views/fp_treatment_views.xml',
'views/fp_part_catalog_views.xml',
'views/fp_coating_config_views.xml',

View File

@@ -51,6 +51,27 @@ class FpCoatingConfig(models.Model):
'fp.treatment', 'fp_coating_config_post_treatment_rel', 'config_id', 'treatment_id',
string='Post-Treatments', domain="[('treatment_type', '=', 'post')]",
)
# ---- Hydrogen embrittlement relief (AMS 2759/9) ----
requires_bake_relief = fields.Boolean(
string='Requires Bake Relief',
help='Hydrogen embrittlement relief bake required (high-strength steel, '
'Rockwell C ≥ 31). When set, finishing the plating WO auto-creates '
'a bake window record and blocks shipment until bake is complete.',
)
bake_window_hours = fields.Float(
string='Bake Window (hours)', default=4.0,
help='Maximum time between plate exit and bake start. Typically 4h per AMS 2759/9.',
)
bake_temperature = fields.Float(
string='Bake Temperature (°F)', default=375.0,
help='Relief bake temperature. Typical: 375°F for steel ≥ HRC 40.',
)
bake_duration_hours = fields.Float(
string='Bake Duration (hours)', default=23.0,
help='Minimum bake hold time at temperature. Typical: 23h.',
)
sequence = fields.Integer(string='Sequence', default=10)
description = fields.Text(string='Description')
active = fields.Boolean(string='Active', default=True)

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models
from odoo import api, fields, models, _
class ResPartner(models.Model):
@@ -35,3 +35,27 @@ class ResPartner(models.Model):
'context': {'default_partner_id': self.id},
'target': 'current',
}
def action_fp_import_parts(self):
"""Open the CSV import wizard with this partner pre-selected."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Import Parts from CSV'),
'res_model': 'fp.part.catalog.import.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_partner_id': self.id},
}
def action_fp_new_direct_order(self):
"""Open the Direct Order wizard with this partner pre-selected."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('New Direct Order'),
'res_model': 'fp.direct.order.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_partner_id': self.id},
}

View File

@@ -17,3 +17,7 @@ access_fp_pricing_surcharge_manager,fp.pricing.complexity.surcharge.manager,mode
access_fp_quote_configurator_operator,fp.quote.configurator.operator,model_fp_quote_configurator,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_quote_configurator_estimator,fp.quote.configurator.estimator,model_fp_quote_configurator,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_quote_configurator_manager,fp.quote.configurator.manager,model_fp_quote_configurator,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_direct_order_wizard_estimator,fp.direct.order.wizard.estimator,model_fp_direct_order_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_direct_order_wizard_manager,fp.direct.order.wizard.manager,model_fp_direct_order_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_import_wizard_estimator,fp.part.catalog.import.wizard.estimator,model_fp_part_catalog_import_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_part_import_wizard_manager,fp.part.catalog.import.wizard.manager,model_fp_part_catalog_import_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
17 access_fp_quote_configurator_operator fp.quote.configurator.operator model_fp_quote_configurator fusion_plating.group_fusion_plating_operator 1 0 0 0
18 access_fp_quote_configurator_estimator fp.quote.configurator.estimator model_fp_quote_configurator fusion_plating_configurator.group_fp_estimator 1 1 1 0
19 access_fp_quote_configurator_manager fp.quote.configurator.manager model_fp_quote_configurator fusion_plating.group_fusion_plating_manager 1 1 1 1
20 access_fp_direct_order_wizard_estimator fp.direct.order.wizard.estimator model_fp_direct_order_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
21 access_fp_direct_order_wizard_manager fp.direct.order.wizard.manager model_fp_direct_order_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
22 access_fp_part_import_wizard_estimator fp.part.catalog.import.wizard.estimator model_fp_part_catalog_import_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
23 access_fp_part_import_wizard_manager fp.part.catalog.import.wizard.manager model_fp_part_catalog_import_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -28,6 +28,12 @@
action="action_fp_quotations"
sequence="10"/>
<menuitem id="menu_fp_direct_order"
name="New Direct Order"
parent="menu_fp_sales"
action="action_fp_direct_order_wizard"
sequence="15"/>
<menuitem id="menu_fp_sale_orders"
name="Sale Orders"
parent="menu_fp_sales"
@@ -46,6 +52,12 @@
action="action_fp_part_catalog"
sequence="40"/>
<menuitem id="menu_fp_part_catalog_import"
name="Import Parts (CSV)"
parent="menu_fp_sales"
action="action_fp_part_catalog_import_wizard"
sequence="45"/>
<!-- ===== CONFIGURATOR submenu ===== -->
<menuitem id="menu_fp_configurator"
name="Configurator"

View File

@@ -15,6 +15,24 @@
invisible="x_fc_part_count == 0">
<field name="x_fc_part_count" widget="statinfo" string="Parts"/>
</button>
<button name="action_fp_new_direct_order"
type="object"
class="oe_stat_button"
icon="fa-shopping-cart"
invisible="customer_rank == 0">
<div class="o_stat_info">
<span class="o_stat_text">Direct Order</span>
</div>
</button>
<button name="action_fp_import_parts"
type="object"
class="oe_stat_button"
icon="fa-upload"
invisible="customer_rank == 0">
<div class="o_stat_info">
<span class="o_stat_text">Import Parts</span>
</div>
</button>
</xpath>
<xpath expr="//page[@name='sales_purchases']" position="after">

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_direct_order_wizard
from . import fp_part_catalog_import_wizard

View File

@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class FpDirectOrderWizard(models.TransientModel):
"""Direct order entry for repeat customers.
Skips the quotation stage when the customer has already sent a PO.
Creates a sale.order and calls action_confirm() in one step.
Optionally bumps the part catalog revision when a new drawing is uploaded.
"""
_name = 'fp.direct.order.wizard'
_description = 'Fusion Plating — Direct Order Entry'
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True,
domain="[('customer_rank', '>', 0)]",
)
# Part selection
part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part', required=True,
domain="[('partner_id', '=', partner_id), ('is_latest_revision', '=', True)]",
)
part_number = fields.Char(related='part_catalog_id.part_number', readonly=True)
current_revision = fields.Char(related='part_catalog_id.revision', readonly=True)
surface_area = fields.Float(
related='part_catalog_id.surface_area', readonly=True, digits=(12, 4),
)
surface_area_uom = fields.Selection(
related='part_catalog_id.surface_area_uom', readonly=True,
)
# Revision upload (optional — creates a new revision of the part)
create_new_revision = fields.Boolean(
string='This is a New Revision',
help='Check if the customer sent an updated drawing or 3D model. '
'A new part revision will be created and linked to this order.',
)
new_drawing_file = fields.Binary(
string='New Drawing / 3D Model',
help='STEP, STL, IGES, or PDF. Used when creating a new revision.',
)
new_drawing_filename = fields.Char(string='Filename')
revision_note = fields.Char(
string='Revision Note', help='What changed in this revision?',
)
# Order details
coating_config_id = fields.Many2one(
'fp.coating.config', string='Coating', required=True,
)
quantity = fields.Integer(string='Quantity', required=True, default=1)
unit_price = fields.Float(
string='Unit Price', digits=(12, 2),
help='Negotiated price per part. Leave blank to set later.',
)
rush_order = fields.Boolean(string='Rush Order')
delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'),
('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
string='Delivery Method',
)
# PO (required — that's what makes this a "direct" order)
po_number = fields.Char(string='Customer PO #', required=True)
po_attachment_file = fields.Binary(string='PO Document', required=True)
po_attachment_filename = fields.Char(string='PO Filename')
# Invoice strategy (pulled from partner default if set)
invoice_strategy = fields.Selection(
[('deposit', 'Deposit'), ('progress', 'Progress Billing'),
('net_terms', 'Net Terms'), ('cod_prepay', 'COD / Prepay')],
string='Invoice Strategy',
)
deposit_percent = fields.Float(string='Deposit %')
notes = fields.Text(string='Internal Notes')
@api.onchange('partner_id')
def _onchange_partner_id(self):
"""Reset part selection when customer changes + pull invoice defaults."""
self.part_catalog_id = False
if self.partner_id and 'x_fc_default_invoice_strategy' in self.partner_id._fields:
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
def action_create_order(self):
"""Create and confirm the sale order, optionally bumping part revision."""
self.ensure_one()
if self.create_new_revision and not self.new_drawing_file:
raise UserError(_(
'Please upload the new drawing when creating a new revision.'
))
if self.quantity <= 0:
raise UserError(_('Quantity must be positive.'))
# 1. Optional: create a new part revision from the uploaded drawing
part = self.part_catalog_id
if self.create_new_revision:
drawing_att = self.env['ir.attachment'].create({
'name': self.new_drawing_filename or 'drawing.pdf',
'datas': self.new_drawing_file,
'res_model': 'fp.part.catalog',
'res_id': part.id,
})
# action_create_revision returns an action dict; we keep the part
part.action_create_revision()
new_rev = self.env['fp.part.catalog'].search(
[('parent_part_id', '=', (part.parent_part_id or part).id),
('is_latest_revision', '=', True)],
limit=1, order='revision_number desc',
)
if new_rev:
new_rev.write({
'revision_note': self.revision_note or False,
})
# Attach drawing/model based on extension
fname = (self.new_drawing_filename or '').lower()
if fname.endswith(('.step', '.stp', '.stl', '.iges', '.igs', '.brep', '.brp')):
new_rev.model_attachment_id = drawing_att.id
else:
new_rev.drawing_attachment_ids = [(4, drawing_att.id)]
part = new_rev
# 2. Save the PO attachment
po_att = self.env['ir.attachment'].create({
'name': self.po_attachment_filename or 'po.pdf',
'datas': self.po_attachment_file,
'mimetype': 'application/pdf',
})
# 3. Find or create the generic plating service product (same as configurator)
product = self.env['product.product'].search(
[('default_code', '=', 'FP-SERVICE')], limit=1,
)
if not product:
product = self.env['product.product'].create({
'name': 'Plating Service',
'default_code': 'FP-SERVICE',
'type': 'service',
'list_price': 0,
'sale_ok': True,
'purchase_ok': False,
})
line_desc = '%s%s Rev %s (x%d)' % (
self.coating_config_id.name,
part.name,
part.revision or part.revision_number,
self.quantity,
)
so_vals = {
'partner_id': self.partner_id.id,
'x_fc_part_catalog_id': part.id,
'x_fc_coating_config_id': self.coating_config_id.id,
'x_fc_rush_order': self.rush_order,
'x_fc_delivery_method': self.delivery_method,
'x_fc_po_number': self.po_number,
'x_fc_po_attachment_id': po_att.id,
'x_fc_po_received': True,
'x_fc_invoice_strategy': self.invoice_strategy,
'x_fc_deposit_percent': self.deposit_percent,
'origin': 'Direct Order',
'note': self.notes or False,
'order_line': [(0, 0, {
'product_id': product.id,
'name': line_desc,
'product_uom_qty': self.quantity,
'price_unit': self.unit_price or 0.0,
})],
}
so = self.env['sale.order'].create(so_vals)
# Immediately confirm — skips quote/send step entirely
so.action_confirm()
so.message_post(
body=_(
'Direct order created from PO %s. Quotation stage skipped.'
) % self.po_number,
)
return {
'type': 'ir.actions.act_window',
'name': _('Sale Order'),
'res_model': 'sale.order',
'res_id': so.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fp_direct_order_wizard_form" model="ir.ui.view">
<field name="name">fp.direct.order.wizard.form</field>
<field name="model">fp.direct.order.wizard</field>
<field name="arch" type="xml">
<form string="Direct Order Entry">
<sheet>
<div class="oe_title">
<h1>New Direct Order</h1>
<p class="text-muted">
Skip the quotation stage — create a confirmed order
when the customer has already sent a PO.
</p>
</div>
<group>
<group string="Customer">
<field name="partner_id" options="{'no_create_edit': True}"/>
</group>
<group string="Purchase Order">
<field name="po_number"/>
<field name="po_attachment_file" filename="po_attachment_filename"/>
<field name="po_attachment_filename" invisible="1"/>
</group>
</group>
<group string="Part">
<group>
<field name="part_catalog_id"
options="{'no_create_edit': True}"
context="{'default_partner_id': partner_id}"/>
<field name="part_number" invisible="not part_catalog_id"/>
<field name="current_revision" invisible="not part_catalog_id"/>
</group>
<group>
<field name="surface_area" invisible="not part_catalog_id"/>
<field name="surface_area_uom" invisible="not part_catalog_id"/>
</group>
</group>
<group string="New Revision (optional)">
<field name="create_new_revision"/>
<field name="new_drawing_file"
filename="new_drawing_filename"
invisible="not create_new_revision"
required="create_new_revision"/>
<field name="new_drawing_filename" invisible="1"/>
<field name="revision_note" invisible="not create_new_revision"/>
</group>
<group>
<group string="Order">
<field name="coating_config_id"/>
<field name="quantity"/>
<field name="unit_price"/>
</group>
<group string="Fulfilment">
<field name="rush_order"/>
<field name="delivery_method"/>
</group>
</group>
<group string="Invoicing">
<group>
<field name="invoice_strategy"/>
<field name="deposit_percent"
invisible="invoice_strategy != 'deposit'"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1" colspan="2" placeholder="Internal notes..."/>
</group>
</sheet>
<footer>
<button name="action_create_order"
type="object"
string="Create &amp; Confirm Order"
class="btn-primary"/>
<button string="Cancel" special="cancel" class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<record id="action_fp_direct_order_wizard" model="ir.actions.act_window">
<field name="name">New Direct Order</field>
<field name="res_model">fp.direct.order.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,501 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import base64
import csv
import io
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------
# CSV column spec — order matters for the downloadable template
# ---------------------------------------------------------------------
CSV_COLUMNS = [
'part_number', # required
'name', # required
'customer', # required unless wizard.partner_id set
'revision',
'revision_number',
'substrate_material',
'surface_area',
'surface_area_uom',
'complexity',
'weight',
'dimensions_length',
'dimensions_width',
'dimensions_height',
'masking_zones',
'masking_description',
'has_blind_holes',
'has_recesses',
'has_threads',
'notes',
]
# Map human-friendly inputs to field-key values
SUBSTRATE_ALIASES = {
'aluminium': 'aluminium', 'aluminum': 'aluminium', 'al': 'aluminium',
'steel': 'steel', 'carbon steel': 'steel', 'cs': 'steel',
'stainless': 'stainless', 'stainless steel': 'stainless', 'ss': 'stainless',
'copper': 'copper', 'cu': 'copper',
'titanium': 'titanium', 'ti': 'titanium',
'other': 'other', '': 'steel',
}
UOM_ALIASES = {
'sq_in': 'sq_in', 'sq in': 'sq_in', 'sqin': 'sq_in', 'in2': 'sq_in', 'in²': 'sq_in',
'sq_ft': 'sq_ft', 'sq ft': 'sq_ft', 'sqft': 'sq_ft', 'ft2': 'sq_ft',
'sq_cm': 'sq_cm', 'sq cm': 'sq_cm', 'sqcm': 'sq_cm', 'cm2': 'sq_cm',
'sq_m': 'sq_m', 'sq m': 'sq_m', 'sqm': 'sq_m', 'm2': 'sq_m',
'': 'sq_in',
}
COMPLEXITY_ALIASES = {
'simple': 'simple', '1': 'simple', 'low': 'simple',
'moderate': 'moderate', '2': 'moderate', 'medium': 'moderate', 'med': 'moderate',
'complex': 'complex', '3': 'complex', 'high': 'complex',
'very_complex': 'very_complex', 'very complex': 'very_complex', '4': 'very_complex',
'': 'simple',
}
TRUE_VALUES = {'1', 'true', 'yes', 'y', 't'}
def _to_float(v, default=0.0):
if v is None or v == '':
return default
try:
return float(str(v).replace(',', '').strip())
except (ValueError, TypeError):
return default
def _to_int(v, default=0):
if v is None or v == '':
return default
try:
return int(float(str(v).replace(',', '').strip()))
except (ValueError, TypeError):
return default
def _to_bool(v):
if v is None or v == '':
return False
return str(v).strip().lower() in TRUE_VALUES
class FpPartCatalogImportWizard(models.TransientModel):
"""Two-step CSV import for the part catalog.
Step 1 (draft): user uploads CSV and clicks Preview.
Step 2 (preview): wizard shows row counts, first-10 errors, and an
Import button. User can fix and re-upload, or commit.
"""
_name = 'fp.part.catalog.import.wizard'
_description = 'Fusion Plating — Part Catalog CSV Import'
state = fields.Selection(
[('draft', 'Draft'), ('preview', 'Preview'), ('done', 'Done')],
default='draft',
)
csv_file = fields.Binary(string='CSV File', required=True)
csv_filename = fields.Char(string='Filename')
partner_id = fields.Many2one(
'res.partner', string='Default Customer',
domain="[('customer_rank', '>', 0)]",
help='Optional. If set, rows without a customer column use this. '
'If the CSV has a customer column, values there win.',
)
create_missing_customers = fields.Boolean(
string='Create Missing Customers',
help='If a customer name in the CSV does not exist, create it as a '
'new contact with customer_rank=1.',
)
skip_existing = fields.Boolean(
string='Skip Duplicates',
default=True,
help='When (customer, part_number) already exists, skip that row '
'instead of erroring.',
)
# Preview / result counters
total_rows = fields.Integer(readonly=True)
valid_rows = fields.Integer(readonly=True)
duplicate_rows = fields.Integer(readonly=True)
error_rows = fields.Integer(readonly=True)
created_count = fields.Integer(readonly=True)
preview_html = fields.Html(readonly=True)
# Hidden: stash the parsed rows between preview and import
parsed_rows_json = fields.Text()
# ---------------------------------------------------------------
# Actions
# ---------------------------------------------------------------
def action_download_template(self):
"""Return a minimal CSV template with just the header row."""
self.ensure_one()
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(CSV_COLUMNS)
# One example row for clarity
writer.writerow([
'PN-12345', 'Widget A', 'Acme Corp', 'Rev A', '1',
'steel', '12.5', 'sq_in', 'moderate', '0.4',
'50', '30', '20', '2', 'Mask threaded holes',
'no', 'no', 'yes', 'Example row — delete before import',
])
data = buf.getvalue().encode('utf-8')
att = self.env['ir.attachment'].create({
'name': 'fp_part_catalog_template.csv',
'type': 'binary',
'datas': base64.b64encode(data),
'mimetype': 'text/csv',
'res_model': self._name,
'res_id': self.id,
})
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{att.id}?download=true',
'target': 'self',
}
def action_preview(self):
"""Parse + validate the CSV. Does not write anything."""
self.ensure_one()
rows, parse_errors = self._parse_csv()
errors = list(parse_errors)
valid_rows = []
duplicates = []
Partner = self.env['res.partner']
Part = self.env['fp.part.catalog']
for i, row in enumerate(rows, start=2): # start=2 → row 1 is header
row_err = []
part_number = (row.get('part_number') or '').strip()
name = (row.get('name') or '').strip()
if not part_number:
row_err.append('part_number is required')
if not name:
row_err.append('name is required')
# Resolve customer
customer_raw = (row.get('customer') or '').strip()
partner = False
if customer_raw:
partner = self._find_partner(customer_raw)
if not partner and not self.create_missing_customers:
row_err.append(f'customer "{customer_raw}" not found')
elif self.partner_id:
partner = self.partner_id
else:
row_err.append(
'customer is required (column empty and no default set)'
)
# Normalise selection fields
substrate = SUBSTRATE_ALIASES.get(
(row.get('substrate_material') or '').strip().lower()
)
if substrate is None:
row_err.append(
f'substrate_material "{row.get("substrate_material")}" '
'not recognised'
)
substrate = 'steel'
uom = UOM_ALIASES.get(
(row.get('surface_area_uom') or '').strip().lower()
)
if uom is None:
row_err.append(
f'surface_area_uom "{row.get("surface_area_uom")}" '
'not recognised'
)
uom = 'sq_in'
complexity = COMPLEXITY_ALIASES.get(
(row.get('complexity') or '').strip().lower()
)
if complexity is None:
row_err.append(
f'complexity "{row.get("complexity")}" not recognised'
)
complexity = 'simple'
# Check duplicate if the customer resolved
is_duplicate = False
if partner and part_number:
existing = Part.search([
('partner_id', '=', partner.id),
('part_number', '=', part_number),
], limit=1)
if existing:
is_duplicate = True
if not self.skip_existing:
row_err.append(
f'part ({partner.name}, {part_number}) already exists'
)
if row_err:
errors.append({'row': i, 'errors': row_err, 'data': row})
continue
if is_duplicate:
duplicates.append({'row': i, 'customer': partner.name, 'part_number': part_number})
continue
# Build the prepared vals (no partner id yet — may need creating)
valid_rows.append({
'row': i,
'customer_raw': customer_raw,
'partner_id': partner.id if partner else None,
'vals': {
'part_number': part_number,
'name': name,
'revision': (row.get('revision') or '').strip() or False,
'revision_number': _to_int(row.get('revision_number'), 1),
'substrate_material': substrate,
'surface_area': _to_float(row.get('surface_area')),
'surface_area_uom': uom,
'complexity': complexity,
'weight': _to_float(row.get('weight')),
'dimensions_length': _to_float(row.get('dimensions_length')),
'dimensions_width': _to_float(row.get('dimensions_width')),
'dimensions_height': _to_float(row.get('dimensions_height')),
'masking_zones': _to_int(row.get('masking_zones')),
'masking_description': (row.get('masking_description') or '').strip() or False,
'has_blind_holes': _to_bool(row.get('has_blind_holes')),
'has_recesses': _to_bool(row.get('has_recesses')),
'has_threads': _to_bool(row.get('has_threads')),
'notes': (row.get('notes') or '').strip() or False,
'is_latest_revision': True,
},
})
# Stash parsed rows for the import step
import json
self.parsed_rows_json = json.dumps(valid_rows)
# Write counters
self.total_rows = len(rows)
self.valid_rows = len(valid_rows)
self.duplicate_rows = len(duplicates)
self.error_rows = len(errors)
self.preview_html = self._build_preview_html(errors, duplicates, rows)
self.state = 'preview'
return {
'type': 'ir.actions.act_window',
'name': _('Import Preview'),
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_import(self):
"""Commit the valid rows. Called from the Preview screen."""
self.ensure_one()
if self.state != 'preview':
raise UserError(_('Run Preview first.'))
import json
try:
valid_rows = json.loads(self.parsed_rows_json or '[]')
except ValueError:
raise UserError(_('Preview data lost — please Preview again.'))
Partner = self.env['res.partner']
Part = self.env['fp.part.catalog']
created = 0
# Resolve/create customers once per name, then create parts in bulk
partner_cache = {}
for row in valid_rows:
pid = row.get('partner_id')
if pid:
continue
name = row['customer_raw']
if name in partner_cache:
row['partner_id'] = partner_cache[name]
continue
partner = self._find_partner(name)
if not partner and self.create_missing_customers:
partner = Partner.create({
'name': name,
'customer_rank': 1,
})
if partner:
partner_cache[name] = partner.id
row['partner_id'] = partner.id
batch = []
for row in valid_rows:
if not row.get('partner_id'):
continue # Shouldn't happen after preview, but guard anyway
vals = dict(row['vals'])
vals['partner_id'] = row['partner_id']
batch.append(vals)
if batch:
Part.create(batch)
created = len(batch)
self.created_count = created
self.state = 'done'
return {
'type': 'ir.actions.act_window',
'name': _('Import Complete'),
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_open_imported_parts(self):
"""Open the part catalog filtered to the customer (if one was set)."""
self.ensure_one()
domain = [('is_latest_revision', '=', True)]
if self.partner_id:
domain.append(('partner_id', '=', self.partner_id.id))
return {
'type': 'ir.actions.act_window',
'name': _('Part Catalog'),
'res_model': 'fp.part.catalog',
'view_mode': 'list,form',
'domain': domain,
'target': 'current',
}
# ---------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------
def _parse_csv(self):
"""Decode the binary upload and yield dict rows."""
self.ensure_one()
if not self.csv_file:
raise UserError(_('Upload a CSV file first.'))
try:
raw = base64.b64decode(self.csv_file)
# Try utf-8 with BOM, fall back to latin-1
try:
text = raw.decode('utf-8-sig')
except UnicodeDecodeError:
text = raw.decode('latin-1')
except Exception as exc:
raise UserError(_('Could not decode the file: %s') % exc)
# Sniff delimiter (support comma / semicolon / tab)
try:
sample = text[:4096]
dialect = csv.Sniffer().sniff(sample, delimiters=',;\t')
except csv.Error:
dialect = csv.excel
reader = csv.DictReader(io.StringIO(text), dialect=dialect)
# Normalise header names: lowercase + underscores
def _norm(h):
return (h or '').strip().lower().replace(' ', '_')
reader.fieldnames = [_norm(h) for h in (reader.fieldnames or [])]
missing = [c for c in ('part_number', 'name') if c not in reader.fieldnames]
parse_errors = []
if missing:
parse_errors.append({
'row': 1,
'errors': [f'missing required columns: {", ".join(missing)}'],
'data': {},
})
return [], parse_errors
rows = []
for raw_row in reader:
# Keys already normalised; strip values
rows.append({k: (v.strip() if isinstance(v, str) else v)
for k, v in raw_row.items()})
return rows, parse_errors
def _find_partner(self, raw):
"""Match partner by external ID, email, or name (case-insensitive)."""
self.ensure_one()
Partner = self.env['res.partner']
raw = raw.strip()
if not raw:
return False
# External ID (module.xmlid)
if '.' in raw and ' ' not in raw:
try:
p = self.env.ref(raw, raise_if_not_found=False)
if p and p._name == 'res.partner':
return p
except Exception:
pass
# Email
if '@' in raw:
p = Partner.search([('email', '=ilike', raw)], limit=1)
if p:
return p
# Name (case-insensitive exact, then loose)
p = Partner.search([('name', '=ilike', raw)], limit=1)
if p:
return p
p = Partner.search([('name', 'ilike', raw)], limit=1)
return p or False
def _build_preview_html(self, errors, duplicates, all_rows):
"""Render a compact summary for the preview screen."""
pieces = []
pieces.append(
f'<div style="font-family: sans-serif;">'
f'<h3 style="margin:0 0 8px 0;">Import Preview</h3>'
f'<p style="margin:0 0 16px 0;">'
f'<b>{len(all_rows)}</b> rows parsed · '
f'<span style="color:#2e7d32;"><b>{self.valid_rows}</b> valid</span> · '
f'<span style="color:#f57f17;"><b>{self.duplicate_rows}</b> duplicates</span> · '
f'<span style="color:#c62828;"><b>{self.error_rows}</b> errors</span>'
f'</p>'
)
if errors:
pieces.append('<h4 style="margin:16px 0 8px 0;">Errors (first 10)</h4>')
pieces.append('<table style="width:100%;border-collapse:collapse;font-size:12px;">')
pieces.append(
'<tr style="background:#f5f5f5;">'
'<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;">Row</th>'
'<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;">Issue</th>'
'<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;">Part #</th>'
'<th style="text-align:left;padding:4px 8px;border-bottom:1px solid #ccc;">Customer</th>'
'</tr>'
)
for e in errors[:10]:
pn = (e.get('data') or {}).get('part_number', '')
cust = (e.get('data') or {}).get('customer', '')
for msg in e['errors']:
pieces.append(
f'<tr>'
f'<td style="padding:4px 8px;border-bottom:1px solid #eee;">{e["row"]}</td>'
f'<td style="padding:4px 8px;border-bottom:1px solid #eee;color:#c62828;">{msg}</td>'
f'<td style="padding:4px 8px;border-bottom:1px solid #eee;">{pn}</td>'
f'<td style="padding:4px 8px;border-bottom:1px solid #eee;">{cust}</td>'
f'</tr>'
)
pieces.append('</table>')
if duplicates:
pieces.append(
f'<p style="margin:12px 0 0 0;color:#f57f17;font-size:12px;">'
f'{len(duplicates)} duplicate rows will be skipped.</p>'
)
pieces.append('</div>')
return ''.join(pieces)

View File

@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fp_part_catalog_import_wizard_form" model="ir.ui.view">
<field name="name">fp.part.catalog.import.wizard.form</field>
<field name="model">fp.part.catalog.import.wizard</field>
<field name="arch" type="xml">
<form string="Part Catalog CSV Import">
<field name="state" invisible="1"/>
<!-- ========== STEP 1: Upload ========== -->
<div invisible="state != 'draft'">
<sheet>
<div class="oe_title">
<h1>Import Parts from CSV</h1>
<p class="text-muted">
Bulk-load part catalog entries. The wizard validates
every row before writing — nothing is imported until
you approve the preview.
</p>
</div>
<group>
<group>
<field name="csv_file" filename="csv_filename"/>
<field name="csv_filename" invisible="1"/>
</group>
<group>
<field name="partner_id"
options="{'no_create_edit': True}"/>
<field name="create_missing_customers"/>
<field name="skip_existing"/>
</group>
</group>
<div class="alert alert-info" role="alert">
<strong>CSV format:</strong>
<code>part_number</code>, <code>name</code>, <code>customer</code>,
<code>revision</code>, <code>substrate_material</code>,
<code>surface_area</code>, <code>surface_area_uom</code>,
<code>complexity</code>, <code>weight</code>,
dimensions, masking, flags. The importer accepts
readable values — e.g. "Stainless Steel" maps to
<code>stainless</code>, "sq in" to <code>sq_in</code>.
</div>
</sheet>
<footer>
<button name="action_download_template"
type="object"
string="Download CSV Template"
class="btn-secondary"/>
<button name="action_preview"
type="object"
string="Preview"
class="btn-primary"
invisible="not csv_file"/>
<button string="Cancel" special="cancel"/>
</footer>
</div>
<!-- ========== STEP 2: Preview ========== -->
<div invisible="state != 'preview'">
<sheet>
<div class="oe_title">
<h1>Preview Import</h1>
</div>
<group>
<group>
<field name="total_rows" string="Rows Parsed"/>
<field name="valid_rows" string="Valid"/>
</group>
<group>
<field name="duplicate_rows" string="Duplicates (skipped)"/>
<field name="error_rows" string="Errors"/>
</group>
</group>
<field name="preview_html" nolabel="1" widget="html" readonly="1"/>
</sheet>
<footer>
<button name="action_import"
type="object"
string="Import Valid Rows"
class="btn-primary"
invisible="valid_rows == 0"/>
<button name="action_preview"
type="object"
string="Re-Preview"
class="btn-secondary"/>
<button string="Cancel" special="cancel"/>
</footer>
</div>
<!-- ========== STEP 3: Done ========== -->
<div invisible="state != 'done'">
<sheet>
<div class="oe_title">
<h1>Import Complete</h1>
<p class="text-muted">
<field name="created_count" readonly="1" nolabel="1" class="oe_inline"/>
parts created.
</p>
</div>
</sheet>
<footer>
<button name="action_open_imported_parts"
type="object"
string="View Parts"
class="btn-primary"/>
<button string="Close" special="cancel"/>
</footer>
</div>
</form>
</field>
</record>
<record id="action_fp_part_catalog_import_wizard" model="ir.actions.act_window">
<field name="name">Import Parts from CSV</field>
<field name="res_model">fp.part.catalog.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>