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:
@@ -58,6 +58,28 @@ class MrpProduction(models.Model):
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -418,6 +440,26 @@ class MrpProduction(models.Model):
|
||||
for override in production.x_fc_override_ids:
|
||||
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
|
||||
# can read coating config / spec without an extra search per WO.
|
||||
so = False
|
||||
@@ -433,13 +475,18 @@ class MrpProduction(models.Model):
|
||||
|
||||
def _is_node_included(node):
|
||||
"""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)
|
||||
- opt_in: excluded by default, included 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
|
||||
if allowed_ids is not None and nid not in allowed_ids:
|
||||
return False
|
||||
opt = node.opt_in_out or 'disabled'
|
||||
if opt == 'disabled':
|
||||
return True
|
||||
@@ -492,6 +539,11 @@ class MrpProduction(models.Model):
|
||||
'workcenter_id': mrp_wc,
|
||||
'duration_expected': node.estimated_duration or 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
|
||||
# x_fc_dwell_time_minutes — operators see the recipe-
|
||||
@@ -578,27 +630,69 @@ class MrpProduction(models.Model):
|
||||
# Recipe auto-assignment from SO coating config
|
||||
# ------------------------------------------------------------------
|
||||
def _auto_assign_recipe_from_so(self):
|
||||
"""If no recipe is set, pull the default recipe from the SO's
|
||||
coating config (fp.coating.config.recipe_id).
|
||||
"""Pull the default recipe for this MO when none is set.
|
||||
|
||||
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:
|
||||
if mo.x_fc_recipe_id:
|
||||
continue # Already set — respect planner's choice
|
||||
if not mo.origin:
|
||||
continue
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', mo.origin)], limit=1,
|
||||
)
|
||||
if not so or 'x_fc_coating_config_id' not in so._fields:
|
||||
continue
|
||||
coating = so.x_fc_coating_config_id
|
||||
if coating and coating.recipe_id:
|
||||
mo.x_fc_recipe_id = coating.recipe_id
|
||||
mo.message_post(
|
||||
body=_('Recipe "%s" auto-assigned from coating config "%s".') % (
|
||||
coating.recipe_id.name, coating.name,
|
||||
),
|
||||
if not mo.x_fc_recipe_id:
|
||||
# 1. SO coating config (legacy path)
|
||||
so = False
|
||||
if mo.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', mo.origin)], limit=1,
|
||||
)
|
||||
if so and 'x_fc_coating_config_id' in so._fields:
|
||||
coating = so.x_fc_coating_config_id
|
||||
if coating and coating.recipe_id:
|
||||
mo.x_fc_recipe_id = coating.recipe_id
|
||||
mo.message_post(
|
||||
body=_('Recipe "%s" auto-assigned from coating config "%s".') % (
|
||||
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
|
||||
|
||||
@@ -94,23 +94,132 @@ class SaleOrder(models.Model):
|
||||
return res
|
||||
|
||||
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:
|
||||
1. The configurator's part catalog → linked product (if any).
|
||||
2. The configurator's coating config → linked product (if any).
|
||||
3. The shop's fallback FP-WIDGET (used for service-line orders).
|
||||
Grouping rules (new in v19.0.7.x):
|
||||
- Lines sharing a non-empty x_fc_wo_group_tag collapse into ONE MO
|
||||
with product = first line's part product, qty = sum of line
|
||||
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:
|
||||
1. configurator.coating_config_id.recipe_id (if the field exists)
|
||||
2. configurator.part_catalog_id.recipe_id (if the field exists)
|
||||
3. The first installed fp.process.node of node_type='recipe'.
|
||||
Idempotent: skips any group for which an MO with matching
|
||||
(origin, x_fc_wo_group_tag) already exists.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Production = self.env['mrp.production']
|
||||
existing = Production.search_count([('origin', '=', self.name)])
|
||||
if existing:
|
||||
return # idempotent
|
||||
existing_tags = set(Production.search([
|
||||
('origin', '=', self.name),
|
||||
]).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
|
||||
product = False
|
||||
@@ -132,8 +241,7 @@ class SaleOrder(models.Model):
|
||||
)
|
||||
if not product:
|
||||
self.message_post(body=_(
|
||||
'Auto-MO skipped — no manufacturable product available '
|
||||
'(neither part catalog nor FP-WIDGET fallback resolved).'
|
||||
'Auto-MO skipped — no manufacturable product available.'
|
||||
))
|
||||
return
|
||||
|
||||
@@ -149,8 +257,7 @@ class SaleOrder(models.Model):
|
||||
mo = Production.create(mo_vals)
|
||||
self.message_post(body=Markup(_(
|
||||
'Draft Manufacturing Order <a href="/odoo/manufacturing/%s">%s</a> '
|
||||
'auto-created. Accept the parts and click <b>Assign to Me</b> to '
|
||||
'release it to the floor.'
|
||||
'auto-created (legacy path).'
|
||||
)) % (mo.id, mo.name))
|
||||
|
||||
@api.depends(
|
||||
|
||||
Reference in New Issue
Block a user