changes
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.18.8.0',
|
||||
'version': '19.0.18.12.9',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
@@ -59,6 +59,7 @@ Provides:
|
||||
'wizard/fp_add_from_quote_wizard_views.xml',
|
||||
'wizard/fp_quote_promote_wizard_views.xml',
|
||||
'wizard/fp_part_catalog_import_wizard_views.xml',
|
||||
'wizard/fp_part_revision_bump_wizard_views.xml',
|
||||
'wizard/fp_serial_bulk_add_wizard_views.xml',
|
||||
'views/fp_configurator_menu.xml',
|
||||
'data/fp_sale_description_template_data.xml',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Drop the redundant ``revision_number`` Integer column on fp.part.catalog.
|
||||
|
||||
The model historically carried two revision fields:
|
||||
* ``revision`` (Char, required) — the customer's actual revision label
|
||||
* ``revision_number`` (Integer) — an internal counter
|
||||
|
||||
The Integer counter duplicated information already in ``revision`` and
|
||||
got out of sync whenever the customer used a non-numeric scheme
|
||||
(A/B/C, A1/A2, "ECO-2024-014" etc.). This migration drops the column.
|
||||
``action_create_revision`` and the auto-rev path on 3D-model upload now
|
||||
use ``_bump_revision_label`` which best-effort bumps the alphanumeric
|
||||
label and lets the user adjust to the customer's actual scheme.
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
cr.execute("""
|
||||
ALTER TABLE fp_part_catalog DROP COLUMN IF EXISTS revision_number;
|
||||
""")
|
||||
@@ -3,11 +3,56 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import re
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
def _bump_revision_label(label):
|
||||
"""Best-effort next revision after ``label``.
|
||||
|
||||
Customers use varied revision schemes (A/B/C, A1/A2, "Rev 1"/"Rev 2",
|
||||
custom strings). This helper handles the common ones; for unrecognised
|
||||
formats it returns ``label + '*'`` so the user knows they need to
|
||||
fix the label manually.
|
||||
|
||||
- 'A' → 'B' ... 'Y' → 'Z' → 'AA'
|
||||
- 'a' → 'b' (case preserved on single letter)
|
||||
- 'A1' → 'A2', 'B12' → 'B13', 'Rev 1' → 'Rev 2'
|
||||
- 'AB' → 'AC' (last letter incremented)
|
||||
- everything else → ``label + '*'``
|
||||
"""
|
||||
if not label:
|
||||
return 'A'
|
||||
label = label.strip()
|
||||
|
||||
# Trailing digits — "Rev 1" → "Rev 2", "A1" → "A2".
|
||||
# Preserve zero-padding when the original was padded ("014" → "015").
|
||||
m = re.match(r'^(.*?)(\d+)$', label)
|
||||
if m:
|
||||
prefix, digits = m.group(1), m.group(2)
|
||||
bumped = int(digits) + 1
|
||||
if digits.startswith('0') and len(str(bumped)) <= len(digits):
|
||||
return f"{prefix}{str(bumped).zfill(len(digits))}"
|
||||
return f"{prefix}{bumped}"
|
||||
|
||||
# Single letter
|
||||
if len(label) == 1 and label.isalpha():
|
||||
if label.upper() == 'Z':
|
||||
return 'AA' if label.isupper() else 'aa'
|
||||
return chr(ord(label) + 1)
|
||||
|
||||
# Multi-char ending in letter — "AB" → "AC"
|
||||
m = re.match(r'^(.*?)([A-Za-z])$', label)
|
||||
if m and m.group(2).upper() != 'Z':
|
||||
return m.group(1) + chr(ord(m.group(2)) + 1)
|
||||
|
||||
# Unknown format — caller must edit
|
||||
return label + '*'
|
||||
|
||||
|
||||
class FpPartCatalog(models.Model):
|
||||
"""Customer part library.
|
||||
|
||||
@@ -36,8 +81,12 @@ class FpPartCatalog(models.Model):
|
||||
tracking=True, domain="[('customer_rank', '>', 0)]",
|
||||
)
|
||||
part_number = fields.Char(string='Part Number', required=True, tracking=True, help="Customer's part number (e.g. VS-R392007E01).")
|
||||
revision = fields.Char(string='Revision', required=True, default='A', help='Revision letter or number (e.g. Rev: 1B).')
|
||||
revision_number = fields.Integer(string='Rev #', default=1)
|
||||
revision = fields.Char(
|
||||
string='Revision', required=True, default='A',
|
||||
help="Customer's drawing revision label. Free-text — accepts any "
|
||||
"format the customer uses (A, B, C / A1, B2 / Rev 1, Rev 2 / "
|
||||
"ECO-2024-014 etc.).",
|
||||
)
|
||||
revision_note = fields.Char(string='Revision Note', help='What changed in this revision.')
|
||||
revision_date = fields.Datetime(string='Revision Date', default=fields.Datetime.now)
|
||||
parent_part_id = fields.Many2one(
|
||||
@@ -643,21 +692,46 @@ class FpPartCatalog(models.Model):
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_create_revision(self):
|
||||
"""Create a new revision of this part. Copies all data, increments revision number."""
|
||||
def action_open_revision_wizard(self):
|
||||
"""Open the interactive Create-New-Revision wizard.
|
||||
|
||||
This is what the form-header button calls. The wizard asks
|
||||
the user for the revision label, note, and optionally a new
|
||||
drawing/3D file BEFORE the new record is created — which is
|
||||
what most users want.
|
||||
|
||||
For non-interactive callers (auto-rev on 3D upload, direct
|
||||
order line bump) use ``action_create_revision`` directly.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Create New Revision'),
|
||||
'res_model': 'fp.part.revision.bump.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_part_id': self.id,
|
||||
'active_id': self.id,
|
||||
'active_model': 'fp.part.catalog',
|
||||
},
|
||||
}
|
||||
|
||||
def action_create_revision(self):
|
||||
"""Programmatic, non-interactive revision bump.
|
||||
|
||||
Copies the part with a best-effort label bump via
|
||||
``_bump_revision_label``. Used by code paths that don't have
|
||||
a user prompt (auto-rev when a new 3D model is uploaded on a
|
||||
quote, direct-order line bump). User-facing flows should call
|
||||
``action_open_revision_wizard`` instead.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Mark current as no longer latest
|
||||
self.is_latest_revision = False
|
||||
# Determine the root part for the chain
|
||||
root = self.parent_part_id or self
|
||||
# Find highest revision number in chain
|
||||
all_revs = self.env['fp.part.catalog'].search([
|
||||
'|', ('id', '=', root.id), ('parent_part_id', '=', root.id),
|
||||
])
|
||||
max_rev = max(all_revs.mapped('revision_number') or [0])
|
||||
new_label = _bump_revision_label(self.revision)
|
||||
new_rev = self.copy({
|
||||
'revision_number': max_rev + 1,
|
||||
'revision': f'Rev {max_rev + 1}',
|
||||
'revision': new_label,
|
||||
'revision_date': fields.Datetime.now(),
|
||||
'revision_note': False,
|
||||
'parent_part_id': root.id,
|
||||
|
||||
@@ -697,13 +697,10 @@ class FpQuoteConfigurator(models.Model):
|
||||
old_part = self.part_catalog_id
|
||||
old_part.is_latest_revision = False
|
||||
root = old_part.parent_part_id or old_part
|
||||
all_revs = self.env['fp.part.catalog'].search([
|
||||
'|', ('id', '=', root.id), ('parent_part_id', '=', root.id),
|
||||
])
|
||||
max_rev = max(all_revs.mapped('revision_number') or [0])
|
||||
from .fp_part_catalog import _bump_revision_label
|
||||
new_label = _bump_revision_label(old_part.revision)
|
||||
new_part = old_part.copy({
|
||||
'revision_number': max_rev + 1,
|
||||
'revision': f'Rev {max_rev + 1}',
|
||||
'revision': new_label,
|
||||
'revision_date': fields.Datetime.now(),
|
||||
'revision_note': f'Updated 3D model: {fname}',
|
||||
'parent_part_id': root.id,
|
||||
|
||||
@@ -46,6 +46,8 @@ access_fp_serial_estimator,fp.serial.estimator,model_fp_serial,fusion_plating_co
|
||||
access_fp_serial_manager,fp.serial.manager,model_fp_serial,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial_bulk_add_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_part_revision_bump_estimator,fp.part.revision.bump.estimator,model_fp_part_revision_bump_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
|
||||
access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part_revision_bump_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
|
||||
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -41,10 +41,22 @@ $fp-composer-muted: var(--fp-composer-muted, $_fp-composer-muted-hex);
|
||||
|
||||
.o_fp_part_composer {
|
||||
padding: 16px;
|
||||
max-width: 900px;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
color: $fp-composer-text;
|
||||
|
||||
// Variants table — keep the 5 action buttons (Tree / Simple /
|
||||
// Duplicate / Rename / Delete) on a single row. Without this the
|
||||
// Delete button wraps even on wide screens because Bootstrap's
|
||||
// `.table` lets cells shrink to content+wrap.
|
||||
.o_fp_part_composer_variants {
|
||||
td:last-child,
|
||||
th:last-child {
|
||||
white-space: nowrap;
|
||||
width: 1%; // shrink-to-fit so buttons stay tight on the right
|
||||
}
|
||||
}
|
||||
|
||||
&_state {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Part Catalog">
|
||||
<header>
|
||||
<button name="action_create_revision"
|
||||
<button name="action_open_revision_wizard"
|
||||
string="Create New Revision"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
@@ -110,40 +110,26 @@
|
||||
<div class="oe_title">
|
||||
<label for="part_number" string="Part Number"/>
|
||||
<h1><field name="part_number" placeholder="e.g. VS-R392007E01"/></h1>
|
||||
<field name="name" placeholder="Descriptive part name (e.g. Valve Body Housing)"/>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<group string="Identity">
|
||||
<field name="name" string="Part Name"
|
||||
placeholder="Descriptive part name (e.g. Valve Body Housing)"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="revision"/>
|
||||
<field name="revision_number"/>
|
||||
<field name="material_id"
|
||||
options="{'no_quick_create': True}"/>
|
||||
<field name="substrate_material" invisible="1"/>
|
||||
<field name="geometry_source"/>
|
||||
<field name="is_latest_revision" invisible="1"/>
|
||||
<field name="parent_part_id" invisible="not parent_part_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<label for="surface_area"/>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<field name="surface_area" class="oe_inline"/>
|
||||
<button name="action_calculate_surface_area" type="object"
|
||||
string="Calculate from 3D Model"
|
||||
class="btn-link" icon="fa-calculator"
|
||||
invisible="not model_attachment_id"/>
|
||||
</div>
|
||||
<field name="surface_area_uom"/>
|
||||
<field name="masking_area_sqin"/>
|
||||
<field name="effective_area_sqin" readonly="1"/>
|
||||
<field name="weight"/>
|
||||
<field name="material_weight_kg" readonly="1"/>
|
||||
<group string="Manufacturing Defaults">
|
||||
<field name="material_id"
|
||||
options="{'no_quick_create': True}"/>
|
||||
<field name="substrate_material" invisible="1"/>
|
||||
<field name="x_fc_default_lead_time_days"/>
|
||||
<field name="certificate_requirement"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Quality & Delivery" name="quality_delivery">
|
||||
<field name="certificate_requirement"/>
|
||||
</group>
|
||||
<!-- Quality & Delivery moved into its own notebook tab below
|
||||
(was a top-level group above the notebook). -->
|
||||
<!-- Auto-extracted geometry from 3D model -->
|
||||
<group string="3D Model Analysis"
|
||||
invisible="not volume_mm3 and not bbox_summary_in and hole_count == 0">
|
||||
@@ -215,15 +201,48 @@
|
||||
class="btn-link"/>
|
||||
</list>
|
||||
</field>
|
||||
<separator string="Default Treatments" class="mt-4"/>
|
||||
<group>
|
||||
<field name="x_fc_default_coating_config_id"
|
||||
string="Default Treatment"
|
||||
options="{'no_create_edit': True}"/>
|
||||
<field name="x_fc_default_treatment_ids"
|
||||
string="Default Additional Treatments"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create_edit': True}"/>
|
||||
</group>
|
||||
<p class="text-muted">
|
||||
Seeds the treatment fields on new direct-order
|
||||
lines for this part. Updated whenever "Save as
|
||||
Default" is ticked while placing an order.
|
||||
</p>
|
||||
</page>
|
||||
<page string="Dimensions & Complexity" name="dimensions">
|
||||
<group>
|
||||
<group>
|
||||
<field name="geometry_source"/>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Surface & Weight">
|
||||
<label for="surface_area"/>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<field name="surface_area" class="oe_inline"/>
|
||||
<button name="action_calculate_surface_area" type="object"
|
||||
string="Calculate from 3D Model"
|
||||
class="btn-link" icon="fa-calculator"
|
||||
invisible="not model_attachment_id"/>
|
||||
</div>
|
||||
<field name="surface_area_uom"/>
|
||||
<field name="masking_area_sqin"/>
|
||||
<field name="effective_area_sqin" readonly="1"/>
|
||||
<field name="weight"/>
|
||||
<field name="material_weight_kg" readonly="1"/>
|
||||
</group>
|
||||
<group string="Bounding Box">
|
||||
<field name="dimensions_length"/>
|
||||
<field name="dimensions_width"/>
|
||||
<field name="dimensions_height"/>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Complexity">
|
||||
<field name="complexity"/>
|
||||
<field name="masking_zones"/>
|
||||
<field name="has_blind_holes"/>
|
||||
@@ -284,8 +303,7 @@
|
||||
<page string="Revision History" name="revisions"
|
||||
invisible="not parent_part_id and not revision_ids">
|
||||
<field name="revision_ids" mode="list">
|
||||
<list default_order="revision_number desc">
|
||||
<field name="revision_number" string="Rev #"/>
|
||||
<list default_order="revision_date desc">
|
||||
<field name="revision"/>
|
||||
<field name="revision_note"/>
|
||||
<field name="revision_date"/>
|
||||
@@ -295,20 +313,6 @@
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Defaults" name="direct_order_defaults">
|
||||
<group>
|
||||
<field name="x_fc_default_coating_config_id"
|
||||
options="{'no_create_edit': True}"/>
|
||||
<field name="x_fc_default_treatment_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create_edit': True}"/>
|
||||
</group>
|
||||
<p class="text-muted">
|
||||
Seeds the treatment fields on new direct-order
|
||||
lines. Updated whenever "Save as Default" is
|
||||
ticked while placing an order.
|
||||
</p>
|
||||
</page>
|
||||
<page string="Notes" name="notes">
|
||||
<field name="notes" placeholder="Additional notes about this part..."/>
|
||||
</page>
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
<field name="part_number"/>
|
||||
<field name="name"/>
|
||||
<field name="revision"/>
|
||||
<field name="revision_number" string="Rev #"/>
|
||||
<field name="substrate_material"/>
|
||||
<field name="surface_area"/>
|
||||
<field name="surface_area_uom" string="UoM"/>
|
||||
|
||||
@@ -8,4 +8,5 @@ from . import fp_add_from_so_wizard
|
||||
from . import fp_add_from_quote_wizard
|
||||
from . import fp_quote_promote_wizard
|
||||
from . import fp_part_catalog_import_wizard
|
||||
from . import fp_part_revision_bump_wizard
|
||||
from . import fp_serial_bulk_add_wizard
|
||||
|
||||
@@ -55,7 +55,10 @@ class FpDirectOrderLine(models.Model):
|
||||
coating_config_id = fields.Many2one(
|
||||
'fp.coating.config',
|
||||
string='Primary Treatment',
|
||||
required=True,
|
||||
help='Optional. Some orders are non-coating work (re-inspection, '
|
||||
'rework, masking-only, etc.) and the operator picks the '
|
||||
'workflow downstream — leaving this blank lets that path '
|
||||
'through.',
|
||||
)
|
||||
treatment_ids = fields.Many2many(
|
||||
'fp.treatment',
|
||||
@@ -665,7 +668,7 @@ class FpDirectOrderLine(models.Model):
|
||||
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')
|
||||
], limit=1, order='revision_date desc')
|
||||
if not new_rev:
|
||||
return part
|
||||
|
||||
|
||||
@@ -189,21 +189,23 @@ class FpDirectOrderWizard(models.Model):
|
||||
rec.total_qty = sum(rec.line_ids.mapped('quantity'))
|
||||
rec.total_line_count = len(rec.line_ids)
|
||||
|
||||
@api.depends('line_ids.part_catalog_id', 'line_ids.coating_config_id',
|
||||
@api.depends('line_ids.part_catalog_id',
|
||||
'line_ids.unit_price', 'line_ids.quantity')
|
||||
def _compute_missing_info_msg(self):
|
||||
for rec in self:
|
||||
has_missing = False
|
||||
for line in rec.line_ids:
|
||||
# coating_config_id intentionally NOT in the gate —
|
||||
# it's optional now (rework / inspection-only / masking
|
||||
# work doesn't need a primary treatment).
|
||||
if (not line.part_catalog_id
|
||||
or not line.coating_config_id
|
||||
or not line.unit_price
|
||||
or not line.quantity):
|
||||
has_missing = True
|
||||
break
|
||||
rec.missing_info_msg = (
|
||||
'Some lines are missing quote information '
|
||||
'(part / treatment / price / qty). '
|
||||
'(part / price / qty). '
|
||||
'Verify before confirming the order.'
|
||||
if has_missing else False
|
||||
)
|
||||
@@ -272,7 +274,10 @@ class FpDirectOrderWizard(models.Model):
|
||||
|
||||
# Account-hold early warning. Hard block lives in action_confirm
|
||||
# but Sarah deserves to know NOW before she builds 5 lines.
|
||||
if getattr(self.partner_id, 'x_fc_account_hold', False):
|
||||
# Resolve via commercial_partner so a hold on the company is
|
||||
# caught even when an Acme-AP child contact is selected.
|
||||
commercial = self.partner_id.commercial_partner_id
|
||||
if getattr(commercial, 'x_fc_account_hold', False):
|
||||
return {
|
||||
'warning': {
|
||||
'title': _('Customer on Account Hold'),
|
||||
@@ -280,7 +285,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
'%s is currently on account hold. You can still '
|
||||
'build the quotation, but it cannot be confirmed '
|
||||
'until the hold is cleared by accounting.'
|
||||
) % self.partner_id.display_name,
|
||||
) % commercial.display_name,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,14 +443,24 @@ class FpDirectOrderWizard(models.Model):
|
||||
# Account-hold hard block — same policy as sale.order.action_confirm
|
||||
# but enforced earlier so the wizard doesn't waste Sarah's time.
|
||||
# Manager override allowed via context key fp_skip_account_hold=True.
|
||||
if (getattr(self.partner_id, 'x_fc_account_hold', False)
|
||||
# Resolved through commercial_partner so a hold on the company
|
||||
# blocks every child-contact entry too.
|
||||
commercial = self.partner_id.commercial_partner_id
|
||||
# Bypass: Plating Manager OR Plating Administrator. Both checked
|
||||
# because Odoo's implied_ids cascade (Administrator → Manager)
|
||||
# doesn't always propagate to existing users on upgrade. See
|
||||
# CLAUDE.md "Implied group cascade" rule.
|
||||
can_override = (
|
||||
self.env.user.has_group('fusion_plating.group_fusion_plating_manager')
|
||||
or self.env.user.has_group('fusion_plating.group_fusion_plating_administrator')
|
||||
)
|
||||
if (getattr(commercial, 'x_fc_account_hold', False)
|
||||
and not self.env.context.get('fp_skip_account_hold')
|
||||
and not self.env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager')):
|
||||
and not can_override):
|
||||
raise UserError(_(
|
||||
'Customer %s is on account hold. Have a manager clear the '
|
||||
'hold (or override) before creating the order.'
|
||||
) % self.partner_id.display_name)
|
||||
) % commercial.display_name)
|
||||
|
||||
# Accept EITHER a PO (document + number) OR the PO Pending
|
||||
# flag. Customers who haven't sent paperwork yet use Pending;
|
||||
@@ -535,10 +550,14 @@ class FpDirectOrderWizard(models.Model):
|
||||
for line in self.line_ids:
|
||||
part = line._get_or_bump_revision()
|
||||
resolved_parts[line.id] = part
|
||||
# Build the line header. Primary treatment is optional now;
|
||||
# when missing, drop it from the header rather than printing
|
||||
# "False - PartName Rev A".
|
||||
treatment_label = line.coating_config_id.name or _('No coating')
|
||||
header = '%s - %s Rev %s (x%d)' % (
|
||||
line.coating_config_id.name,
|
||||
treatment_label,
|
||||
part.name,
|
||||
part.revision or part.revision_number,
|
||||
part.revision,
|
||||
line.quantity,
|
||||
)
|
||||
extended = (line.line_description or '').strip()
|
||||
|
||||
@@ -154,7 +154,8 @@
|
||||
optional="hide"/>
|
||||
<field name="internal_description"
|
||||
optional="hide"/>
|
||||
<field name="coating_config_id"/>
|
||||
<field name="coating_config_id"
|
||||
optional="show"/>
|
||||
<field name="process_variant_id"
|
||||
string="Process / Recipe"
|
||||
options="{'no_quick_create': True}"
|
||||
@@ -196,10 +197,12 @@
|
||||
<field name="treatment_ids"
|
||||
widget="many2many_tags"
|
||||
optional="hide"/>
|
||||
<field name="quantity"/>
|
||||
<field name="quantity"
|
||||
optional="show"/>
|
||||
<field name="unit_price"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
optional="show"/>
|
||||
<field name="tax_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"
|
||||
|
||||
@@ -22,7 +22,6 @@ CSV_COLUMNS = [
|
||||
'name', # required
|
||||
'customer', # required unless wizard.partner_id set
|
||||
'revision',
|
||||
'revision_number',
|
||||
'substrate_material',
|
||||
'surface_area',
|
||||
'surface_area_uom',
|
||||
@@ -266,7 +265,6 @@ class FpPartCatalogImportWizard(models.TransientModel):
|
||||
'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,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# -*- 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, ValidationError
|
||||
|
||||
from ..models.fp_part_catalog import _bump_revision_label
|
||||
|
||||
|
||||
class FpPartRevisionBumpWizard(models.TransientModel):
|
||||
"""Interactive wizard for creating a new revision of a part.
|
||||
|
||||
Replaces the old "click Create New Revision and immediately get a
|
||||
half-blank form" UX. Now the user supplies the revision label,
|
||||
revision note, and optionally a new drawing file BEFORE the
|
||||
revision is created. The wizard pre-fills a best-effort label
|
||||
via ``_bump_revision_label`` so the common case (A → B, A1 → A2,
|
||||
Rev 1 → Rev 2, ECO-2024-014 → ECO-2024-015) is one click.
|
||||
"""
|
||||
_name = 'fp.part.revision.bump.wizard'
|
||||
_description = 'Create Part Revision Wizard'
|
||||
|
||||
part_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Source Part', required=True,
|
||||
readonly=True, ondelete='cascade',
|
||||
)
|
||||
current_revision = fields.Char(
|
||||
string='Current Revision', related='part_id.revision', readonly=True,
|
||||
)
|
||||
new_revision = fields.Char(
|
||||
string='New Revision', required=True,
|
||||
help="The revision label for the new copy. Pre-filled with a "
|
||||
"best-effort guess; edit to match the customer's actual "
|
||||
"revision scheme (A/B/C, A1/A2, Rev 2, ECO-2024-015, etc.).",
|
||||
)
|
||||
revision_note = fields.Char(
|
||||
string='Revision Note',
|
||||
help='What changed in this revision? (e.g. "Updated tolerance on '
|
||||
'feature B per ECN-2024-014".)',
|
||||
)
|
||||
revision_date = fields.Datetime(
|
||||
string='Revision Date', default=fields.Datetime.now, required=True,
|
||||
)
|
||||
new_drawing_file = fields.Binary(
|
||||
string='New Drawing (PDF, optional)',
|
||||
help='Drop a PDF drawing here. It will be added to the new '
|
||||
'revision\'s drawing list. Leave empty to inherit the '
|
||||
'existing drawings.',
|
||||
)
|
||||
new_drawing_filename = fields.Char(string='Drawing Filename')
|
||||
new_model_file = fields.Binary(
|
||||
string='New 3D Model (STEP/STL/IGES, optional)',
|
||||
help='Drop a STEP, STP, STL, IGES, IGS, BREP, or BRP file here. '
|
||||
'Replaces the 3D model on the new revision. Leave empty to '
|
||||
'inherit the existing 3D model.',
|
||||
)
|
||||
new_model_filename = fields.Char(string='Model Filename')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
vals = super().default_get(fields_list)
|
||||
# Resolve part_id from context (button passes active_id).
|
||||
part_id = vals.get('part_id') or self.env.context.get('default_part_id') \
|
||||
or self.env.context.get('active_id')
|
||||
if part_id and self.env.context.get('active_model') in (
|
||||
'fp.part.catalog', None,
|
||||
):
|
||||
part = self.env['fp.part.catalog'].browse(part_id)
|
||||
if part.exists():
|
||||
vals['part_id'] = part.id
|
||||
if 'new_revision' in fields_list and not vals.get('new_revision'):
|
||||
vals['new_revision'] = _bump_revision_label(part.revision or '')
|
||||
return vals
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation
|
||||
# ------------------------------------------------------------------
|
||||
@api.constrains('new_revision', 'part_id')
|
||||
def _check_new_revision_unique(self):
|
||||
for wiz in self:
|
||||
label = (wiz.new_revision or '').strip()
|
||||
if not label:
|
||||
raise ValidationError(_('New revision label cannot be empty.'))
|
||||
if not wiz.part_id:
|
||||
continue
|
||||
if label == (wiz.part_id.revision or '').strip():
|
||||
raise ValidationError(_(
|
||||
'New revision label must differ from the current '
|
||||
'revision (%s).'
|
||||
) % wiz.part_id.revision)
|
||||
# Uniqueness within the part chain (same root + same label).
|
||||
root = wiz.part_id.parent_part_id or wiz.part_id
|
||||
sibling = self.env['fp.part.catalog'].search([
|
||||
'|',
|
||||
('id', '=', root.id),
|
||||
('parent_part_id', '=', root.id),
|
||||
('revision', '=', label),
|
||||
], limit=1)
|
||||
if sibling:
|
||||
raise ValidationError(_(
|
||||
'A revision "%(rev)s" already exists for this part '
|
||||
'(part %(pn)s). Pick a different label.'
|
||||
) % {'rev': label, 'pn': wiz.part_id.part_number or ''})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action
|
||||
# ------------------------------------------------------------------
|
||||
def action_create_revision(self):
|
||||
"""Create the new revision and navigate to it."""
|
||||
self.ensure_one()
|
||||
part = self.part_id
|
||||
if not part:
|
||||
raise UserError(_('No source part selected.'))
|
||||
|
||||
new_label = (self.new_revision or '').strip()
|
||||
part.is_latest_revision = False
|
||||
root = part.parent_part_id or part
|
||||
|
||||
new_part = part.copy({
|
||||
'revision': new_label,
|
||||
'revision_date': self.revision_date or fields.Datetime.now(),
|
||||
'revision_note': self.revision_note or False,
|
||||
'parent_part_id': root.id,
|
||||
'is_latest_revision': True,
|
||||
'model_attachment_id': part.model_attachment_id.id,
|
||||
})
|
||||
|
||||
# Optional new PDF drawing — appended to the drawing list.
|
||||
if self.new_drawing_file:
|
||||
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': new_part.id,
|
||||
})
|
||||
new_part.drawing_attachment_ids = [(4, drawing_att.id)]
|
||||
|
||||
# Optional new 3D model — replaces the model attachment.
|
||||
if self.new_model_file:
|
||||
model_att = self.env['ir.attachment'].create({
|
||||
'name': self.new_model_filename or 'model.step',
|
||||
'datas': self.new_model_file,
|
||||
'res_model': 'fp.part.catalog',
|
||||
'res_id': new_part.id,
|
||||
})
|
||||
new_part.model_attachment_id = model_att.id
|
||||
|
||||
new_part.message_post(body=_(
|
||||
'Revision %(new)s created from %(old)s. %(note)s'
|
||||
) % {
|
||||
'new': new_label,
|
||||
'old': part.revision or '',
|
||||
'note': self.revision_note or '',
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Part Revision'),
|
||||
'res_model': 'fp.part.catalog',
|
||||
'res_id': new_part.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_part_revision_bump_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.part.revision.bump.wizard.form</field>
|
||||
<field name="model">fp.part.revision.bump.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Create New Revision">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>Create New Revision</h2>
|
||||
<p class="text-muted">
|
||||
Bump the revision label for
|
||||
<strong><field name="part_id" readonly="1" nolabel="1" options="{'no_open': True}"/></strong>.
|
||||
The pre-filled label is a best-effort guess —
|
||||
adjust it to match the customer's actual scheme.
|
||||
</p>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Revision">
|
||||
<field name="current_revision"/>
|
||||
<field name="new_revision" placeholder="e.g. B, A2, Rev 2, ECO-2024-015"/>
|
||||
<field name="revision_date"/>
|
||||
</group>
|
||||
<group string="Details">
|
||||
<field name="revision_note"
|
||||
placeholder="What changed? (e.g. tolerance update on feature B)"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Updated Files (optional)">
|
||||
<group string="2D Drawing (PDF)">
|
||||
<field name="new_drawing_filename" invisible="1"/>
|
||||
<field name="new_drawing_file"
|
||||
filename="new_drawing_filename"
|
||||
widget="binary"
|
||||
nolabel="1"/>
|
||||
<div class="text-muted small">
|
||||
Added to the new revision's drawing list.
|
||||
Leave empty to inherit the current drawings.
|
||||
</div>
|
||||
</group>
|
||||
<group string="3D Model (STEP/STL/IGES)">
|
||||
<field name="new_model_filename" invisible="1"/>
|
||||
<field name="new_model_file"
|
||||
filename="new_model_filename"
|
||||
widget="binary"
|
||||
nolabel="1"/>
|
||||
<div class="text-muted small">
|
||||
Replaces the 3D model on the new revision.
|
||||
Leave empty to inherit the current model.
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_create_revision"
|
||||
string="Create Revision"
|
||||
type="object"
|
||||
class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_part_revision_bump_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Create New Revision</field>
|
||||
<field name="res_model">fp.part.revision.bump.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user