changes
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.17.16.0',
|
||||
'version': '19.0.18.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
@@ -50,11 +50,13 @@ Provides:
|
||||
'views/sale_order_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/fp_sale_description_template_views.xml',
|
||||
'views/fp_serial_views.xml',
|
||||
'wizard/fp_direct_order_wizard_views.xml',
|
||||
'wizard/fp_add_from_so_wizard_views.xml',
|
||||
'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_serial_bulk_add_wizard_views.xml',
|
||||
'views/fp_configurator_menu.xml',
|
||||
'data/fp_sale_description_template_data.xml',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Phase 1 multi-serial — backfill the new M2M relations from the
|
||||
# pre-existing single-M2O column on sale.order.line and account.move.line.
|
||||
#
|
||||
# x_fc_serial_id was historically a stored Many2one. Phase 1 made it a
|
||||
# computed alias of `x_fc_serial_ids` (the new M2M). Existing rows have
|
||||
# the old FK column populated but no rows in the M2M relation table.
|
||||
# This migration walks the legacy column and inserts one M2M row per
|
||||
# (line, serial) pair so smart buttons / reverse links continue to find
|
||||
# the linked records.
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""Backfill fp_sale_order_line_serial_rel + fp_account_move_line_serial_rel."""
|
||||
backfill_table(cr, 'sale_order_line', 'fp_sale_order_line_serial_rel', 'line_id')
|
||||
backfill_table(cr, 'account_move_line', 'fp_account_move_line_serial_rel', 'line_id')
|
||||
|
||||
|
||||
def backfill_table(cr, source_table, m2m_table, line_col):
|
||||
cr.execute(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = %s AND column_name = 'x_fc_serial_id'",
|
||||
(source_table,),
|
||||
)
|
||||
if not cr.fetchone():
|
||||
_logger.info("Phase 1 multi-serial: %s has no x_fc_serial_id column, skip", source_table)
|
||||
return
|
||||
|
||||
# Make sure the M2M table exists (Odoo creates it on registry load,
|
||||
# but the migration runs BEFORE the registry comes up on upgrade —
|
||||
# use IF NOT EXISTS to be safe).
|
||||
cr.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS "{m2m_table}" (
|
||||
"{line_col}" integer NOT NULL REFERENCES "{source_table}"(id) ON DELETE CASCADE,
|
||||
"serial_id" integer NOT NULL REFERENCES "fp_serial"(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY ("{line_col}", "serial_id")
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
cr.execute(
|
||||
f"""
|
||||
INSERT INTO "{m2m_table}" ("{line_col}", "serial_id")
|
||||
SELECT id, x_fc_serial_id FROM "{source_table}"
|
||||
WHERE x_fc_serial_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
)
|
||||
_logger.info(
|
||||
"Phase 1 multi-serial: backfilled %s rows from %s.x_fc_serial_id into %s",
|
||||
cr.rowcount, source_table, m2m_table,
|
||||
)
|
||||
@@ -19,12 +19,23 @@ class AccountMoveLine(models.Model):
|
||||
help="Copied from sale.order.line on invoice creation so customer-"
|
||||
"facing invoice PDFs can render the customer's part number.",
|
||||
)
|
||||
# ---- Sub 5 ---------------------------------------------------------------
|
||||
# ---- Sub 5 / Phase 1 multi-serial ---------------------------------------
|
||||
x_fc_serial_ids = fields.Many2many(
|
||||
'fp.serial',
|
||||
relation='fp_account_move_line_serial_rel',
|
||||
column1='line_id',
|
||||
column2='serial_id',
|
||||
string='Serial Numbers',
|
||||
help='Copied from sale.order.line for traceability. Multi-serial '
|
||||
'support added 2026-04-28.',
|
||||
)
|
||||
x_fc_serial_id = fields.Many2one(
|
||||
'fp.serial',
|
||||
string='Serial Number',
|
||||
index=True,
|
||||
help='Copied from sale.order.line for traceability.',
|
||||
help='Back-compat alias of the first serial in x_fc_serial_ids. '
|
||||
'Kept so legacy invoice templates that read the singular '
|
||||
'continue to render.',
|
||||
)
|
||||
x_fc_job_number = fields.Char(
|
||||
string='Job #', index=True,
|
||||
|
||||
@@ -444,6 +444,33 @@ class FpPartCatalog(models.Model):
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_open_default_simple_editor(self):
|
||||
"""Open the Simple Recipe Editor for this part's default variant.
|
||||
|
||||
One-click path that skips the Composer's variants list — useful
|
||||
when the part only has one variant and the user wants to dive
|
||||
straight into editing.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.default_process_id:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'No default process variant for %s yet. Click Compose to '
|
||||
'create the first variant.'
|
||||
) % (self.display_name or self.part_number))
|
||||
return self.default_process_id.action_open_simple_editor()
|
||||
|
||||
def action_open_default_tree_editor(self):
|
||||
"""Open the Tree Editor for this part's default variant."""
|
||||
self.ensure_one()
|
||||
if not self.default_process_id:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'No default process variant for %s yet. Click Compose to '
|
||||
'create the first variant.'
|
||||
) % (self.display_name or self.part_number))
|
||||
return self.default_process_id.action_open_tree_editor()
|
||||
|
||||
def action_set_default_variant(self, variant_id):
|
||||
"""Flip the default variant for this part.
|
||||
|
||||
|
||||
@@ -58,6 +58,170 @@ class FpSerial(models.Model):
|
||||
)
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
# ==================================================================
|
||||
# Phase 2 (2026-04-28) — per-serial state machine
|
||||
# ==================================================================
|
||||
# Each physical part owns its own state independent of the parent
|
||||
# job's qty roll-ups. When 30 parts arrive on one SO line, all 30
|
||||
# serials are independently trackable through the shop. State
|
||||
# auto-promotes from job-step transitions (see fp.job.button_*
|
||||
# overrides in fusion_plating_jobs); operator can also flip a
|
||||
# single serial manually (e.g. mark serial #5 scrapped after a
|
||||
# plating defect).
|
||||
state = fields.Selection(
|
||||
[
|
||||
('received', 'Received'),
|
||||
('racked', 'Racked'),
|
||||
('in_process', 'In Process'),
|
||||
('inspected', 'Inspected'),
|
||||
('packed', 'Packed'),
|
||||
('shipped', 'Shipped'),
|
||||
('returned', 'Returned'),
|
||||
('scrapped', 'Scrapped'),
|
||||
('on_hold', 'On Hold'),
|
||||
],
|
||||
string='Status',
|
||||
default='received',
|
||||
required=True,
|
||||
tracking=True,
|
||||
index=True,
|
||||
help='Per-serial workflow state. Transitions auto-promote from '
|
||||
'parent job step events; supervisors can also flip a single '
|
||||
'serial manually (e.g. scrap one part out of a 30-part rack).',
|
||||
)
|
||||
state_color = fields.Integer(
|
||||
string='Status Color',
|
||||
compute='_compute_state_color',
|
||||
help='Kanban / many2many_tags color index derived from state.',
|
||||
)
|
||||
last_state_change = fields.Datetime(
|
||||
string='Last Status Change',
|
||||
readonly=True,
|
||||
help='Timestamp of the most recent state transition. Auto-stamped '
|
||||
'by every state-changing action.',
|
||||
)
|
||||
scrap_reason = fields.Text(
|
||||
string='Scrap / Return Reason',
|
||||
help='Captured when state transitions to scrapped or returned. '
|
||||
'Surfaces on per-serial CoC entries (Phase 4).',
|
||||
)
|
||||
|
||||
# Reverse from move log — Phase 3 will populate this directly when
|
||||
# operators record per-serial moves on the tablet. Defined here so
|
||||
# views can already render the count column.
|
||||
move_count = fields.Integer(
|
||||
compute='_compute_move_count',
|
||||
string='# Moves',
|
||||
)
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_state_color(self):
|
||||
# Odoo color-index mapping aligned with the standard kanban palette.
|
||||
# 0 default · 1 red · 2 orange · 3 yellow · 4 green · 5 purple ·
|
||||
# 6 magenta · 7 sky · 8 blue · 9 brown · 10 grey · 11 olive
|
||||
mapping = {
|
||||
'received': 8, # blue — fresh
|
||||
'racked': 7, # sky — staged
|
||||
'in_process': 3, # yellow — running
|
||||
'inspected': 11, # olive — passed QC, ready to ship
|
||||
'packed': 4, # green — boxed
|
||||
'shipped': 4, # green — out the door
|
||||
'returned': 2, # orange — back from customer
|
||||
'scrapped': 1, # red
|
||||
'on_hold': 1, # red — quality issue
|
||||
}
|
||||
for rec in self:
|
||||
rec.state_color = mapping.get(rec.state, 0)
|
||||
|
||||
@api.depends_context('uid')
|
||||
def _compute_move_count(self):
|
||||
# Phase 3 will replace this with a real reverse link via
|
||||
# fp.job.step.move.serial_ids (M2M added next phase).
|
||||
# Defined here as 0-stub so views don't break on upgrade.
|
||||
for rec in self:
|
||||
rec.move_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State transitions — log each one to chatter and stamp last_state_change
|
||||
# ------------------------------------------------------------------
|
||||
def _set_state(self, new_state, message=None):
|
||||
"""Internal helper. Validates the source state, flips, stamps,
|
||||
chatters. Raises UserError on illegal transitions."""
|
||||
labels = dict(self._fields['state'].selection)
|
||||
for rec in self:
|
||||
old = rec.state
|
||||
if old == new_state:
|
||||
continue
|
||||
# Terminal states are write-protected (operator must explicitly
|
||||
# un-set via action_reopen if they really need to).
|
||||
if old in ('shipped', 'scrapped') and new_state not in ('returned', 'received'):
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Serial %(name)s is %(old)s — cannot transition to '
|
||||
'%(new)s. Use Reopen if this is a correction.'
|
||||
) % {
|
||||
'name': rec.name,
|
||||
'old': labels.get(old, old),
|
||||
'new': labels.get(new_state, new_state),
|
||||
})
|
||||
rec.state = new_state
|
||||
rec.last_state_change = fields.Datetime.now()
|
||||
body = message or _('Status %(old)s → %(new)s by %(user)s') % {
|
||||
'old': labels.get(old, old),
|
||||
'new': labels.get(new_state, new_state),
|
||||
'user': self.env.user.name,
|
||||
}
|
||||
rec.message_post(body=body)
|
||||
return True
|
||||
|
||||
def action_mark_racked(self):
|
||||
return self._set_state('racked')
|
||||
|
||||
def action_mark_in_process(self):
|
||||
return self._set_state('in_process')
|
||||
|
||||
def action_mark_inspected(self):
|
||||
return self._set_state('inspected')
|
||||
|
||||
def action_mark_packed(self):
|
||||
return self._set_state('packed')
|
||||
|
||||
def action_mark_shipped(self):
|
||||
return self._set_state('shipped')
|
||||
|
||||
def action_mark_returned(self):
|
||||
return self._set_state('returned')
|
||||
|
||||
def action_mark_on_hold(self):
|
||||
return self._set_state('on_hold')
|
||||
|
||||
def action_release_hold(self):
|
||||
"""Lift on_hold and return the serial to in_process. Used when a
|
||||
hold is resolved without scrap (e.g. visual blemish was actually
|
||||
within tolerance after re-inspection)."""
|
||||
return self._set_state('in_process')
|
||||
|
||||
def action_mark_scrapped(self):
|
||||
"""Scrap a single serial. Operator should fill scrap_reason next
|
||||
— view enforces it via a wizard form. Phase 3 hooks this into
|
||||
the move log so the parent job's qty_scrapped auto-increments."""
|
||||
return self._set_state('scrapped')
|
||||
|
||||
def action_reopen(self):
|
||||
"""Manager-only override — un-pin a terminal state when a
|
||||
correction is needed (e.g. wrong serial marked shipped). Audit
|
||||
trail preserved via chatter; never silently rewrites history."""
|
||||
for rec in self:
|
||||
if not self.env.user.has_group('fusion_plating.group_fusion_plating_manager'):
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Only the Plating Manager group can reopen a terminal '
|
||||
'serial state. Contact your shop manager.'
|
||||
))
|
||||
return self._set_state('in_process', message=_(
|
||||
'Serial reopened by %s — terminal state reverted for correction.'
|
||||
) % self.env.user.name)
|
||||
|
||||
# Reverse link to invoice lines — safe here because account.move.line
|
||||
# lives in this same module. Production (mrp) and delivery (logistics)
|
||||
# reverse links are defined in their own modules' fp_serial inherits
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
@@ -60,18 +61,29 @@ class SaleOrderLine(models.Model):
|
||||
string='Linked Quote',
|
||||
help='Quote that seeded this line. Links back for audit trail.',
|
||||
)
|
||||
# Sub 9 — process variant override per line. NULL means "use the
|
||||
# part's default variant". Domain restricts to root recipe nodes
|
||||
# owned by the chosen part.
|
||||
# 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 _onchange_process_variant_clone) so per-line edits never
|
||||
# bleed across customers.
|
||||
x_fc_process_variant_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Process Variant',
|
||||
domain="[('part_catalog_id', '=', x_fc_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 order. Leave blank '
|
||||
'to use the part\'s default variant. Variants are managed 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. If the chosen recipe '
|
||||
'doesn\'t belong to this part, it will be cloned onto the part '
|
||||
'when the order saves so per-line edits stay scoped. Use the '
|
||||
'Customize button on the line to open the Process Composer.',
|
||||
)
|
||||
x_fc_save_as_default_process = fields.Boolean(
|
||||
string='Save as Default for Part',
|
||||
default=False,
|
||||
help='When ticked, the chosen process variant becomes this part\'s '
|
||||
'default on order save — future orders for the same part '
|
||||
'pre-fill with this variant.',
|
||||
)
|
||||
x_fc_archived = fields.Boolean(
|
||||
string='Archived',
|
||||
@@ -84,15 +96,61 @@ class SaleOrderLine(models.Model):
|
||||
# NB: sale.order.line in Odoo 19 does not support `tracking=True` on
|
||||
# inherited fields — Odoo emits a warning and ignores it. Audit trail
|
||||
# for these values lives on fp.serial.mail.thread instead.
|
||||
#
|
||||
# 2026-04-28 Phase 1 — multi-serial support. Customer can ship 30 parts
|
||||
# with 30 distinct serials on a single line. The M2M is the source of
|
||||
# truth; `x_fc_serial_id` (M2O) becomes a computed alias of the first
|
||||
# serial so existing reports / smart buttons / downstream code that
|
||||
# still reads the singular keep working unchanged.
|
||||
x_fc_serial_ids = fields.Many2many(
|
||||
'fp.serial',
|
||||
relation='fp_sale_order_line_serial_rel',
|
||||
column1='line_id',
|
||||
column2='serial_id',
|
||||
string='Serial Numbers',
|
||||
copy=False,
|
||||
help='Customer-supplied serial numbers for the parts on this line. '
|
||||
'Use the Bulk Add Serials button to paste a list, range-fill '
|
||||
'(SN-001..SN-030), or scan barcodes. Count must not exceed '
|
||||
'the line quantity.',
|
||||
)
|
||||
x_fc_serial_id = fields.Many2one(
|
||||
'fp.serial',
|
||||
string='Serial Number',
|
||||
ondelete='set null',
|
||||
string='Primary Serial',
|
||||
compute='_compute_primary_serial',
|
||||
inverse='_inverse_primary_serial',
|
||||
search='_search_primary_serial',
|
||||
store=False,
|
||||
copy=False,
|
||||
help='Customer-supplied serial number for this line. Optional. '
|
||||
'Typing a value offers to create a new fp.serial record on '
|
||||
'the fly; use the Generate Serial button to auto-sequence.',
|
||||
help='First of the line\'s serials — back-compat alias kept so '
|
||||
'pre-Phase-1 code (reports, smart buttons, downstream M2M '
|
||||
'reverse links) keeps working. Setting this prepends the '
|
||||
'serial to the M2M.',
|
||||
)
|
||||
x_fc_serial_count = fields.Integer(
|
||||
string='# Serials',
|
||||
compute='_compute_serial_count',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_serial_ids')
|
||||
def _compute_primary_serial(self):
|
||||
for line in self:
|
||||
line.x_fc_serial_id = line.x_fc_serial_ids[:1]
|
||||
|
||||
def _inverse_primary_serial(self):
|
||||
for line in self:
|
||||
if not line.x_fc_serial_id:
|
||||
continue
|
||||
if line.x_fc_serial_id not in line.x_fc_serial_ids:
|
||||
line.x_fc_serial_ids = [(4, line.x_fc_serial_id.id)]
|
||||
|
||||
def _search_primary_serial(self, operator, value):
|
||||
return [('x_fc_serial_ids', operator, value)]
|
||||
|
||||
@api.depends('x_fc_serial_ids')
|
||||
def _compute_serial_count(self):
|
||||
for line in self:
|
||||
line.x_fc_serial_count = len(line.x_fc_serial_ids)
|
||||
x_fc_job_number = fields.Char(
|
||||
string='Job #',
|
||||
copy=False,
|
||||
@@ -140,6 +198,27 @@ class SaleOrderLine(models.Model):
|
||||
if line.x_fc_revision_pick_id:
|
||||
line.x_fc_part_catalog_id = line.x_fc_revision_pick_id
|
||||
|
||||
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() so the polish runs on every
|
||||
save path — onchange alone doesn't cover programmatic creates
|
||||
(the direct-order wizard, imports, the sale_mrp bridge, etc.).
|
||||
"""
|
||||
for line in self:
|
||||
if not line.x_fc_part_catalog_id or not line.x_fc_process_variant_id:
|
||||
continue
|
||||
recipe = line.x_fc_process_variant_id
|
||||
if (not recipe.part_catalog_id
|
||||
or recipe.part_catalog_id.id != line.x_fc_part_catalog_id.id):
|
||||
clone = line._fp_clone_recipe_to_part()
|
||||
if clone and clone.id != recipe.id:
|
||||
line.x_fc_process_variant_id = clone.id
|
||||
recipe = clone
|
||||
if line.x_fc_save_as_default_process and recipe.part_catalog_id:
|
||||
line.x_fc_part_catalog_id.action_set_default_variant(recipe.id)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Default `x_fc_internal_description` from `name` when a caller
|
||||
@@ -175,7 +254,9 @@ class SaleOrderLine(models.Model):
|
||||
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
|
||||
if part and part.revision:
|
||||
vals['x_fc_revision_snapshot'] = part.revision
|
||||
return super().create(vals_list)
|
||||
lines = super().create(vals_list)
|
||||
lines._fp_apply_recipe_polish()
|
||||
return lines
|
||||
|
||||
def write(self, vals):
|
||||
# Sub 5 — keep the revision snapshot in lockstep with the line's
|
||||
@@ -190,7 +271,16 @@ class SaleOrderLine(models.Model):
|
||||
for line in self:
|
||||
if line.x_fc_part_catalog_id.id != new_part.id:
|
||||
line.x_fc_revision_snapshot = new_part.revision
|
||||
return super().write(vals)
|
||||
result = super().write(vals)
|
||||
# Only run the polish when something relevant actually changed —
|
||||
# avoids re-running on every unrelated write (e.g. price updates).
|
||||
if any(k in vals for k in (
|
||||
'x_fc_process_variant_id',
|
||||
'x_fc_part_catalog_id',
|
||||
'x_fc_save_as_default_process',
|
||||
)):
|
||||
self._fp_apply_recipe_polish()
|
||||
return result
|
||||
|
||||
@api.onchange('x_fc_description_template_id')
|
||||
def _onchange_description_template(self):
|
||||
@@ -229,7 +319,12 @@ class SaleOrderLine(models.Model):
|
||||
vals = super()._prepare_invoice_line(**optional_values)
|
||||
if self.x_fc_part_catalog_id:
|
||||
vals['x_fc_part_catalog_id'] = self.x_fc_part_catalog_id.id
|
||||
if self.x_fc_serial_id:
|
||||
if self.x_fc_serial_ids:
|
||||
# Carry the full M2M to the invoice line. Back-compat alias
|
||||
# x_fc_serial_id will still resolve to the first one if any
|
||||
# downstream code only reads the singular.
|
||||
vals['x_fc_serial_ids'] = [(6, 0, self.x_fc_serial_ids.ids)]
|
||||
elif self.x_fc_serial_id:
|
||||
vals['x_fc_serial_id'] = self.x_fc_serial_id.id
|
||||
if self.x_fc_job_number:
|
||||
vals['x_fc_job_number'] = self.x_fc_job_number
|
||||
@@ -241,13 +336,95 @@ class SaleOrderLine(models.Model):
|
||||
|
||||
@api.onchange('x_fc_part_catalog_id')
|
||||
def _onchange_part_default_variant(self):
|
||||
"""Clear process variant when the part changes — domain would
|
||||
otherwise leave a stale value pointing at the wrong part."""
|
||||
"""When the part changes, pre-fill the variant from the part's
|
||||
default_process_id (if set) so the line carries a sensible
|
||||
starting point. The estimator can override after.
|
||||
|
||||
Previously cleared the variant entirely when the part changed
|
||||
(because the variant picker was scoped to the part). Now that
|
||||
the picker is system-wide, we instead pre-fill from the part's
|
||||
default — much more useful.
|
||||
"""
|
||||
for line in self:
|
||||
if (line.x_fc_process_variant_id
|
||||
and line.x_fc_process_variant_id.part_catalog_id
|
||||
!= line.x_fc_part_catalog_id):
|
||||
line.x_fc_process_variant_id = False
|
||||
if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id:
|
||||
line.x_fc_process_variant_id = line.x_fc_part_catalog_id.default_process_id
|
||||
|
||||
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.
|
||||
|
||||
Edge cases handled:
|
||||
* No recipe picked → no-op, return False.
|
||||
* No part on the line → no-op (we need a part to scope the clone).
|
||||
* Recipe already belongs to this part → no-op, return as-is.
|
||||
* Recipe belongs to a different part / is a template / is unscoped
|
||||
→ deep-copy via Odoo's standard recursive copy(), reparent the
|
||||
clone onto this part, name-stamp it for traceability.
|
||||
"""
|
||||
self.ensure_one()
|
||||
recipe = self.x_fc_process_variant_id
|
||||
part = self.x_fc_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 # already scoped — nothing to do
|
||||
# Clone — Odoo's default copy() recurses through child_ids when the
|
||||
# field has copy=True. fp.process.node sets that on its tree, so
|
||||
# one call gets us a full sub-tree clone.
|
||||
clone_name = recipe.name or _('Untitled Recipe')
|
||||
# If the source carried a part scope, preface the clone name with
|
||||
# the customer's part number for quick identification on the
|
||||
# variant dropdown later.
|
||||
if not clone_name.lower().endswith(part.part_number.lower() if part.part_number else ''):
|
||||
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, # never propagate template flag
|
||||
'is_default_variant': False, # estimator opts in via toggle
|
||||
})
|
||||
return clone
|
||||
|
||||
def action_customize_process(self):
|
||||
"""Open the Process Composer for this line's process variant.
|
||||
|
||||
Auto-clones first if the variant isn't yet scoped to this part —
|
||||
the operator should never edit a recipe that's shared across
|
||||
customers (their edits would bleed). After cloning, the line
|
||||
ends up pointing at the fresh per-part copy.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_part_catalog_id:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Pick a part on this line before customizing the process — '
|
||||
'the recipe needs a part to scope the variant.'
|
||||
))
|
||||
if not self.x_fc_process_variant_id:
|
||||
from odoo.exceptions import UserError
|
||||
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.x_fc_process_variant_id.id:
|
||||
self.x_fc_process_variant_id = clone_or_existing.id
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_part_process_composer',
|
||||
'name': _('Customize Process — %s') % (
|
||||
self.x_fc_part_catalog_id.display_name
|
||||
or self.x_fc_part_catalog_id.part_number
|
||||
or '?'
|
||||
),
|
||||
'params': {
|
||||
'part_id': self.x_fc_part_catalog_id.id,
|
||||
'part_display': self.x_fc_part_catalog_id.display_name
|
||||
or self.x_fc_part_catalog_id.part_number,
|
||||
'focus_variant_id': clone_or_existing.id,
|
||||
},
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
@api.onchange('x_fc_coating_config_id')
|
||||
def _onchange_coating_clears_thickness(self):
|
||||
@@ -263,19 +440,55 @@ class SaleOrderLine(models.Model):
|
||||
line.x_fc_thickness_id = False
|
||||
|
||||
def action_generate_serial(self):
|
||||
"""Create a fresh fp.serial for this line using the shop sequence."""
|
||||
"""Generate one new auto-sequenced serial and append it to the M2M.
|
||||
|
||||
Phase 1 polish: the legacy single-serial behaviour was "create one
|
||||
serial and pin it to x_fc_serial_id". Now we append to the M2M so
|
||||
repeated clicks add more serials (handy when the customer didn't
|
||||
send any and the shop wants to assign N).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.x_fc_serial_id:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.serial',
|
||||
'res_id': self.x_fc_serial_id.id,
|
||||
'view_mode': 'form',
|
||||
}
|
||||
seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000'
|
||||
serial = self.env['fp.serial'].create({
|
||||
'name': seq,
|
||||
'sale_order_line_id': self.id,
|
||||
})
|
||||
self.x_fc_serial_id = serial.id
|
||||
self.x_fc_serial_ids = [(4, serial.id)]
|
||||
return False
|
||||
|
||||
def action_open_serial_bulk_add(self):
|
||||
"""Open the Bulk Add Serials wizard for this 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': 'sale.order.line',
|
||||
'default_target_id': self.id,
|
||||
'default_qty_expected': int(self.product_uom_qty or 0),
|
||||
},
|
||||
}
|
||||
|
||||
@api.constrains('x_fc_serial_ids', 'product_uom_qty')
|
||||
def _check_serial_count_against_qty(self):
|
||||
"""Block save when the operator has attached more serials than
|
||||
the line quantity. Under-count is allowed (some customers ship
|
||||
with serials only on a subset of parts).
|
||||
"""
|
||||
for line in self:
|
||||
if line.x_fc_serial_ids and line.product_uom_qty:
|
||||
n = len(line.x_fc_serial_ids)
|
||||
if n > int(line.product_uom_qty):
|
||||
raise ValidationError(_(
|
||||
'Line "%(part)s": %(n)s serials attached but only '
|
||||
'%(qty)s parts ordered. Either reduce the serial '
|
||||
'list, increase the quantity, or split the line.'
|
||||
) % {
|
||||
'part': (line.x_fc_part_catalog_id.display_name
|
||||
or line.product_id.display_name or ''),
|
||||
'n': n,
|
||||
'qty': int(line.product_uom_qty),
|
||||
})
|
||||
|
||||
@@ -44,6 +44,8 @@ access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_
|
||||
access_fp_serial_user,fp.serial.user,model_fp_serial,base.group_user,1,0,0,0
|
||||
access_fp_serial_estimator,fp.serial.estimator,model_fp_serial,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
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_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
|
||||
|
||||
|
@@ -188,6 +188,7 @@ export class FpPartProcessComposer extends Component {
|
||||
}
|
||||
|
||||
openRecipeEditor(rootId) {
|
||||
// Tree editor — the original drag-and-drop hierarchy view.
|
||||
const id = rootId || this.state.rootId;
|
||||
if (!id) return;
|
||||
this.action.doAction({
|
||||
@@ -199,6 +200,22 @@ export class FpPartProcessComposer extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
openRecipeSimpleEditor(rootId) {
|
||||
// Simple Recipe Editor (Sub 12a) — flat 2-pane drag-drop layout.
|
||||
// Lives alongside the tree editor; the user picks per-variant
|
||||
// which one to open. Both edit the same underlying tree, so
|
||||
// changes flow back-and-forth without conflict.
|
||||
const id = rootId || this.state.rootId;
|
||||
if (!id) return;
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_simple_recipe_editor",
|
||||
name: `Process Editor (Simple) — ${(this.state.part && this.state.part.display) || ""}`,
|
||||
context: { recipe_id: id, part_id: this.partId },
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
backToPart() {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
|
||||
@@ -83,8 +83,15 @@
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-primary me-1"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.openRecipeEditor(v.id)">
|
||||
<i class="fa fa-pencil"/> Edit
|
||||
t-on-click="() => this.openRecipeEditor(v.id)"
|
||||
title="Open the tree editor (drag-and-drop hierarchy view)">
|
||||
<i class="fa fa-pencil"/> Tree
|
||||
</button>
|
||||
<button class="btn btn-sm btn-info me-1"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.openRecipeSimpleEditor(v.id)"
|
||||
title="Open the Simple Recipe Editor (flat 2-pane drag-drop)">
|
||||
<i class="fa fa-list-alt"/> Simple
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary me-1"
|
||||
t-att-disabled="state.busy"
|
||||
|
||||
@@ -45,9 +45,8 @@
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes" colspan="2"/>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
|
||||
@@ -176,12 +176,26 @@
|
||||
icon="fa-wrench"
|
||||
class="btn-primary"
|
||||
help="Open the Process Composer to manage this part's process variants."/>
|
||||
<button name="action_open_default_simple_editor" type="object"
|
||||
string="Edit Default (Simple)"
|
||||
icon="fa-list-alt"
|
||||
class="btn-info ms-1"
|
||||
invisible="not default_process_id"
|
||||
help="Jump straight to the Simple Recipe Editor for the default variant — flat 2-pane drag-drop layout."/>
|
||||
<button name="action_open_default_tree_editor" type="object"
|
||||
string="Edit Default (Tree)"
|
||||
icon="fa-sitemap"
|
||||
class="btn-secondary ms-1"
|
||||
invisible="not default_process_id"
|
||||
help="Jump straight to the Tree Editor for the default variant."/>
|
||||
</div>
|
||||
<p class="text-muted mt-3">
|
||||
The <strong>Compose</strong> button opens the Process Composer where you can add
|
||||
multiple process <em>variants</em> for this part — for example "Standard ENP",
|
||||
"Selective Masking", "Rework". One variant is flagged as default; estimators
|
||||
may pick a different variant on a per-order basis.
|
||||
may pick a different variant on a per-order basis. Each variant can be edited
|
||||
in either the <strong>Tree</strong> or <strong>Simple</strong> view — same data,
|
||||
two layouts.
|
||||
</p>
|
||||
<field name="process_variant_ids" readonly="1">
|
||||
<list>
|
||||
@@ -189,6 +203,12 @@
|
||||
<field name="variant_label"/>
|
||||
<field name="name"/>
|
||||
<field name="estimated_duration" optional="hide"/>
|
||||
<button name="action_open_simple_editor" type="object"
|
||||
string="Simple" icon="fa-list-alt"
|
||||
class="btn-link"/>
|
||||
<button name="action_open_tree_editor" type="object"
|
||||
string="Tree" icon="fa-sitemap"
|
||||
class="btn-link"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
@@ -207,9 +227,7 @@
|
||||
<field name="has_threads"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="masking_description" placeholder="e.g. Mask threaded holes, mask bore ID"/>
|
||||
</group>
|
||||
<field name="masking_description" placeholder="e.g. Mask threaded holes, mask bore ID"/>
|
||||
</page>
|
||||
<page string="Attachments" name="attachments">
|
||||
<group>
|
||||
|
||||
@@ -276,9 +276,7 @@
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<field name="price_breakdown_html" readonly="1" colspan="2"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -299,10 +297,9 @@
|
||||
<field name="lost_date" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="lost_details" nolabel="1" colspan="2"
|
||||
<separator string="Notes"/>
|
||||
<field name="lost_details" colspan="2"
|
||||
placeholder="What did we learn? (Price point competitor beat, spec we didn't meet, etc.)"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Notes" name="notes">
|
||||
<field name="notes" placeholder="Internal notes about this quote..."/>
|
||||
|
||||
@@ -58,10 +58,9 @@
|
||||
<field name="internal_description" nolabel="1" colspan="2"
|
||||
placeholder="What the shop floor sees on the WO / traveler…"/>
|
||||
</group>
|
||||
<group string="Customer-Facing Description">
|
||||
<field name="customer_facing_description" nolabel="1" colspan="2"
|
||||
<separator string="Customer-Facing Description"/>
|
||||
<field name="customer_facing_description" colspan="2"
|
||||
placeholder="Electroless nickel plating per AMS 2404, Class I, Type II…"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<?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.
|
||||
|
||||
Phase 2 (2026-04-28) — relocates the fp.serial views from
|
||||
fusion_plating_bridge_mrp (uninstalled in Sub 11) into configurator
|
||||
where the model lives. Adds the new state machine to the form +
|
||||
list with workflow buttons + status badge.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_serial_form" model="ir.ui.view">
|
||||
<field name="name">fp.serial.form</field>
|
||||
<field name="model">fp.serial</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Serial Number">
|
||||
<header>
|
||||
<button name="action_mark_racked" type="object"
|
||||
string="Mark Racked" class="btn-primary"
|
||||
invisible="state != 'received'"/>
|
||||
<button name="action_mark_in_process" type="object"
|
||||
string="Start Processing" class="btn-primary"
|
||||
invisible="state != 'racked'"/>
|
||||
<button name="action_mark_inspected" type="object"
|
||||
string="Pass Inspection" class="btn-success"
|
||||
invisible="state != 'in_process'"/>
|
||||
<button name="action_mark_packed" type="object"
|
||||
string="Mark Packed" class="btn-primary"
|
||||
invisible="state != 'inspected'"/>
|
||||
<button name="action_mark_shipped" type="object"
|
||||
string="Mark Shipped" class="btn-success"
|
||||
invisible="state != 'packed'"/>
|
||||
<button name="action_mark_on_hold" type="object"
|
||||
string="Hold" class="btn-warning"
|
||||
invisible="state in ('on_hold', 'shipped', 'scrapped')"/>
|
||||
<button name="action_release_hold" type="object"
|
||||
string="Release Hold" class="btn-secondary"
|
||||
invisible="state != 'on_hold'"/>
|
||||
<button name="action_mark_scrapped" type="object"
|
||||
string="Scrap" class="btn-danger"
|
||||
invisible="state in ('shipped', 'scrapped')"
|
||||
confirm="Mark this serial as scrapped? This is reversible only via Reopen."/>
|
||||
<button name="action_mark_returned" type="object"
|
||||
string="Returned by Customer" class="btn-secondary"
|
||||
invisible="state != 'shipped'"/>
|
||||
<button name="action_reopen" type="object"
|
||||
string="Reopen" class="btn-secondary"
|
||||
groups="fusion_plating.group_fusion_plating_manager"
|
||||
invisible="state not in ('shipped', 'scrapped')"
|
||||
confirm="Reopen a terminal-state serial? Manager-only override; the chatter audit will record who, when, why."/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="received,racked,in_process,inspected,packed,shipped"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="Scrapped" bg_color="text-bg-danger"
|
||||
invisible="state != 'scrapped'"/>
|
||||
<widget name="web_ribbon" title="On Hold" bg_color="text-bg-warning"
|
||||
invisible="state != 'on_hold'"/>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_sale_order" type="object"
|
||||
class="oe_stat_button" icon="fa-file-text-o"
|
||||
invisible="not sale_order_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Sale Order</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_invoices" type="object"
|
||||
class="oe_stat_button" icon="fa-money"
|
||||
invisible="invoice_count == 0">
|
||||
<field name="invoice_count" widget="statinfo" string="Invoices"/>
|
||||
</button>
|
||||
<button name="action_view_part" type="object"
|
||||
class="oe_stat_button" icon="fa-cube"
|
||||
invisible="not part_id">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Part</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name" string="Serial #"/>
|
||||
<h1><field name="name" placeholder="e.g. SN-12345"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="customer_id" readonly="1"/>
|
||||
<field name="part_id" readonly="1"/>
|
||||
<field name="sale_order_id" readonly="1"/>
|
||||
<field name="sale_order_line_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="last_state_change" readonly="1"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Scrap / Return Reason"
|
||||
invisible="state not in ('scrapped', 'returned', 'on_hold')"/>
|
||||
<field name="scrap_reason"
|
||||
invisible="state not in ('scrapped', 'returned', 'on_hold')"
|
||||
placeholder="What happened? Cause / disposition / who decided..."/>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_serial_list" model="ir.ui.view">
|
||||
<field name="name">fp.serial.list</field>
|
||||
<field name="model">fp.serial</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Serial Numbers"
|
||||
decoration-success="state in ('inspected', 'packed', 'shipped')"
|
||||
decoration-info="state in ('received', 'racked', 'in_process')"
|
||||
decoration-warning="state in ('on_hold', 'returned')"
|
||||
decoration-danger="state == 'scrapped'">
|
||||
<field name="name"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state in ('inspected', 'packed', 'shipped')"
|
||||
decoration-info="state in ('received', 'racked', 'in_process')"
|
||||
decoration-warning="state in ('on_hold', 'returned')"
|
||||
decoration-danger="state == 'scrapped'"/>
|
||||
<field name="customer_id"/>
|
||||
<field name="part_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="last_state_change" optional="show"/>
|
||||
<field name="invoice_count" string="Invoices" optional="hide"/>
|
||||
<field name="create_date" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_serial_search" model="ir.ui.view">
|
||||
<field name="name">fp.serial.search</field>
|
||||
<field name="model">fp.serial</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="customer_id"/>
|
||||
<field name="part_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
<separator/>
|
||||
<filter name="state_received" string="Received"
|
||||
domain="[('state','=','received')]"/>
|
||||
<filter name="state_in_process" string="In Process"
|
||||
domain="[('state','=','in_process')]"/>
|
||||
<filter name="state_inspected" string="Inspected"
|
||||
domain="[('state','=','inspected')]"/>
|
||||
<filter name="state_packed" string="Packed"
|
||||
domain="[('state','=','packed')]"/>
|
||||
<filter name="state_shipped" string="Shipped"
|
||||
domain="[('state','=','shipped')]"/>
|
||||
<separator/>
|
||||
<filter name="state_on_hold" string="On Hold"
|
||||
domain="[('state','=','on_hold')]"/>
|
||||
<filter name="state_scrapped" string="Scrapped"
|
||||
domain="[('state','=','scrapped')]"/>
|
||||
<filter name="state_returned" string="Returned"
|
||||
domain="[('state','=','returned')]"/>
|
||||
<separator/>
|
||||
<filter name="active_only" string="In Flight (not shipped/scrapped)"
|
||||
domain="[('state','not in',('shipped','scrapped'))]"/>
|
||||
<group>
|
||||
<filter name="group_state" string="Status"
|
||||
context="{'group_by': 'state'}"/>
|
||||
<filter name="group_customer" string="Customer"
|
||||
context="{'group_by': 'customer_id'}"/>
|
||||
<filter name="group_part" string="Part"
|
||||
context="{'group_by': 'part_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_serial" model="ir.actions.act_window">
|
||||
<field name="name">Serial Numbers</field>
|
||||
<field name="res_model">fp.serial</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fp_serial_search"/>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_serial"
|
||||
name="Serial Numbers"
|
||||
parent="fusion_plating_configurator.menu_fp_sales"
|
||||
action="action_fp_serial"
|
||||
sequence="60"/>
|
||||
|
||||
</odoo>
|
||||
@@ -52,9 +52,7 @@
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description" placeholder="Description of this treatment step..."/>
|
||||
</group>
|
||||
<field name="description" placeholder="Description of this treatment step..."/>
|
||||
<group>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
|
||||
@@ -188,10 +188,9 @@
|
||||
<field name="x_fc_internal_note" nolabel="1"
|
||||
placeholder="Internal notes for estimator / planner / shop floor..."/>
|
||||
</group>
|
||||
<group string="External Notes (customer-visible)">
|
||||
<field name="x_fc_external_note" nolabel="1"
|
||||
<separator string="External Notes (customer-visible)"/>
|
||||
<field name="x_fc_external_note"
|
||||
placeholder="Notes that appear on the acknowledgement and portal..."/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
@@ -214,17 +213,33 @@
|
||||
optional="hide"/>
|
||||
<field name="x_fc_coating_config_id" optional="show"/>
|
||||
<field name="x_fc_process_variant_id"
|
||||
string="Variant"
|
||||
options="{'no_create': True}"
|
||||
string="Process / Recipe"
|
||||
options="{'no_quick_create': True}"
|
||||
invisible="not x_fc_part_catalog_id"
|
||||
optional="show"/>
|
||||
<field name="x_fc_save_as_default_process"
|
||||
string="Set as Part Default"
|
||||
widget="boolean_toggle"
|
||||
invisible="not x_fc_process_variant_id"
|
||||
optional="hide"/>
|
||||
<button name="action_customize_process" type="object"
|
||||
string="Customize" icon="fa-pencil-square-o"
|
||||
class="btn-link"
|
||||
invisible="not x_fc_process_variant_id"/>
|
||||
<field name="x_fc_thickness_id"
|
||||
options="{'no_create': True}"
|
||||
invisible="not x_fc_coating_config_id"
|
||||
optional="show"/>
|
||||
<field name="x_fc_serial_id"
|
||||
options="{'no_create_edit': False}"
|
||||
<field name="x_fc_serial_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_quick_create': False, 'color_field': 'state_color'}"
|
||||
optional="show"/>
|
||||
<field name="x_fc_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="x_fc_job_number" optional="show"/>
|
||||
<field name="x_fc_revision_pick_id"
|
||||
string="Revision"
|
||||
|
||||
@@ -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