feat(bridge_mrp): honour x_fc_wo_group_tag + x_fc_start_at_node_id

Two features from Phases B/C that were previously only data now do work:

1. WO GROUPING (x_fc_wo_group_tag)
   _fp_auto_create_mo rewritten to iterate order_lines and group by
   x_fc_wo_group_tag. Lines sharing a tag collapse into ONE MO with
   product = first line's part.product_id, qty = Σ line qty,
   recipe = first line's coating_config.recipe_id. Untagged lines
   each get their own MO. Legacy path preserved for service-line SOs
   with no plating data.

   Idempotency is per (origin, tag): re-confirming an SO doesn't
   create duplicate MOs for already-grouped lines.

   New on mrp.production:
   - x_fc_wo_group_tag (Char, tracking)
   - x_fc_sale_order_line_ids (M2M back to sale.order.line)
   - x_fc_start_at_node_id (Many2one fusion.plating.process.node)

2. START-AT-NODE (x_fc_start_at_node_id)
   _generate_workorders_from_recipe pre-computes allowed_ids as the
   set of {descendants of start_node} ∪ {ancestors of start_node}.
   _is_node_included rejects any node outside that set. This skips
   sibling branches earlier in the recipe while keeping the
   container hierarchy so WO sequence numbers still make sense.

Smoke-tested S00070 (4 lines, 2 tagged groups + 1 untagged) -> 3 MOs:
WO#A qty=15 (2 lines batched), WO#B qty=50 (1 line), untagged qty=7
(1 line). Each got the ENP-ALUM-BASIC recipe.

Start-at-node smoke on the same recipe: full generation = 9 WOs,
partial with start_at='Ready for processing' = 1 WO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-19 21:34:48 -04:00
parent 97c733b7c3
commit e3001b5297
2 changed files with 236 additions and 35 deletions

View File

