This commit is contained in:
gsinghpal
2026-04-28 19:39:37 -04:00
parent 2d42b33d68
commit 13e300d90e
103 changed files with 4959 additions and 331 deletions

View File

@@ -8,3 +8,4 @@ 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_serial_bulk_add_wizard

View File

@@ -60,17 +60,27 @@ class FpDirectOrderLine(models.Model):
string='Additional Treatments',
help='Extra pre/post treatments applied to this line.',
)
# Sub 9 — explicit per-line process variant override. NULL means
# "use the part's default variant".
# Sub 9 (polished 2026-04-28) — process variant per line. The picker
# now lets the estimator pick ANY root recipe in the system: the
# part's own variants, another customer's variants, or a template
# marked is_template. Cross-part picks auto-clone onto this part on
# save (see _fp_apply_recipe_polish) so per-line edits never bleed.
process_variant_id = fields.Many2one(
'fusion.plating.process.node',
string='Process Variant',
domain="[('part_catalog_id', '=', part_catalog_id), "
"('parent_id', '=', False), ('node_type', '=', 'recipe')]",
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
ondelete='set null',
help='Pick a specific process variant for this line. Leave blank '
'to use the part\'s default variant. Manage variants via the '
'Process Composer on the part form.',
help='Pick any recipe — the part\'s own variant, another part\'s '
'recipe, or a template from the library. Cross-part picks '
'are cloned onto this part on save so per-line edits stay '
'scoped. Use the Customize button to open the Process '
'Composer for the chosen variant.',
)
save_as_default_process = fields.Boolean(
string='Set as Part Default',
help='When ticked, the chosen process variant becomes this part\'s '
'default on order submit — future orders for the same part '
'pre-fill with this variant.',
)
# Read-only preview of the process tree that WILL drive WO generation
# for this line. Resolution priority:
@@ -116,26 +126,38 @@ class FpDirectOrderLine(models.Model):
@api.onchange('part_catalog_id')
def _onchange_part_clears_variant(self):
"""Clear variant pick when the part changes (variants are part-scoped).
"""Pre-fill the line from the part's saved defaults when the part
changes.
2026-04-28 polish: variant is no longer cleared — instead it
pre-fills from the part's `default_process_id` so the estimator
gets a sensible starting point. (Domain is system-wide now, so
a stale value would still load fine; we just upgrade the UX.)
Pre-fill coating + treatments from the part's saved defaults so
Sarah doesn't re-pick the same coating every repeat customer.
Defaults only apply when the line currently has no coating set
— editing an existing line with a chosen coating doesn't get
clobbered.
the estimator doesn't re-pick the same coating every repeat
customer. Defaults only apply when the line currently has no
coating set — editing an existing line with a chosen coating
doesn't get clobbered.
For BRAND-NEW parts (no defaults saved yet) auto-tick
`push_to_defaults` so Sarah's first coating pick gets persisted
back to the part. Without this Sarah has to remember to tick the
toggle herself, and the second order doesn't pre-fill.
`push_to_defaults` so the first coating pick gets persisted
back to the part. Without this, the estimator has to remember
to tick the toggle and the second order doesn't pre-fill.
Returns a warning popup explaining what's happening.
"""
warning = None
for rec in self:
# Variant clear (original behaviour).
if (rec.process_variant_id
and rec.process_variant_id.part_catalog_id != rec.part_catalog_id):
rec.process_variant_id = False
# Pre-fill variant from the part's default (was: blanket clear).
if rec.part_catalog_id and rec.part_catalog_id.default_process_id:
# Only overwrite when blank or pointing at a different part —
# don't clobber a deliberate cross-part pick the estimator
# made before changing the part.
if (not rec.process_variant_id
or (rec.process_variant_id.part_catalog_id
and rec.process_variant_id.part_catalog_id.id
!= rec.part_catalog_id.id)):
rec.process_variant_id = rec.part_catalog_id.default_process_id
if not rec.part_catalog_id:
continue
part = rec.part_catalog_id
@@ -266,17 +288,50 @@ class FpDirectOrderLine(models.Model):
compute='_compute_is_missing_info',
)
# ---- Sub 5 — Serial / Job# / Thickness -------------------------------
# ---- Sub 5 / Phase 1 — Serials / Job# / Thickness --------------------
# These mirror the SO-line fields and are carried over when the wizard
# creates the sale order. Serial stays optional; Job# is left blank
# here and gets auto-assigned by action_confirm on the SO.
#
# 2026-04-28 Phase 1 — multi-serial. M2M is the source of truth;
# serial_id stays as a computed alias so existing flows that read
# the singular continue to work.
serial_ids = fields.Many2many(
'fp.serial',
relation='fp_direct_order_line_serial_rel',
column1='line_id',
column2='serial_id',
string='Serial Numbers',
help='Customer-supplied serial numbers. Use Bulk Add to paste a '
'list, range-fill (SN-001..SN-030), or scan barcodes.',
)
serial_count = fields.Integer(
compute='_compute_serial_count',
string='# Serials',
)
serial_id = fields.Many2one(
'fp.serial',
string='Serial Number',
ondelete='set null',
help='Optional. Typing a value offers to create a new serial on '
'the fly, or hit "Generate Serial" to auto-sequence.',
string='Primary Serial',
compute='_compute_primary_serial',
inverse='_inverse_primary_serial',
store=False,
help='First of the line\'s serials — back-compat alias.',
)
@api.depends('serial_ids')
def _compute_serial_count(self):
for rec in self:
rec.serial_count = len(rec.serial_ids)
@api.depends('serial_ids')
def _compute_primary_serial(self):
for rec in self:
rec.serial_id = rec.serial_ids[:1]
def _inverse_primary_serial(self):
for rec in self:
if rec.serial_id and rec.serial_id not in rec.serial_ids:
rec.serial_ids = [(4, rec.serial_id.id)]
job_number = fields.Char(string='Job #')
thickness_id = fields.Many2one(
'fp.coating.thickness',
@@ -309,14 +364,48 @@ class FpDirectOrderLine(models.Model):
rec.thickness_id = False
def action_generate_serial(self):
"""Create an auto-sequenced fp.serial and assign it to this line."""
"""Generate one auto-sequenced fp.serial and append to the M2M.
Phase 1: appends instead of replacing — repeated clicks add more.
"""
self.ensure_one()
if self.serial_id:
return False
seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000'
self.serial_id = self.env['fp.serial'].create({'name': seq}).id
new_serial = self.env['fp.serial'].create({'name': seq})
self.serial_ids = [(4, new_serial.id)]
return False
def action_open_serial_bulk_add(self):
"""Open the Bulk Add Serials wizard for this Direct Order line."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.serial.bulk.add.wizard',
'view_mode': 'form',
'target': 'new',
'name': _('Bulk Add Serials'),
'context': {
'default_target_model': 'fp.direct.order.line',
'default_target_id': self.id,
'default_qty_expected': int(self.quantity or 0),
},
}
@api.constrains('serial_ids', 'quantity')
def _check_serial_count_against_qty(self):
for rec in self:
if rec.serial_ids and rec.quantity:
n = len(rec.serial_ids)
if n > int(rec.quantity):
raise UserError(_(
'Line "%(part)s": %(n)s serials attached but only '
'%(qty)s parts ordered. Reduce the serial list, '
'increase the quantity, or split the line.'
) % {
'part': (rec.part_catalog_id.display_name or ''),
'n': n,
'qty': int(rec.quantity),
})
# ---- Onchange ----
@api.onchange('quote_id')
def _onchange_quote_id(self):
@@ -504,3 +593,100 @@ class FpDirectOrderLine(models.Model):
else:
new_rev.drawing_attachment_ids = [(4, drawing_att.id)]
return new_rev
# ==================================================================
# 2026-04-28 polish — recipe handling shared with sale.order.line
# ==================================================================
def _fp_clone_recipe_to_part(self):
"""Deep-copy the picked recipe onto this line's part if it isn't
already scoped there. Returns the cloned (or unchanged) variant.
Mirrors `sale.order.line._fp_clone_recipe_to_part` — same
contract, same edge cases. The wizard runs this on every save
path (create/write) plus when Customize is clicked, so a
cross-part pick never leaks edits to the source recipe.
"""
self.ensure_one()
recipe = self.process_variant_id
part = self.part_catalog_id
if not recipe or not part:
return recipe
if recipe.part_catalog_id and recipe.part_catalog_id.id == part.id:
return recipe
clone_name = recipe.name or _('Untitled Recipe')
if part.part_number and part.part_number.lower() not in clone_name.lower():
clone_name = '%s%s' % (clone_name, part.part_number or part.display_name)
clone = recipe.copy({
'name': clone_name,
'part_catalog_id': part.id,
'is_template': False,
'is_default_variant': False,
})
return clone
def _fp_apply_recipe_polish(self):
"""Post-write step: auto-clone any cross-part recipe pick and
honour the Save-as-Default toggle. Called from create() and
write()."""
for line in self:
if not line.part_catalog_id or not line.process_variant_id:
continue
recipe = line.process_variant_id
if (not recipe.part_catalog_id
or recipe.part_catalog_id.id != line.part_catalog_id.id):
clone = line._fp_clone_recipe_to_part()
if clone and clone.id != recipe.id:
line.process_variant_id = clone.id
recipe = clone
if line.save_as_default_process and recipe.part_catalog_id:
line.part_catalog_id.action_set_default_variant(recipe.id)
def action_customize_process(self):
"""Open the Process Composer for this line's variant — auto-clones
first if the variant isn't yet scoped to this part."""
self.ensure_one()
if not self.part_catalog_id:
raise UserError(_(
'Pick a part on this line before customizing the process — '
'the recipe needs a part to scope the variant.'
))
if not self.process_variant_id:
raise UserError(_(
'Pick a process variant on this line first. To start from '
'scratch, use the part\'s Compose button instead.'
))
clone_or_existing = self._fp_clone_recipe_to_part()
if clone_or_existing.id != self.process_variant_id.id:
self.process_variant_id = clone_or_existing.id
return {
'type': 'ir.actions.client',
'tag': 'fp_part_process_composer',
'name': _('Customize Process — %s') % (
self.part_catalog_id.display_name
or self.part_catalog_id.part_number
or '?'
),
'params': {
'part_id': self.part_catalog_id.id,
'part_display': self.part_catalog_id.display_name
or self.part_catalog_id.part_number,
'focus_variant_id': clone_or_existing.id,
},
'target': 'current',
}
@api.model_create_multi
def create(self, vals_list):
lines = super().create(vals_list)
lines._fp_apply_recipe_polish()
return lines
def write(self, vals):
result = super().write(vals)
if any(k in vals for k in (
'process_variant_id',
'part_catalog_id',
'save_as_default_process',
)):
self._fp_apply_recipe_polish()
return result

