feat(shopfloor): Phase 3 — plant_kanban endpoint + dispatch

PV-Phase3 of the plant-view redesign.

- /fp/landing/plant_kanban JSONRPC endpoint returns {kpis, columns,
  cards} in one payload. One card per fp.job; cards denormalized so
  the OWL component doesn't fan out RPCs. Server-side filter handling
  for All / Mine / Running / Blocked / Overdue / FAIR. Within-column
  sort by (overdue, _SORT_PRIORITY[card_state], due_date).
- fusion_plating_shopfloor.action_fp_plant_kanban client action
  registered alongside the existing fp_shopfloor_landing action.
- fp_landing_data.xml resolver extended to read the layout flag and
  dispatch to v2 when x_fc_shopfloor_layout='v2' (default still legacy).

Card payload (23 fields): WO, customer, PN+rev, qty, PO, recipe, spec,
tags, current step + work centre, state chip, mini_timeline, operator,
icons (signoff / bake / tracking / etc.), progress.

State-chip mapping per spec §6.1 — one map keyed by card_state with
running-time elapsed, idle-hours, and operator-name interpolation.

Verified live — card payload sample on WO-30036 (contract_review state)
produces all expected keys + 9-element mini_timeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-23 20:51:36 -04:00
parent 7c2ae84e32
commit a90eace4d0
4 changed files with 417 additions and 2 deletions

View File

