diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py
index 158630e8..ab0ccfba 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py
@@ -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
diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py
index 740d64d0..1d601716 100644
--- a/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py
+++ b/fusion_plating/fusion_plating_bridge_mrp/models/sale_order.py
@@ -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 = '
'.join([
+ _('MO %s '
+ '(%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:
%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 %s '
- 'auto-created. Accept the parts and click Assign to Me to '
- 'release it to the floor.'
+ 'auto-created (legacy path).'
)) % (mo.id, mo.name))
@api.depends(