Compare commits

...

14 Commits

Author SHA1 Message Date
gsinghpal
2cdb2e3d0b chore(fusion_plating): bump versions for Phase 4 — Manager Desk refactor
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
fusion_plating            19.0.20.8.0  (bottleneck_score on fp.work.centre)
  fusion_plating_shopfloor  19.0.29.0.0  (3 new endpoints + 4-tab manager dashboard
                                          + 2 new KPI tiles)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:22:18 -04:00
gsinghpal
f00dda2abd feat(fusion_plating_shopfloor): Manager Desk 4-tab refactor (P4.5-P4.10)
Plan tasks P4.5 through P4.10 batched. Existing 3-column Plant Board
becomes one tab of four; adds Workflow Funnel (default), Approval
Inbox, and At-Risk siblings. Adds 2 new KPI tiles for Pending Cert +
At-Risk.

  WORKFLOW FUNNEL (default tab)
    Calls /fp/manager/funnel. Renders one row per fp.job.workflow.state
    with stage chip + count + top 5 WO cards. Tap a card → JobWorkspace.
    Bar chart bar behind each row scales with stage count.

  APPROVAL INBOX
    Calls /fp/manager/approval_inbox. Three strips: Holds to Release,
    Certs to Issue, Scrap to Review. Per-row open + Open Workspace
    buttons. Tab badge shows total pending count.

  PLANT BOARD (existing — relocated as one tab)
    The 3-column Needs Worker / In Progress / Team layout that already
    exists, wrapped in t-if="activeTab === 'plant_board'". No behaviour
    change — still uses /fp/manager/overview with 8s refresh.

  AT-RISK
    Calls /fp/manager/at_risk. 3 sub-panels: Trending Late (sorted by
    late_risk_ratio desc), Hold Reasons (read_group), Bottleneck heatmap
    (bottleneck_score from P4.1 with red/yellow/green bars).

  KPI STRIP (new conditional tiles)
    Pending Cert  — count from inbox.certs_to_issue, click to open Inbox tab.
    At-Risk       — count from at_risk.trending_late, click to open At-Risk.

Auto-refresh: 8s for /fp/manager/overview (existing); the active tab's
data also refreshes every 8s via refreshActiveTab().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:21:53 -04:00
gsinghpal
3b7b2477cf feat(fusion_plating_shopfloor): 3 new manager endpoints — funnel, inbox, at_risk (P4.2-P4.4)
Plan tasks P4.2 + P4.3 + P4.4 batched. Adds the backend data layer
for the Manager Desk's 3 new sibling tabs (Phase 4 tablet redesign).

  POST /fp/manager/funnel
      Workflow funnel: jobs grouped by fp.job.workflow.state. Returns
      stages[] with count + top 5 WO cards per stage. Drives the
      default tab on the refactored dashboard.

  POST /fp/manager/approval_inbox
      Four buckets: holds_to_release (state=on_hold|under_review),
      certs_to_issue (all_steps_terminal + draft cert), scrap_to_review
      (last 24h mark_for_scrap holds), override_requests (deferred —
      empty placeholder).

  POST /fp/manager/at_risk
      Three panels: trending_late (top 20 by late_risk_ratio desc),
      hold_reasons (read_group on hold_reason), bottleneck (top 10
      work centres by bottleneck_score from P4.1).

All endpoints respect optional facility_id scope. Cheap implementations
— no caching yet; performance can be added if entech load demands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:17:53 -04:00
gsinghpal
e762ee4b68 feat(fusion_plating): fp.work.centre bottleneck_score + avg_wait_minutes (P4.1)
Computes for the Manager At-Risk heatmap (Phase 4 tablet redesign).
Non-stored — recomputed on /fp/manager/at_risk read; that endpoint
caches its full payload for 60s so the cost is bounded.

  bottleneck_score = active_step_count * avg_wait_minutes
  avg_wait_minutes = rolling-7-day avg of (date_started - create_date)

Work centres with high score show red in the heatmap — combination
of queue length AND average wait time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:16:37 -04:00
gsinghpal
5d086c7f27 chore(fusion_plating_shopfloor): bump 19.0.28.0.0 for Phase 3 — Landing refactor
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
Phase 3 ships:
- /fp/landing/kanban endpoint (P3.1)
- ShopfloorLanding OWL client action with Station/All-Plant toggle,
  KPI strip, search, kanban with DnD, QR scan, card-tap to Workspace (P3.2-P3.4)
- Menu rewire: 'Tablet Station' + 'Plant Overview' → single 'Workstation'
  entry; legacy actions retargeted to fp_shopfloor_landing for bookmark
  back-compat (P3.5)
- DEPRECATED markers on legacy /fp/shopfloor/tablet_overview, plant_overview,
  queue endpoints (P3.6 — pragmatic deviation: bodies kept intact for the
  still-registered legacy OWL components)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:12:06 -04:00
gsinghpal
3eba80bb31 docs(fusion_plating_shopfloor): deprecation markers on legacy endpoints (P3.6)
Plan task P3.6 — pragmatic deviation. The plan called for stubs that
internally route to /fp/landing/kanban + reshape; in practice the
legacy fp_shopfloor_tablet OWL component (still registered, just
unhooked from the menu) consumes a much richer payload (my_queue,
active_wo, baths, bake_windows, gates, holds, pending_qcs, stations)
than /fp/landing/kanban returns. Gutting tablet_overview to a stub
would break that legacy component.

Instead: add explicit DEPRECATED markers + INFO log lines on the three
endpoints (tablet_overview, plant_overview, queue). Bodies stay intact
so the legacy components keep working until Phase 5 cleanup retires
both endpoints AND the legacy OWL components together.

Note: /fp/shopfloor/plant_overview/move_card is NOT deprecated — the
new Landing component still uses it for drag-and-drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:11:49 -04:00
gsinghpal
2a0d1862df feat(fusion_plating_shopfloor): rewire menus to Shop Floor Landing (P3.5)
Plan task P3.5. Single 'Workstation' menu item replaces both the
legacy 'Tablet Station' and 'Plant Overview' entries. The new
fp_shopfloor_landing component has a Station/All-Plant toggle so
one menu covers both old surfaces.

Old action records redirected for back-compat (so existing bookmarks
+ smart-button references keep working):

  action_fp_shopfloor_tablet  tag → fp_shopfloor_landing
  action_fp_plant_overview    tag → fp_shopfloor_landing
                              params → {'mode': 'all_plant'}

The legacy OWL components (fp_shopfloor_tablet, fp_plant_overview)
remain registered — no code removed, just no menu points at them.
Phase 5 cleanup will remove the OWL components after a release of
soak time on entech.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:10:36 -04:00
gsinghpal
7f70785b79 feat(fusion_plating_shopfloor): ShopfloorLanding client action (P3.2-P3.4)
Plan tasks P3.2 + P3.3 + P3.4 batched. Full ShopfloorLanding OWL
client action — replaces fp_shopfloor_tablet AND folds in
fp_plant_overview.

  Header strip          Title, station chip, station picker dropdown,
                        Station/All-Plant mode toggle, QR scan controls,
                        last-refresh indicator.
  KPI strip             4 tech-relevant tiles: Ready · Running ·
                        Bakes Due (warning) · Holds (red when > 0).
  Search                Live debounced (200ms) across WO# + customer +
                        part. ESC clears.
  Kanban board          Columns = work centres from /fp/landing/kanban.
                        Cards = FpKanbanCard (Phase 1 — P1.7).
                        Drag-and-drop reuses existing
                        /fp/shopfloor/plant_overview/move_card.
  Card tap              doAction → fp_job_workspace with
                        {job_id, focus_step_id}.
  QR scan               FP-STATION pairs, FP-JOB / FP-STEP jump to the
                        Workspace.

Mode + station_id persist in localStorage (LS_STATION_ID, LS_MODE).
Auto-refresh every 15s; suppressed during a drop and for 5s after.

