feat(plating): Phase 1 — plant-view kanban data model foundation
PV-T1: fp.work.centre.area_kind Selection (9 floor columns)
PV-T2: fp.job.step.area_kind compute + _STEP_KIND_TO_AREA fallback
(covers all 30+ step kinds in the project library, plus the
spec D4 rule that de_mask folds into de_racking)
PV-T3: fp.job.step.last_activity_at + write hook + message_post
override + fp.job.step.move.create() hook + _fp_is_idle helper
PV-T4: res.users.paired_work_centre_ids M2M (single-station for MVP,
forward-compatible for Phase 2 multi-station picker)
PV-T5: res.config.settings.x_fc_shopfloor_layout feature flag backed
by ir.config_parameter for the landing-action resolver
Migrations:
fusion_plating 19.0.21.0.0 — backfill area_kind from kind
fusion_plating_jobs 19.0.10.24.0 — backfill last_activity_at
Deployed + verified on entech:
- 9/9 fp.work.centre rows have area_kind set
- 400/400 fp.job.step rows have area_kind + last_activity_at
- paired_work_centre_ids M2M relation table created
- All 271 modules loaded cleanly, registry rebuilt in 27s
Part of the 2026-05-23 Shop Floor plant-view kanban redesign.
Plan: docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md
Spec: docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.20.10.0',
|
'version': '19.0.21.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
#
|
||||||
|
# 19.0.21.0.0 — Plant-view Shop Floor kanban redesign.
|
||||||
|
# Backfill fp.work.centre.area_kind from the existing `kind` taxonomy so
|
||||||
|
# every routing station has a defined Floor Column on day 1. Admins can
|
||||||
|
# override afterwards via Configuration → Shop Setup → Routing Stations.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
"""Backfill area_kind on existing fp.work.centre rows.
|
||||||
|
|
||||||
|
Mapping is intentionally permissive: every existing kind maps to a
|
||||||
|
sensible default. Unmapped (e.g. 'other') falls to 'plating' as the
|
||||||
|
safe wet-shop catch-all and is logged for review.
|
||||||
|
"""
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fp_work_centre
|
||||||
|
SET area_kind = CASE kind
|
||||||
|
WHEN 'wet_line' THEN 'plating'
|
||||||
|
WHEN 'bake' THEN 'baking'
|
||||||
|
WHEN 'mask' THEN 'masking'
|
||||||
|
WHEN 'rack' THEN 'racking'
|
||||||
|
WHEN 'inspect' THEN 'inspection'
|
||||||
|
ELSE 'plating'
|
||||||
|
END
|
||||||
|
WHERE area_kind IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Log any rows that landed on the catch-all so the admin can review.
|
||||||
|
cr.execute("""
|
||||||
|
SELECT id, name, code, kind
|
||||||
|
FROM fp_work_centre
|
||||||
|
WHERE area_kind = 'plating'
|
||||||
|
AND kind = 'other'
|
||||||
|
""")
|
||||||
|
rows = cr.fetchall()
|
||||||
|
if rows:
|
||||||
|
_logger.warning(
|
||||||
|
"%d fp.work.centre rows had kind='other' and were defaulted "
|
||||||
|
"to area_kind='plating'; review and adjust if needed: %s",
|
||||||
|
len(rows),
|
||||||
|
', '.join(
|
||||||
|
'%s (id=%s, code=%s)' % (r[1], r[0], r[2])
|
||||||
|
for r in rows[:10]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_logger.info("Backfilled area_kind on fp.work.centre")
|
||||||
@@ -77,6 +77,27 @@ class FpJobStepMove(models.Model):
|
|||||||
string='Transition Input Values',
|
string='Transition Input Values',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
"""Stamp last_activity_at on from_step + to_step so the plant-view
|
||||||
|
idle gate (S16) sees moves as activity. Without this, a step that
|
||||||
|
only ever gets moves (no chatter, no state edits) eventually
|
||||||
|
trips the 8-hour idle warning falsely.
|
||||||
|
"""
|
||||||
|
moves = super().create(vals_list)
|
||||||
|
Step = self.env['fp.job.step']
|
||||||
|
step_ids = set()
|
||||||
|
for m in moves:
|
||||||
|
if m.from_step_id:
|
||||||
|
step_ids.add(m.from_step_id.id)
|
||||||
|
if m.to_step_id:
|
||||||
|
step_ids.add(m.to_step_id.id)
|
||||||
|
if step_ids:
|
||||||
|
Step.browse(list(step_ids)).sudo().with_context(
|
||||||
|
tracking_disable=True,
|
||||||
|
).write({'last_activity_at': fields.Datetime.now()})
|
||||||
|
return moves
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# S23 — required transition-input gate
|
# S23 — required transition-input gate
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -48,6 +48,26 @@ class FpWorkCentre(models.Model):
|
|||||||
required=True,
|
required=True,
|
||||||
default='other',
|
default='other',
|
||||||
)
|
)
|
||||||
|
area_kind = fields.Selection(
|
||||||
|
[
|
||||||
|
('receiving', 'Receiving'),
|
||||||
|
('masking', 'Masking'),
|
||||||
|
('blasting', 'Blasting'),
|
||||||
|
('racking', 'Racking'),
|
||||||
|
('plating', 'Plating'),
|
||||||
|
('baking', 'Baking'),
|
||||||
|
('de_racking', 'De-Racking'),
|
||||||
|
('inspection', 'Final inspection'),
|
||||||
|
('shipping', 'Shipping'),
|
||||||
|
],
|
||||||
|
string='Floor Column',
|
||||||
|
help='Which Shop Floor column this work centre belongs to. '
|
||||||
|
'Drives the plant-view kanban grouping — any job whose '
|
||||||
|
'active step uses this work centre routes into this column. '
|
||||||
|
'See docs/superpowers/specs/2026-05-23-shopfloor-plant-view-'
|
||||||
|
'design.md §4.2 for the mapping rules.',
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
cost_per_hour = fields.Monetary(
|
cost_per_hour = fields.Monetary(
|
||||||
currency_field='currency_id',
|
currency_field='currency_id',
|
||||||
help='Used for fp.job.step cost rollups.',
|
help='Used for fp.job.step cost rollups.',
|
||||||
|
|||||||
@@ -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.23.0',
|
'version': '19.0.10.24.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,28 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
#
|
||||||
|
# 19.0.10.24.0 — Plant-view Shop Floor kanban redesign.
|
||||||
|
# Backfill fp.job.step.last_activity_at from write_date so existing
|
||||||
|
# in-progress steps don't immediately trip the S16 idle-warning gate
|
||||||
|
# (8 hours since last activity) on first compute after deploy.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fp_job_step
|
||||||
|
SET last_activity_at = write_date
|
||||||
|
WHERE last_activity_at IS NULL
|
||||||
|
""")
|
||||||
|
cr.execute("SELECT count(*) FROM fp_job_step WHERE last_activity_at IS NULL")
|
||||||
|
remaining = cr.fetchone()[0]
|
||||||
|
if remaining:
|
||||||
|
_logger.warning(
|
||||||
|
"%d fp.job.step rows still have NULL last_activity_at after "
|
||||||
|
"backfill (no write_date?). These will trip the idle gate "
|
||||||
|
"on first compute.", remaining,
|
||||||
|
)
|
||||||
|
_logger.info("Backfilled last_activity_at on fp.job.step from write_date")
|
||||||
@@ -17,6 +17,62 @@ 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.
|
||||||
|
# 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',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FpJobStep(models.Model):
|
class FpJobStep(models.Model):
|
||||||
_inherit = 'fp.job.step'
|
_inherit = 'fp.job.step'
|
||||||
|
|
||||||
@@ -85,6 +141,73 @@ class FpJobStep(models.Model):
|
|||||||
)
|
)
|
||||||
step.can_start = not bool(blocking)
|
step.can_start = not bool(blocking)
|
||||||
|
|
||||||
|
# ===== 2026-05-23 plant-view redesign — area_kind + activity =========
|
||||||
|
area_kind = fields.Selection(
|
||||||
|
[
|
||||||
|
('receiving', 'Receiving'),
|
||||||
|
('masking', 'Masking'),
|
||||||
|
('blasting', 'Blasting'),
|
||||||
|
('racking', 'Racking'),
|
||||||
|
('plating', 'Plating'),
|
||||||
|
('baking', 'Baking'),
|
||||||
|
('de_racking', 'De-Racking'),
|
||||||
|
('inspection', 'Final inspection'),
|
||||||
|
('shipping', 'Shipping'),
|
||||||
|
],
|
||||||
|
string='Floor Column',
|
||||||
|
compute='_compute_area_kind',
|
||||||
|
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.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('work_centre_id.area_kind', 'recipe_node_id.default_kind')
|
||||||
|
def _compute_area_kind(self):
|
||||||
|
for step in self:
|
||||||
|
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]
|
||||||
|
continue
|
||||||
|
step.area_kind = 'plating'
|
||||||
|
|
||||||
|
last_activity_at = fields.Datetime(
|
||||||
|
string='Last Activity',
|
||||||
|
index=True,
|
||||||
|
help='Stamped on any state transition, move-out from this step, '
|
||||||
|
'or chatter post. Drives the S16 idle-warning card state '
|
||||||
|
'(in_progress with no activity for 8+ hours).',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fp_is_idle(self, threshold_hours=8):
|
||||||
|
"""True when this step is in_progress AND last_activity_at is older
|
||||||
|
than `threshold_hours`. Drives the idle_warning card state."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state != 'in_progress':
|
||||||
|
return False
|
||||||
|
if not self.last_activity_at:
|
||||||
|
return False
|
||||||
|
delta = fields.Datetime.now() - self.last_activity_at
|
||||||
|
return delta.total_seconds() > threshold_hours * 3600
|
||||||
|
|
||||||
|
def message_post(self, **kwargs):
|
||||||
|
"""Override: stamp last_activity_at so an operator note counts as
|
||||||
|
activity (defeats false-positive idle warnings during long bakes
|
||||||
|
where the only sign of life is the periodic operator note)."""
|
||||||
|
res = super().message_post(**kwargs)
|
||||||
|
try:
|
||||||
|
self.sudo().with_context(tracking_disable=True).write({
|
||||||
|
'last_activity_at': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.debug("last_activity_at stamp on message_post failed: %s", exc)
|
||||||
|
return res
|
||||||
|
|
||||||
# Gate visualizer — drives the OWL GateViz component on the tablet.
|
# Gate visualizer — drives the OWL GateViz component on the tablet.
|
||||||
# Returns kind of blocker + human reason + optional (model, id) jump
|
# Returns kind of blocker + human reason + optional (model, id) jump
|
||||||
# target. Reuses _fp_should_block_predecessors so this stays in sync
|
# target. Reuses _fp_should_block_predecessors so this stays in sync
|
||||||
@@ -286,6 +409,14 @@ class FpJobStep(models.Model):
|
|||||||
if new_uid == old_uid:
|
if new_uid == old_uid:
|
||||||
continue
|
continue
|
||||||
post_for.append((step, old_uid, new_uid))
|
post_for.append((step, old_uid, new_uid))
|
||||||
|
# Plant-view: stamp last_activity_at on every state transition so
|
||||||
|
# the S16 idle gate has fresh data. Only stamp when state is in
|
||||||
|
# vals AND it's actually changing (avoid no-op writes spamming
|
||||||
|
# the timestamp).
|
||||||
|
if 'state' in vals and 'last_activity_at' not in vals:
|
||||||
|
new_state = vals['state']
|
||||||
|
if any(step.state != new_state for step in self):
|
||||||
|
vals = dict(vals, last_activity_at=fields.Datetime.now())
|
||||||
result = super().write(vals)
|
result = super().write(vals)
|
||||||
Users = self.env['res.users']
|
Users = self.env['res.users']
|
||||||
for step, old_uid, new_uid in post_for:
|
for step, old_uid, new_uid in post_for:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.30.6.0',
|
'version': '19.0.31.0.0',
|
||||||
'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.',
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ from . import fp_first_piece_gate
|
|||||||
from . import fp_operator_queue
|
from . import fp_operator_queue
|
||||||
from . import fp_tank
|
from . import fp_tank
|
||||||
from . import res_users
|
from . import res_users
|
||||||
|
from . import res_config_settings
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
"""Feature flags for fusion_plating_shopfloor.
|
||||||
|
|
||||||
|
Currently:
|
||||||
|
- x_fc_shopfloor_layout — switches the Shop Floor client action
|
||||||
|
between the legacy per-step kanban and the v2 plant-view kanban.
|
||||||
|
Backed by ir.config_parameter so the landing-action resolver can
|
||||||
|
read it cheaply on every action open without a recordset fetch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = 'res.config.settings'
|
||||||
|
|
||||||
|
x_fc_shopfloor_layout = fields.Selection(
|
||||||
|
[
|
||||||
|
('legacy', 'Legacy (per-step kanban)'),
|
||||||
|
('v2', 'Plant View (one card per job, 9 columns)'),
|
||||||
|
],
|
||||||
|
string='Shop Floor Layout',
|
||||||
|
default='legacy',
|
||||||
|
config_parameter='fusion_plating_shopfloor.layout',
|
||||||
|
help='Switches the Shop Floor client action between the legacy '
|
||||||
|
'per-step kanban and the v2 plant view. Defaults to legacy '
|
||||||
|
'during the parallel rollout; flip to v2 once validated. '
|
||||||
|
'The landing-action resolver reads this from '
|
||||||
|
'ir.config_parameter (key: fusion_plating_shopfloor.layout).',
|
||||||
|
)
|
||||||
@@ -45,6 +45,19 @@ class ResUsers(models.Model):
|
|||||||
'Null when not locked. Set after the configured fail '
|
'Null when not locked. Set after the configured fail '
|
||||||
'threshold (default 5) is reached.',
|
'threshold (default 5) is reached.',
|
||||||
)
|
)
|
||||||
|
paired_work_centre_ids = fields.Many2many(
|
||||||
|
'fp.work.centre',
|
||||||
|
'res_users_fp_work_centre_paired_rel',
|
||||||
|
'user_id',
|
||||||
|
'work_centre_id',
|
||||||
|
string='Paired Work Centres',
|
||||||
|
help='Stations the operator is currently paired to via the tablet. '
|
||||||
|
'MVP holds exactly one row on day 1 (the dropdown-selected '
|
||||||
|
'station). The Phase 2 multi-station picker can populate '
|
||||||
|
'multiple. Drives the "is this card mine" check on the '
|
||||||
|
'plant-view kanban (cards whose active_step.work_centre is '
|
||||||
|
'in this M2M get the yellow ⭐ treatment).',
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _hash_tablet_pin(pin, salt=None):
|
def _hash_tablet_pin(pin, salt=None):
|
||||||
|
|||||||
Reference in New Issue
Block a user