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:
@@ -5,3 +5,4 @@
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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 & 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>
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user