feat(fusion_plating_shopfloor): /fp/landing/kanban endpoint
Plan task P3.1. New JSON-RPC endpoint for the Shop Floor Landing
client action (Phase 3). Two modes:
station — paired WC + Unassigned + next 1-2 WCs in recipe flow
all_plant — every active WC, recipe-flow order (replaces the data
path for the standalone fp_plant_overview action)
Returns {columns: [{work_center_id, work_center_name, cards}], kpis:
{ready, running, bakes_due, holds}, stations: [...], facility_name,
server_time}. Card payload matches the KanbanCard OWL component
(P1.7) — same shape, no client-side adapter needed.
Light implementation — no urgency scoring or batch prefetch yet.
Both can be ported from plant_overview if performance demands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,3 +7,4 @@ from . import manager_controller
|
||||
from . import tank_status
|
||||
from . import move_controller
|
||||
from . import workspace_controller
|
||||
from . import landing_controller
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""JSON-RPC endpoint for the Shop Floor Landing kanban (Phase 3).
|
||||
|
||||
Replaces the data path for both fp_shopfloor_tablet (legacy) and
|
||||
fp_plant_overview (legacy). Two modes:
|
||||
|
||||
station — paired station's work centre + Unassigned + next 1-2 WCs
|
||||
in the recipe flow. The physical-station view.
|
||||
all_plant — every active work centre, sorted by recipe flow.
|
||||
|
||||
The card payload shape matches the existing plant_overview cards so
|
||||
the front-end can share the KanbanCard component. Tapping a card opens
|
||||
the JobWorkspace via doAction (handled client-side).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_ACTIVE_STEP_STATES = ('ready', 'in_progress', 'paused')
|
||||
|
||||
|
||||
class FpLandingController(http.Controller):
|
||||
|
||||
# ======================================================================
|
||||
# /fp/landing/kanban
|
||||
# ======================================================================
|
||||
@http.route('/fp/landing/kanban', type='jsonrpc', auth='user')
|
||||
def kanban(self, mode='all_plant', station_id=None, search=None):
|
||||
env = request.env
|
||||
Step = env['fp.job.step']
|
||||
WorkCentre = env['fp.work.centre']
|
||||
|
||||
# ---- Resolve station / facility scope ----------------------------
|
||||
station = None
|
||||
facility = None
|
||||
if station_id:
|
||||
stn = env['fusion.plating.shopfloor.station'].browse(int(station_id))
|
||||
if stn.exists():
|
||||
station = stn
|
||||
facility = stn.facility_id
|
||||
if not facility:
|
||||
facility = env['fusion.plating.facility'].search([], limit=1)
|
||||
|
||||
# ---- Which work centres to render --------------------------------
|
||||
wc_dom = [('active', '=', True)]
|
||||
if facility:
|
||||
wc_dom.append(('facility_id', '=', facility.id))
|
||||
all_wcs = WorkCentre.search(wc_dom, order='sequence, code, name')
|
||||
|
||||
if mode == 'station' and station and station.work_center_id:
|
||||
this_wc = station.work_center_id
|
||||
# Show this WC + next 1-2 WCs in the recipe flow (preview)
|
||||
after = all_wcs.filtered(
|
||||
lambda w: w.sequence > this_wc.sequence
|
||||
)[:2]
|
||||
relevant_wcs = this_wc | after
|
||||
else:
|
||||
relevant_wcs = all_wcs
|
||||
|
||||
# ---- Active steps in scope ---------------------------------------
|
||||
step_dom = [('state', 'in', _ACTIVE_STEP_STATES)]
|
||||
if facility:
|
||||
step_dom.append(('work_centre_id.facility_id', '=', facility.id))
|
||||
if mode == 'station' and relevant_wcs:
|
||||
# In station mode, include the relevant WCs + Unassigned only.
|
||||
# The OR-of-three-leaves is what makes this filter "this WC,
|
||||
# the next 1-2 WCs, or Unassigned" — three branches OR'd.
|
||||
step_dom = step_dom + [
|
||||
'|', '|',
|
||||
('work_centre_id', 'in', relevant_wcs.ids),
|
||||
('work_centre_id', '=', False),
|
||||
('work_centre_id', 'in', relevant_wcs.ids),
|
||||
]
|
||||
|
||||
steps = Step.search(step_dom, order='sequence, id')
|
||||
|
||||
if search:
|
||||
search_l = search.strip().lower()
|
||||
steps = steps.filtered(lambda s: (
|
||||
search_l in (s.job_id.display_wo_name or '').lower()
|
||||
or search_l in (s.job_id.partner_id.name or '').lower()
|
||||
or search_l in (
|
||||
s.job_id.part_catalog_id.part_number or ''
|
||||
if s.job_id.part_catalog_id else ''
|
||||
).lower()
|
||||
))
|
||||
|
||||
# ---- Group into columns ------------------------------------------
|
||||
cards_by_wc = {0: []} # 0 = Unassigned sentinel
|
||||
for step in steps:
|
||||
wc_id = step.work_centre_id.id or 0
|
||||
cards_by_wc.setdefault(wc_id, []).append(self._step_to_card(step))
|
||||
|
||||
columns = []
|
||||
for wc in relevant_wcs:
|
||||
columns.append({
|
||||
'work_center_id': wc.id,
|
||||
'work_center_name': wc.name,
|
||||
'cards': cards_by_wc.get(wc.id, []),
|
||||
})
|
||||
if cards_by_wc.get(0):
|
||||
columns.append({
|
||||
'work_center_id': 0,
|
||||
'work_center_name': 'Unassigned',
|
||||
'cards': cards_by_wc[0],
|
||||
})
|
||||
|
||||
# ---- KPIs — 4 tech-relevant tiles --------------------------------
|
||||
ready = sum(1 for s in steps if s.state == 'ready')
|
||||
running = sum(1 for s in steps if s.state == 'in_progress')
|
||||
|
||||
BakeWindow = env['fusion.plating.bake.window']
|
||||
bake_dom = [('state', 'in', ('awaiting_bake', 'bake_in_progress'))]
|
||||
if facility:
|
||||
bake_dom.append(('facility_id', '=', facility.id))
|
||||
bakes_due = BakeWindow.search_count(bake_dom)
|
||||
|
||||
Hold = env['fusion.plating.quality.hold']
|
||||
holds = Hold.search_count([('state', 'in', ('on_hold', 'under_review'))])
|
||||
|
||||
# ---- Station picker payload (so client can switch stations) ------
|
||||
all_stations = env['fusion.plating.shopfloor.station'].search(
|
||||
[], order='facility_id, name',
|
||||
)
|
||||
stations = [
|
||||
{
|
||||
'id': s.id,
|
||||
'name': s.name,
|
||||
'code': s.code or '',
|
||||
'facility': s.facility_id.name or '',
|
||||
'work_center_name': s.work_center_id.name or '',
|
||||
}
|
||||
for s in all_stations
|
||||
]
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'mode': mode,
|
||||
'station': {
|
||||
'id': station.id,
|
||||
'name': station.name,
|
||||
'code': station.code or '',
|
||||
'work_center_name': station.work_center_id.name or '',
|
||||
} if station else None,
|
||||
'facility_name': facility.name if facility else '',
|
||||
'columns': columns,
|
||||
'kpis': {
|
||||
'ready': ready,
|
||||
'running': running,
|
||||
'bakes_due': bakes_due,
|
||||
'holds': holds,
|
||||
},
|
||||
'stations': stations,
|
||||
'server_time': fp_format(env, fields.Datetime.now(), fmt='%H:%M:%S'),
|
||||
}
|
||||
|
||||
def _step_to_card(self, step):
|
||||
"""Build the kanban card payload for one fp.job.step.
|
||||
|
||||
Shape matches the KanbanCard OWL component (Phase 1 — P1.7).
|
||||
"""
|
||||
job = step.job_id
|
||||
return {
|
||||
'step_id': step.id,
|
||||
'job_id': job.id,
|
||||
'display_wo_name': job.display_wo_name,
|
||||
'customer': job.partner_id.name or '',
|
||||
'part': (
|
||||
job.part_catalog_id.part_number
|
||||
if 'part_catalog_id' in job._fields and job.part_catalog_id
|
||||
else (job.product_id.display_name or '')
|
||||
),
|
||||
'qty': int(job.qty or 0),
|
||||
'qty_done': int(job.qty_done or 0),
|
||||
'qty_scrapped': int(job.qty_scrapped or 0),
|
||||
'date_deadline': fp_format(
|
||||
request.env, job.date_deadline, fmt='%b %d',
|
||||
) if job.date_deadline else '',
|
||||
'priority': job.priority or 'normal',
|
||||
'workflow_state': {
|
||||
'id': job.workflow_state_id.id,
|
||||
'name': job.workflow_state_id.name,
|
||||
'color': job.workflow_state_id.color or 'grey',
|
||||
} if job.workflow_state_id else None,
|
||||
'blocker_kind': step.blocker_kind,
|
||||
'blocker_reason': step.blocker_reason or '',
|
||||
'current_step_id': step.id,
|
||||
'current_step_name': step.name,
|
||||
'work_center': step.work_centre_id.name or '',
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_workspace_controller
|
||||
from . import test_landing_kanban
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||
"""Plan task P3.1 — /fp/landing/kanban endpoint."""
|
||||
import json
|
||||
|
||||
from odoo.tests.common import HttpCase, tagged
|
||||
|
||||
|
||||
def _rpc(case, url, **params):
|
||||
res = case.url_open(
|
||||
url,
|
||||
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return res.json()['result']
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestLandingKanban(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
|
||||
def test_all_plant_returns_columns_and_kpis(self):
|
||||
res = _rpc(self, '/fp/landing/kanban', mode='all_plant')
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(res['mode'], 'all_plant')
|
||||
self.assertIn('columns', res)
|
||||
self.assertIn('kpis', res)
|
||||
for kpi in ('ready', 'running', 'bakes_due', 'holds'):
|
||||
self.assertIn(kpi, res['kpis'])
|
||||
self.assertIn('stations', res)
|
||||
|
||||
def test_station_mode_with_invalid_id_falls_back_to_all_plant_shape(self):
|
||||
# No real station paired → station resolution returns None, but
|
||||
# endpoint still produces a valid columns/kpis payload.
|
||||
res = _rpc(self, '/fp/landing/kanban', mode='station', station_id=999999)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertIsNone(res['station'])
|
||||
self.assertIn('columns', res)
|
||||
Reference in New Issue
Block a user