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 tank_status
|
||||||
from . import move_controller
|
from . import move_controller
|
||||||
from . import workspace_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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import test_workspace_controller
|
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