@@ -24,15 +24,37 @@
<field name="model_id" ref="base.model_res_users"/>
<field name="state">code</field>
<field name="code"><![CDATA[
# Resolve in priority order: user pref → company default → Sale Orders fallback.
# Resolve in priority order:
# 1. user.x_fc_plating_landing_action_id (per-user override)
# 2. company.x_fc_default_landing_action_id (company default)
# 3. Shop Floor plant-view kanban (when x_fc_shopfloor_layout='v2')
# 4. Sale Orders (when v2 flag unset / legacy)
# 5. Process recipes (configurator absent)
user = env.user
target = False
if 'x_fc_plating_landing_action_id' in user._fields and user.x_fc_plating_landing_action_id:
target = user.x_fc_plating_landing_action_id.sudo()
elif 'x_fc_default_landing_action_id' in env.company._fields and env.company.x_fc_default_landing_action_id:
target = env.company.x_fc_default_landing_action_id.sudo()
if not target:
target = env.ref('fusion_plating_configurator.action_fp_sale_orders', raise_if_not_found=False)
# 2026-05-23 — plant-view dispatch. Read the layout flag and pick the
# appropriate Shop Floor action. Falls through to Sale Orders if no
# client action is registered (e.g. shopfloor module not installed).
layout = env['ir.config_parameter'].sudo().get_param(
'fusion_plating_shopfloor.layout', default='legacy',
)
if layout == 'v2':
target = env.ref(
'fusion_plating_shopfloor.action_fp_plant_kanban',
raise_if_not_found=False,
)
# Legacy or v2-missing → fall through to Sale Orders
if not target:
target = env.ref(
'fusion_plating_configurator.action_fp_sale_orders',
raise_if_not_found=False,
)
if target:
action = target.sudo().read()[0]

View File

@@ -9,3 +9,4 @@ from . import move_controller
from . import workspace_controller
from . import landing_controller
from . import tablet_controller
from . import plant_kanban

View File

@@ -0,0 +1,378 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Plant-view Shop Floor kanban endpoint.
Returns {kpis, columns, cards} in one JSONRPC payload so the OWL
FpPlantKanban component doesn't fan out per-card RPCs. One card per
fp.job; cards grouped into the 9 fixed Shop Floor columns. See spec at
docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md.
"""
import json
import logging
from datetime import date, datetime, timedelta
from odoo import _, http
from odoo.http import request
_logger = logging.getLogger(__name__)
# Mirrors fusion_plating_jobs.models.fp_job._COLUMN_SEQUENCE exactly.
# Keep these two in sync — the column order on the board IS the sequence.
_COLUMN_LABELS = [
('receiving', _('Receiving')),
('masking', _('Masking')),
('blasting', _('Blasting')),
('racking', _('Racking')),
('plating', _('Plating')),
('baking', _('Baking')),
('de_racking', _('De-Racking')),
('inspection', _('Final inspection')),
('shipping', _('Shipping')),
]
# Sort priority within a column (overdue → bake_due → mine → ready/run
# → idle → locked → done). Lower number wins (sorted ascending).
_SORT_PRIORITY = {
'on_hold': 0,
'no_parts': 1,
'bake_due': 2,
'awaiting_signoff': 3,
'awaiting_qc': 4,
'ready_mine': 5,
'running_mine': 6,
'ready': 7,
'running': 8,
'idle_warning': 9,
'predecessor_locked': 10,
'contract_review': 11,
'done': 12,
}
class PlantKanbanController(http.Controller):
@http.route('/fp/landing/plant_kanban', type='jsonrpc', auth='user')
def plant_kanban(self, mode='station', filters=None):
"""Returns the assembled board payload. See spec §9.2."""
env = request.env
user = env.user
Job = env['fp.job']
# Resolve paired station (first row of M2M for MVP)
paired = (user.paired_work_centre_ids[:1]
if 'paired_work_centre_ids' in user._fields
else env['fp.work.centre'])
paired_area = paired.area_kind if paired else None
# Base domain — every job with active recipe steps
domain = [
('state', 'in', ('confirmed', 'in_progress', 'done')),
]
filters = filters or {}
if filters.get('overdue'):
domain.append(('date_deadline', '<', fields_today_ts()))
domain.append(('state', '!=', 'done'))
if filters.get('on_hold'):
domain.append(('card_state', '=', 'on_hold'))
if filters.get('running'):
domain.append(('card_state', 'in', ('running', 'running_mine')))
if filters.get('blocked'):
domain.append(('card_state', 'in', (
'on_hold', 'predecessor_locked', 'awaiting_signoff',
'awaiting_qc', 'no_parts',
)))
if filters.get('mine'):
domain.append(('card_state', 'in', ('ready_mine', 'running_mine')))
if filters.get('fair'):
# Match either part-catalog or partner level requires_first_article
domain.append('|')
domain.append(('customer_spec_id.x_fc_requires_first_article', '=', True))
domain.append(('part_catalog_id.certificate_requirement', 'in', ('coc', 'coc_thickness')))
jobs = Job.search(domain, limit=500)
# Bucket by area_kind of the active step (or 'receiving' when no
# active step yet — matches the contract_review / no_parts states
# that live in Receiving column per spec §3 D5).
cards = {}
cards_by_area = {area: [] for area, _label in _COLUMN_LABELS}
for job in jobs:
area = _resolve_card_area(job)
cards_by_area.setdefault(area, []).append(job.id)
cards[str(job.id)] = _render_card(job, paired)
# Sort within each column by priority then due date
for area in cards_by_area:
cards_by_area[area].sort(key=lambda jid: _sort_key(cards[str(jid)]))
columns = [
{
'area_kind': area,
'label': label,
'is_mine': (area == paired_area),
'card_ids': cards_by_area.get(area, []),
}
for area, label in _COLUMN_LABELS
]
# KPI strip
kpis = {
'active_jobs': sum(1 for j in jobs if j.state != 'done'),
'at_my_station': sum(
1 for j in jobs
if j.card_state in ('ready_mine', 'running_mine')
),
'bakes_due_soon': sum(
1 for j in jobs if j.card_state == 'bake_due'
),
'on_hold': sum(
1 for j in jobs if j.card_state == 'on_hold'
),
'overdue': sum(
1 for j in jobs
if j.date_deadline and j.date_deadline.date() < date.today()
and j.state != 'done'
),
}
return {
'ok': True,
'mode': mode,
'paired_station': ({
'id': paired.id,
'name': paired.name,
'area_kind': paired_area,
} if paired else None),
'kpis': kpis,
'columns': columns,
'cards': cards,
}
# ===== helpers ==========================================================
def fields_today_ts():
"""Return today as the start-of-day datetime string for date_deadline
comparisons (date_deadline is a Datetime in the schema)."""
return datetime.combine(date.today(), datetime.min.time())
def _resolve_card_area(job):
"""Pick the column a card lives in.
Active-step area_kind wins. When there's no active step the card
lives in Receiving (covers contract_review + no_parts edge cases).
"""
if job.active_step_id and job.active_step_id.area_kind:
return job.active_step_id.area_kind
# Fallback: receiving column
return 'receiving'
def _render_card(job, paired):
"""Build the full card payload for one fp.job."""
step = job.active_step_id
try:
timeline = json.loads(job.mini_timeline_json or '[]')
except (TypeError, ValueError):
timeline = []
# Cross-module field probes
part = job.part_catalog_id if 'part_catalog_id' in job._fields else None
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None
so = job.sale_order_id
po_number = ''
if so and 'x_fc_po_number' in so._fields:
po_number = so.x_fc_po_number or ''
# Tag chips (Rush / FAIR / VIP / AS9100 — only render when applicable)
tags = _compute_tags(job, part, spec)
# Step + tank labels
step_name = step.name if step else _('')
step_seq = step.sequence if step else 0
step_total = len(job.step_ids)
tank_label = ''
if step and step.work_centre_id:
tank_label = step.work_centre_id.name or step.work_centre_id.code or ''
# State chip
state_chip = _state_chip(job.card_state, step)
# Operator pill (only when step has an assigned user)
operator = None
if step and step.assigned_user_id:
u = step.assigned_user_id
operator = {
'id': u.id,
'name': u.name,
'initials': _initials_for(u),
}
# Icon row
icons = _icons(job, step)
# Due label
due_label = _due_label(job.date_deadline) if job.date_deadline else ''
is_overdue = (
bool(job.date_deadline)
and job.date_deadline.date() < date.today()
and job.state != 'done'
)
return {
'job_id': job.id,
'wo_name': job.display_wo_name or job.name or '',
'is_mine': job.card_state in ('ready_mine', 'running_mine'),
'card_state': job.card_state or '',
'due_date': (job.date_deadline.strftime('%Y-%m-%d')
if job.date_deadline else None),
'due_label': due_label,
'is_overdue': is_overdue,
'customer': job.partner_id.name if job.partner_id else '',
'part_number': (part.part_number if part else '') or '',
'part_revision': (part.revision if part and 'revision' in part._fields else '') or '',
'qty': job.qty or 0,
'po_number': po_number,
'recipe_name': job.recipe_id.name if job.recipe_id else '',
'spec_code': (spec.code if spec and 'code' in spec._fields else '') or '',
'tags': tags,
'step_name': step_name,
'step_seq': step_seq,
'step_total': step_total,
'tank_label': tank_label,
'state_chip': state_chip,
'operator': operator,
'icons': icons,
'mini_timeline': timeline,
}
def _compute_tags(job, part, spec):
tags = []
partner = job.partner_id
if partner:
if 'x_fc_rush' in partner._fields and partner.x_fc_rush:
tags.append('rush')
if 'x_fc_vip' in partner._fields and partner.x_fc_vip:
tags.append('vip')
if spec and 'x_fc_requires_first_article' in spec._fields \
and spec.x_fc_requires_first_article:
tags.append('fair')
if part and 'aerospace' in (part.name or '').lower():
tags.append('as9100')
return tags
def _state_chip(card_state, step):
"""Map card_state → {label, kind} for the chip on the card."""
if card_state == 'ready':
return {'label': _('● Ready'), 'kind': 'ready'}
if card_state == 'ready_mine':
return {'label': _('● Ready to start'), 'kind': 'ready'}
if card_state == 'running':
return {'label': _('%s') % _running_elapsed(step), 'kind': 'running'}
if card_state == 'running_mine':
return {'label': _('%s') % _running_elapsed(step), 'kind': 'running'}
if card_state == 'on_hold':
return {'label': _('🔴 Quality Hold'), 'kind': 'hold'}
if card_state == 'awaiting_signoff':
return {'label': _('🔏 Awaiting QA sign-off'), 'kind': 'signoff'}
if card_state == 'awaiting_qc':
return {'label': _('🔬 QC pending'), 'kind': 'qc'}
if card_state == 'bake_due':
return {'label': _('⏰ Bake window soon'), 'kind': 'due'}
if card_state == 'predecessor_locked':
return {'label': _('🔒 Waiting on predecessor'), 'kind': 'locked'}
if card_state == 'idle_warning':
op = (step.assigned_user_id.name.split()[0]
if step and step.assigned_user_id else _('operator'))
hrs = _idle_hours(step)
return {'label': _('⏸ Idle %dh · %s') % (hrs, op), 'kind': 'idle'}
if card_state == 'no_parts':
return {'label': _('📦 Parts in transit'), 'kind': 'no_parts'}
if card_state == 'contract_review':
return {'label': _('📋 QA-005 review'), 'kind': 'paperwork'}
if card_state == 'done':
return {'label': _('✓ Ready for pickup'), 'kind': 'done'}
return {'label': '', 'kind': ''}
def _running_elapsed(step):
"""Compact 'Running 8m' / 'Running 1h:45' label."""
if not step or not step.date_started:
return _('Running')
delta = datetime.now() - step.date_started
minutes = int(delta.total_seconds() / 60)
if minutes < 60:
return _('Running %dm') % minutes
hours = minutes // 60
rem = minutes % 60
return _('Running %dh:%02d') % (hours, rem)
def _idle_hours(step):
if not step or not step.last_activity_at:
return 0
delta = datetime.now() - step.last_activity_at
return int(delta.total_seconds() / 3600)
def _due_label(deadline):
"""'Due May 16 · 3d' style label."""
if not deadline:
return ''
d = deadline.date() if hasattr(deadline, 'date') else deadline
today = date.today()
days = (d - today).days
base = d.strftime('%b %d')
if days == 0:
return _('Due %s · today') % base
if days == 1:
return _('Due %s · tomorrow') % base
if days > 1:
return _('Due %s · %dd') % (base, days)
return _('Due %s · %dd late') % (base, -days)
def _icons(job, step):
"""Compact icon row at the card footer."""
icons = []
if step:
if step.requires_signoff and not step.signoff_user_id:
icons.append('🔏')
if step.recipe_node_id \
and step.recipe_node_id.default_kind == 'bake':
icons.append('🔥')
if job.card_state == 'bake_due':
icons.append('')
if job.card_state == 'no_parts':
icons.append('🚚')
if job.card_state == 'on_hold':
icons.append('💬')
if job.card_state == 'predecessor_locked':
icons.append('🔒')
if job.card_state == 'done':
icons.append('📜')
return icons
def _initials_for(user):
if not user or not user.name:
return ''
parts = user.name.strip().split()
if len(parts) == 1:
return parts[0][:2].upper()
return (parts[0][0] + parts[-1][0]).upper()
def _sort_key(card):
"""Sort within a column: overdue first, then by state priority,
then by due date (earlier = higher priority)."""
return (
0 if card['is_overdue'] else 1,
_SORT_PRIORITY.get(card['card_state'], 99),
card['due_date'] or '9999-12-31',
)

View File

@@ -28,4 +28,18 @@
<field name="tag">fp_process_tree</field>
</record>
<!-- ================================================================== -->
<!-- Plant-view kanban (2026-05-23 redesign). -->
<!-- One card per fp.job grouped into 9 fixed columns by area_kind. -->
<!-- Replaces fp_shopfloor_landing when x_fc_shopfloor_layout='v2'. -->
<!-- The landing-action resolver in fusion_plating/data/fp_landing_data -->
<!-- .xml dispatches between this and the legacy action based on the -->
<!-- ir.config_parameter set by the new feature-flag setting. -->
<!-- ================================================================== -->
<record id="action_fp_plant_kanban" model="ir.actions.client">
<field name="name">Shop Floor</field>
<field name="tag">fp_plant_kanban</field>
<field name="target">main</field>
</record>
</odoo>