Compare commits
10 Commits
phase1-wor
...
phase3-lan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d086c7f27 | ||
|
|
3eba80bb31 | ||
|
|
2a0d1862df | ||
|
|
7f70785b79 | ||
|
|
9dcd00d9b2 | ||
|
|
5a28c7e90f | ||
|
|
3c2efae951 | ||
|
|
c06d3d442a | ||
|
|
c76eb94724 | ||
|
|
06dc6a62b9 |
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.20.6.2',
|
'version': '19.0.20.7.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -263,6 +263,16 @@ class FpProcessNode(models.Model):
|
|||||||
'progress (e.g. paperwork or QA review that runs alongside '
|
'progress (e.g. paperwork or QA review that runs alongside '
|
||||||
'production).',
|
'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(
|
opt_in_out = fields.Selection(
|
||||||
[
|
[
|
||||||
('disabled', 'Required'),
|
('disabled', 'Required'),
|
||||||
|
|||||||
@@ -96,6 +96,11 @@
|
|||||||
<field name="parallel_start"
|
<field name="parallel_start"
|
||||||
invisible="node_type not in ('operation', 'step')"
|
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."/>
|
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"
|
<field name="requires_predecessor_done"
|
||||||
invisible="node_type not in ('operation', 'step')"
|
invisible="node_type not in ('operation', 'step')"
|
||||||
groups="fusion_plating.group_fusion_plating_supervisor"
|
groups="fusion_plating.group_fusion_plating_supervisor"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Certificates',
|
'name': 'Fusion Plating — Certificates',
|
||||||
'version': '19.0.7.8.0',
|
'version': '19.0.7.9.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
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_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_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_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_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
|
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
|
||||||
|
|||||||
|
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.10.19.0',
|
'version': '19.0.10.20.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -31,4 +31,21 @@
|
|||||||
<field name="interval_type">hours</field>
|
<field name="interval_type">hours</field>
|
||||||
<field name="active" eval="True"/>
|
<field name="active" eval="True"/>
|
||||||
</record>
|
</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>
|
</odoo>
|
||||||
|
|||||||
@@ -121,6 +121,57 @@ class FpJob(models.Model):
|
|||||||
tail = raw.rsplit('/', 1)[-1]
|
tail = raw.rsplit('/', 1)[-1]
|
||||||
job.display_wo_name = f'WO # {tail}'
|
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)
|
# Sub 14 — Configurable workflow state (status bar milestone)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -151,6 +151,62 @@ class FpJobStep(models.Model):
|
|||||||
step.blocker_jump_target_model = False
|
step.blocker_jump_target_model = False
|
||||||
step.blocker_jump_target_id = 0
|
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
|
# NOTE: the actual button_start override lives further down (~line
|
||||||
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
|
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
|
||||||
# Review auto-open + Sub 8 Racking auto-open + the receiving soft
|
# Review auto-open + Sub 8 Racking auto-open + the receiving soft
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ from . import test_fp_job_milestone_cascade
|
|||||||
from . import test_qty_received_propagation
|
from . import test_qty_received_propagation
|
||||||
from . import test_display_wo_name
|
from . import test_display_wo_name
|
||||||
from . import test_blocker_compute
|
from . import test_blocker_compute
|
||||||
|
from . import test_late_risk_ratio
|
||||||
|
from . import test_active_step_id
|
||||||
|
from . import test_autopause_cron
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.27.0.0',
|
'version': '19.0.28.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||||
'first-piece inspection gates.',
|
'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/scss/job_workspace.scss',
|
||||||
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
|
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
|
||||||
'fusion_plating_shopfloor/static/src/js/job_workspace.js',
|
'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/qr_scanner.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
|
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ from . import manager_controller
|
|||||||
from . import tank_status
|
from . import tank_status
|
||||||
from . import move_controller
|
from . import move_controller
|
||||||
from . import workspace_controller
|
from . import workspace_controller
|
||||||
|
from . import landing_controller
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
"""JSON-RPC endpoint for the Shop Floor Landing kanban (Phase 3).
|
||||||
|
|
||||||
|
Replaces the data path for both fp_shopfloor_tablet (legacy) and
|
||||||
|
fp_plant_overview (legacy). Two modes:
|
||||||
|
|
||||||
|
station — paired station's work centre + Unassigned + next 1-2 WCs
|
||||||
|
in the recipe flow. The physical-station view.
|
||||||
|
all_plant — every active work centre, sorted by recipe flow.
|
||||||
|
|
||||||
|
The card payload shape matches the existing plant_overview cards so
|
||||||
|
the front-end can share the KanbanCard component. Tapping a card opens
|
||||||
|
the JobWorkspace via doAction (handled client-side).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import fields, http
|
||||||
|
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ACTIVE_STEP_STATES = ('ready', 'in_progress', 'paused')
|
||||||
|
|
||||||
|
|
||||||
|
class FpLandingController(http.Controller):
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# /fp/landing/kanban
|
||||||
|
# ======================================================================
|
||||||
|
@http.route('/fp/landing/kanban', type='jsonrpc', auth='user')
|
||||||
|
def kanban(self, mode='all_plant', station_id=None, search=None):
|
||||||
|
env = request.env
|
||||||
|
Step = env['fp.job.step']
|
||||||
|
WorkCentre = env['fp.work.centre']
|
||||||
|
|
||||||
|
# ---- Resolve station / facility scope ----------------------------
|
||||||
|
station = None
|
||||||
|
facility = None
|
||||||
|
if station_id:
|
||||||
|
stn = env['fusion.plating.shopfloor.station'].browse(int(station_id))
|
||||||
|
if stn.exists():
|
||||||
|
station = stn
|
||||||
|
facility = stn.facility_id
|
||||||
|
if not facility:
|
||||||
|
facility = env['fusion.plating.facility'].search([], limit=1)
|
||||||
|
|
||||||
|
# ---- Which work centres to render --------------------------------
|
||||||
|
wc_dom = [('active', '=', True)]
|
||||||
|
if facility:
|
||||||
|
wc_dom.append(('facility_id', '=', facility.id))
|
||||||
|
all_wcs = WorkCentre.search(wc_dom, order='sequence, code, name')
|
||||||
|
|
||||||
|
if mode == 'station' and station and station.work_center_id:
|
||||||
|
this_wc = station.work_center_id
|
||||||
|
# Show this WC + next 1-2 WCs in the recipe flow (preview)
|
||||||
|
after = all_wcs.filtered(
|
||||||
|
lambda w: w.sequence > this_wc.sequence
|
||||||
|
)[:2]
|
||||||
|
relevant_wcs = this_wc | after
|
||||||
|
else:
|
||||||
|
relevant_wcs = all_wcs
|
||||||
|
|
||||||
|
# ---- Active steps in scope ---------------------------------------
|
||||||
|
step_dom = [('state', 'in', _ACTIVE_STEP_STATES)]
|
||||||
|
if facility:
|
||||||
|
step_dom.append(('work_centre_id.facility_id', '=', facility.id))
|
||||||
|
if mode == 'station' and relevant_wcs:
|
||||||
|
# In station mode, include the relevant WCs + Unassigned only.
|
||||||
|
# The OR-of-three-leaves is what makes this filter "this WC,
|
||||||
|
# the next 1-2 WCs, or Unassigned" — three branches OR'd.
|
||||||
|
step_dom = step_dom + [
|
||||||
|
'|', '|',
|
||||||
|
('work_centre_id', 'in', relevant_wcs.ids),
|
||||||
|
('work_centre_id', '=', False),
|
||||||
|
('work_centre_id', 'in', relevant_wcs.ids),
|
||||||
|
]
|
||||||
|
|
||||||
|
steps = Step.search(step_dom, order='sequence, id')
|
||||||
|
|
||||||
|
if search:
|
||||||
|
search_l = search.strip().lower()
|
||||||
|
steps = steps.filtered(lambda s: (
|
||||||
|
search_l in (s.job_id.display_wo_name or '').lower()
|
||||||
|
or search_l in (s.job_id.partner_id.name or '').lower()
|
||||||
|
or search_l in (
|
||||||
|
s.job_id.part_catalog_id.part_number or ''
|
||||||
|
if s.job_id.part_catalog_id else ''
|
||||||
|
).lower()
|
||||||
|
))
|
||||||
|
|
||||||
|
# ---- Group into columns ------------------------------------------
|
||||||
|
cards_by_wc = {0: []} # 0 = Unassigned sentinel
|
||||||
|
for step in steps:
|
||||||
|
wc_id = step.work_centre_id.id or 0
|
||||||
|
cards_by_wc.setdefault(wc_id, []).append(self._step_to_card(step))
|
||||||
|
|
||||||
|
columns = []
|
||||||
|
for wc in relevant_wcs:
|
||||||
|
columns.append({
|
||||||
|
'work_center_id': wc.id,
|
||||||
|
'work_center_name': wc.name,
|
||||||
|
'cards': cards_by_wc.get(wc.id, []),
|
||||||
|
})
|
||||||
|
if cards_by_wc.get(0):
|
||||||
|
columns.append({
|
||||||
|
'work_center_id': 0,
|
||||||
|
'work_center_name': 'Unassigned',
|
||||||
|
'cards': cards_by_wc[0],
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- KPIs — 4 tech-relevant tiles --------------------------------
|
||||||
|
ready = sum(1 for s in steps if s.state == 'ready')
|
||||||
|
running = sum(1 for s in steps if s.state == 'in_progress')
|
||||||
|
|
||||||
|
BakeWindow = env['fusion.plating.bake.window']
|
||||||
|
bake_dom = [('state', 'in', ('awaiting_bake', 'bake_in_progress'))]
|
||||||
|
if facility:
|
||||||
|
bake_dom.append(('facility_id', '=', facility.id))
|
||||||
|
bakes_due = BakeWindow.search_count(bake_dom)
|
||||||
|
|
||||||
|
Hold = env['fusion.plating.quality.hold']
|
||||||
|
holds = Hold.search_count([('state', 'in', ('on_hold', 'under_review'))])
|
||||||
|
|
||||||
|
# ---- Station picker payload (so client can switch stations) ------
|
||||||
|
all_stations = env['fusion.plating.shopfloor.station'].search(
|
||||||
|
[], order='facility_id, name',
|
||||||
|
)
|
||||||
|
stations = [
|
||||||
|
{
|
||||||
|
'id': s.id,
|
||||||
|
'name': s.name,
|
||||||
|
'code': s.code or '',
|
||||||
|
'facility': s.facility_id.name or '',
|
||||||
|
'work_center_name': s.work_center_id.name or '',
|
||||||
|
}
|
||||||
|
for s in all_stations
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'mode': mode,
|
||||||
|
'station': {
|
||||||
|
'id': station.id,
|
||||||
|
'name': station.name,
|
||||||
|
'code': station.code or '',
|
||||||
|
'work_center_name': station.work_center_id.name or '',
|
||||||
|
} if station else None,
|
||||||
|
'facility_name': facility.name if facility else '',
|
||||||
|
'columns': columns,
|
||||||
|
'kpis': {
|
||||||
|
'ready': ready,
|
||||||
|
'running': running,
|
||||||
|
'bakes_due': bakes_due,
|
||||||
|
'holds': holds,
|
||||||
|
},
|
||||||
|
'stations': stations,
|
||||||
|
'server_time': fp_format(env, fields.Datetime.now(), fmt='%H:%M:%S'),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _step_to_card(self, step):
|
||||||
|
"""Build the kanban card payload for one fp.job.step.
|
||||||
|
|
||||||
|
Shape matches the KanbanCard OWL component (Phase 1 — P1.7).
|
||||||
|
"""
|
||||||
|
job = step.job_id
|
||||||
|
return {
|
||||||
|
'step_id': step.id,
|
||||||
|
'job_id': job.id,
|
||||||
|
'display_wo_name': job.display_wo_name,
|
||||||
|
'customer': job.partner_id.name or '',
|
||||||
|
'part': (
|
||||||
|
job.part_catalog_id.part_number
|
||||||
|
if 'part_catalog_id' in job._fields and job.part_catalog_id
|
||||||
|
else (job.product_id.display_name or '')
|
||||||
|
),
|
||||||
|
'qty': int(job.qty or 0),
|
||||||
|
'qty_done': int(job.qty_done or 0),
|
||||||
|
'qty_scrapped': int(job.qty_scrapped or 0),
|
||||||
|
'date_deadline': fp_format(
|
||||||
|
request.env, job.date_deadline, fmt='%b %d',
|
||||||
|
) if job.date_deadline else '',
|
||||||
|
'priority': job.priority or 'normal',
|
||||||
|
'workflow_state': {
|
||||||
|
'id': job.workflow_state_id.id,
|
||||||
|
'name': job.workflow_state_id.name,
|
||||||
|
'color': job.workflow_state_id.color or 'grey',
|
||||||
|
} if job.workflow_state_id else None,
|
||||||
|
'blocker_kind': step.blocker_kind,
|
||||||
|
'blocker_reason': step.blocker_reason or '',
|
||||||
|
'current_step_id': step.id,
|
||||||
|
'current_step_name': step.name,
|
||||||
|
'work_center': step.work_centre_id.name or '',
|
||||||
|
}
|
||||||
@@ -620,15 +620,26 @@ class FpShopfloorController(http.Controller):
|
|||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Tablet Overview — one-shot dashboard payload
|
# 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')
|
@http.route('/fp/shopfloor/tablet_overview', type='jsonrpc', auth='user')
|
||||||
def tablet_overview(self, station_id=None, facility_id=None):
|
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
|
New consumers should use /fp/landing/kanban via the
|
||||||
keep the legacy `_wo` suffix where they were referenced from the
|
fp_shopfloor_landing client action (Phase 3 tablet redesign).
|
||||||
XML so the template doesn't need to be rewritten — internally
|
|
||||||
these now point at fp.job.step rows.
|
|
||||||
"""
|
"""
|
||||||
|
_logger.info(
|
||||||
|
"DEPRECATED /fp/shopfloor/tablet_overview called by uid %s — "
|
||||||
|
"Phase 5 cleanup will remove this endpoint.",
|
||||||
|
request.env.uid,
|
||||||
|
)
|
||||||
env = request.env
|
env = request.env
|
||||||
user = env.user
|
user = env.user
|
||||||
|
|
||||||
@@ -1002,8 +1013,20 @@ class FpShopfloorController(http.Controller):
|
|||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Operator queue snapshot (legacy fusion.plating.operator.queue helper)
|
# 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')
|
@http.route('/fp/shopfloor/queue', type='jsonrpc', auth='user')
|
||||||
def queue(self, facility_id=None):
|
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')
|
Queue = request.env.get('fusion.plating.operator.queue')
|
||||||
if Queue is None or not hasattr(Queue, 'build_for_user'):
|
if Queue is None or not hasattr(Queue, 'build_for_user'):
|
||||||
# Fallback: synthesize the queue directly from fp.job.step.
|
# Fallback: synthesize the queue directly from fp.job.step.
|
||||||
@@ -1093,14 +1116,26 @@ class FpShopfloorController(http.Controller):
|
|||||||
|
|
||||||
return {'ok': True}
|
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')
|
@http.route('/fp/shopfloor/plant_overview', type='jsonrpc', auth='user')
|
||||||
def plant_overview(self, facility_id=None, search=None):
|
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 /
|
New consumers should use /fp/landing/kanban with mode='all_plant'
|
||||||
paused state. Columns are fp.work.centre rows; an "Unassigned"
|
via the fp_shopfloor_landing client action (Phase 3 tablet
|
||||||
pseudo-column collects steps without a work centre.
|
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
|
env = request.env
|
||||||
search = (search or '').strip().lower()
|
search = (search or '').strip().lower()
|
||||||
|
|
||||||
|
|||||||
@@ -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_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_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_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
|
||||||
|
|||||||
|
@@ -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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import test_workspace_controller
|
from . import test_workspace_controller
|
||||||
|
from . import test_landing_kanban
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||||
|
"""Plan task P3.1 — /fp/landing/kanban endpoint."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo.tests.common import HttpCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
def _rpc(case, url, **params):
|
||||||
|
res = case.url_open(
|
||||||
|
url,
|
||||||
|
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
)
|
||||||
|
return res.json()['result']
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||||
|
class TestLandingKanban(HttpCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.authenticate("admin", "admin")
|
||||||
|
|
||||||
|
def test_all_plant_returns_columns_and_kpis(self):
|
||||||
|
res = _rpc(self, '/fp/landing/kanban', mode='all_plant')
|
||||||
|
self.assertTrue(res['ok'])
|
||||||
|
self.assertEqual(res['mode'], 'all_plant')
|
||||||
|
self.assertIn('columns', res)
|
||||||
|
self.assertIn('kpis', res)
|
||||||
|
for kpi in ('ready', 'running', 'bakes_due', 'holds'):
|
||||||
|
self.assertIn(kpi, res['kpis'])
|
||||||
|
self.assertIn('stations', res)
|
||||||
|
|
||||||
|
def test_station_mode_with_invalid_id_falls_back_to_all_plant_shape(self):
|
||||||
|
# No real station paired → station resolution returns None, but
|
||||||
|
# endpoint still produces a valid columns/kpis payload.
|
||||||
|
res = _rpc(self, '/fp/landing/kanban', mode='station', station_id=999999)
|
||||||
|
self.assertTrue(res['ok'])
|
||||||
|
self.assertIsNone(res['station'])
|
||||||
|
self.assertIn('columns', res)
|
||||||
@@ -26,14 +26,12 @@
|
|||||||
sequence="3"
|
sequence="3"
|
||||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||||
|
|
||||||
<menuitem id="menu_fp_shopfloor_plant_overview"
|
<!-- Phase 3 tablet redesign — single Workstation menu entry replaces
|
||||||
name="Plant Overview"
|
the legacy "Tablet Station" + "Plant Overview" pair. The new
|
||||||
parent="menu_fp_shopfloor"
|
fp_shopfloor_landing component has a Station/All-Plant toggle
|
||||||
action="action_fp_plant_overview"
|
so one menu item covers both old surfaces. -->
|
||||||
sequence="5"/>
|
|
||||||
|
|
||||||
<menuitem id="menu_fp_shopfloor_tablet"
|
<menuitem id="menu_fp_shopfloor_tablet"
|
||||||
name="Tablet Station"
|
name="Workstation"
|
||||||
parent="menu_fp_shopfloor"
|
parent="menu_fp_shopfloor"
|
||||||
action="action_fp_shopfloor_tablet"
|
action="action_fp_shopfloor_tablet"
|
||||||
sequence="10"/>
|
sequence="10"/>
|
||||||
|
|||||||
@@ -7,11 +7,17 @@
|
|||||||
<odoo>
|
<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">
|
<record id="action_fp_plant_overview" model="ir.actions.client">
|
||||||
<field name="name">Plant Overview</field>
|
<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>
|
</record>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
|
|||||||
@@ -89,11 +89,15 @@
|
|||||||
</record>
|
</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">
|
<record id="action_fp_shopfloor_tablet" model="ir.actions.client">
|
||||||
<field name="name">Tablet Station</field>
|
<field name="name">Shop Floor</field>
|
||||||
<field name="tag">fp_shopfloor_tablet</field>
|
<field name="tag">fp_shopfloor_landing</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
Reference in New Issue
Block a user