This commit is contained in:
gsinghpal
2026-05-10 10:25:12 -04:00
parent 6c6a59ceef
commit 6b7b44264a
59 changed files with 2461 additions and 324 deletions

View File

@@ -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',

View File

@@ -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;
""")

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
46 access_fp_serial_manager fp.serial.manager model_fp_serial fusion_plating.group_fusion_plating_manager 1 1 1 1
47 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
48 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
49 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
50 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
51 access_fp_coating_thickness_user fp.coating.thickness.user model_fp_coating_thickness base.group_user 1 0 0 0
52 access_fp_coating_thickness_estimator fp.coating.thickness.estimator model_fp_coating_thickness fusion_plating_configurator.group_fp_estimator 1 1 1 0
53 access_fp_coating_thickness_manager fp.coating.thickness.manager model_fp_coating_thickness fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -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;

View File

@@ -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 &amp; 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 &amp; Complexity" name="dimensions">
<group>
<group>
<field name="geometry_source"/>
</group>
<group>
<group string="Surface &amp; 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>

View File

@@ -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"/>

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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}"

View File

@@ -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,

View File

@@ -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',
}

View File

@@ -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>