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',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.18.15.12',
|
'version': '19.0.18.15.13',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class FpJob(models.Model):
|
|||||||
dt = pytz.UTC.localize(dt)
|
dt = pytz.UTC.localize(dt)
|
||||||
return dt.astimezone(tz).strftime(fmt)
|
return dt.astimezone(tz).strftime(fmt)
|
||||||
_description = 'Work Order'
|
_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
|
# Sub 12d — state-aware sort. Active work bubbles to the top
|
||||||
# (in_progress → confirmed/draft → on_hold → done → cancelled),
|
# (in_progress → confirmed/draft → on_hold → done → cancelled),
|
||||||
# then high-priority first within each state, then nearest deadline.
|
# then high-priority first within each state, then nearest deadline.
|
||||||
@@ -389,12 +389,50 @@ class FpJob(models.Model):
|
|||||||
continue
|
continue
|
||||||
job.current_step_id = False
|
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
|
@api.model_create_multi
|
||||||
def create(self, vals_list):
|
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:
|
for vals in vals_list:
|
||||||
if vals.get('name', _('New')) == _('New'):
|
if not vals.get('name'):
|
||||||
vals['name'] = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
|
vals['name'] = _('New')
|
||||||
return super().create(vals_list)
|
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
|
# State machine — actions
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.8.22.2',
|
'version': '19.0.8.22.3',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -296,12 +296,55 @@ class SaleOrder(models.Model):
|
|||||||
) % {'job': job.name, 'err': exc})
|
) % {'job': job.name, 'err': exc})
|
||||||
return result
|
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):
|
def _fp_auto_create_job(self):
|
||||||
"""Create fp.job(s) from the SO's plating lines.
|
"""Create fp.job(s) from the SO's plating lines.
|
||||||
|
|
||||||
Lines that share a `x_fc_wo_group_tag` collapse into one job;
|
2026-05-12 parent-number rewrite: lines are grouped by resolved
|
||||||
untagged lines get one job per line. Mirrors bridge_mrp's
|
recipe id (NOT by x_fc_wo_group_tag). If 1 group → one WO named
|
||||||
_fp_auto_create_mo grouping logic.
|
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()
|
self.ensure_one()
|
||||||
Job = self.env['fp.job'].sudo()
|
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)
|
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Group by x_fc_wo_group_tag (untagged → distinct group per line)
|
# Group by resolved recipe id (lines sharing a recipe → one WO).
|
||||||
groups = {} # tag → recordset of lines
|
# No-recipe lines get their own group each (preserves the legacy
|
||||||
untagged_idx = 0
|
# "one job per line" behaviour for unrecipe'd SOs).
|
||||||
|
groups = {}
|
||||||
|
unrecipe_idx = 0
|
||||||
for line in plating_lines:
|
for line in plating_lines:
|
||||||
tag = (
|
recipe = self._fp_resolve_recipe_for_line(line)
|
||||||
'x_fc_wo_group_tag' in line._fields and line.x_fc_wo_group_tag
|
if recipe:
|
||||||
) or False
|
key = recipe.id
|
||||||
if not tag:
|
else:
|
||||||
untagged_idx += 1
|
unrecipe_idx += 1
|
||||||
tag = '__untagged_%d' % untagged_idx
|
key = ('no_recipe', unrecipe_idx)
|
||||||
groups[tag] = groups.get(tag, self.env['sale.order.line']) | line
|
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
|
# 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]
|
first_line = lines[0]
|
||||||
qty = sum(lines.mapped('product_uom_qty'))
|
qty = sum(lines.mapped('product_uom_qty'))
|
||||||
part = (
|
part = (
|
||||||
@@ -360,49 +415,11 @@ class SaleOrder(models.Model):
|
|||||||
and first_line.x_fc_coating_config_id
|
and first_line.x_fc_coating_config_id
|
||||||
or False
|
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:
|
if not part and 'x_fc_part_catalog_id' in self._fields:
|
||||||
part = self.x_fc_part_catalog_id or False
|
part = self.x_fc_part_catalog_id or False
|
||||||
if not coating and 'x_fc_coating_config_id' in self._fields:
|
if not coating and 'x_fc_coating_config_id' in self._fields:
|
||||||
coating = self.x_fc_coating_config_id or False
|
coating = self.x_fc_coating_config_id or False
|
||||||
# Recipe lookup priority (specific → generic):
|
recipe = self._fp_resolve_recipe_for_line(first_line)
|
||||||
# 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
|
|
||||||
|
|
||||||
vals = {
|
vals = {
|
||||||
'partner_id': self.partner_id.id,
|
'partner_id': self.partner_id.id,
|
||||||
@@ -457,11 +474,32 @@ class SaleOrder(models.Model):
|
|||||||
# Quoted revenue: sum line totals
|
# Quoted revenue: sum line totals
|
||||||
vals['quoted_revenue'] = sum(lines.mapped('price_subtotal'))
|
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)
|
job = Job.create(vals)
|
||||||
_logger.info(
|
_logger.info(
|
||||||
'SO %s: created fp.job %s (qty=%s, recipe=%s)',
|
'SO %s: created fp.job %s (qty=%s, recipe=%s)',
|
||||||
self.name, job.name, qty, (recipe.name if recipe else '-'),
|
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
|
return True
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user