Registers client action `fp_shopfloor_landing`. Menu rewire + endpoint
stubs land in P3.5 + P3.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:09:09 -04:00
gsinghpal
9dcd00d9b2 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>
2026-05-22 22:06:40 -04:00
gsinghpal
5a28c7e90f chore(fusion_plating): bump versions for Phase 2 — cron + ACL + supporting computes
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
fusion_plating              19.0.20.7.0  (long_running on process node)
  fusion_plating_jobs         19.0.10.20.0 (late_risk_ratio, active_step_id, autopause cron)
  fusion_plating_shopfloor    19.0.27.1.0  (no code change; data-version bump for Phase 2)
  fusion_plating_certificates 19.0.7.9.0   (ACL lift — bumped in P2.6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:04:26 -04:00
gsinghpal
3c2efae951 feat(fusion_plating): lift operator ACL for cert write + thickness create + override read
Plan task P2.6. Per the spec's "techs wear multiple hats" rule, lift
gates so technicians can do their work without permission walls:

  fp.certificate         operator: read → read+write
                         (flip draft→issued from tablet)
  fp.thickness.reading   operator: read → read+write+create
                         (capture Fischerscope readings from tablet)
  fp.job.node.override   operator: NEW read-only
                         (see opt-out badges on steps)

Supervisor-only operations (step Skip, hold Release, override
Re-include) remain enforced in workspace_controller, not ACL — so the
ACL stays minimal and the controller centralizes the gate logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:04:05 -04:00
gsinghpal
c06d3d442a feat(fusion_plating_jobs): auto-pause cron for stale in-progress steps
Plan tasks P2.4 + P2.5 batched.

Adds _cron_autopause_stale_steps method on fp.job.step + 30-min cron
registration. Flips in_progress steps idle > threshold to paused with
a chatter audit ("Auto-paused after Nh idle. Resume from the tablet
when work continues.").

Threshold from ir.config_parameter:
    fp.shopfloor.autopause_threshold_hours  (default 8.0)

Recipe nodes opt out via fusion.plating.process.node.long_running
(added in P2.1) — useful for 24h bakes and multi-shift soaks.

Fixes the 411-hour ghost timer that motivated the redesign. Doesn't
replace the existing nudge crons — those still notify the supervisor;
this one actually pauses the timer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:03:20 -04:00
gsinghpal
c76eb94724 feat(fusion_plating_jobs): late_risk_ratio + active_step_id computes on fp.job
Plan tasks P2.2 + P2.3 batched (both small additive computes on fp.job;
local tests not run between them — entech verifies).

  late_risk_ratio  — stored Float, remaining_planned / minutes_to_deadline.
                     Drives the Manager At-Risk view (Phase 4).
                     Recomputes on step state, duration, deadline changes.

  active_step_id   — non-stored Many2one. Currently in_progress step
                     (lowest sequence if multiple — defensive).
                     Drives JobWorkspace landing focus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:01:58 -04:00
gsinghpal
06dc6a62b9 feat(fusion_plating): long_running flag on process node (auto-pause opt-out)
Plan task P2.1. Boolean on fusion.plating.process.node that exempts
steps generated from this node from the shop-floor auto-pause cron
(added in P2.4/P2.5). Use for 24h bakes, multi-shift soaks, and
similar long-but-legitimate operations.

Toggle visible on the process-node form for operation/step types,
grouped with parallel_start in the Behaviour section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:01:13 -04:00
31 changed files with 2137 additions and 30 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.20.6.2',
'version': '19.0.20.8.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

View File

@@ -263,6 +263,16 @@ class FpProcessNode(models.Model):
'progress (e.g. paperwork or QA review that runs alongside '
'production).',
)
long_running = fields.Boolean(
string='Long-running step',
default=False,
help='When True, steps generated from this recipe node are exempt '
'from the shop-floor auto-pause cron. Use for 24h bakes, '
'multi-shift soaks, and similar legitimately-long operations '
'that would otherwise be auto-paused after the idle threshold '
'(ir.config_parameter fp.shopfloor.autopause_threshold_hours, '
'default 8h). See plan 2026-05-22-shopfloor-tablet-redesign.',
)
opt_in_out = fields.Selection(
[
('disabled', 'Required'),

View File

@@ -65,6 +65,51 @@ class FpWorkCentre(models.Model):
# field via _inherit if/when the bake-oven coupling is needed.
active = fields.Boolean(default=True)
# Phase 4 tablet redesign — Manager At-Risk heatmap inputs.
# Non-stored (recomputed on every read by /fp/manager/at_risk; the
# endpoint caches the payload for 60s anyway so the cost is bounded).
bottleneck_score = fields.Float(
compute='_compute_bottleneck',
string='Bottleneck Score',
help='active_step_count * avg_wait_minutes (rolling 7-day). '
'Drives the Manager At-Risk heatmap — work centres with '
'high score have queue + wait pressure.',
)
avg_wait_minutes = fields.Float(
compute='_compute_bottleneck',
string='Avg Wait (min)',
help='Average minutes that steps at this work centre waited in '
'ready state before starting, over the last 7 days.',
)
def _compute_bottleneck(self):
from datetime import timedelta
Step = self.env['fp.job.step']
now = fields.Datetime.now()
seven_days_ago = now - timedelta(days=7)
for wc in self:
active_n = Step.search_count([
('work_centre_id', '=', wc.id),
('state', 'in', ('ready', 'in_progress')),
])
# Avg wait: recent steps where date_started is set; approximate
# "ready since" as create_date when no explicit ready timestamp
# is recorded. Bounded set (last 7 days) keeps the search cheap.
recent = Step.search([
('work_centre_id', '=', wc.id),
('date_started', '>=', seven_days_ago),
('date_started', '!=', False),
])
waits = []
for s in recent:
if s.create_date and s.date_started:
waits.append(
(s.date_started - s.create_date).total_seconds() / 60.0
)
avg = (sum(waits) / len(waits)) if waits else 0.0
wc.avg_wait_minutes = avg
wc.bottleneck_score = active_n * avg
_sql_constraints = [
('unique_code', 'UNIQUE(code)', 'Work centre code must be unique.'),
]

View File

@@ -96,6 +96,11 @@
<field name="parallel_start"
invisible="node_type not in ('operation', 'step')"
help="When the parent recipe is Sequential, ticking this lets the step start while earlier-sequence steps are still in progress."/>
<!-- Phase 2 tablet redesign — opt out of the
auto-pause cron for legitimately-long steps
(24h bakes, multi-shift soaks). -->
<field name="long_running"
invisible="node_type not in ('operation', 'step')"/>
<field name="requires_predecessor_done"
invisible="node_type not in ('operation', 'step')"
groups="fusion_plating.group_fusion_plating_supervisor"

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.7.8.0',
'version': '19.0.7.9.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -1,8 +1,8 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,1,0,0
access_fp_certificate_supervisor,fp.certificate.supervisor,model_fp_certificate,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_certificate_operator fp.certificate.operator model_fp_certificate fusion_plating.group_fusion_plating_operator 1 0 1 0 0
3 access_fp_certificate_supervisor fp.certificate.supervisor model_fp_certificate fusion_plating.group_fusion_plating_supervisor 1 1 1 0
4 access_fp_certificate_manager fp.certificate.manager model_fp_certificate fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_thickness_reading_operator fp.thickness.reading.operator model_fp_thickness_reading fusion_plating.group_fusion_plating_operator 1 0 1 0 1 0
6 access_fp_thickness_reading_supervisor fp.thickness.reading.supervisor model_fp_thickness_reading fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_thickness_reading_manager fp.thickness.reading.manager model_fp_thickness_reading fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_cert_void_wiz_sup fp.cert.void.wiz.supervisor model_fp_cert_void_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 1

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.10.19.0',
'version': '19.0.10.20.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -31,4 +31,21 @@
<field name="interval_type">hours</field>
<field name="active" eval="True"/>
</record>
<!-- Phase 2 tablet redesign — actual auto-pause (not just nudge).
Flips in_progress steps idle > N hours to paused with chatter
audit. Threshold configurable via ir.config_parameter
`fp.shopfloor.autopause_threshold_hours` (default 8.0). Recipe
nodes can opt out via long_running=True (e.g. 24h bakes). -->
<record id="ir_cron_autopause_stale_steps" model="ir.cron">
<field name="name">Fusion Plating: Auto-pause stale in-progress steps</field>
<field name="model_id" ref="fusion_plating.model_fp_job_step"/>
<field name="state">code</field>
<field name="code">model._cron_autopause_stale_steps()</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -121,6 +121,57 @@ class FpJob(models.Model):
tail = raw.rsplit('/', 1)[-1]
job.display_wo_name = f'WO # {tail}'
# Phase 2 — At-Risk view + Workspace landing focus.
late_risk_ratio = fields.Float(
compute='_compute_late_risk_ratio',
store=True,
string='Late-risk Ratio',
help='remaining_planned_minutes / minutes_to_deadline. '
'>1.0 means the job will be late if nothing changes. '
'Drives the At-Risk view on the manager dashboard.',
)
active_step_id = fields.Many2one(
'fp.job.step',
compute='_compute_active_step_id',
string='Active Step',
help='Currently in-progress step (lowest sequence if multiple — '
'defensive). Drives JobWorkspace landing focus.',
)
@api.depends(
'date_deadline',
'step_ids.state',
'step_ids.duration_expected',
)
def _compute_late_risk_ratio(self):
from datetime import datetime
for job in self:
if not job.date_deadline:
job.late_risk_ratio = 0.0
continue
open_steps = job.step_ids.filtered(
lambda s: s.state not in ('done', 'skipped', 'cancelled')
)
remaining_planned = sum(open_steps.mapped('duration_expected') or [0])
if remaining_planned <= 0:
job.late_risk_ratio = 0.0
continue
now = datetime.now()
# date_deadline is naive UTC in Odoo; compare directly
minutes_to_deadline = max(
1.0,
(job.date_deadline - now).total_seconds() / 60.0,
)
job.late_risk_ratio = remaining_planned / minutes_to_deadline
@api.depends('step_ids.state', 'step_ids.sequence')
def _compute_active_step_id(self):
for job in self:
active = job.step_ids.filtered(
lambda s: s.state == 'in_progress'
).sorted('sequence')
job.active_step_id = active[:1].id if active else False
# ------------------------------------------------------------------
# Sub 14 — Configurable workflow state (status bar milestone)
# ------------------------------------------------------------------

View File

@@ -151,6 +151,62 @@ class FpJobStep(models.Model):
step.blocker_jump_target_model = False
step.blocker_jump_target_id = 0
# ==================================================================
# Shop-Floor auto-pause cron (Phase 2 — tablet redesign)
# ==================================================================
@api.model
def _cron_autopause_stale_steps(self):
"""Flip in_progress steps idle > threshold to paused.
Threshold read from ir.config_parameter
fp.shopfloor.autopause_threshold_hours (default 8.0)
Recipes can opt out per node via
fusion.plating.process.node.long_running (Phase 2 — P2.1)
Fixes the 411-hour ghost timer that bit us on the original tablet
when an operator started a step and never tapped Finish. Posts an
audit chatter entry on the step so the operator can see what
happened when they resume.
"""
from datetime import timedelta
threshold = float(
self.env['ir.config_parameter'].sudo()
.get_param('fp.shopfloor.autopause_threshold_hours', 8)
)
deadline = fields.Datetime.now() - timedelta(hours=threshold)
domain = [
('state', '=', 'in_progress'),
('date_started', '<', deadline),
'|',
('recipe_node_id', '=', False),
('recipe_node_id.long_running', '=', False),
]
stale = self.search(domain)
paused = 0
for step in stale:
try:
step.button_pause()
step.message_post(body=Markup(
"<b>Auto-paused</b> after %.1fh idle. "
"Resume from the tablet when work continues."
) % threshold)
_logger.info(
"Auto-paused step %s (%s) after %.1fh idle",
step.id, step.name, threshold,
)
paused += 1
except Exception:
_logger.exception(
"Auto-pause failed for step %s — skipping", step.id,
)
if paused:
_logger.info(
"_cron_autopause_stale_steps: paused %d step(s) "
"(threshold %.1fh)", paused, threshold,
)
return paused
# NOTE: the actual button_start override lives further down (~line
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
# Review auto-open + Sub 8 Racking auto-open + the receiving soft

View File

@@ -4,3 +4,6 @@ from . import test_fp_job_milestone_cascade
from . import test_qty_received_propagation
from . import test_display_wo_name
from . import test_blocker_compute
from . import test_late_risk_ratio
from . import test_active_step_id
from . import test_autopause_cron

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
"""Plan task P2.3 — fp.job.active_step_id compute."""
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestActiveStepId(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'AS'})
self.product = self.env['product.product'].create({'name': 'AS'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/AS',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
def test_no_active_step(self):
self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'S1',
'sequence': 10,
'state': 'ready',
})
self.job.invalidate_recordset(['active_step_id'])
self.assertFalse(self.job.active_step_id.id)
def test_single_in_progress_step(self):
s = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'S1',
'sequence': 10,
'state': 'in_progress',
})
self.job.invalidate_recordset(['active_step_id'])
self.assertEqual(self.job.active_step_id.id, s.id)
def test_multiple_in_progress_picks_lowest_sequence(self):
s1 = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'S1',
'sequence': 10,
'state': 'in_progress',
})
self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'S2',
'sequence': 20,
'state': 'in_progress',
})
self.job.invalidate_recordset(['active_step_id'])
self.assertEqual(self.job.active_step_id.id, s1.id)

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
"""Plan task P2.4 — _cron_autopause_stale_steps method."""
from datetime import datetime, timedelta
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestAutopauseCron(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'AP'})
self.product = self.env['product.product'].create({'name': 'AP'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/AP',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
def test_stale_step_flips_to_paused(self):
step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'Stale',
'sequence': 10,
'state': 'in_progress',
'date_started': datetime.now() - timedelta(hours=10),
})
paused = self.env['fp.job.step']._cron_autopause_stale_steps()
self.assertGreaterEqual(paused, 1)
step.invalidate_recordset(['state'])
self.assertEqual(step.state, 'paused')
def test_fresh_step_unchanged(self):
step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'Fresh',
'sequence': 10,
'state': 'in_progress',
'date_started': datetime.now() - timedelta(hours=2),
})
self.env['fp.job.step']._cron_autopause_stale_steps()
step.invalidate_recordset(['state'])
self.assertEqual(step.state, 'in_progress')
def test_long_running_node_exempt(self):
node = self.env['fusion.plating.process.node'].create({
'name': 'Long bake',
'long_running': True,
'node_type': 'operation',
})
step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'Long',
'sequence': 10,
'state': 'in_progress',
'date_started': datetime.now() - timedelta(hours=20),
'recipe_node_id': node.id,
})
self.env['fp.job.step']._cron_autopause_stale_steps()
step.invalidate_recordset(['state'])
self.assertEqual(step.state, 'in_progress')
def test_threshold_config_parameter_respected(self):
self.env['ir.config_parameter'].sudo().set_param(
'fp.shopfloor.autopause_threshold_hours', '24',
)
step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'Within 24h',
'sequence': 10,
'state': 'in_progress',
'date_started': datetime.now() - timedelta(hours=10),
})
self.env['fp.job.step']._cron_autopause_stale_steps()
step.invalidate_recordset(['state'])
# 10h < 24h → still in_progress
self.assertEqual(step.state, 'in_progress')

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
"""Plan task P2.2 — fp.job.late_risk_ratio compute."""
from datetime import datetime, timedelta
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestLateRiskRatio(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'LR'})
self.product = self.env['product.product'].create({'name': 'LR'})
def _make_job(self, deadline=None):
return self.env['fp.job'].create({
'name': 'WH/JOB/LR',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
'date_deadline': deadline,
})
def test_no_deadline_zero(self):
job = self._make_job(deadline=False)
self.assertEqual(job.late_risk_ratio, 0.0)
def test_no_open_steps_zero(self):
job = self._make_job(deadline=datetime.now() + timedelta(hours=8))
self.assertEqual(job.late_risk_ratio, 0.0)
def test_ratio_above_one_when_overrun(self):
job = self._make_job(deadline=datetime.now() + timedelta(hours=2))
# One step planned for 240 min, only 120 min left → ratio ~ 2.0
self.env['fp.job.step'].create({
'job_id': job.id,
'name': 'Long step',
'sequence': 10,
'state': 'ready',
'duration_expected': 240,
})
job.invalidate_recordset(['late_risk_ratio'])
self.assertGreaterEqual(job.late_risk_ratio, 1.5)
def test_done_steps_dont_count_toward_remaining(self):
job = self._make_job(deadline=datetime.now() + timedelta(hours=4))
self.env['fp.job.step'].create({
'job_id': job.id,
'name': 'Done',
'sequence': 10,
'state': 'done',
'duration_expected': 999,
})
self.env['fp.job.step'].create({
'job_id': job.id,
'name': 'Tiny remaining',
'sequence': 20,
'state': 'ready',
'duration_expected': 30,
})
job.invalidate_recordset(['late_risk_ratio'])
# 30 min remaining vs 240 min to deadline → ratio ~ 0.125
self.assertLess(job.late_risk_ratio, 0.3)

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.27.0.0',
'version': '19.0.29.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
@@ -84,6 +84,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
'fusion_plating_shopfloor/static/src/js/job_workspace.js',
# ---- Shop Floor Landing (Phase 3 — tablet redesign) ----
'fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss',
'fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml',
'fusion_plating_shopfloor/static/src/js/shopfloor_landing.js',
'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss',
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',

