From 6c6fb8d2a4ee3270a533e4883cf5249005fc8e03 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 12 May 2026 13:18:10 -0400 Subject: [PATCH] feat(numbering): WO grouping by recipe + parent-derived bulk naming Replaces x_fc_wo_group_tag grouping with resolved-recipe grouping. Bare WO- when 1 recipe, WO--NN zero-padded for N>1 ordered by min line sequence. fp.job inherits parent-numbered mixin for the manual-add path; bulk SO-confirm sets names explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) --- fusion_plating/fusion_plating/__manifest__.py | 2 +- .../fusion_plating/models/fp_job.py | 46 +++++- .../fusion_plating_jobs/__manifest__.py | 2 +- .../fusion_plating_jobs/models/sale_order.py | 144 +++++++++++------- 4 files changed, 135 insertions(+), 59 deletions(-) diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 7d76609b..99fb7be1 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.18.15.12', + 'version': '19.0.18.15.13', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/models/fp_job.py b/fusion_plating/fusion_plating/models/fp_job.py index cbfc9e33..1e61909d 100644 --- a/fusion_plating/fusion_plating/models/fp_job.py +++ b/fusion_plating/fusion_plating/models/fp_job.py @@ -51,7 +51,7 @@ class FpJob(models.Model): dt = pytz.UTC.localize(dt) return dt.astimezone(tz).strftime(fmt) _description = 'Work Order' - _inherit = ['mail.thread', 'mail.activity.mixin'] + _inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin'] # Sub 12d — state-aware sort. Active work bubbles to the top # (in_progress → confirmed/draft → on_hold → done → cancelled), # then high-priority first within each state, then nearest deadline. @@ -389,12 +389,50 @@ class FpJob(models.Model): continue job.current_step_id = False + # ------------------------------------------------------------------ + # Parent-numbered mixin hooks (2026-05-12 numbering hierarchy) + # ------------------------------------------------------------------ + def _fp_parent_sale_order(self): + return self.sale_order_id + + def _fp_name_prefix(self): + return 'WO' + + def _fp_parent_counter_field(self): + return 'x_fc_wo_count' + @api.model_create_multi def create(self, vals_list): + """fp.job naming priority: + 1. Caller-provided name (bulk SO-confirm path sets these explicitly). + 2. Mixin parent-derived name (manual WO add to an existing SO). + 3. Legacy fp.job sequence (standalone job, no SO link). + """ + # Pass A: fall back to legacy 'New' sentinel for records that + # don't get a parent-derived name. The mixin's post-create + # _fp_assign_parent_name() will override 'New' once the record + # exists if a parent SO is reachable. for vals in vals_list: - if vals.get('name', _('New')) == _('New'): - vals['name'] = self.env['ir.sequence'].next_by_code('fp.job') or _('New') - return super().create(vals_list) + if not vals.get('name'): + vals['name'] = _('New') + records = super().create(vals_list) + # Pass B: any record that came through with 'New' (no explicit + # name from the bulk SO path) tries the parent-derived path, + # falling back to the legacy sequence if there's no parent SO. + for rec in records: + if rec.name and rec.name != _('New') and rec.name != 'New': + continue # caller set an explicit name (e.g. bulk SO confirm) + if not rec._fp_assign_parent_name(): + seq = self.env['ir.sequence'].next_by_code('fp.job') or _('New') + # Raw SQL — fp.job has no immutability guard yet in this + # task, but Task 11 will add one. Using SQL here keeps the + # fallback path consistent across all child models. + self.env.cr.execute( + "UPDATE fp_job SET name = %s WHERE id = %s", + (seq, rec.id), + ) + rec.invalidate_recordset(['name']) + return records # ------------------------------------------------------------------ # State machine — actions diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 75a4f5c7..a3e15bb9 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.8.22.2', + 'version': '19.0.8.22.3', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/models/sale_order.py b/fusion_plating/fusion_plating_jobs/models/sale_order.py index 2f93af0f..c333cddf 100644 --- a/fusion_plating/fusion_plating_jobs/models/sale_order.py +++ b/fusion_plating/fusion_plating_jobs/models/sale_order.py @@ -296,12 +296,55 @@ class SaleOrder(models.Model): ) % {'job': job.name, 'err': exc}) return result + def _fp_resolve_recipe_for_line(self, line): + """4-tier recipe resolution. Used BOTH for grouping (Task 6 + recipe-driven WO splits) AND for the per-job vals construction. + + Priority (most-specific first): + 1. line.x_fc_process_variant_id — Sarah explicitly picked a + part-scoped variant on this order line. Always wins. + 2. part.default_process_id — part's flagged default + variant. Customer-and-part-tuned recipe; must beat any + generic coating template. + 3. coating.recipe_id — coating-config recipe + (generic template fallback). + 4. part.recipe_id — legacy fallback. + Returns the recipe record or an empty recordset. + """ + Node = self.env['fusion.plating.process.node'] + part = ( + 'x_fc_part_catalog_id' in line._fields and line.x_fc_part_catalog_id + ) or False + if not part and 'x_fc_part_catalog_id' in self._fields: + part = self.x_fc_part_catalog_id or False + coating = ( + 'x_fc_coating_config_id' in line._fields and line.x_fc_coating_config_id + ) or False + if not coating and 'x_fc_coating_config_id' in self._fields: + coating = self.x_fc_coating_config_id or False + picked = ( + 'x_fc_process_variant_id' in line._fields + and line.x_fc_process_variant_id + ) or False + if picked: + return picked + if part and 'default_process_id' in part._fields and part.default_process_id: + return part.default_process_id + if coating and 'recipe_id' in coating._fields and coating.recipe_id: + return coating.recipe_id + if part and 'recipe_id' in part._fields and part.recipe_id: + return part.recipe_id + return Node + def _fp_auto_create_job(self): """Create fp.job(s) from the SO's plating lines. - Lines that share a `x_fc_wo_group_tag` collapse into one job; - untagged lines get one job per line. Mirrors bridge_mrp's - _fp_auto_create_mo grouping logic. + 2026-05-12 parent-number rewrite: lines are grouped by resolved + recipe id (NOT by x_fc_wo_group_tag). If 1 group → one WO named + WO- (bare). If N>1 groups → N WOs named WO--01, + WO--02, ..., ordered by min line sequence so suffixes + mirror SO display order. WO names are then immutable; later + manual additions to the SO get the next index via the mixin. """ self.ensure_one() Job = self.env['fp.job'].sudo() @@ -334,20 +377,32 @@ class SaleOrder(models.Model): _logger.info('SO %s: no plating lines, skipping job creation.', self.name) return - # Group by x_fc_wo_group_tag (untagged → distinct group per line) - groups = {} # tag → recordset of lines - untagged_idx = 0 + # Group by resolved recipe id (lines sharing a recipe → one WO). + # No-recipe lines get their own group each (preserves the legacy + # "one job per line" behaviour for unrecipe'd SOs). + groups = {} + unrecipe_idx = 0 for line in plating_lines: - tag = ( - 'x_fc_wo_group_tag' in line._fields and line.x_fc_wo_group_tag - ) or False - if not tag: - untagged_idx += 1 - tag = '__untagged_%d' % untagged_idx - groups[tag] = groups.get(tag, self.env['sale.order.line']) | line + recipe = self._fp_resolve_recipe_for_line(line) + if recipe: + key = recipe.id + else: + unrecipe_idx += 1 + key = ('no_recipe', unrecipe_idx) + groups[key] = groups.get(key, self.env['sale.order.line']) | line + + # Order groups by min line sequence so dash-suffixes mirror SO + # display order. Deterministic regardless of dict iteration order. + ordered_keys = sorted( + groups.keys(), + key=lambda k: min(groups[k].mapped('sequence') or [0]), + ) + n_groups = len(ordered_keys) + parent = self.x_fc_parent_number # set by action_confirm earlier # Create a job per group - for tag, lines in groups.items(): + for idx, key in enumerate(ordered_keys, start=1): + lines = groups[key] first_line = lines[0] qty = sum(lines.mapped('product_uom_qty')) part = ( @@ -360,49 +415,11 @@ class SaleOrder(models.Model): and first_line.x_fc_coating_config_id or False ) - # Header fallback for legacy/configurator SOs that put part + - # coating on the SO header instead of the line. if not part and 'x_fc_part_catalog_id' in self._fields: part = self.x_fc_part_catalog_id or False if not coating and 'x_fc_coating_config_id' in self._fields: coating = self.x_fc_coating_config_id or False - # Recipe lookup priority (specific → generic): - # 1. line.x_fc_process_variant_id — Sarah explicitly picked - # a part-scoped variant on this order line. Always wins. - # 2. part.default_process_id — part's flagged default - # variant. This is the customer-and-part-tuned recipe - # (months of process refinement) and must beat any - # generic template attached to the coating config. - # 3. coating.recipe_id — coating-config recipe - # (generic template fallback when no part variant exists). - # 4. part.recipe_id — legacy fallback. - # - # Order swap 2026-05-12: before this, step (3) ran before (2), - # so jobs with both a part variant AND a coating-attached - # template silently picked the template. WO #01373 (S00063) - # is the documented case — part 9876699373 Rev A had its own - # variant but the job linked to ENP-ALUM-BASIC instead. - # - # If multiple lines in the same WO group have different - # variants we use the FIRST line's variant (consistent with - # everything else in this loop using `first_line`). - recipe = False - picked_variant = ( - 'x_fc_process_variant_id' in first_line._fields - and first_line.x_fc_process_variant_id - or False - ) - if picked_variant: - recipe = picked_variant - if not recipe and part and 'default_process_id' in part._fields \ - and part.default_process_id: - recipe = part.default_process_id - if not recipe and coating and 'recipe_id' in coating._fields \ - and coating.recipe_id: - recipe = coating.recipe_id - if not recipe and part and 'recipe_id' in part._fields \ - and part.recipe_id: - recipe = part.recipe_id + recipe = self._fp_resolve_recipe_for_line(first_line) vals = { 'partner_id': self.partner_id.id, @@ -457,11 +474,32 @@ class SaleOrder(models.Model): # Quoted revenue: sum line totals vals['quoted_revenue'] = sum(lines.mapped('price_subtotal')) + # Parent-number naming (2026-05-12). Bare for the single-group + # case; zero-padded -NN suffix when multiple recipes split the + # SO into multiple WOs. Set explicitly so fp.job.create() skips + # its own naming fallback. + if parent: + if n_groups == 1: + vals['name'] = f'WO-{parent}' + vals['x_fc_doc_index'] = 1 + else: + vals['name'] = f'WO-{parent}-{idx:02d}' if idx <= 99 else f'WO-{parent}-{idx}' + vals['x_fc_doc_index'] = idx + job = Job.create(vals) _logger.info( 'SO %s: created fp.job %s (qty=%s, recipe=%s)', self.name, job.name, qty, (recipe.name if recipe else '-'), ) + + # Bump SO counter to reflect the bulk creation. Future manual + # WO additions pick up from here via the mixin standard path. + if parent and n_groups: + self.env.cr.execute( + "UPDATE sale_order SET x_fc_wo_count = %s WHERE id = %s", + (n_groups, self.id), + ) + self.invalidate_recordset(['x_fc_wo_count']) return True # ------------------------------------------------------------------