View File

@@ -510,8 +510,13 @@ class FpDirectOrderWizard(models.Model):
'x_fc_is_one_off': line.is_one_off,
'x_fc_quote_id': line.quote_id.id or False,
'x_fc_process_variant_id': line.process_variant_id.id or False,
# Sub 5 — carry serial / job# / thickness onto the SO line.
# Revision snapshot auto-fills on SO-line create from the part.
'x_fc_save_as_default_process': line.save_as_default_process,
# Sub 5 / Phase 1 — carry serial M2M to the SO line.
# x_fc_serial_id is back-compat alias and auto-resolves
# from x_fc_serial_ids on SO-line read; passing both is
# safe (the alias setter just appends to the M2M).
'x_fc_serial_ids': ([(6, 0, line.serial_ids.ids)]
if line.serial_ids else False),
'x_fc_serial_id': line.serial_id.id or False,
'x_fc_job_number': line.job_number or False,
'x_fc_thickness_id': line.thickness_id.id or False,

View File

@@ -156,14 +156,23 @@
optional="hide"/>
<field name="coating_config_id"/>
<field name="process_variant_id"
string="Variant"
options="{'no_create': True}"
string="Process / Recipe"
options="{'no_quick_create': True}"
invisible="not part_catalog_id"
optional="show"/>
<field name="save_as_default_process"
string="Set as Part Default"
widget="boolean_toggle"
invisible="not process_variant_id"
optional="hide"/>
<button name="action_customize_process" type="object"
string="Customize" icon="fa-pencil-square-o"
class="btn-link"
invisible="not process_variant_id"/>
<field name="effective_process_id"
string="Process"
string="Effective Process"
readonly="1"
optional="show"/>
optional="hide"/>
<field name="effective_process_source"
string="Process Source"
readonly="1"
@@ -172,9 +181,16 @@
options="{'no_create': True}"
invisible="not coating_config_id"
optional="show"/>
<field name="serial_id"
options="{'no_create_edit': False}"
<field name="serial_ids"
widget="many2many_tags"
options="{'no_quick_create': False, 'color_field': 'state_color'}"
optional="show"/>
<field name="serial_count"
string="# SN"
optional="hide"/>
<button name="action_open_serial_bulk_add" type="object"
string="Bulk Add Serials" icon="fa-list-ol"
class="btn-link"/>
<field name="job_number" optional="hide"/>
<field name="treatment_ids"
widget="many2many_tags"
@@ -210,9 +226,17 @@
<field name="treatment_ids"
widget="many2many_tags"/>
<field name="process_variant_id"
string="Process Variant"
options="{'no_create': True}"
string="Process / Recipe"
options="{'no_quick_create': True}"
invisible="not part_catalog_id"/>
<field name="save_as_default_process"
string="Set as Part Default"
widget="boolean_toggle"
invisible="not process_variant_id"/>
<button name="action_customize_process" type="object"
string="Customize Process" icon="fa-pencil-square-o"
class="btn-link"
invisible="not process_variant_id"/>
<field name="effective_process_id"
string="Effective Process"
readonly="1"/>

