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:
@@ -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.',
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user