View File

@@ -7,3 +7,4 @@ from . import manager_controller
from . import tank_status
from . import move_controller
from . import workspace_controller
from . import landing_controller

View File

@@ -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 '',
}

View File

@@ -466,3 +466,231 @@ class FpManagerDashboardController(http.Controller):
),
)
return {'ok': True, 'user_name': user.name}
# ======================================================================
# Phase 4 tablet redesign — 3 new tabs on the Manager Desk
# ======================================================================
@http.route('/fp/manager/funnel', type='jsonrpc', auth='user')
def funnel(self, facility_id=None):
"""Workflow funnel: jobs grouped by fp.job.workflow.state.
One row per workflow stage with its count + top 5 WO cards.
Drives the default tab on the refactored Manager Dashboard.
"""
env = request.env
Job = env['fp.job']
all_states = env['fp.job.workflow.state'].search(
[], order='sequence, id',
)
# All in-flight jobs (not done/cancelled)
job_dom = [('state', 'not in', _NEG_JOB_STATES)]
if facility_id:
job_dom.append(('facility_id', '=', int(facility_id)))
jobs = Job.search(job_dom, order='priority desc, date_deadline asc')
# Group jobs by workflow_state_id (in-memory — list is bounded by
# active job count, typically < 200)
by_stage = {ws.id: [] for ws in all_states}
for job in jobs:
if job.workflow_state_id and job.workflow_state_id.id in by_stage:
by_stage[job.workflow_state_id.id].append(job)
def _job_card_compact(job):
return {
'job_id': job.id,
'display_wo_name': job.display_wo_name,
'customer': job.partner_id.name or '',
'priority': job.priority or 'normal',
'days_in_stage': (
(fields.Datetime.now() - job.write_date).days
if job.write_date else 0
),
}
stages = []
for ws in all_states:
jobs_in_stage = by_stage[ws.id]
stages.append({
'id': ws.id,
'name': ws.name,
'color': ws.color or 'grey',
'sequence': ws.sequence or 0,
'count': len(jobs_in_stage),
'jobs': [_job_card_compact(j) for j in jobs_in_stage[:5]],
})
return {'ok': True, 'stages': stages}
@http.route('/fp/manager/approval_inbox', type='jsonrpc', auth='user')
def approval_inbox(self, facility_id=None):
"""Approval Inbox: things waiting on a manager decision.
Four buckets: holds to release, certs to issue, recent scrap to
acknowledge, override requests (deferred — empty for now).
"""
env = request.env
# ---- Holds to Release -------------------------------------------
Hold = env['fusion.plating.quality.hold']
hold_dom = [('state', 'in', ('on_hold', 'under_review'))]
holds = Hold.search(hold_dom, order='create_date desc', limit=50)
holds_to_release = [
{
'hold_id': h.id,
'name': h.name,
'job_name': (
h.x_fc_job_id.display_wo_name
if 'x_fc_job_id' in Hold._fields and h.x_fc_job_id
else (h.x_fc_job_id.name or '' if 'x_fc_job_id' in Hold._fields else '')
),
'reason': dict(Hold._fields['hold_reason'].selection).get(
h.hold_reason, h.hold_reason or '',
),
'qty': h.qty_on_hold or 0,
'requested_by': h.operator_id.name or '',
'requested_at': fp_format(env, h.create_date) if h.create_date else '',
}
for h in holds
]
# ---- Certs to Issue ---------------------------------------------
# Jobs where all_steps_terminal AND at least one required cert
# is still draft.
Job = env['fp.job']
job_dom = [
('all_steps_terminal', '=', True),
('state', 'not in', _NEG_JOB_STATES),
]
if facility_id:
job_dom.append(('facility_id', '=', int(facility_id)))
terminal_jobs = Job.search(job_dom, order='write_date desc', limit=100)
certs_to_issue = []
for job in terminal_jobs:
try:
if not job._fp_has_draft_required_certs():
continue
except Exception:
continue
certs_to_issue.append({
'job_id': job.id,
'display_wo_name': job.display_wo_name,
'customer': job.partner_id.name or '',
'cert_types': list(job._resolve_required_cert_types()),
'all_steps_done_at': fp_format(env, job.write_date) if job.write_date else '',
})
# ---- Scrap to Review --------------------------------------------
# Recent qty_scrapped bumps via S17 hook auto-spawn holds with
# hold_reason in ('scrap', 'other'). Surface the last 24h worth.
from datetime import timedelta
scrap_cutoff = fields.Datetime.now() - timedelta(hours=24)
scrap_holds = Hold.search([
('mark_for_scrap', '=', True),
('create_date', '>=', scrap_cutoff),
], order='create_date desc', limit=20)
scrap_to_review = [
{
'hold_id': h.id,
'job_name': (
h.x_fc_job_id.display_wo_name
if 'x_fc_job_id' in Hold._fields and h.x_fc_job_id else ''
),
'scrap_qty': h.qty_on_hold or 0,
'reason': h.description or '',
'operator': h.operator_id.name or '',
'at': fp_format(env, h.create_date) if h.create_date else '',
}
for h in scrap_holds
]
return {
'ok': True,
'holds_to_release': holds_to_release,
'certs_to_issue': certs_to_issue,
'scrap_to_review': scrap_to_review,
'override_requests': [], # deferred — placeholder
}
@http.route('/fp/manager/at_risk', type='jsonrpc', auth='user')
def at_risk(self, facility_id=None):
"""At-Risk view: trending-late jobs + hold reasons + bottleneck.
Sub-panels:
- trending_late: top 20 jobs by late_risk_ratio desc (> 0)
- hold_reasons: open holds grouped by hold_reason
- bottleneck: work centres sorted by bottleneck_score desc
"""
env = request.env
# ---- Trending Late ----------------------------------------------
Job = env['fp.job']
job_dom = [
('state', 'not in', _NEG_JOB_STATES),
('late_risk_ratio', '>', 0),
]
if facility_id:
job_dom.append(('facility_id', '=', int(facility_id)))
late_jobs = Job.search(
job_dom, order='late_risk_ratio desc', limit=20,
)
trending_late = [
{
'job_id': j.id,
'display_wo_name': j.display_wo_name,
'customer': j.partner_id.name or '',
'late_risk_ratio': round(j.late_risk_ratio, 2),
'deadline': fp_format(env, j.date_deadline, fmt='%Y-%m-%d') if j.date_deadline else '',
'stuck_at': (
j.active_step_id.name
if 'active_step_id' in j._fields and j.active_step_id
else ''
),
}
for j in late_jobs
]
# ---- Hold Reasons grouped --------------------------------------
Hold = env['fusion.plating.quality.hold']
reason_selection = dict(Hold._fields['hold_reason'].selection)
# read_group is the cheap way to bucket
groups = Hold.read_group(
domain=[('state', 'in', ('on_hold', 'under_review'))],
fields=['hold_reason'],
groupby=['hold_reason'],
)
hold_reasons = [
{
'reason': g.get('hold_reason') or 'unknown',
'label': reason_selection.get(g.get('hold_reason'), g.get('hold_reason') or 'unknown'),
'count': g.get('hold_reason_count', 0),
}
for g in groups
]
hold_reasons.sort(key=lambda r: r['count'], reverse=True)
# ---- Bottleneck heatmap ----------------------------------------
WC = env['fp.work.centre']
wc_dom = [('active', '=', True)]
if facility_id:
wc_dom.append(('facility_id', '=', int(facility_id)))
wcs = WC.search(wc_dom)
bottlenecks = []
for wc in wcs:
# Skip work centres with zero queue — no signal
if wc.bottleneck_score <= 0:
continue
bottlenecks.append({
'work_centre_id': wc.id,
'work_centre_name': wc.name,
'score': round(wc.bottleneck_score, 1),
'avg_wait_minutes': round(wc.avg_wait_minutes, 1),
})
bottlenecks.sort(key=lambda b: b['score'], reverse=True)
return {
'ok': True,
'trending_late': trending_late,
'hold_reasons': hold_reasons,
'bottleneck': bottlenecks[:10], # top 10
}

