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(