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:
@@ -17,6 +17,62 @@ 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',
|
||||
}
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
_inherit = 'fp.job.step'
|
||||
|
||||
@@ -85,6 +141,73 @@ class FpJobStep(models.Model):
|
||||
)
|
||||
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.
|
||||
# Returns kind of blocker + human reason + optional (model, id) jump
|
||||
# target. Reuses _fp_should_block_predecessors so this stays in sync
|
||||
@@ -286,6 +409,14 @@ class FpJobStep(models.Model):
|
||||
if new_uid == old_uid:
|
||||
continue
|
||||
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)
|
||||
Users = self.env['res.users']
|
||||
for step, old_uid, new_uid in post_for:
|
||||
|
||||
Reference in New Issue
Block a user