View File

@@ -620,15 +620,26 @@ class FpShopfloorController(http.Controller):
# ----------------------------------------------------------------------
# Tablet Overview — one-shot dashboard payload
# ----------------------------------------------------------------------
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
# New Shop Floor Landing client action (fp_shopfloor_landing) uses
# /fp/landing/kanban. The Tablet Station menu now points at the new
# surface. This endpoint stays live as long as the legacy
# fp_shopfloor_tablet OWL component is still registered — it consumes
# the rich payload (my_queue, active_wo, baths, bake_windows, gates,
# holds, pending_qcs, stations). Phase 5 cleanup will retire both the
# legacy component and this endpoint together.
@http.route('/fp/shopfloor/tablet_overview', type='jsonrpc', auth='user')
def tablet_overview(self, station_id=None, facility_id=None):
"""Return a rich dashboard snapshot for the Tablet Station page.
"""[DEPRECATED] Legacy Tablet Station dashboard payload.
Data layer: fp.job + fp.job.step. Field names on the response
keep the legacy `_wo` suffix where they were referenced from the
XML so the template doesn't need to be rewritten — internally
these now point at fp.job.step rows.
New consumers should use /fp/landing/kanban via the
fp_shopfloor_landing client action (Phase 3 tablet redesign).
"""
_logger.info(
"DEPRECATED /fp/shopfloor/tablet_overview called by uid %s"
"Phase 5 cleanup will remove this endpoint.",
request.env.uid,
)
env = request.env
user = env.user
@@ -1002,8 +1013,20 @@ class FpShopfloorController(http.Controller):
# ----------------------------------------------------------------------
# Operator queue snapshot (legacy fusion.plating.operator.queue helper)
# ----------------------------------------------------------------------
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
# The new fp_shopfloor_landing component does NOT use this endpoint;
# it uses /fp/landing/kanban which already filters per station. The
# only remaining consumer is the legacy fp_shopfloor_tablet OWL
# component (still registered, no menu pointing at it). Phase 5
# cleanup will retire both this endpoint and the legacy component
# together — no replacement, the kanban supersedes it entirely.
@http.route('/fp/shopfloor/queue', type='jsonrpc', auth='user')
def queue(self, facility_id=None):
_logger.info(
"DEPRECATED /fp/shopfloor/queue called by uid %s"
"Phase 5 cleanup will remove this endpoint.",
request.env.uid,
)
Queue = request.env.get('fusion.plating.operator.queue')
if Queue is None or not hasattr(Queue, 'build_for_user'):
# Fallback: synthesize the queue directly from fp.job.step.
@@ -1093,14 +1116,26 @@ class FpShopfloorController(http.Controller):
return {'ok': True}
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
# The new fp_shopfloor_landing client action has an "All Plant" mode
# that supersedes the standalone Plant Overview surface. Old endpoint
# stays live for the move_card sibling endpoint and the legacy
# fp_plant_overview OWL component (still registered but unhooked
# from the menu). Phase 5 cleanup will retire both together.
@http.route('/fp/shopfloor/plant_overview', type='jsonrpc', auth='user')
def plant_overview(self, facility_id=None, search=None):
"""Return active fp.job.step rows grouped by fp.work.centre.
"""[DEPRECATED] Legacy Plant Overview payload.
Cards are individual fp.job.step rows in ready / in_progress /
paused state. Columns are fp.work.centre rows; an "Unassigned"
pseudo-column collects steps without a work centre.
New consumers should use /fp/landing/kanban with mode='all_plant'
via the fp_shopfloor_landing client action (Phase 3 tablet
redesign). Note: /fp/shopfloor/plant_overview/move_card is NOT
deprecated — the Landing component still uses it for drag-drop.
"""
_logger.info(
"DEPRECATED /fp/shopfloor/plant_overview called by uid %s"
"Phase 5 cleanup will remove this endpoint.",
request.env.uid,
)
env = request.env
search = (search or '').strip().lower()

View File

@@ -14,3 +14,4 @@ access_fp_first_piece_gate_manager,fp.first.piece.gate.manager,model_fusion_plat
access_fp_operator_queue_operator,fp.operator.queue.operator,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_operator,1,1,1,1
access_fp_operator_queue_supervisor,fp.operator.queue.supervisor,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_operator_queue_manager,fp.operator.queue.manager,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_node_override_operator,fp.job.node.override.operator,fusion_plating_jobs.model_fp_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
14 access_fp_operator_queue_operator fp.operator.queue.operator model_fusion_plating_operator_queue fusion_plating.group_fusion_plating_operator 1 1 1 1
15 access_fp_operator_queue_supervisor fp.operator.queue.supervisor model_fusion_plating_operator_queue fusion_plating.group_fusion_plating_supervisor 1 1 1 1
16 access_fp_operator_queue_manager fp.operator.queue.manager model_fusion_plating_operator_queue fusion_plating.group_fusion_plating_manager 1 1 1 1
17 access_fp_job_node_override_operator fp.job.node.override.operator fusion_plating_jobs.model_fp_job_node_override fusion_plating.group_fusion_plating_operator 1 0 0 0

View File

