changes
This commit is contained in:
@@ -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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user