diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 2ae9bf2a..c39268ad 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.10.24.2', + 'version': '19.0.10.25.0', '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/migrations/19.0.10.25.0/post-migrate.py b/fusion_plating/fusion_plating_jobs/migrations/19.0.10.25.0/post-migrate.py new file mode 100644 index 00000000..3f547eac --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/migrations/19.0.10.25.0/post-migrate.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +"""19.0.10.25.0 — Template metadata backfill + recipe-node repointing. + +Runs AFTER fusion_plating's pre-migrate 19.0.21.2.0 (which seeds +kind.area_kind and activates derack/demask/gating). At this point: + - All kinds have area_kind set. + - blast / derack / demask / gating exist and are active. + - XML data files have loaded (new templates exist). + +This migration: + 1. Backfills code / description / icon / kind_id on the ~30 library + templates seeded without metadata. + 2. Repoints existing recipe nodes from wrong kinds to correct ones + using unambiguous name patterns. + 3. Recomputes area_kind on all fp.job.step rows. + 4. Recomputes active_step_id + card_state on in-flight jobs. + +All phases idempotent — re-running -u is safe. + +See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md. +""" +import logging + +from odoo.api import Environment, SUPERUSER_ID + +_logger = logging.getLogger(__name__) + +# (name : (code, icon, kind_code, description_snippet)) +TEMPLATE_BACKFILL = { + 'Acid Dip': ('ACID_DIP_STD', 'fa-flask', 'wet_process', 'Short acid immersion to activate the substrate before plating.'), + 'Air Dry': ('AIR_DRY_STD', 'fa-sun-o', 'wet_process', 'Air drying step between wet-line operations.'), + 'Bake': ('BAKE_STD', 'fa-fire', 'bake', 'Post-plate bake for hydrogen embrittlement relief.'), + 'Blasting': ('BLAST_STD', 'fa-bullseye', 'blast', 'Media or bead blasting to prepare the substrate.'), + 'Check Sulfamate Nickel Area': ('CHECK_SN_AREA', 'fa-search', 'inspect', 'Quick visual area check on the sulfamate nickel line.'), + 'Contract Review': ('CR_STD', 'fa-file-text-o', 'contract_review', 'QA-005 contract review gate. Required when the customer flag is on.'), + 'De-Masking': ('DEMASK_STD', 'fa-eraser', 'demask', 'Remove masking material after plating. Folds into De-Racking column.'), + 'DeRacking': ('DERACK_STD', 'fa-th', 'derack', 'Remove parts from racks for inspection / packaging.'), + 'Desmut': ('DESMUT_STD', 'fa-flask', 'wet_process', 'Remove smut from aluminium surfaces after etching.'), + 'Drying': ('DRYING_STD', 'fa-sun-o', 'wet_process', 'Drying step (oven or air) at the end of the wet line.'), + 'E-Nickel Plating': ('ENP_STD', 'fa-diamond', 'plate', 'Electroless nickel plate operation. Time and temp per recipe.'), + 'Electroclean': ('ECLEAN_STD', 'fa-bolt', 'wet_process', 'Anodic / cathodic electrocleaning step on the cleaning line.'), + 'Etch': ('ETCH_STD', 'fa-flask', 'wet_process', 'Chemical etching to prepare the substrate.'), + 'Final Inspection': ('FINAL_INSP_STD', 'fa-check-circle', 'final_inspect', 'Final visual + dimensional QA before packing.'), + 'HCl Activation': ('HCL_ACT_STD', 'fa-flask', 'wet_process', 'HCl activation dip prior to strike or plate.'), + 'Inspection': ('INSP_STD', 'fa-search', 'inspect', 'In-process inspection step.'), + 'Masking': ('MASK_STD', 'fa-paint-brush', 'mask', 'Apply masking to areas that should not be plated.'), + 'Nickel Strip (S-1)': ('NI_STRIP_S1', 'fa-undo', 'wet_process', 'Chemical strip of prior nickel deposit (rework path).'), + 'Nickel Strip - Steel Line': ('NI_STRIP_SL', 'fa-undo', 'wet_process', 'Chemical strip on the steel line (rework path).'), + 'Post-plate Inspection': ('POST_INSP_STD', 'fa-check-circle', 'inspect', 'Post-plate inspection - thickness sample + visual.'), + 'Pre-Measurements': ('PRE_MEAS_STD', 'fa-tachometer', 'inspect', 'Pre-process dimensional measurements (FAIR start point).'), + 'Racking': ('RACK_STD', 'fa-th', 'racking', 'Load parts onto racks for plating.'), + 'Ready for Plating': ('GATE_PLATE', 'fa-flag', 'gating', 'Gating step - parts staged ready for the plating line.'), + 'Ready for processing': ('GATE_PROC', 'fa-flag', 'gating', 'Generic gating step - parts staged ready for the next operation.'), + 'Rinse': ('RINSE_STD', 'fa-tint', 'wet_process', 'Rinse step between wet-line operations.'), + 'Shipping': ('SHIP_STD', 'fa-paper-plane', 'ship', 'Final shipping / hand-off to logistics.'), + 'Soak Clean': ('SOAK_CLEAN_STD', 'fa-bathtub', 'wet_process', 'Soak cleaning step at the start of the wet line.'), + 'Surface Activation': ('SURF_ACT_STD', 'fa-flask', 'wet_process', 'Surface activation dip prior to plate.'), + 'Water Break Test': ('WBF_TEST_STD', 'fa-tint', 'wet_process', 'Water-break test for surface cleanliness.'), + 'Zincate': ('ZINCATE_STD', 'fa-flask', 'wet_process', 'Zincate immersion on aluminium prior to plate.'), +} + +# (filter_sql, current_kind_code, new_kind_code, description) +# current_kind_code=None means "any kind that isn't the target" +NODE_REPOINTING = [ + ("n.name = 'Blasting'", 'other', 'blast', 'Blasting -> blast'), + ("n.name ILIKE 'Ready %%'", None, 'gating', 'Ready For X -> gating'), + ("n.name ILIKE '%%De-Masking%%' OR n.name ILIKE '%%DeMasking%%'", 'mask', 'demask', 'De-Masking -> demask'), + ("n.name = 'Scheduling'", 'other', 'gating', 'Scheduling -> gating'), + ("n.name ILIKE '%%Nickel Strip%%'", 'plate', 'wet_process', 'Nickel Strip -> wet_process'), + ("n.name ILIKE '%%Pre-Measurement%%' OR n.name ILIKE '%%Check Sulfamate%%'", 'other', 'inspect', 'Pre-Meas/Check Sulfamate -> inspect'), +] + + +def migrate(cr, version): + env = Environment(cr, SUPERUSER_ID, {}) + + # Phase 1 — Template metadata backfill. Idempotent: only fills + # NULL/empty fields, doesn't overwrite admin edits. + Tpl = env['fp.step.template'] + Kind = env['fp.step.kind'] + fixed_tpl = 0 + for name, (code, icon, kind_code, desc) in TEMPLATE_BACKFILL.items(): + tpl = Tpl.search([('name', '=', name)], limit=1) + if not tpl: + continue + vals = {} + if not tpl.code: + vals['code'] = code + cur_desc = (tpl.description or '').strip() + if cur_desc in ('', '
%s
' % desc + if tpl.icon == 'fa-cog': + vals['icon'] = icon + kind = Kind.search([('code', '=', kind_code)], limit=1) + if kind and tpl.kind_id.code != kind_code: + vals['kind_id'] = kind.id + if vals: + tpl.write(vals) + fixed_tpl += 1 + _logger.info( + '[live-step-fix] template metadata backfilled: %s templates updated', + fixed_tpl, + ) + + # Phase 2 — Recipe node repointing. Idempotent: AND k.code != %s + # ensures already-correct rows are skipped on re-run. + for filter_sql, cur_code, new_code, desc in NODE_REPOINTING: + params = [new_code] + sql = ( + "UPDATE fusion_plating_process_node n " + "SET kind_id = (SELECT id FROM fp_step_kind WHERE code = %s LIMIT 1) " + "FROM fp_step_kind k " + "WHERE n.kind_id = k.id " + "AND (" + filter_sql + ")" + ) + if cur_code is not None: + sql += " AND k.code = %s" + params.append(cur_code) + sql += " AND k.code != %s" + params.append(new_code) + cr.execute(sql, params) + _logger.info( + '[live-step-fix] repointed %s nodes: %s', + cr.rowcount, desc, + ) + + # Phase 3 — Recompute area_kind on every fp.job.step row. + steps = env['fp.job.step'].search([]) + if steps: + steps._compute_area_kind() + steps.flush_recordset(['area_kind']) + _logger.info( + '[live-step-fix] recomputed area_kind on %s steps', len(steps), + ) + + # Phase 4 — Recompute active_step_id + card_state on in-flight jobs. + jobs = env['fp.job'].search([ + ('state', 'in', ('confirmed', 'in_progress')), + ]) + if jobs: + jobs._compute_active_step_id() + jobs._compute_card_state() + jobs.flush_recordset(['active_step_id', 'card_state']) + _logger.info( + '[live-step-fix] recomputed active_step_id + card_state on %s jobs', + len(jobs), + ) diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index d0455558..1b97c46f 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -257,13 +257,21 @@ class FpJob(models.Model): def _compute_card_state(self): """Dispatch matching spec §6.2 / §9.3 explicit precedence list.""" for job in self: - # Edge: no active step (all pending or all done) + # Edge: no live step (all steps done OR no steps at all). + # - job.state='done' → 'done' (defensive — done jobs are + # filtered off the Shop Floor board upstream, but the + # field still needs a value). + # - confirmed + parts not yet received → 'no_parts'. + # - else → 'ready' (job awaiting work, no steps yet OR + # recipe not assigned). if not job.active_step_id: - if (job.state == 'confirmed' + if job.state == 'done': + job.card_state = 'done' + elif (job.state == 'confirmed' and job._fp_inbound_not_received()): job.card_state = 'no_parts' else: - job.card_state = 'contract_review' + job.card_state = 'ready' continue step = job.active_step_id @@ -384,11 +392,32 @@ class FpJob(models.Model): @api.depends('step_ids.state', 'step_ids.sequence') def _compute_active_step_id(self): + """Pick the "live" step — first match by priority then sequence. + + Priority order: + in_progress > paused > ready > first pending + + in_progress is the most informative (someone is actively + working on it). paused means someone was working and stopped — + the card belongs at that station so the next operator can + pick it up. ready is the next-up step waiting for an operator. + The first pending after a done step is the "next gate" — + where the card visually waits between steps. + + Returns False only when every step is `done` (job finished) + or when there are no steps at all (recipe not assigned). + + See spec 2026-05-24-shopfloor-live-step-fix-design.md Change 1. + """ + PRIORITY_STATES = ('in_progress', 'paused', 'ready', 'pending') for job in self: - active = job.step_ids.filtered( - lambda s: s.state == 'in_progress' - ).sorted('sequence') - job.active_step_id = active[:1].id if active else False + ordered = job.step_ids.sorted('sequence') + live = job.env['fp.job.step'] + for state in PRIORITY_STATES: + live = ordered.filtered(lambda s: s.state == state) + if live: + break + job.active_step_id = live[:1].id if live else False # ------------------------------------------------------------------ # Sub 14 — Configurable workflow state (status bar milestone) diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py index af6f2047..2a23d464 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -17,60 +17,11 @@ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) -# Mapping from fp.process.node.default_kind → fp.work.centre.area_kind. -# Used as fallback by fp.job.step.area_kind compute when the step has no -# work_centre_id or its work_centre has no area_kind set. Authoritative -# source per the plant-view spec §4.2. -# 2026-05-23 — Shop Floor plant-view redesign. -_STEP_KIND_TO_AREA = { - # Receiving (admin / pre-physical-work) - 'receiving': 'receiving', - 'incoming_inspection': 'receiving', - 'contract_review': 'receiving', - 'gating': 'receiving', - 'ready_for_processing': 'receiving', - # Masking - 'masking': 'masking', - # Blasting - 'blasting': 'blasting', - 'bead_blast': 'blasting', - 'media_blast': 'blasting', - # Racking - 'racking': 'racking', - # Plating (everything wet — rolled up into one column per spec D3) - 'soak_clean': 'plating', - 'electroclean': 'plating', - 'acid_dip': 'plating', - 'etch': 'plating', - 'desmut': 'plating', - 'zincate': 'plating', - 'rinse': 'plating', - 'water_break_test': 'plating', - 'activation': 'plating', - 'e_nickel_plate': 'plating', - 'chrome': 'plating', - 'anodize': 'plating', - 'black_oxide': 'plating', - 'drying': 'plating', - # Baking - 'bake': 'baking', - 'oven_bake': 'baking', - 'post_bake_relief': 'baking', - # De-Racking (folds in de-masking per spec D4) - 'de_rack': 'de_racking', - 'de_mask': 'de_racking', - 'unrack': 'de_racking', - # Final inspection (post-plate inspection / FAIR / thickness QC) - 'inspection': 'inspection', - 'final_inspection': 'inspection', - 'post_plate_inspection':'inspection', - 'thickness_qc': 'inspection', - 'fair': 'inspection', - 'dimensional_check': 'inspection', - # Shipping - 'shipping': 'shipping', - 'pack_ship': 'shipping', -} +# 2026-05-24 — Shop Floor live-step fix (19.0.10.24.0): +# The legacy `_STEP_KIND_TO_AREA` dict that lived here was removed. +# fp.step.kind now self-declares its area_kind, so the kind taxonomy +# IS the source of truth for Shop Floor column routing. +# See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md. class FpJobStep(models.Model): @@ -169,21 +120,40 @@ class FpJobStep(models.Model): store=True, index=True, help='Which Shop Floor column this step belongs to. Resolved as: ' - '(1) work_centre.area_kind if set; else (2) fallback to ' - '_STEP_KIND_TO_AREA[recipe_node.default_kind]; else (3) the ' - 'safe catch-all "plating". Drives plant-view kanban grouping.', + '(1) work_centre.area_kind if set; else (2) the area_kind on ' + 'recipe_node.kind_id; else (3) the safe catch-all "plating". ' + 'Drives plant-view kanban grouping.', ) - @api.depends('work_centre_id.area_kind', 'recipe_node_id.default_kind') + @api.depends( + 'work_centre_id.area_kind', + 'recipe_node_id.kind_id.area_kind', + ) def _compute_area_kind(self): + """Resolve the plant-view column this step belongs in. + + Priority chain: + 1. work_centre.area_kind (explicit operator setup wins) + 2. recipe_node.kind_id.area_kind (kind taxonomy authoritative) + 3. catch-all 'plating' (data integrity issue if we land here) + + The legacy _STEP_KIND_TO_AREA dict was removed — fp.step.kind + now self-declares its area_kind, so the kind taxonomy IS the + source of truth. See spec + 2026-05-24-shopfloor-live-step-fix-design.md Change 6. + """ for step in self: + # 1. Explicit work_centre wins if step.work_centre_id and step.work_centre_id.area_kind: step.area_kind = step.work_centre_id.area_kind continue - kind = step.recipe_node_id.default_kind if step.recipe_node_id else False - if kind and kind in _STEP_KIND_TO_AREA: - step.area_kind = _STEP_KIND_TO_AREA[kind] + # 2. Kind taxonomy + node = step.recipe_node_id + if node and node.kind_id and node.kind_id.area_kind: + step.area_kind = node.kind_id.area_kind continue + # 3. Catch-all — only reached for orphaned steps (no + # work_centre AND no recipe_node). step.area_kind = 'plating' last_activity_at = fields.Datetime( diff --git a/fusion_plating/fusion_plating_quality/scripts/bt_s24_between_steps.py b/fusion_plating/fusion_plating_quality/scripts/bt_s24_between_steps.py new file mode 100644 index 00000000..e53d4340 --- /dev/null +++ b/fusion_plating/fusion_plating_quality/scripts/bt_s24_between_steps.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +"""Battle test S24 — Live step priority chain + board state filter. + +Run end-to-end via odoo shell with stdin redirection: + + ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \\ + \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" \\ + < /mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_s24_between_steps.py'" + +Asserts: + 1. Job between steps (one done, next pending) has live active_step_id + pointing at the next pending step, NOT False. + 2. Card column resolves to that pending step's area_kind, NOT receiving. + 3. Paused steps still count as active. + 4. state='done' jobs are excluded from the live-board search domain. + +See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md. +""" +import logging + +_logger = logging.getLogger(__name__) + + +def _resolve_card_area(job): + """Mirror of plant_kanban._resolve_card_area for test purposes.""" + if job.active_step_id and job.active_step_id.area_kind: + return job.active_step_id.area_kind + return 'receiving' + + +def run(): + partner = env['res.partner'].search([('customer_rank', '>', 0)], limit=1) + if not partner: + raise AssertionError( + 'No customer partner found — seed test data first' + ) + + recipe = env['fusion.plating.process.node'].search([ + ('node_type', '=', 'recipe'), + ('child_ids', '!=', False), + ], limit=1) + if not recipe: + raise AssertionError('No recipe found — seed test data first') + + job = env['fp.job'].create({ + 'partner_id': partner.id, + 'recipe_id': recipe.id, + 'qty': 1, + }) + job._fp_generate_steps_from_recipe() + steps = job.step_ids.sorted('sequence') + if len(steps) < 3: + raise AssertionError('Need at least 3 steps for the test') + + # === Phase A — between-step assertion === + s1 = steps[0] + s2 = steps[1] + s1.button_start() + s1.button_finish() + job.invalidate_recordset(['active_step_id', 'card_state']) + if job.active_step_id.id != s2.id: + raise AssertionError( + 'Expected active_step_id = %s (next pending), got %s' + % (s2.id, job.active_step_id.id) + ) + if _resolve_card_area(job) != s2.area_kind: + raise AssertionError( + 'Card column should match s2.area_kind=%s, got %s' + % (s2.area_kind, _resolve_card_area(job)) + ) + _logger.info('[bt_s24] Phase A OK — between-step routing correct') + + # === Phase B — paused step assertion === + s2.button_start() + s2.button_pause('lunch break') + job.invalidate_recordset(['active_step_id', 'card_state']) + if job.active_step_id.id != s2.id: + raise AssertionError( + 'Paused step should remain the live step, got %s' + % job.active_step_id.id + ) + _logger.info('[bt_s24] Phase B OK — paused step stays live') + + # === Phase C — done job filter === + for s in steps: + if s.state != 'done': + if s.state == 'paused': + s.button_resume() + if s.state != 'in_progress': + s.button_start() + s.button_finish() + job.with_context( + fp_skip_step_gate=True, + fp_skip_qty_reconcile=True, + fp_skip_bake_gate=True, + ).button_mark_done() + if job.state != 'done': + raise AssertionError('job did not transition to done') + + jobs_on_board = env['fp.job'].search([ + ('state', 'in', ('confirmed', 'in_progress')), + ]) + if job.id in jobs_on_board.ids: + raise AssertionError( + 'Done job %s should be filtered off board' % job.id + ) + _logger.info('[bt_s24] Phase C OK — done jobs filtered off board') + + _logger.info('[bt_s24] ALL ASSERTIONS PASSED') + + +run() diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index d6466808..cd593a1b 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.33.1.2', + 'version': '19.0.33.1.3', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py b/fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py index fa477012..76d1ec98 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py @@ -65,9 +65,13 @@ class PlantKanbanController(http.Controller): else env['fp.work.centre']) paired_area = paired.area_kind if paired else None - # Base domain — every job with active recipe steps + # Base domain — only in-flight jobs. + # 2026-05-24 (spec 2026-05-24-shopfloor-live-step-fix-design.md + # Defect 4 / Change 3): done + cancelled jobs drop off the live + # board. They stay reachable via smart buttons, the Plating Jobs + # backend list, and history reports. domain = [ - ('state', 'in', ('confirmed', 'in_progress', 'done')), + ('state', 'in', ('confirmed', 'in_progress')), ] filters = filters or {} if filters.get('overdue'): @@ -161,12 +165,19 @@ def fields_today_ts(): def _resolve_card_area(job): """Pick the column a card lives in. - Active-step area_kind wins. When there's no active step the card - lives in Receiving (covers contract_review + no_parts edge cases). + Active-step area_kind wins. With the live-step priority chain + (see fp.job._compute_active_step_id), active_step_id is False only + when the job has NO steps at all (recipe not assigned) OR every + step is `done`. Done jobs are filtered off the board upstream + (state-domain in plant_kanban), so this fallback fires only for + truly orphaned cards. + + See spec 2026-05-24-shopfloor-live-step-fix-design.md Change 4. """ if job.active_step_id and job.active_step_id.area_kind: return job.active_step_id.area_kind - # Fallback: receiving column + # Orphan fallback — represents a data integrity issue, not a + # normal state. Cards here have NO steps assigned at all. return 'receiving'