From 9dcd00d9b20c548cb906f7c9f0a347305d92048f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 22 May 2026 22:06:40 -0400 Subject: [PATCH] feat(fusion_plating_shopfloor): /fp/landing/kanban endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../controllers/__init__.py | 1 + .../controllers/landing_controller.py | 198 ++++++++++++++++++ .../tests/__init__.py | 1 + .../tests/test_landing_kanban.py | 41 ++++ 4 files changed, 241 insertions(+) create mode 100644 fusion_plating/fusion_plating_shopfloor/controllers/landing_controller.py create mode 100644 fusion_plating/fusion_plating_shopfloor/tests/test_landing_kanban.py diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py b/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py index 4e90467d..5fe813de 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py @@ -7,3 +7,4 @@ from . import manager_controller from . import tank_status from . import move_controller from . import workspace_controller +from . import landing_controller diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/landing_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/landing_controller.py new file mode 100644 index 00000000..5b22e172 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/controllers/landing_controller.py @@ -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 '', + } diff --git a/fusion_plating/fusion_plating_shopfloor/tests/__init__.py b/fusion_plating/fusion_plating_shopfloor/tests/__init__.py index e80e21a7..5f0627ad 100644 --- a/fusion_plating/fusion_plating_shopfloor/tests/__init__.py +++ b/fusion_plating/fusion_plating_shopfloor/tests/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- from . import test_workspace_controller +from . import test_landing_kanban diff --git a/fusion_plating/fusion_plating_shopfloor/tests/test_landing_kanban.py b/fusion_plating/fusion_plating_shopfloor/tests/test_landing_kanban.py new file mode 100644 index 00000000..48970ffc --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/tests/test_landing_kanban.py @@ -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)