diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index 36ba8a26..c7fb68d2 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.20.10.0', + 'version': '19.0.21.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/migrations/19.0.21.0.0/post-migrate.py b/fusion_plating/fusion_plating/migrations/19.0.21.0.0/post-migrate.py new file mode 100644 index 00000000..24304625 --- /dev/null +++ b/fusion_plating/fusion_plating/migrations/19.0.21.0.0/post-migrate.py @@ -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") diff --git a/fusion_plating/fusion_plating/models/fp_job_step_move.py b/fusion_plating/fusion_plating/models/fp_job_step_move.py index 2bec0617..5e1db95a 100644 --- a/fusion_plating/fusion_plating/models/fp_job_step_move.py +++ b/fusion_plating/fusion_plating/models/fp_job_step_move.py @@ -77,6 +77,27 @@ class FpJobStepMove(models.Model): 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 # ------------------------------------------------------------------ diff --git a/fusion_plating/fusion_plating/models/fp_work_centre.py b/fusion_plating/fusion_plating/models/fp_work_centre.py index 8b8dcb5d..280b9710 100644 --- a/fusion_plating/fusion_plating/models/fp_work_centre.py +++ b/fusion_plating/fusion_plating/models/fp_work_centre.py @@ -48,6 +48,26 @@ class FpWorkCentre(models.Model): required=True, 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( currency_field='currency_id', help='Used for fp.job.step cost rollups.', diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 30ab4f8d..81d4ffc4 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.23.0', + 'version': '19.0.10.24.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.24.0/post-migrate.py b/fusion_plating/fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py new file mode 100644 index 00000000..0f1b15b3 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py @@ -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") 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 372a771b..3680433f 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -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: diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index 2296e4c2..0000ae5b 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.30.6.0', + 'version': '19.0.31.0.0', '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/models/__init__.py b/fusion_plating/fusion_plating_shopfloor/models/__init__.py index 9f018fd6..5bc602ee 100644 --- a/fusion_plating/fusion_plating_shopfloor/models/__init__.py +++ b/fusion_plating/fusion_plating_shopfloor/models/__init__.py @@ -9,3 +9,4 @@ from . import fp_first_piece_gate from . import fp_operator_queue from . import fp_tank from . import res_users +from . import res_config_settings diff --git a/fusion_plating/fusion_plating_shopfloor/models/res_config_settings.py b/fusion_plating/fusion_plating_shopfloor/models/res_config_settings.py new file mode 100644 index 00000000..bc83dfa0 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/models/res_config_settings.py @@ -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).', + ) diff --git a/fusion_plating/fusion_plating_shopfloor/models/res_users.py b/fusion_plating/fusion_plating_shopfloor/models/res_users.py index 8363bd06..0e470982 100644 --- a/fusion_plating/fusion_plating_shopfloor/models/res_users.py +++ b/fusion_plating/fusion_plating_shopfloor/models/res_users.py @@ -45,6 +45,19 @@ class ResUsers(models.Model): 'Null when not locked. Set after the configured fail ' '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 def _hash_tablet_pin(pin, salt=None):