feat(numbering): WO grouping by recipe + parent-derived bulk naming
Replaces x_fc_wo_group_tag grouping with resolved-recipe grouping. Bare WO-<parent> when 1 recipe, WO-<parent>-NN zero-padded for N>1 ordered by min line sequence. fp.job inherits parent-numbered mixin for the manual-add path; bulk SO-confirm sets names explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.18.15.12',
|
||||
'version': '19.0.18.15.13',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -51,7 +51,7 @@ class FpJob(models.Model):
|
||||
dt = pytz.UTC.localize(dt)
|
||||
return dt.astimezone(tz).strftime(fmt)
|
||||
_description = 'Work Order'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
|
||||
# Sub 12d — state-aware sort. Active work bubbles to the top
|
||||
# (in_progress → confirmed/draft → on_hold → done → cancelled),
|
||||
# then high-priority first within each state, then nearest deadline.
|
||||
@@ -389,12 +389,50 @@ class FpJob(models.Model):
|
||||
continue
|
||||
job.current_step_id = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parent-numbered mixin hooks (2026-05-12 numbering hierarchy)
|
||||
# ------------------------------------------------------------------
|
||||
def _fp_parent_sale_order(self):
|
||||
return self.sale_order_id
|
||||
|
||||
def _fp_name_prefix(self):
|
||||
return 'WO'
|
||||
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_wo_count'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""fp.job naming priority:
|
||||
1. Caller-provided name (bulk SO-confirm path sets these explicitly).
|
||||
2. Mixin parent-derived name (manual WO add to an existing SO).
|
||||
3. Legacy fp.job sequence (standalone job, no SO link).
|
||||
"""
|
||||
# Pass A: fall back to legacy 'New' sentinel for records that
|
||||
# don't get a parent-derived name. The mixin's post-create
|
||||
# _fp_assign_parent_name() will override 'New' once the record
|
||||
# exists if a parent SO is reachable.
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
|
||||
return super().create(vals_list)
|
||||
if not vals.get('name'):
|
||||
vals['name'] = _('New')
|
||||
records = super().create(vals_list)
|
||||
# Pass B: any record that came through with 'New' (no explicit
|
||||
# name from the bulk SO path) tries the parent-derived path,
|
||||
# falling back to the legacy sequence if there's no parent SO.
|
||||
for rec in records:
|
||||
if rec.name and rec.name != _('New') and rec.name != 'New':
|
||||
continue # caller set an explicit name (e.g. bulk SO confirm)
|
||||
if not rec._fp_assign_parent_name():
|
||||
seq = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
|
||||
# Raw SQL — fp.job has no immutability guard yet in this
|
||||
# task, but Task 11 will add one. Using SQL here keeps the
|
||||
# fallback path consistent across all child models.
|
||||
self.env.cr.execute(
|
||||
"UPDATE fp_job SET name = %s WHERE id = %s",
|
||||
(seq, rec.id),
|
||||
)
|
||||
rec.invalidate_recordset(['name'])
|
||||
return records
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State machine — actions
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.8.22.2',
|
||||
'version': '19.0.8.22.3',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -296,12 +296,55 @@ class SaleOrder(models.Model):
|
||||
) % {'job': job.name, 'err': exc})
|
||||
return result
|
||||
|
||||
def _fp_resolve_recipe_for_line(self, line):
|
||||
"""4-tier recipe resolution. Used BOTH for grouping (Task 6
|
||||
recipe-driven WO splits) AND for the per-job vals construction.
|
||||
|
||||
Priority (most-specific first):
|
||||
1. line.x_fc_process_variant_id — Sarah explicitly picked a
|
||||
part-scoped variant on this order line. Always wins.
|
||||
2. part.default_process_id — part's flagged default
|
||||
variant. Customer-and-part-tuned recipe; must beat any
|
||||
generic coating template.
|
||||
3. coating.recipe_id — coating-config recipe
|
||||
(generic template fallback).
|
||||
4. part.recipe_id — legacy fallback.
|
||||
Returns the recipe record or an empty recordset.
|
||||
"""
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
part = (
|
||||
'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id
|
||||
) or False
|
||||
if not part and 'x_fc_part_catalog_id' in self._fields:
|
||||
part = self.x_fc_part_catalog_id or False
|
||||
coating = (
|
||||
'x_fc_coating_config_id' in line._fields and line.x_fc_coating_config_id
|
||||
) or False
|
||||
if not coating and 'x_fc_coating_config_id' in self._fields:
|
||||
coating = self.x_fc_coating_config_id or False
|
||||
picked = (
|
||||
'x_fc_process_variant_id' in line._fields
|
||||
and line.x_fc_process_variant_id
|
||||
) or False
|
||||
if picked:
|
||||
return picked
|
||||
if part and 'default_process_id' in part._fields and part.default_process_id:
|
||||
return part.default_process_id
|
||||
if coating and 'recipe_id' in coating._fields and coating.recipe_id:
|
||||
return coating.recipe_id
|
||||
if part and 'recipe_id' in part._fields and part.recipe_id:
|
||||
return part.recipe_id
|
||||
return Node
|
||||
|
||||
def _fp_auto_create_job(self):
|
||||
"""Create fp.job(s) from the SO's plating lines.
|
||||
|
||||
Lines that share a `x_fc_wo_group_tag` collapse into one job;
|
||||
untagged lines get one job per line. Mirrors bridge_mrp's
|
||||
_fp_auto_create_mo grouping logic.
|
||||
2026-05-12 parent-number rewrite: lines are grouped by resolved
|
||||
recipe id (NOT by x_fc_wo_group_tag). If 1 group → one WO named
|
||||
WO-<parent> (bare). If N>1 groups → N WOs named WO-<parent>-01,
|
||||
WO-<parent>-02, ..., ordered by min line sequence so suffixes
|
||||
mirror SO display order. WO names are then immutable; later
|
||||
manual additions to the SO get the next index via the mixin.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Job = self.env['fp.job'].sudo()
|
||||
@@ -334,20 +377,32 @@ class SaleOrder(models.Model):
|
||||
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
|
||||
return
|
||||
|
||||
# Group by x_fc_wo_group_tag (untagged → distinct group per line)
|
||||
groups = {} # tag → recordset of lines
|
||||
untagged_idx = 0
|
||||
# Group by resolved recipe id (lines sharing a recipe → one WO).
|
||||
# No-recipe lines get their own group each (preserves the legacy
|
||||
# "one job per line" behaviour for unrecipe'd SOs).
|
||||
groups = {}
|
||||
unrecipe_idx = 0
|
||||
for line in plating_lines:
|
||||
tag = (
|
||||
'x_fc_wo_group_tag' in line._fields and line.x_fc_wo_group_tag
|
||||
) or False
|
||||
if not tag:
|
||||
untagged_idx += 1
|
||||
tag = '__untagged_%d' % untagged_idx
|
||||
groups[tag] = groups.get(tag, self.env['sale.order.line']) | line
|
||||
recipe = self._fp_resolve_recipe_for_line(line)
|
||||
if recipe:
|
||||
key = recipe.id
|
||||
else:
|
||||
unrecipe_idx += 1
|
||||
key = ('no_recipe', unrecipe_idx)
|
||||
groups[key] = groups.get(key, self.env['sale.order.line']) | line
|
||||
|
||||
# Order groups by min line sequence so dash-suffixes mirror SO
|
||||
# display order. Deterministic regardless of dict iteration order.
|
||||
ordered_keys = sorted(
|
||||
groups.keys(),
|
||||
key=lambda k: min(groups[k].mapped('sequence') or [0]),
|
||||
)
|
||||
n_groups = len(ordered_keys)
|
||||
parent = self.x_fc_parent_number # set by action_confirm earlier
|
||||
|
||||
# Create a job per group
|
||||
for tag, lines in groups.items():
|
||||
for idx, key in enumerate(ordered_keys, start=1):
|
||||
lines = groups[key]
|
||||
first_line = lines[0]
|
||||
qty = sum(lines.mapped('product_uom_qty'))
|
||||
part = (
|
||||
@@ -360,49 +415,11 @@ class SaleOrder(models.Model):
|
||||
and first_line.x_fc_coating_config_id
|
||||
or False
|
||||
)
|
||||
# Header fallback for legacy/configurator SOs that put part +
|
||||
# coating on the SO header instead of the line.
|
||||
if not part and 'x_fc_part_catalog_id' in self._fields:
|
||||
part = self.x_fc_part_catalog_id or False
|
||||
if not coating and 'x_fc_coating_config_id' in self._fields:
|
||||
coating = self.x_fc_coating_config_id or False
|
||||
# Recipe lookup priority (specific → generic):
|
||||
# 1. line.x_fc_process_variant_id — Sarah explicitly picked
|
||||
# a part-scoped variant on this order line. Always wins.
|
||||
# 2. part.default_process_id — part's flagged default
|
||||
# variant. This is the customer-and-part-tuned recipe
|
||||
# (months of process refinement) and must beat any
|
||||
# generic template attached to the coating config.
|
||||
# 3. coating.recipe_id — coating-config recipe
|
||||
# (generic template fallback when no part variant exists).
|
||||
# 4. part.recipe_id — legacy fallback.
|
||||
#
|
||||
# Order swap 2026-05-12: before this, step (3) ran before (2),
|
||||
# so jobs with both a part variant AND a coating-attached
|
||||
# template silently picked the template. WO #01373 (S00063)
|
||||
# is the documented case — part 9876699373 Rev A had its own
|
||||
# variant but the job linked to ENP-ALUM-BASIC instead.
|
||||
#
|
||||
# If multiple lines in the same WO group have different
|
||||
# variants we use the FIRST line's variant (consistent with
|
||||
# everything else in this loop using `first_line`).
|
||||
recipe = False
|
||||
picked_variant = (
|
||||
'x_fc_process_variant_id' in first_line._fields
|
||||
and first_line.x_fc_process_variant_id
|
||||
or False
|
||||
)
|
||||
if picked_variant:
|
||||
recipe = picked_variant
|
||||
if not recipe and part and 'default_process_id' in part._fields \
|
||||
and part.default_process_id:
|
||||
recipe = part.default_process_id
|
||||
if not recipe and coating and 'recipe_id' in coating._fields \
|
||||
and coating.recipe_id:
|
||||
recipe = coating.recipe_id
|
||||
if not recipe and part and 'recipe_id' in part._fields \
|
||||
and part.recipe_id:
|
||||
recipe = part.recipe_id
|
||||
recipe = self._fp_resolve_recipe_for_line(first_line)
|
||||
|
||||
vals = {
|
||||
'partner_id': self.partner_id.id,
|
||||
@@ -457,11 +474,32 @@ class SaleOrder(models.Model):
|
||||
# Quoted revenue: sum line totals
|
||||
vals['quoted_revenue'] = sum(lines.mapped('price_subtotal'))
|
||||
|
||||
# Parent-number naming (2026-05-12). Bare for the single-group
|
||||
# case; zero-padded -NN suffix when multiple recipes split the
|
||||
# SO into multiple WOs. Set explicitly so fp.job.create() skips
|
||||
# its own naming fallback.
|
||||
if parent:
|
||||
if n_groups == 1:
|
||||
vals['name'] = f'WO-{parent}'
|
||||
vals['x_fc_doc_index'] = 1
|
||||
else:
|
||||
vals['name'] = f'WO-{parent}-{idx:02d}' if idx <= 99 else f'WO-{parent}-{idx}'
|
||||
vals['x_fc_doc_index'] = idx
|
||||
|
||||
job = Job.create(vals)
|
||||
_logger.info(
|
||||
'SO %s: created fp.job %s (qty=%s, recipe=%s)',
|
||||
self.name, job.name, qty, (recipe.name if recipe else '-'),
|
||||
)
|
||||
|
||||
# Bump SO counter to reflect the bulk creation. Future manual
|
||||
# WO additions pick up from here via the mixin standard path.
|
||||
if parent and n_groups:
|
||||
self.env.cr.execute(
|
||||
"UPDATE sale_order SET x_fc_wo_count = %s WHERE id = %s",
|
||||
(n_groups, self.id),
|
||||
)
|
||||
self.invalidate_recordset(['x_fc_wo_count'])
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user