changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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'}
|
||||
@@ -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. Example: SN-001 SN-002 CUST-12345 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>
|
||||
Reference in New Issue
Block a user