feat(jobs+shopfloor): live-step priority chain + done-job filter

Fix the Shop Floor plant kanban so cards land in the right column:
- fp.job._compute_active_step_id walks priority chain
  (in_progress > paused > ready > pending), not just in_progress
- fp.job._compute_card_state edge case respects job.state='done'
  (no more bogus 'contract_review' label on done jobs)
- fp.job.step._compute_area_kind reads kind.area_kind directly;
  legacy _STEP_KIND_TO_AREA dict removed (50+ lines deleted)
- /fp/landing/plant_kanban filters out done/cancelled jobs from
  the live board

Migration 19.0.10.25.0 backfills template metadata (codes,
descriptions, icons, kind_id) on 30 unfinished library templates
and repoints recipe nodes for 6 unambiguous name patterns
(Blasting -> blast, Ready For X -> gating, De-Masking -> demask,
Scheduling -> gating, Nickel Strip -> wet_process,
Pre-Meas/Check Sulfamate -> inspect).

Battle test bt_s24_between_steps.py covers between-step routing,
paused step lifecycle, and done-job board filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 17:06:53 -04:00
parent 7b90f210b9
commit b06d28e7f6
7 changed files with 346 additions and 75 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.10.24.2', 'version': '19.0.10.25.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',

View File

@@ -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 ('', '<p><br></p>', '<p></p>'):
vals['description'] = '<p>%s</p>' % 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),
)

View File

@@ -257,13 +257,21 @@ class FpJob(models.Model):
def _compute_card_state(self): def _compute_card_state(self):
"""Dispatch matching spec §6.2 / §9.3 explicit precedence list.""" """Dispatch matching spec §6.2 / §9.3 explicit precedence list."""
for job in self: 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 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()): and job._fp_inbound_not_received()):
job.card_state = 'no_parts' job.card_state = 'no_parts'
else: else:
job.card_state = 'contract_review' job.card_state = 'ready'
continue continue
step = job.active_step_id step = job.active_step_id
@@ -384,11 +392,32 @@ class FpJob(models.Model):
@api.depends('step_ids.state', 'step_ids.sequence') @api.depends('step_ids.state', 'step_ids.sequence')
def _compute_active_step_id(self): 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: for job in self:
active = job.step_ids.filtered( ordered = job.step_ids.sorted('sequence')
lambda s: s.state == 'in_progress' live = job.env['fp.job.step']
).sorted('sequence') for state in PRIORITY_STATES:
job.active_step_id = active[:1].id if active else False 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) # Sub 14 — Configurable workflow state (status bar milestone)

View File

@@ -17,60 +17,11 @@ from odoo.exceptions import UserError
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# Mapping from fp.process.node.default_kind → fp.work.centre.area_kind. # 2026-05-24 — Shop Floor live-step fix (19.0.10.24.0):
# Used as fallback by fp.job.step.area_kind compute when the step has no # The legacy `_STEP_KIND_TO_AREA` dict that lived here was removed.
# work_centre_id or its work_centre has no area_kind set. Authoritative # fp.step.kind now self-declares its area_kind, so the kind taxonomy
# source per the plant-view spec §4.2. # IS the source of truth for Shop Floor column routing.
# 2026-05-23 — Shop Floor plant-view redesign. # See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
_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',
}
class FpJobStep(models.Model): class FpJobStep(models.Model):
@@ -169,21 +120,40 @@ class FpJobStep(models.Model):
store=True, store=True,
index=True, index=True,
help='Which Shop Floor column this step belongs to. Resolved as: ' help='Which Shop Floor column this step belongs to. Resolved as: '
'(1) work_centre.area_kind if set; else (2) fallback to ' '(1) work_centre.area_kind if set; else (2) the area_kind on '
'_STEP_KIND_TO_AREA[recipe_node.default_kind]; else (3) the ' 'recipe_node.kind_id; else (3) the safe catch-all "plating". '
'safe catch-all "plating". Drives plant-view kanban grouping.', '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): 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: for step in self:
# 1. Explicit work_centre wins
if step.work_centre_id and step.work_centre_id.area_kind: if step.work_centre_id and step.work_centre_id.area_kind:
step.area_kind = step.work_centre_id.area_kind step.area_kind = step.work_centre_id.area_kind
continue continue
kind = step.recipe_node_id.default_kind if step.recipe_node_id else False # 2. Kind taxonomy
if kind and kind in _STEP_KIND_TO_AREA: node = step.recipe_node_id
step.area_kind = _STEP_KIND_TO_AREA[kind] if node and node.kind_id and node.kind_id.area_kind:
step.area_kind = node.kind_id.area_kind
continue continue
# 3. Catch-all — only reached for orphaned steps (no
# work_centre AND no recipe_node).
step.area_kind = 'plating' step.area_kind = 'plating'
last_activity_at = fields.Datetime( last_activity_at = fields.Datetime(

View File

@@ -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()

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Shop Floor', 'name': 'Fusion Plating — Shop Floor',
'version': '19.0.33.1.2', 'version': '19.0.33.1.3',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.', 'first-piece inspection gates.',

View File

@@ -65,9 +65,13 @@ class PlantKanbanController(http.Controller):
else env['fp.work.centre']) else env['fp.work.centre'])
paired_area = paired.area_kind if paired else None 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 = [ domain = [
('state', 'in', ('confirmed', 'in_progress', 'done')), ('state', 'in', ('confirmed', 'in_progress')),
] ]
filters = filters or {} filters = filters or {}
if filters.get('overdue'): if filters.get('overdue'):
@@ -161,12 +165,19 @@ def fields_today_ts():
def _resolve_card_area(job): def _resolve_card_area(job):
"""Pick the column a card lives in. """Pick the column a card lives in.
Active-step area_kind wins. When there's no active step the card Active-step area_kind wins. With the live-step priority chain
lives in Receiving (covers contract_review + no_parts edge cases). (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: if job.active_step_id and job.active_step_id.area_kind:
return 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' return 'receiving'