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',
|
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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user