View File

@@ -0,0 +1,253 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Bulk-add serial numbers to a sale.order.line or fp.direct.order.line.
Three input modes — operator picks one:
1. **Paste a list** — one per line, comma- or whitespace-separated.
2. **Range fill** — prefix + start..end (e.g. SN- + 1..30 → SN-001..SN-030).
3. **Scan barcodes** — repeated input (kept simple for Phase 1: the same
paste textarea works for a barcode reader that types-and-Enters).
Existing serials with the same `name` are reused (the company-uniqueness
SQL constraint on fp.serial would block dupes anyway). New ones are
created and linked to the source line via sale_order_line_id when the
target is a sale.order.line.
Target abstracted via target_model + target_id so one wizard works for
both the SO line and the Direct Order wizard line.
"""
import re
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
class FpSerialBulkAddWizard(models.TransientModel):
_name = 'fp.serial.bulk.add.wizard'
_description = 'Fusion Plating — Bulk Add Serials'
target_model = fields.Selection(
[
('sale.order.line', 'Sale Order Line'),
('fp.direct.order.line', 'Direct Order Line'),
],
string='Target Model', required=True, readonly=True,
)
target_id = fields.Integer(string='Target Record ID', required=True, readonly=True)
qty_expected = fields.Integer(
string='Line Quantity', readonly=True,
help='How many parts the target line is ordered for. The wizard '
'warns if you try to add more serials than this.',
)
mode = fields.Selection(
[
('paste', 'Paste a List'),
('range', 'Range Fill'),
],
string='Mode', default='paste', required=True,
)
# --- Paste mode ---
paste_text = fields.Text(
string='Serial List',
help='One serial per line, or comma-separated. Whitespace and '
'blank lines are ignored. Barcode scanners that emit one '
'serial + Enter at a time also work — just leave the cursor '
'in this box and scan.',
)
# --- Range mode ---
prefix = fields.Char(
string='Prefix',
default='SN-',
help='Text prefix prepended to each generated serial (e.g. "SN-", '
'"WO123-", or blank for pure numeric).',
)
start_number = fields.Integer(
string='Start',
default=1,
help='First number in the range (inclusive).',
)
end_number = fields.Integer(
string='End',
default=10,
help='Last number in the range (inclusive). Must be ≥ start.',
)
pad_width = fields.Integer(
string='Pad Width',
default=3,
help='Zero-pad numbers to this width (3 → 001, 002, ... 030). '
'Set to 0 to disable padding.',
)
suffix = fields.Char(
string='Suffix',
help='Optional text appended after the number (e.g. "-A").',
)
range_preview = fields.Text(
string='Preview',
compute='_compute_range_preview',
readonly=True,
)
@api.depends('mode', 'prefix', 'start_number', 'end_number', 'pad_width', 'suffix')
def _compute_range_preview(self):
for wiz in self:
if wiz.mode != 'range':
wiz.range_preview = ''
continue
try:
names = wiz._build_range_names()
except (UserError, ValidationError) as e:
wiz.range_preview = '%s' % (e.args[0] if e.args else str(e))
continue
if len(names) <= 6:
wiz.range_preview = '\n'.join(names)
else:
wiz.range_preview = (
'\n'.join(names[:3])
+ '\n ...\n'
+ '\n'.join(names[-3:])
+ '\n(%s total)' % len(names)
)
# ==================================================================
def _build_range_names(self):
"""Resolve range_mode fields into a list of serial names."""
self.ensure_one()
if self.end_number < self.start_number:
raise ValidationError(_(
'End (%s) is before Start (%s).'
) % (self.end_number, self.start_number))
count = self.end_number - self.start_number + 1
if count > 1000:
raise ValidationError(_(
'Range covers %s entries — too many. Cap at 1000 per call.'
) % count)
names = []
prefix = self.prefix or ''
suffix = self.suffix or ''
pad = max(self.pad_width or 0, 0)
for n in range(self.start_number, self.end_number + 1):
num = str(n).zfill(pad) if pad else str(n)
names.append(f'{prefix}{num}{suffix}')
return names
def _parse_paste_text(self):
"""Split paste_text into a clean ordered list of serial names.
Splits on newline or comma. Trims whitespace. Drops blanks.
Preserves first-occurrence order (paste duplicates collapse to
one, with the dupe count surfaced in the chatter audit).
"""
self.ensure_one()
if not self.paste_text:
return []
tokens = re.split(r'[\s,;]+', self.paste_text.strip())
seen = set()
ordered = []
for tok in tokens:
tok = tok.strip()
if not tok or tok in seen:
continue
seen.add(tok)
ordered.append(tok)
return ordered
def _resolve_target(self):
"""Return the browseable target record."""
self.ensure_one()
if self.target_model not in ('sale.order.line', 'fp.direct.order.line'):
raise UserError(_('Unsupported target model: %s') % self.target_model)
target = self.env[self.target_model].browse(self.target_id).exists()
if not target:
raise UserError(_('Target line not found.'))
return target
# ==================================================================
def action_apply(self):
"""Materialise serials and append them to the target line's M2M."""
self.ensure_one()
target = self._resolve_target()
# 1. Resolve the list of names from the chosen mode.
if self.mode == 'paste':
names = self._parse_paste_text()
if not names:
raise UserError(_(
'Paste at least one serial number, or switch to Range '
'Fill mode.'
))
elif self.mode == 'range':
names = self._build_range_names()
else:
raise UserError(_('Unsupported mode: %s') % self.mode)
# 2. Quantity sanity check — block if we'd exceed the line qty.
target_field = (
'x_fc_serial_ids' if self.target_model == 'sale.order.line'
else 'serial_ids'
)
existing_count = len(target[target_field])
proposed_total = existing_count + len(names)
if self.qty_expected and proposed_total > self.qty_expected:
raise UserError(_(
'%(new)s new serials + %(existing)s already on the line '
'= %(total)s, but the line is only ordered for '
'%(qty)s parts. Reduce the list or increase the line '
'quantity first.'
) % {
'new': len(names),
'existing': existing_count,
'total': proposed_total,
'qty': self.qty_expected,
})
# 3. Reuse existing fp.serial records by name (uniqueness per
# company is SQL-constrained anyway), create the rest.
Serial = self.env['fp.serial']
existing = Serial.search([
('name', 'in', names),
('company_id', '=', self.env.company.id),
])
existing_by_name = {s.name: s for s in existing}
to_create = []
link_kwargs = {}
if self.target_model == 'sale.order.line':
link_kwargs['sale_order_line_id'] = target.id
for name in names:
if name in existing_by_name:
continue
to_create.append(dict(name=name, **link_kwargs))
created = Serial.create(to_create) if to_create else Serial.browse([])
all_serials = existing + created
# Order-preserving: rebuild from the input order so paste/range
# ordering is preserved on the M2M (matters for paste_text — the
# operator typed them in physical-rack order).
serial_by_name = {s.name: s for s in all_serials}
ordered_ids = [serial_by_name[n].id for n in names if n in serial_by_name]
target[target_field] = [(4, sid) for sid in ordered_ids]
# 4. Audit on the target's chatter (SO line for sale.order.line;
# parent SO for the wizard line which has no chatter).
msg = _(
'+%(n)s serials added (%(reused)s reused, %(created)s new): '
'%(preview)s'
) % {
'n': len(names),
'reused': len(existing),
'created': len(created),
'preview': ', '.join(names[:5]) + ('...' if len(names) > 5 else ''),
}
if hasattr(target, 'message_post'):
target.message_post(body=msg)
elif self.target_model == 'fp.direct.order.line' and target.wizard_id:
target.wizard_id.message_post(body=msg)
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_serial_bulk_add_wizard_form" model="ir.ui.view">
<field name="name">fp.serial.bulk.add.wizard.form</field>
<field name="model">fp.serial.bulk.add.wizard</field>
<field name="arch" type="xml">
<form string="Bulk Add Serials">
<sheet>
<group>
<field name="target_model" invisible="1"/>
<field name="target_id" invisible="1"/>
<field name="qty_expected"/>
<field name="mode" widget="radio"/>
</group>
<!-- Paste mode -->
<group invisible="mode != 'paste'">
<separator string="Paste a List"/>
<field name="paste_text" nolabel="1"
placeholder="One per line, or comma-separated.&#10;Example:&#10;SN-001&#10;SN-002&#10;CUST-12345&#10;or scan barcodes — one per Enter."/>
</group>
<!-- Range mode -->
<group invisible="mode != 'range'">
<separator string="Range Fill"/>
<group>
<field name="prefix"/>
<field name="suffix"/>
<field name="pad_width"/>
</group>
<group>
<field name="start_number"/>
<field name="end_number"/>
</group>
<separator string="Preview"/>
<field name="range_preview" nolabel="1" readonly="1"/>
</group>
</sheet>
<footer>
<button name="action_apply" type="object"
string="Add Serials" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fp_serial_bulk_add_wizard" model="ir.actions.act_window">
<field name="name">Bulk Add Serials</field>
<field name="res_model">fp.serial.bulk.add.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>