@@ -43,15 +43,27 @@ export class ManagerDashboard extends Component {
// Defaults to false because lead-hand coverage often needs
// off-roster names.
hideOffShift: false,
// Phase 4 tablet redesign — 4 sibling tabs.
// funnel | inbox | plant_board | at_risk
activeTab: "funnel",
funnel: null, // /fp/manager/funnel payload
inbox: null, // /fp/manager/approval_inbox payload
atRisk: null, // /fp/manager/at_risk payload
});
this._lastHash = null; // sent to server to skip unchanged polls
onMounted(async () => {
await this.refresh();
// Load the default tab's data (Workflow Funnel) on first paint
await this.loadFunnel();
// 8s cadence: fast enough for production pace, light on the
// network since unchanged payloads short-circuit server-side.
this._interval = setInterval(() => this.refresh(), 8000);
// The active tab's data also refreshes on each tick.
this._interval = setInterval(() => {
this.refresh();
this.refreshActiveTab();
}, 8000);
});
onWillUnmount(() => {
@@ -283,6 +295,85 @@ export class ManagerDashboard extends Component {
target: "current",
});
}
// ==================================================================
// Phase 4 tablet redesign — 4 sibling tabs
// ==================================================================
async setActiveTab(tab) {
if (this.state.activeTab === tab) return;
this.state.activeTab = tab;
// Load the tab's data on first switch — subsequent ticks refresh
// via the auto-poll.
await this.refreshActiveTab();
}
async refreshActiveTab() {
if (this.state.activeTab === "funnel") return this.loadFunnel();
if (this.state.activeTab === "inbox") return this.loadInbox();
if (this.state.activeTab === "at_risk") return this.loadAtRisk();
// plant_board uses /fp/manager/overview via refresh()
}
async loadFunnel() {
try {
const res = await rpc("/fp/manager/funnel", {});
if (res && res.ok) this.state.funnel = res;
} catch (err) {
this.setMessage(`Funnel: ${err.message}`, "danger");
}
}
async loadInbox() {
try {
const res = await rpc("/fp/manager/approval_inbox", {});
if (res && res.ok) this.state.inbox = res;
} catch (err) {
this.setMessage(`Inbox: ${err.message}`, "danger");
}
}
async loadAtRisk() {
try {
const res = await rpc("/fp/manager/at_risk", {});
if (res && res.ok) this.state.atRisk = res;
} catch (err) {
this.setMessage(`At-Risk: ${err.message}`, "danger");
}
}
// Tap a WO card on any tab → open the JobWorkspace (Phase 1)
openJobWorkspace(jobId) {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_job_workspace",
params: { job_id: jobId },
target: "current",
});
}
// Pill colour from workflow_state.color (mirrors WorkflowChip toneClass)
funnelStageTone(color) {
const map = {
grey: "muted", blue: "info", cyan: "info",
yellow: "warning", orange: "warning",
green: "success", success: "success",
danger: "danger", purple: "info",
};
return map[color] || "muted";
}
// Bottleneck severity tone for the heatmap bar colour
bottleneckTone(score) {
if (score >= 200) return "danger";
if (score >= 60) return "warning";
return "success";
}
bottleneckPct(score) {
// Normalize to 0-100 for the bar width; cap at 100
return Math.min(100, Math.round(score / 5));
}
}
registry.category("actions").add("fp_manager_dashboard", ManagerDashboard);

View File