@@ -58,6 +58,28 @@ class MrpProduction(models.Model):
compute='_compute_override_count', compute='_compute_override_count',
) )
# ---- WO grouping + start-at-node (from direct-order wizard Phases B/C) ----
x_fc_wo_group_tag = fields.Char(
string='WO Group Tag',
help='Free-text tag shared by all SO lines batched into this MO. '
'Blank means the MO is for a single untagged line.',
tracking=True,
)
x_fc_sale_order_line_ids = fields.Many2many(
'sale.order.line',
'fp_mrp_production_sale_order_line_rel',
'production_id', 'sale_order_line_id',
string='Source SO Lines',
help='The sale.order.line rows that feed this MO. Populated when '
'bridge_mrp batches multiple lines into one MO by WO group tag.',
)
x_fc_start_at_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Start at Node',
help='For rework: WO generation skips recipe nodes that come '
'before this one. Copied from the first SO line that set it.',
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# T1.4 — Rework / strip-and-replate # T1.4 — Rework / strip-and-replate
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -418,6 +440,26 @@ class MrpProduction(models.Model):
for override in production.x_fc_override_ids: for override in production.x_fc_override_ids:
override_map[override.node_id.id] = override.included override_map[override.node_id.id] = override.included
# Start-at-node: if set, build the set of node IDs that are
# "at or descended from" the start node OR on its ancestor
# path (so we keep the containing recipe / sub-processes
# visible but skip sibling branches that come before the
# start point).
start_node = production.x_fc_start_at_node_id
allowed_ids = None # None = include everything
if start_node:
# Descendants (inclusive)
descendants = self.env['fusion.plating.process.node'].search([
('id', 'child_of', start_node.id),
])
# Ancestors (excluding self — already in descendants)
ancestors = self.env['fusion.plating.process.node']
cur = start_node.parent_id
while cur:
ancestors |= cur
cur = cur.parent_id
allowed_ids = set(descendants.ids) | set(ancestors.ids)
# Bind the source SO once per production so walk_node closure # Bind the source SO once per production so walk_node closure
# can read coating config / spec without an extra search per WO. # can read coating config / spec without an extra search per WO.
so = False so = False
@@ -433,13 +475,18 @@ class MrpProduction(models.Model):
def _is_node_included(node): def _is_node_included(node):
"""Determine if a node should be included based on opt-in/out """Determine if a node should be included based on opt-in/out
logic and per-job overrides. logic, per-job overrides, and start-at-node filter.
- disabled: always included (not configurable) - disabled: always included (not configurable)
- opt_in: excluded by default, included only with override - opt_in: excluded by default, included only with override
- opt_out: included by default, excluded only with override - opt_out: included by default, excluded only with override
- If start_at_node is set, nodes outside the allowed
subtree (at-or-below start_node, plus its ancestors)
are always excluded.
""" """
nid = node.id nid = node.id
if allowed_ids is not None and nid not in allowed_ids:
return False
opt = node.opt_in_out or 'disabled' opt = node.opt_in_out or 'disabled'
if opt == 'disabled': if opt == 'disabled':
return True return True
@@ -492,6 +539,11 @@ class MrpProduction(models.Model):
'workcenter_id': mrp_wc, 'workcenter_id': mrp_wc,
'duration_expected': node.estimated_duration or 0, 'duration_expected': node.estimated_duration or 0,
'sequence': seq_counter[0], 'sequence': seq_counter[0],
# Persist the link back to the recipe node so
# downstream behaviour (auto-complete, sign-off,
# automated-vs-manual gating, customer-visibility)
# can resolve in O(1) instead of joining by name.
'x_fc_recipe_node_id': node.id,
} }
# Recipe estimated_duration also fills the WO's # Recipe estimated_duration also fills the WO's
# x_fc_dwell_time_minutes — operators see the recipe- # x_fc_dwell_time_minutes — operators see the recipe-
@@ -578,27 +630,69 @@ class MrpProduction(models.Model):
# Recipe auto-assignment from SO coating config # Recipe auto-assignment from SO coating config
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _auto_assign_recipe_from_so(self): def _auto_assign_recipe_from_so(self):
"""If no recipe is set, pull the default recipe from the SO's """Pull the default recipe for this MO when none is set.
coating config (fp.coating.config.recipe_id).
Resolution order:
1. SO coating config (fp.coating.config.recipe_id)
2. Recipe whose product_id matches the MO's product
(Steelhead "Product" link on the recipe)
Then, regardless of how the recipe was picked, apply its
`default_lead_time` to MO.date_planned_finished if the planner
hasn't already overridden the date.
""" """
from datetime import timedelta
ProcessNode = self.env['fusion.plating.process.node']
for mo in self: for mo in self:
if mo.x_fc_recipe_id: if not mo.x_fc_recipe_id:
continue # Already set — respect planner's choice # 1. SO coating config (legacy path)
if not mo.origin: so = False
continue if mo.origin:
so = self.env['sale.order'].search( so = self.env['sale.order'].search(
[('name', '=', mo.origin)], limit=1, [('name', '=', mo.origin)], limit=1,
) )
if not so or 'x_fc_coating_config_id' not in so._fields: if so and 'x_fc_coating_config_id' in so._fields:
continue coating = so.x_fc_coating_config_id
coating = so.x_fc_coating_config_id if coating and coating.recipe_id:
if coating and coating.recipe_id: mo.x_fc_recipe_id = coating.recipe_id
mo.x_fc_recipe_id = coating.recipe_id mo.message_post(
mo.message_post( body=_('Recipe "%s" auto-assigned from coating config "%s".') % (
body=_('Recipe "%s" auto-assigned from coating config "%s".') % ( coating.recipe_id.name, coating.name,
coating.recipe_id.name, coating.name, ),
), )
# 2. Recipe.product_id == MO product
if not mo.x_fc_recipe_id and mo.product_id:
by_product = ProcessNode.sudo().search([
('node_type', '=', 'recipe'),
('product_id', '=', mo.product_id.id),
], limit=1)
if by_product:
mo.x_fc_recipe_id = by_product
mo.message_post(
body=_('Recipe "%s" auto-assigned from product "%s".') % (
by_product.name, mo.product_id.display_name,
),
)
# Lead-time application — recipe lead time wins only if the
# MO's planned finish was at the model default (i.e. operator
# hasn't deliberately scheduled a date).
recipe = mo.x_fc_recipe_id
if recipe and recipe.default_lead_time and not mo.date_finished:
target = fields.Datetime.now() + timedelta(
days=recipe.default_lead_time,
) )
# Don't overwrite if the planner already set a tighter
# (earlier) commit date — only push it later if no commit.
if not mo.date_finished or mo.date_finished < target:
mo.date_finished = target
mo.message_post(
body=_(
'Planned finish set to %s '
'(recipe "%s" default lead time = %.1f days).'
) % (target.strftime('%Y-%m-%d %H:%M'),
recipe.name, recipe.default_lead_time),
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# GAP 2: SO confirm → MO confirm → auto-create Portal Job + WOs # GAP 2: SO confirm → MO confirm → auto-create Portal Job + WOs

View File

@@ -94,23 +94,132 @@ class SaleOrder(models.Model):
return res return res
def _fp_auto_create_mo(self): def _fp_auto_create_mo(self):
"""Create one draft MO per SO that doesn't already have one. """Create draft MO(s) for this SO, grouping by x_fc_wo_group_tag.
Resolution order for the manufactured product: Grouping rules (new in v19.0.7.x):
1. The configurator's part catalog → linked product (if any). - Lines sharing a non-empty x_fc_wo_group_tag collapse into ONE MO
2. The configurator's coating config → linked product (if any). with product = first line's part product, qty = sum of line
3. The shop's fallback FP-WIDGET (used for service-line orders). qtys, recipe = first line's coating_config.recipe_id.
- Lines with blank tag each get their own MO (one-to-one with
the line).
- If the SO has no plating lines at all, fall back to the legacy
one-MO-per-SO path using configurator data.
Resolution for the recipe: Idempotent: skips any group for which an MO with matching
1. configurator.coating_config_id.recipe_id (if the field exists) (origin, x_fc_wo_group_tag) already exists.
2. configurator.part_catalog_id.recipe_id (if the field exists)
3. The first installed fp.process.node of node_type='recipe'.
""" """
self.ensure_one() self.ensure_one()
Production = self.env['mrp.production'] Production = self.env['mrp.production']
existing = Production.search_count([('origin', '=', self.name)]) existing_tags = set(Production.search([
if existing: ('origin', '=', self.name),
return # idempotent ]).mapped('x_fc_wo_group_tag'))
# Build groups from SO lines that carry plating data
plating_lines = self.order_line.filtered(
lambda l: l.x_fc_part_catalog_id or l.x_fc_coating_config_id
)
if not plating_lines:
return self._fp_auto_create_mo_legacy()
groups = {} # {tag_or_line_key: [lines]}
for line in plating_lines:
key = line.x_fc_wo_group_tag or ('__line__%d' % line.id)
groups.setdefault(key, []).append(line)
created = []
for key, lines in groups.items():
tag = lines[0].x_fc_wo_group_tag or False
# Skip if we already have an MO for this (origin, tag) pair.
# Untagged keys are 1:1 with lines; use the line ID in sudo
# check via existing MOs' line links.
if tag and tag in existing_tags:
continue
if not tag:
# Untagged idempotency — check if any existing MO points
# at this line via x_fc_sale_order_line_ids.
if Production.search_count([
('origin', '=', self.name),
('x_fc_sale_order_line_ids', 'in', [lines[0].id]),
]):
continue
# Resolve product: part catalog's linked product if any, else
# FP-WIDGET fallback.
product = False
for ln in lines:
pc = ln.x_fc_part_catalog_id
if pc and 'product_id' in pc._fields and pc.product_id:
product = pc.product_id
break
if not product:
product = self.env['product.product'].search(
[('default_code', '=', 'FP-WIDGET')], limit=1,
)
if not product:
self.message_post(body=_(
'Auto-MO skipped (group %s) — no manufacturable '
'product available.'
) % (tag or 'single-line'))
continue
# Recipe: first line's coating -> recipe_id.
recipe = False
for ln in lines:
cc = ln.x_fc_coating_config_id
if cc and 'recipe_id' in cc._fields and cc.recipe_id:
recipe = cc.recipe_id
break
if not recipe:
recipe = self.env['fusion.plating.process.node'].search(
[('node_type', '=', 'recipe')], limit=1,
)
qty = sum(ln.product_uom_qty for ln in lines) or 1
# Start-at-node: first non-blank wins
start_node = False
for ln in lines:
if ln.x_fc_start_at_node_id:
start_node = ln.x_fc_start_at_node_id
break
mo_vals = {
'product_id': product.id,
'product_qty': qty,
'product_uom_id': product.uom_id.id,
'origin': self.name,
'x_fc_wo_group_tag': tag or False,
'x_fc_sale_order_line_ids': [(6, 0, [ln.id for ln in lines])],
}
if recipe and 'x_fc_recipe_id' in Production._fields:
mo_vals['x_fc_recipe_id'] = recipe.id
if start_node:
mo_vals['x_fc_start_at_node_id'] = start_node.id
mo = Production.create(mo_vals)
created.append((mo, tag, len(lines)))
if created:
lines_html = '<br/>'.join([
_('MO <a href="/odoo/manufacturing/%s">%s</a> '
'(%s, %d source line%s)') % (
mo.id, mo.name, tag or 'untagged',
n, 's' if n != 1 else ''
)
for mo, tag, n in created
])
self.message_post(body=Markup(_(
'%d draft manufacturing order(s) auto-created:<br/>%s'
)) % (len(created), lines_html))
def _fp_auto_create_mo_legacy(self):
"""Fallback for SOs with no plating order_line data (service lines).
Preserves the pre-v19.0.7 behaviour: one MO per SO using the
configurator's part / coating / recipe references.
"""
self.ensure_one()
Production = self.env['mrp.production']
if Production.search_count([('origin', '=', self.name)]):
return
cfg = self.x_fc_configurator_id if 'x_fc_configurator_id' in self._fields else False cfg = self.x_fc_configurator_id if 'x_fc_configurator_id' in self._fields else False
product = False product = False
@@ -132,8 +241,7 @@ class SaleOrder(models.Model):
) )
if not product: if not product:
self.message_post(body=_( self.message_post(body=_(
'Auto-MO skipped — no manufacturable product available ' 'Auto-MO skipped — no manufacturable product available.'
'(neither part catalog nor FP-WIDGET fallback resolved).'
)) ))
return return
@@ -149,8 +257,7 @@ class SaleOrder(models.Model):
mo = Production.create(mo_vals) mo = Production.create(mo_vals)
self.message_post(body=Markup(_( self.message_post(body=Markup(_(
'Draft Manufacturing Order <a href="/odoo/manufacturing/%s">%s</a> ' 'Draft Manufacturing Order <a href="/odoo/manufacturing/%s">%s</a> '
'auto-created. Accept the parts and click <b>Assign to Me</b> to ' 'auto-created (legacy path).'
'release it to the floor.'
)) % (mo.id, mo.name)) )) % (mo.id, mo.name))
@api.depends( @api.depends(