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:
@@ -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]
|
||||
|
||||
@@ -9,3 +9,4 @@ from . import move_controller
|
||||
from . import workspace_controller
|
||||
from . import landing_controller
|
||||
from . import tablet_controller
|
||||
from . import plant_kanban
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user