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

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