@@ -0,0 +1,268 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Shop Floor Landing (OWL client action)
// Client action: fp_shopfloor_landing
//
// Replaces fp_shopfloor_tablet AND folds in fp_plant_overview. Single
// kanban entry surface for technicians. Two modes:
//
// station — paired station's work centre + Unassigned + next 1-2
// WCs in recipe flow. Default when a station is paired.
// all_plant — every active work centre. Default with no station.
//
// Tap a card → JobWorkspace. QR scan: stations pair, jobs jump.
// Drag-and-drop between columns reassigns step.work_centre_id (existing
// /fp/shopfloor/plant_overview/move_card endpoint).
//
// Auto-refresh: 15s. Mode + station_id persist in localStorage.
// =============================================================================
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner";
import { FpKanbanCard } from "./components/kanban_card";
const LS_STATION_ID = "fp_landing_station_id";
const LS_MODE = "fp_landing_mode";
const REFRESH_MS = 15000;
export class FpShopfloorLanding extends Component {
static template = "fusion_plating_shopfloor.ShopfloorLanding";
static props = ["*"];
static components = { QrScanner, FpKanbanCard };
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({
mode: localStorage.getItem(LS_MODE) || "all_plant",
stationId: parseInt(localStorage.getItem(LS_STATION_ID) || "0") || null,
data: null,
search: "",
scanInput: "",
showScan: false,
lastRefresh: "",
});
this._draggedCard = null;
this._movesInFlight = 0;
this._lastDropAt = 0;
this._searchTimer = null;
onMounted(async () => {
await this.refresh();
this._refreshInterval = setInterval(() => {
if (this._movesInFlight > 0) return;
if (Date.now() - this._lastDropAt < 5000) return;
this.refresh();
}, REFRESH_MS);
});
onWillUnmount(() => {
if (this._refreshInterval) clearInterval(this._refreshInterval);
if (this._searchTimer) clearTimeout(this._searchTimer);
});
}
// ---- Data load ---------------------------------------------------------
async refresh() {
try {
const res = await rpc("/fp/landing/kanban", {
mode: this.state.mode,
station_id: this.state.stationId,
search: this.state.search || null,
});
if (res && res.ok) {
this.state.data = res;
this.state.lastRefresh = res.server_time || new Date().toLocaleTimeString();
// If station resolved (e.g. via QR scan), persist its id
if (res.station && res.station.id) {
this.state.stationId = res.station.id;
localStorage.setItem(LS_STATION_ID, String(res.station.id));
}
}
} catch (err) {
this.notification.add(err.message || String(err), { type: "danger" });
}
}
// ---- Mode toggle -------------------------------------------------------
setMode(mode) {
if (this.state.mode === mode) return;
this.state.mode = mode;
localStorage.setItem(LS_MODE, mode);
this.refresh();
}
// ---- Station picker ----------------------------------------------------
onPickStation(ev) {
const id = parseInt(ev.target.value) || null;
this.state.stationId = id;
if (id) {
localStorage.setItem(LS_STATION_ID, String(id));
// Picking a station naturally switches to station mode
this.state.mode = "station";
localStorage.setItem(LS_MODE, "station");
} else {
localStorage.removeItem(LS_STATION_ID);
}
this.refresh();
}
onUnpairStation() {
this.state.stationId = null;
this.state.mode = "all_plant";
localStorage.removeItem(LS_STATION_ID);
localStorage.setItem(LS_MODE, "all_plant");
this.refresh();
}
// ---- Search ------------------------------------------------------------
onSearchInput(ev) {
this.state.search = ev.target.value;
if (this._searchTimer) clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => this.refresh(), 200);
}
onSearchKey(ev) {
if (ev.key === "Enter") {
if (this._searchTimer) clearTimeout(this._searchTimer);
this.refresh();
} else if (ev.key === "Escape") {
this.state.search = "";
this.refresh();
}
}
// ---- Tap card → JobWorkspace ------------------------------------------
onCardTap(cardData) {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_job_workspace",
params: {
job_id: cardData.job_id,
focus_step_id: cardData.current_step_id,
},
target: "current",
});
}
// ---- QR scan -----------------------------------------------------------
toggleScan() {
this.state.showScan = !this.state.showScan;
}
async onScanSubmit() {
const code = (this.state.scanInput || "").trim();
if (!code) return;
try {
const res = await rpc("/fp/shopfloor/scan", { qr_code: code });
if (!res || !res.ok) {
this.notification.add((res && res.error) || "Unrecognised QR", { type: "danger" });
return;
}
if (res.model === "fusion.plating.shopfloor.station") {
this.state.stationId = res.id;
this.state.mode = "station";
localStorage.setItem(LS_STATION_ID, String(res.id));
localStorage.setItem(LS_MODE, "station");
this.notification.add(`Paired to ${res.name}`, { type: "success" });
} else if (res.model === "fp.job") {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_job_workspace",
params: { job_id: res.id },
target: "current",
});
return;
} else if (res.model === "fp.job.step") {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_job_workspace",
params: { job_id: res.job_id || 0, focus_step_id: res.id },
target: "current",
});
return;
} else {
this.notification.add(`Scanned ${res.model}`, { type: "info" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
} finally {
this.state.scanInput = "";
await this.refresh();
}
}
onScanKey(ev) {
if (ev.key === "Enter") this.onScanSubmit();
}
// ---- Drag-and-drop -----------------------------------------------------
// Reuses the existing /fp/shopfloor/plant_overview/move_card endpoint,
// which still works for re-assigning step.work_centre_id.
onCardDragStart(card, col, ev) {
this._draggedCard = {
id: card.step_id,
source_wc_id: col.work_center_id,
};
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", String(card.step_id));
}
onColDragOver(col, ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
}
async onColDrop(col, ev) {
ev.preventDefault();
const dragged = this._draggedCard;
this._draggedCard = null;
if (!dragged) return;
if (dragged.source_wc_id === col.work_center_id) return;
// Optimistic move: pop from source, push to target
const srcIdx = this.state.data.columns.findIndex(c => c.work_center_id === dragged.source_wc_id);
const tgtIdx = this.state.data.columns.findIndex(c => c.work_center_id === col.work_center_id);
let movedCard = null;
if (srcIdx >= 0 && tgtIdx >= 0) {
const src = this.state.data.columns[srcIdx].cards;
const idx = src.findIndex(c => c.step_id === dragged.id);
if (idx >= 0) {
movedCard = src[idx];
this.state.data.columns[srcIdx].cards = [
...src.slice(0, idx), ...src.slice(idx + 1),
];
this.state.data.columns[tgtIdx].cards = [
movedCard, ...this.state.data.columns[tgtIdx].cards,
];
}
}
this._movesInFlight += 1;
this._lastDropAt = Date.now();
try {
const res = await rpc("/fp/shopfloor/plant_overview/move_card", {
card_id: dragged.id,
target_workcenter_id: col.work_center_id,
});
if (res && res.ok) {
this.notification.add(`Moved to ${col.work_center_name}`, { type: "success" });
} else {
this.notification.add((res && res.error) || "Move failed", { type: "warning" });
await this.refresh(); // server is the source of truth on conflict
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
await this.refresh();
} finally {
this._movesInFlight -= 1;
}
}
}
registry.category("actions").add("fp_shopfloor_landing", FpShopfloorLanding);

View File

@@ -646,3 +646,230 @@
display: flex; gap: $fp-space-1; margin-top: 4px;
}
}
// =============================================================================
// Phase 4 tablet redesign — Manager dashboard sibling tabs
// =============================================================================
.o_fp_mgr_tabs {
display: flex;
gap: 4px;
padding: 8px 16px 0;
border-bottom: 1px solid $fp-border;
background: $fp-card;
.o_fp_mgr_tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 8px 14px;
font-size: 0.9rem;
color: $fp-ink-soft;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: color 0.15s ease, border-color 0.15s ease;
&:hover { color: $fp-ink; }
&.active {
color: $fp-accent;
border-bottom-color: $fp-accent;
font-weight: 600;
}
.o_fp_mgr_tab_badge {
background: $fp-accent;
color: white;
border-radius: 999px;
padding: 1px 8px;
font-size: 0.65rem;
font-weight: 700;
margin-left: 4px;
}
}
}
// ---- Workflow Funnel tab -------------------------------------------------
.o_fp_mgr_funnel {
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
.o_fp_funnel_row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid $fp-border;
}
.o_fp_funnel_stage {
display: flex;
align-items: center;
gap: 8px;
min-width: 200px;
}
.o_fp_funnel_count {
font-weight: 700;
font-size: 1.1rem;
min-width: 28px;
text-align: right;
}
.o_fp_funnel_cards {
display: flex;
gap: 6px;
flex: 1;
overflow-x: auto;
align-items: center;
}
.o_fp_funnel_card {
background: $fp-card;
border: 1px solid $fp-border;
border-radius: 6px;
padding: 6px 10px;
font-size: 0.78rem;
min-width: 130px;
cursor: pointer;
transition: background 0.1s ease, border-color 0.1s ease;
&:hover {
background: color-mix(in srgb, #{$fp-accent} 5%, #{$fp-card});
border-color: color-mix(in srgb, #{$fp-accent} 30%, #{$fp-border});
}
.o_fp_funnel_card_wo { font-weight: 600; }
.o_fp_funnel_card_meta { color: $fp-ink-soft; font-size: 0.7rem; }
}
.o_fp_funnel_more, .o_fp_funnel_empty {
color: $fp-ink-soft;
font-size: 0.78rem;
padding: 0 6px;
}
}
// ---- Approval Inbox tab --------------------------------------------------
.o_fp_mgr_inbox {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.o_fp_inbox_strip {
background: $fp-card;
border: 1px solid $fp-border;
border-radius: 8px;
padding: 12px 16px;
h4 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $fp-ink-soft;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
}
.o_fp_inbox_row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
font-size: 0.85rem;
border-bottom: 1px dashed $fp-border;
&:last-child { border-bottom: none; }
.ms-auto { margin-left: auto; }
}
.o_fp_empty_small {
color: $fp-ink-soft;
font-size: 0.8rem;
font-style: italic;
padding: 4px 0;
}
}
// ---- At-Risk tab ---------------------------------------------------------
.o_fp_mgr_atrisk {
padding: 16px;
.o_fp_atrisk_grid {
display: grid;
grid-template-columns: 1.4fr 1fr 1.2fr;
gap: 12px;
@media (max-width: 1100px) {
grid-template-columns: 1fr;
}
}
.o_fp_atrisk_card {
background: $fp-card;
border: 1px solid $fp-border;
border-radius: 8px;
padding: 12px 16px;
h4 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $fp-ink-soft;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
}
.o_fp_atrisk_row {
display: flex;
gap: 6px;
padding: 6px 0;
font-size: 0.82rem;
border-bottom: 1px dashed $fp-border;
align-items: center;
cursor: default;
&[t-on-click], &:hover { cursor: pointer; }
&:last-child { border-bottom: none; }
.ms-auto { margin-left: auto; }
}
.o_fp_atrisk_bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 0.78rem;
.o_fp_atrisk_bar_name { min-width: 100px; }
.o_fp_atrisk_bar_track {
flex: 1;
height: 10px;
background: color-mix(in srgb, #{$fp-ink-soft} 15%, transparent);
border-radius: 5px;
overflow: hidden;
}
.o_fp_atrisk_bar_fill { height: 100%; display: block; }
.o_fp_atrisk_bar_danger { background: #ff3b30; }
.o_fp_atrisk_bar_warning { background: #ff9f0a; }
.o_fp_atrisk_bar_success { background: #34c759; }
.o_fp_atrisk_bar_score { font-weight: 600; min-width: 32px; text-align: right; }
}
.o_fp_empty_small {
color: $fp-ink-soft;
font-size: 0.8rem;
font-style: italic;
padding: 4px 0;
}
}

View File

@@ -0,0 +1,246 @@
// =============================================================================
// Shop Floor Landing — kanban entry surface (Phase 3 tablet redesign)
// Replaces fp_shopfloor_tablet + fp_plant_overview.
// Dark-mode aware via $o-webclient-color-scheme branch.
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_lan-page-hex: #f3f4f6;
$_lan-card-hex: #ffffff;
$_lan-border-hex: #d8dadd;
$_lan-text-hex: #1d1d1f;
@if $o-webclient-color-scheme == dark {
$_lan-page-hex: #1a1d21 !global;
$_lan-card-hex: #22262d !global;
$_lan-border-hex: #424245 !global;
$_lan-text-hex: #f5f5f7 !global;
}
.o_fp_landing {
display: flex;
flex-direction: column;
height: 100%;
background: $_lan-page-hex;
color: $_lan-text-hex;
overflow: hidden;
}
.o_fp_landing_loading {
margin: auto;
text-align: center;
color: var(--text-secondary, #666);
> div { margin-top: 0.6rem; }
}
// ---- HEADER ------------------------------------------------------------
.o_fp_landing_head {
background: $_lan-card-hex;
border-bottom: 1px solid $_lan-border-hex;
padding: 0.55rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.o_fp_landing_title_block {
display: flex;
align-items: center;
gap: 0.6rem;
}
.o_fp_landing_title {
font-size: 1.05rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.4rem;
}
.o_fp_landing_station_chip {
background: rgba(0, 113, 227, 0.12);
color: #0050a0;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.78rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
@if $o-webclient-color-scheme == dark {
.o_fp_landing_station_chip { color: #6cb6ff; }
}
.o_fp_landing_unpair { padding: 0 0.2rem; color: inherit; opacity: 0.6;
&:hover { opacity: 1; }
}
.o_fp_landing_head_actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.o_fp_landing_station_picker { min-width: 180px; }
.o_fp_landing_refresh {
font-size: 0.7rem;
margin-left: 0.5rem;
color: var(--text-secondary, #999);
}
// ---- Scan drawer -------------------------------------------------------
.o_fp_landing_scan_drawer {
background: $_lan-card-hex;
border-bottom: 1px solid $_lan-border-hex;
padding: 0.5rem 1rem;
display: flex;
gap: 0.5rem;
}
// ---- KPI strip ---------------------------------------------------------
.o_fp_landing_kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
padding: 0.55rem 1rem;
background: $_lan-page-hex;
}
.o_fp_landing_kpi {
background: $_lan-card-hex;
border: 1px solid $_lan-border-hex;
border-radius: 6px;
padding: 0.5rem 0.7rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
position: relative;
> i {
position: absolute;
top: 0.45rem;
right: 0.55rem;
opacity: 0.4;
font-size: 0.85rem;
}
.o_fp_landing_kpi_v {
font-size: 1.6rem;
font-weight: 700;
line-height: 1.1;
}
.o_fp_landing_kpi_l {
font-size: 0.72rem;
color: var(--text-secondary, #777);
text-transform: uppercase;
letter-spacing: 0.04em;
}
&.o_fp_landing_kpi_success { border-color: rgba(52, 199, 89, 0.3); }
&.o_fp_landing_kpi_warning {
border-color: rgba(255, 159, 10, 0.4);
.o_fp_landing_kpi_v { color: #b06600; }
}
&.o_fp_landing_kpi_danger {
border-color: rgba(255, 59, 48, 0.4);
background: rgba(255, 59, 48, 0.06);
.o_fp_landing_kpi_v { color: #b00018; }
}
}
@if $o-webclient-color-scheme == dark {
.o_fp_landing_kpi_warning .o_fp_landing_kpi_v { color: #ffb84d; }
.o_fp_landing_kpi_danger .o_fp_landing_kpi_v { color: #ff7a72; }
}
// ---- Search bar --------------------------------------------------------
.o_fp_landing_search {
background: $_lan-page-hex;
padding: 0.3rem 1rem;
display: flex;
align-items: center;
gap: 0.4rem;
> i { color: var(--text-secondary, #999); font-size: 0.85rem; }
> input { max-width: 320px; }
}
// ---- Kanban board ------------------------------------------------------
.o_fp_landing_board {
flex: 1;
display: flex;
gap: 0.6rem;
padding: 0.6rem 1rem 1rem;
overflow-x: auto;
align-items: stretch;
}
.o_fp_landing_empty {
margin: auto;
text-align: center;
color: var(--text-secondary, #999);
> div { margin-top: 0.6rem; max-width: 280px; }
}
.o_fp_landing_col {
flex: 0 0 240px;
background: $_lan-card-hex;
border: 1px solid $_lan-border-hex;
border-radius: 6px;
display: flex;
flex-direction: column;
max-height: 100%;
&.o_fp_drop_target {
outline: 2px dashed #0071e3;
outline-offset: -2px;
}
}
.o_fp_landing_col_head {
padding: 0.4rem 0.7rem;
border-bottom: 1px solid $_lan-border-hex;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 0.78rem;
}
.o_fp_landing_col_name { flex: 1; }
.o_fp_landing_col_count {
background: $_lan-page-hex;
border-radius: 999px;
padding: 0.1rem 0.5rem;
font-size: 0.7rem;
color: var(--text-secondary, #777);
}
.o_fp_landing_col_body {
flex: 1;
overflow-y: auto;
padding: 0.4rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
min-height: 60px;
}
.o_fp_landing_col_empty {
color: var(--text-tertiary, #aaa);
text-align: center;
font-size: 0.78rem;
padding: 1rem 0;
}

View File

@@ -153,10 +153,53 @@
</t>
</div>
</div>
<!-- Phase 4 tablet redesign — Pending Cert + At-Risk tiles -->
<div class="o_fp_kpi o_fp_kpi_warning"
t-if="state.inbox and state.inbox.certs_to_issue and state.inbox.certs_to_issue.length"
t-on-click="() => this.setActiveTab('inbox')">
<i class="fa fa-file-text"/>
<div class="o_fp_kpi_value">
<t t-esc="state.inbox.certs_to_issue.length"/>
</div>
<div class="o_fp_kpi_label">Pending Cert</div>
</div>
<div class="o_fp_kpi o_fp_kpi_danger"
t-if="state.atRisk and state.atRisk.trending_late and state.atRisk.trending_late.length"
t-on-click="() => this.setActiveTab('at_risk')">
<i class="fa fa-exclamation-triangle"/>
<div class="o_fp_kpi_value">
<t t-esc="state.atRisk.trending_late.length"/>
</div>
<div class="o_fp_kpi_label">At-Risk</div>
</div>
</div>
<!-- ============ Workload grid ============ -->
<div class="o_fp_manager_grid" t-if="state.overview">
<!-- ============ Phase 4 tab navigation ============ -->
<div class="o_fp_mgr_tabs" t-if="state.overview">
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'funnel' ? 'active' : '')"
t-on-click="() => this.setActiveTab('funnel')">
<i class="fa fa-filter"/> Workflow Funnel
</button>
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'inbox' ? 'active' : '')"
t-on-click="() => this.setActiveTab('inbox')">
<i class="fa fa-inbox"/> Approval Inbox
<span t-if="state.inbox" class="o_fp_mgr_tab_badge">
<t t-esc="(state.inbox.holds_to_release.length + state.inbox.certs_to_issue.length + state.inbox.scrap_to_review.length)"/>
</span>
</button>
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'plant_board' ? 'active' : '')"
t-on-click="() => this.setActiveTab('plant_board')">
<i class="fa fa-th"/> Plant Board
</button>
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'at_risk' ? 'active' : '')"
t-on-click="() => this.setActiveTab('at_risk')">
<i class="fa fa-fire"/> At-Risk
</button>
</div>
<!-- ============ PLANT BOARD TAB (existing 3-column grid) ============ -->
<div class="o_fp_manager_grid"
t-if="state.overview and state.activeTab === 'plant_board'">
<!-- Needs a Worker -->
<section class="o_fp_panel o_fp_panel_unassigned">
@@ -369,6 +412,171 @@
</section>
</div>
<!-- ============ WORKFLOW FUNNEL TAB (Phase 4) ============ -->
<div class="o_fp_mgr_funnel"
t-if="state.overview and state.activeTab === 'funnel'">
<div t-if="!state.funnel" class="o_fp_empty">
<i class="fa fa-spinner fa-spin"/>
<div>Loading workflow funnel…</div>
</div>
<t t-if="state.funnel">
<div t-foreach="state.funnel.stages" t-as="stage" t-key="stage.id"
class="o_fp_funnel_row">
<div class="o_fp_funnel_stage">
<span t-att-class="'o_fp_wf_chip o_fp_wf_chip_' + funnelStageTone(stage.color)">
<span class="o_fp_wf_dot"/>
<span class="o_fp_wf_label" t-esc="stage.name"/>
</span>
<span class="o_fp_funnel_count" t-esc="stage.count"/>
</div>
<div class="o_fp_funnel_cards">
<t t-foreach="stage.jobs" t-as="card" t-key="card.job_id">
<div class="o_fp_funnel_card"
t-on-click="() => this.openJobWorkspace(card.job_id)">
<div class="o_fp_funnel_card_wo" t-esc="card.display_wo_name"/>
<div class="o_fp_funnel_card_meta">
<t t-esc="card.customer"/> · <t t-esc="card.days_in_stage"/>d
</div>
</div>
</t>
<span t-if="stage.count > stage.jobs.length" class="o_fp_funnel_more">
+<t t-esc="stage.count - stage.jobs.length"/> more
</span>
<span t-if="!stage.jobs.length" class="o_fp_funnel_empty"></span>
</div>
</div>
</t>
</div>
<!-- ============ APPROVAL INBOX TAB (Phase 4) ============ -->
<div class="o_fp_mgr_inbox"
t-if="state.overview and state.activeTab === 'inbox'">
<div t-if="!state.inbox" class="o_fp_empty">
<i class="fa fa-spinner fa-spin"/>
<div>Loading approval inbox…</div>
</div>
<t t-if="state.inbox">
<!-- Holds to Release -->
<section class="o_fp_inbox_strip">
<h4>
<i class="fa fa-pause-circle text-danger"/>
Holds to Release (<t t-esc="state.inbox.holds_to_release.length"/>)
</h4>
<div t-if="!state.inbox.holds_to_release.length" class="o_fp_empty_small">
No open holds.
</div>
<t t-foreach="state.inbox.holds_to_release" t-as="h" t-key="h.hold_id">
<div class="o_fp_inbox_row">
<span><strong t-esc="h.name"/> · <t t-esc="h.job_name"/></span>
<span class="text-muted">· <t t-esc="h.reason"/> · qty <t t-esc="h.qty"/></span>
<span class="text-muted ms-auto"><t t-esc="h.requested_by"/> · <t t-esc="h.requested_at"/></span>
<button class="btn btn-sm btn-outline-secondary ms-2"
t-on-click="() => this.openRecord('fusion.plating.quality.hold', h.hold_id)">
Open
</button>
</div>
</t>
</section>
<!-- Certs to Issue -->
<section class="o_fp_inbox_strip">
<h4>
<i class="fa fa-certificate text-warning"/>
Certs to Issue (<t t-esc="state.inbox.certs_to_issue.length"/>)
</h4>
<div t-if="!state.inbox.certs_to_issue.length" class="o_fp_empty_small">
No certs waiting to be issued.
</div>
<t t-foreach="state.inbox.certs_to_issue" t-as="c" t-key="c.job_id">
<div class="o_fp_inbox_row">
<span><strong t-esc="c.display_wo_name"/> · <t t-esc="c.customer"/></span>
<span class="text-muted">· needs <t t-esc="c.cert_types.join(', ')"/></span>
<span class="text-muted ms-auto">all steps done <t t-esc="c.all_steps_done_at"/></span>
<button class="btn btn-sm btn-primary ms-2"
t-on-click="() => this.openJobWorkspace(c.job_id)">
Open Workspace
</button>
</div>
</t>
</section>
<!-- Scrap to Review -->
<section class="o_fp_inbox_strip">
<h4>
<i class="fa fa-trash text-muted"/>
Scrap to Review (<t t-esc="state.inbox.scrap_to_review.length"/>)
</h4>
<div t-if="!state.inbox.scrap_to_review.length" class="o_fp_empty_small">
No recent scrap to acknowledge.
</div>
<t t-foreach="state.inbox.scrap_to_review" t-as="s" t-key="s.hold_id">
<div class="o_fp_inbox_row">
<span><strong t-esc="s.job_name"/> · <t t-esc="s.scrap_qty"/> scrapped</span>
<span class="text-muted" t-if="s.reason">· "<t t-esc="s.reason"/>"</span>
<span class="text-muted ms-auto"><t t-esc="s.operator"/> · <t t-esc="s.at"/></span>
<button class="btn btn-sm btn-outline-secondary ms-2"
t-on-click="() => this.openRecord('fusion.plating.quality.hold', s.hold_id)">
Open
</button>
</div>
</t>
</section>
</t>
</div>
<!-- ============ AT-RISK TAB (Phase 4) ============ -->
<div class="o_fp_mgr_atrisk"
t-if="state.overview and state.activeTab === 'at_risk'">
<div t-if="!state.atRisk" class="o_fp_empty">
<i class="fa fa-spinner fa-spin"/>
<div>Loading at-risk view…</div>
</div>
<t t-if="state.atRisk">
<div class="o_fp_atrisk_grid">
<section class="o_fp_atrisk_card">
<h4><i class="fa fa-clock-o"/> Trending Late (<t t-esc="state.atRisk.trending_late.length"/>)</h4>
<div t-if="!state.atRisk.trending_late.length" class="o_fp_empty_small">
No late-risk jobs right now.
</div>
<t t-foreach="state.atRisk.trending_late" t-as="j" t-key="j.job_id">
<div class="o_fp_atrisk_row"
t-on-click="() => this.openJobWorkspace(j.job_id)">
<span><strong t-esc="j.display_wo_name"/> · <t t-esc="j.customer"/></span>
<span t-if="j.stuck_at" class="text-muted">· stuck at <t t-esc="j.stuck_at"/></span>
<span class="text-danger ms-auto">×<t t-esc="j.late_risk_ratio"/></span>
</div>
</t>
</section>
<section class="o_fp_atrisk_card">
<h4><i class="fa fa-pause-circle"/> Hold Reasons</h4>
<div t-if="!state.atRisk.hold_reasons.length" class="o_fp_empty_small">
No open holds.
</div>
<t t-foreach="state.atRisk.hold_reasons" t-as="r" t-key="r.reason">
<div class="o_fp_atrisk_row">
<span t-esc="r.label"/>
<strong class="ms-auto" t-esc="r.count"/>
</div>
</t>
</section>
<section class="o_fp_atrisk_card">
<h4><i class="fa fa-fire"/> Bottleneck</h4>
<div t-if="!state.atRisk.bottleneck.length" class="o_fp_empty_small">
No bottlenecks detected.
</div>
<t t-foreach="state.atRisk.bottleneck" t-as="b" t-key="b.work_centre_id">
<div class="o_fp_atrisk_bar">
<span class="o_fp_atrisk_bar_name" t-esc="b.work_centre_name"/>
<span class="o_fp_atrisk_bar_track">
<span t-att-class="'o_fp_atrisk_bar_fill o_fp_atrisk_bar_' + bottleneckTone(b.score)"
t-att-style="'width: ' + bottleneckPct(b.score) + '%'"/>
</span>
<span class="o_fp_atrisk_bar_score" t-esc="b.score"/>
</div>
</t>
</section>
</div>
</t>
</div>
<!-- ============ Loading ============ -->
<div t-if="!state.overview and !state.loadError" class="o_fp_empty">
<i class="fa fa-spinner fa-spin"/>

View File

@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ShopfloorLanding">
<div class="o_fp_landing">
<!-- Loading state -->
<div t-if="!state.data" class="o_fp_landing_loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<div>Loading Shop Floor…</div>
</div>
<t t-if="state.data">
<!-- ===== HEADER ===== -->
<header class="o_fp_landing_head">
<div class="o_fp_landing_title_block">
<h1 class="o_fp_landing_title">
<i class="fa fa-industry"/> Shop Floor
</h1>
<t t-if="state.data.station">
<span class="o_fp_landing_station_chip">
@ <t t-esc="state.data.station.work_center_name or state.data.station.name"/>
<button class="btn btn-sm btn-link o_fp_landing_unpair"
t-on-click="onUnpairStation"
title="Unpair this tablet">
<i class="fa fa-times"/>
</button>
</span>
</t>
</div>
<div class="o_fp_landing_head_actions">
<!-- Station picker -->
<select class="o_fp_landing_station_picker form-select form-select-sm"
t-on-change="onPickStation">
<option value="">— Pick station —</option>
<t t-foreach="state.data.stations" t-as="s" t-key="s.id">
<option t-att-value="s.id"
t-att-selected="state.stationId === s.id">
<t t-esc="s.name"/>
<t t-if="s.work_center_name"> · <t t-esc="s.work_center_name"/></t>
</option>
</t>
</select>
<!-- Mode toggle -->
<div class="o_fp_landing_mode_toggle btn-group btn-group-sm">
<button t-att-class="'btn ' + (state.mode === 'station' ? 'btn-primary' : 'btn-outline-secondary')"
t-on-click="() => this.setMode('station')">
Station
</button>
<button t-att-class="'btn ' + (state.mode === 'all_plant' ? 'btn-primary' : 'btn-outline-secondary')"
t-on-click="() => this.setMode('all_plant')">
All Plant
</button>
</div>
<!-- Scan controls -->
<button class="btn btn-sm btn-outline-secondary" t-on-click="toggleScan">
<i class="fa fa-qrcode"/> Code
</button>
<QrScanner cssClass="'btn btn-sm btn-outline-secondary'" label="'Camera'"/>
<!-- Refresh indicator -->
<span class="o_fp_landing_refresh text-muted">
<i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/>
</span>
</div>
</header>
<!-- ===== Scan drawer ===== -->
<div t-if="state.showScan" class="o_fp_landing_scan_drawer">
<input type="text"
class="form-control"
placeholder="Scan FP-STATION:… FP-JOB:… FP-STEP:…"
t-model="state.scanInput"
t-on-keydown="onScanKey"
autofocus="autofocus"/>
<button class="btn btn-primary" t-on-click="onScanSubmit">Scan</button>
</div>
<!-- ===== KPI strip (4 tech-relevant tiles) ===== -->
<div class="o_fp_landing_kpis">
<div class="o_fp_landing_kpi">
<i class="fa fa-hourglass-half"/>
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.ready"/></span>
<span class="o_fp_landing_kpi_l">Ready</span>
</div>
<div class="o_fp_landing_kpi o_fp_landing_kpi_success">
<i class="fa fa-cogs"/>
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.running"/></span>
<span class="o_fp_landing_kpi_l">Running</span>
</div>
<div t-att-class="'o_fp_landing_kpi ' + (state.data.kpis.bakes_due ? 'o_fp_landing_kpi_warning' : '')">
<i class="fa fa-fire"/>
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.bakes_due"/></span>
<span class="o_fp_landing_kpi_l">Bakes Due</span>
</div>
<div t-att-class="'o_fp_landing_kpi ' + (state.data.kpis.holds ? 'o_fp_landing_kpi_danger' : '')">
<i class="fa fa-pause-circle"/>
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.holds"/></span>
<span class="o_fp_landing_kpi_l">Holds</span>
</div>
</div>
<!-- ===== Search bar ===== -->
<div class="o_fp_landing_search">
<i class="fa fa-search"/>
<input type="text"
class="form-control form-control-sm"
placeholder="Search WO #, customer, part…"
t-model="state.search"
t-on-input="onSearchInput"
t-on-keydown="onSearchKey"/>
</div>
<!-- ===== Kanban board ===== -->
<div class="o_fp_landing_board">
<div t-if="!state.data.columns.length" class="o_fp_landing_empty">
<i class="fa fa-check-circle fa-2x text-success"/>
<div t-if="state.mode === 'station'">
No jobs at this station right now. Switch to All Plant
to pull one over.
</div>
<div t-else="">
Plant is quiet — nothing in progress.
</div>
</div>
<t t-foreach="state.data.columns" t-as="col" t-key="col.work_center_id">
<div class="o_fp_landing_col"
t-on-dragover="(ev) => this.onColDragOver(col, ev)"
t-on-drop="(ev) => this.onColDrop(col, ev)">
<div class="o_fp_landing_col_head">
<span class="o_fp_landing_col_name" t-esc="col.work_center_name"/>
<span class="o_fp_landing_col_count"><t t-esc="col.cards.length"/></span>
</div>
<div class="o_fp_landing_col_body">
<t t-foreach="col.cards" t-as="card" t-key="card.step_id">
<div draggable="true"
t-on-dragstart="(ev) => this.onCardDragStart(card, col, ev)">
<FpKanbanCard
data="card"
density="'normal'"
showWorkflowChip="true"
showWorkcenter="state.mode === 'all_plant'"
onTap.bind="onCardTap"/>
</div>
</t>
<div t-if="!col.cards.length" class="o_fp_landing_col_empty">
</div>
</div>
</div>
</t>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -1,2 +1,3 @@
# -*- coding: utf-8 -*-
from . import test_workspace_controller
from . import test_landing_kanban

View File

@@ -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)

View File

@@ -26,14 +26,12 @@
sequence="3"
groups="fusion_plating.group_fusion_plating_manager"/>
<menuitem id="menu_fp_shopfloor_plant_overview"
name="Plant Overview"
parent="menu_fp_shopfloor"
action="action_fp_plant_overview"
sequence="5"/>
<!-- Phase 3 tablet redesign — single Workstation menu entry replaces
the legacy "Tablet Station" + "Plant Overview" pair. The new
fp_shopfloor_landing component has a Station/All-Plant toggle
so one menu item covers both old surfaces. -->
<menuitem id="menu_fp_shopfloor_tablet"
name="Tablet Station"
name="Workstation"
parent="menu_fp_shopfloor"
action="action_fp_shopfloor_tablet"
sequence="10"/>

View File

@@ -7,11 +7,17 @@
<odoo>
<!-- ================================================================== -->
<!-- Client action — Plant Overview Dashboard -->
<!-- Client action — was "Plant Overview" (fp_plant_overview). -->
<!-- Phase 3 tablet redesign retargets the tag at the new -->
<!-- fp_shopfloor_landing component (it has an "All Plant" mode that -->
<!-- supersedes the standalone plant overview). Old bookmarks keep -->
<!-- working; the legacy fp_plant_overview OWL component is still -->
<!-- registered. Menu entry removed in fp_menu.xml. -->
<!-- ================================================================== -->
<record id="action_fp_plant_overview" model="ir.actions.client">
<field name="name">Plant Overview</field>
<field name="tag">fp_plant_overview</field>
<field name="tag">fp_shopfloor_landing</field>
<field name="params" eval="{'mode': 'all_plant'}"/>
</record>
<!-- ================================================================== -->

View File

@@ -89,11 +89,15 @@
</record>
<!-- ================================================================== -->
<!-- Client action that launches the OWL tablet component -->
<!-- Client action — was "Tablet Station" (fp_shopfloor_tablet). -->
<!-- Phase 3 tablet redesign retargets the tag at the new -->
<!-- fp_shopfloor_landing component so old bookmarks keep working. -->
<!-- The legacy fp_shopfloor_tablet OWL component is still registered -->
<!-- (no code removed) but no menu points at it anymore. -->
<!-- ================================================================== -->
<record id="action_fp_shopfloor_tablet" model="ir.actions.client">
<field name="name">Tablet Station</field>
<field name="tag">fp_shopfloor_tablet</field>
<field name="name">Shop Floor</field>
<field name="tag">fp_shopfloor_landing</field>
</record>
</odoo>