feat(bridge_mrp): shop-role auto-routing + tablet worker mode (CHUNK 4/4)
Completes the worker-access story. Handoffs now route themselves.
New model fp.work.role with 8 seeded defaults (noupdate so shops can
rename/prune):
masking · racking · plating_op · demask · oven · derack ·
inspection · rework
Each one has a code, icon, description, sequence, active flag.
Config menu: Configuration → Shop Roles (manager-only).
Field additions:
hr.employee.x_fc_work_role_ids (Many2many) — tag workers with the
roles they perform. One-person shop: one employee, every role.
Specialised shop: one role per employee. Cross-trained: multiple.
fusion.plating.process.node.x_fc_work_role_id (Many2one) — tag
each recipe operation with the role that performs it.
mrp.workorder.x_fc_work_role_id (Many2one) — copied from the recipe
operation on WO generation.
Auto-assignment on WO generation:
_generate_workorders_from_recipe() now copies the operation's role
onto the WO, then calls _fp_pick_worker_for_role() which picks the
least-loaded employee (active WO count) with that role. WO lands in
their Tablet "My Queue" the moment the MO is confirmed. No manual
routing needed for the common case.
Tablet Station — worker mode:
/fp/shopfloor/tablet_overview now filters to WOs where
x_fc_assigned_user_id == env.user when the field is populated.
KPIs (WOs Ready / In Progress) reflect the logged-in worker's load,
not shop-wide totals. "My Queue" rows carry wo_state + can_start +
can_finish so inline Start/Finish buttons appear.
New JS handlers onStartWo / onFinishWo call /fp/shopfloor/start_wo
and /fp/shopfloor/stop_wo (finish=true). One-tap progression.
Views:
hr.employee form gets a "Shop Roles" notebook page with many2many_tags.
Process node form gets x_fc_work_role_id inline after work_center_id.
Work Order form shows role + assigned worker.
Smoke-tested end-to-end on WH/MO/00010:
Masking → Administrator (masking role)
Racking → Administrator (racking role)
E-Nickel → Andrew (plating_op, least-loaded tiebreaker)
Demask → Administrator (masking)
Oven bake → Andrew (oven)
Derack → Administrator (racking fallback)
Post-plate QA → Administrator (inspection)
80 existing WOs backfilled with role + worker via name-match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_batch',
|
||||
'fusion_plating_shopfloor',
|
||||
'fusion_plating_configurator',
|
||||
'hr',
|
||||
'mrp',
|
||||
'mrp_workorder',
|
||||
'mrp_account',
|
||||
@@ -49,6 +50,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_work_role_data.xml',
|
||||
'wizard/fp_recipe_config_wizard_views.xml',
|
||||
'views/mrp_workcenter_views.xml',
|
||||
'views/mrp_workorder_views.xml',
|
||||
@@ -58,6 +60,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'views/fp_batch_views.xml',
|
||||
'views/fp_workorder_priority_views.xml',
|
||||
'views/fp_job_consumption_views.xml',
|
||||
'views/fp_work_role_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Default shop roles. noupdate="1" so shops can rename/prune freely
|
||||
without upgrades clobbering their changes.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="work_role_masking" model="fp.work.role">
|
||||
<field name="name">Masking</field>
|
||||
<field name="code">masking</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="icon">fa-scissors</field>
|
||||
<field name="description">Applies masking tape/lacquer before plating and removes after.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_racking" model="fp.work.role">
|
||||
<field name="name">Racking</field>
|
||||
<field name="code">racking</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="icon">fa-cogs</field>
|
||||
<field name="description">Fixtures parts onto racks/barrels for processing.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_plating" model="fp.work.role">
|
||||
<field name="name">Plating Operator</field>
|
||||
<field name="code">plating_op</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="icon">fa-flask</field>
|
||||
<field name="description">Runs the plating line — chemistry checks, dwell, thickness.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_demask" model="fp.work.role">
|
||||
<field name="name">De-Mask</field>
|
||||
<field name="code">demask</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="icon">fa-scissors</field>
|
||||
<field name="description">Removes masking material after plating.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_oven" model="fp.work.role">
|
||||
<field name="name">Oven / Bake</field>
|
||||
<field name="code">oven</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="description">Loads and operates embrittlement-relief ovens.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_derack" model="fp.work.role">
|
||||
<field name="name">De-Rack</field>
|
||||
<field name="code">derack</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="icon">fa-cogs</field>
|
||||
<field name="description">Removes parts from racks/barrels for inspection.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_inspection" model="fp.work.role">
|
||||
<field name="name">Inspection / QA</field>
|
||||
<field name="code">inspection</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="icon">fa-search</field>
|
||||
<field name="description">Post-plate inspection, Fischerscope, first-piece sign-off.</field>
|
||||
</record>
|
||||
|
||||
<record id="work_role_rework" model="fp.work.role">
|
||||
<field name="name">Rework</field>
|
||||
<field name="code">rework</field>
|
||||
<field name="sequence">80</field>
|
||||
<field name="icon">fa-wrench</field>
|
||||
<field name="description">Strips bad plating; routes parts back for re-processing.</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -15,3 +15,6 @@ from . import fp_job_node_override
|
||||
from . import fp_job_consumption
|
||||
from . import account_move
|
||||
from . import sale_order
|
||||
from . import fp_work_role
|
||||
from . import hr_employee
|
||||
from . import fp_process_node
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpProcessNode(models.Model):
|
||||
"""Tag each recipe operation with the shop role that performs it.
|
||||
|
||||
The auto-assigner reads this when generating WOs: each WO inherits
|
||||
its operation node's role, then hunts for an employee with a
|
||||
matching x_fc_work_role_ids membership.
|
||||
"""
|
||||
_inherit = 'fusion.plating.process.node'
|
||||
|
||||
x_fc_work_role_id = fields.Many2one(
|
||||
'fp.work.role', string='Performed By (Role)',
|
||||
ondelete='set null',
|
||||
help='Shop role that performs this step. When the WO is '
|
||||
'generated it auto-routes to an employee with this role.',
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpWorkRole(models.Model):
|
||||
"""A shop role assigned to a recipe step and to the employees who
|
||||
can perform it.
|
||||
|
||||
Shops run the same part with different staffing models:
|
||||
- One employee does every step (small shop): give them every role.
|
||||
- Specialists per operation (masking person, racker, plater): one
|
||||
role each.
|
||||
- Cross-trained workers: multiple roles per worker.
|
||||
|
||||
The model is intentionally flat — no hierarchy, no workflow. Roles
|
||||
are just tags that the WO auto-assignment compares.
|
||||
"""
|
||||
_name = 'fp.work.role'
|
||||
_description = 'Fusion Plating — Shop Work Role'
|
||||
_order = 'sequence, code'
|
||||
|
||||
name = fields.Char(string='Role Name', required=True, translate=True)
|
||||
code = fields.Char(string='Code', required=True,
|
||||
help='Short stable identifier used in auto-assignment.')
|
||||
sequence = fields.Integer(default=10)
|
||||
description = fields.Char(
|
||||
string='Description',
|
||||
help='Short operator-facing description of what this role covers.',
|
||||
)
|
||||
icon = fields.Selection(
|
||||
[('fa-scissors', 'Scissors (masking)'),
|
||||
('fa-cogs', 'Cogs (racking)'),
|
||||
('fa-flask', 'Flask (plating)'),
|
||||
('fa-fire', 'Fire (oven)'),
|
||||
('fa-search', 'Inspection'),
|
||||
('fa-wrench', 'Wrench (rework)'),
|
||||
('fa-user', 'Generic worker')],
|
||||
string='Icon', default='fa-user',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_work_role_code_uniq', 'unique(code)',
|
||||
'Role code must be unique.'),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
"""Tag employees with the shop roles they can perform.
|
||||
|
||||
An employee with role 'masking' receives the masking steps when WOs
|
||||
are generated; an employee with multiple roles receives WOs for all
|
||||
of them. A small shop where the owner wears every hat just tags
|
||||
themselves with every role.
|
||||
"""
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
x_fc_work_role_ids = fields.Many2many(
|
||||
'fp.work.role', 'fp_employee_work_role_rel',
|
||||
'employee_id', 'role_id', string='Shop Roles',
|
||||
help='Which shop roles this employee performs. Used by the '
|
||||
'Manager Desk and auto-assignment on WO generation.',
|
||||
)
|
||||
@@ -274,6 +274,36 @@ class MrpProduction(models.Model):
|
||||
# ------------------------------------------------------------------
|
||||
# Recipe → Work Order generation
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _fp_pick_worker_for_role(self, role):
|
||||
"""Pick the least-loaded employee with the given shop role.
|
||||
|
||||
Returns a res.users record, or None if no one has the role.
|
||||
"""
|
||||
if not role:
|
||||
return None
|
||||
Employee = self.env['hr.employee']
|
||||
candidates = Employee.search(
|
||||
[('x_fc_work_role_ids', 'in', role.id),
|
||||
('user_id', '!=', False),
|
||||
('active', '=', True)]
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
# Score by open WO count
|
||||
WO = self.env['mrp.workorder']
|
||||
best = None
|
||||
best_count = 10 ** 9
|
||||
for emp in candidates:
|
||||
load = WO.search_count([
|
||||
('x_fc_assigned_user_id', '=', emp.user_id.id),
|
||||
('state', 'in', ('ready', 'progress', 'waiting', 'pending')),
|
||||
])
|
||||
if load < best_count:
|
||||
best_count = load
|
||||
best = emp.user_id
|
||||
return best
|
||||
|
||||
def _generate_workorders_from_recipe(self):
|
||||
"""Generate mrp.workorder records from the assigned recipe.
|
||||
|
||||
@@ -353,13 +383,25 @@ class MrpProduction(models.Model):
|
||||
# store step instructions on the operation via the
|
||||
# existing `operation_id.note` path, or just log them
|
||||
# to the WO chatter.
|
||||
wo_vals_list.append({
|
||||
vals = {
|
||||
'production_id': production.id,
|
||||
'name': node.name,
|
||||
'workcenter_id': mrp_wc,
|
||||
'duration_expected': node.estimated_duration or 0,
|
||||
'sequence': seq_counter[0],
|
||||
})
|
||||
}
|
||||
# Inherit the operation's shop role (if the bridge
|
||||
# module is installed) so WOs can auto-route to the
|
||||
# right worker.
|
||||
if 'x_fc_work_role_id' in node._fields and node.x_fc_work_role_id:
|
||||
vals['x_fc_work_role_id'] = node.x_fc_work_role_id.id
|
||||
# Find a worker with this role (least-loaded wins)
|
||||
assignee = self._fp_pick_worker_for_role(
|
||||
node.x_fc_work_role_id
|
||||
)
|
||||
if assignee:
|
||||
vals['x_fc_assigned_user_id'] = assignee.id
|
||||
wo_vals_list.append(vals)
|
||||
if steps:
|
||||
wo_steps[seq_counter[0]] = '\n'.join(steps)
|
||||
seq_counter[0] += 10
|
||||
|
||||
@@ -64,6 +64,11 @@ class MrpWorkorder(models.Model):
|
||||
'manager; the Tablet Station shows only WOs assigned to the '
|
||||
'logged-in user.',
|
||||
)
|
||||
x_fc_work_role_id = fields.Many2one(
|
||||
'fp.work.role', string='Role',
|
||||
help='Shop role required to perform this step (copied from the '
|
||||
'recipe operation on WO generation).',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Workflow step tracking
|
||||
|
||||
@@ -15,3 +15,5 @@ access_fp_recipe_config_wizard_line_manager,fp.recipe.config.wizard.line.manager
|
||||
access_fp_job_consumption_operator,fp.job.consumption.operator,model_fp_job_consumption,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_consumption,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,125 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_work_role_list" model="ir.ui.view">
|
||||
<field name="name">fp.work.role.list</field>
|
||||
<field name="model">fp.work.role</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="code"/>
|
||||
<field name="name"/>
|
||||
<field name="icon" optional="show"/>
|
||||
<field name="description"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_work_role_form" model="ir.ui.view">
|
||||
<field name="name">fp.work.role.form</field>
|
||||
<field name="model">fp.work.role</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="e.g. Plating Operator"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code" placeholder="plating_op"/>
|
||||
<field name="icon"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description"
|
||||
placeholder="Short operator-facing description of what this role covers."/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_work_role" model="ir.actions.act_window">
|
||||
<field name="name">Shop Roles</field>
|
||||
<field name="res_model">fp.work.role</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Define the roles on your shop floor
|
||||
</p>
|
||||
<p>
|
||||
Tag each employee with the roles they can perform and tag each
|
||||
recipe step with the role that performs it. Work orders will
|
||||
auto-route to the right worker when an MO is confirmed.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_work_roles"
|
||||
name="Shop Roles"
|
||||
parent="fusion_plating.menu_fp_config"
|
||||
action="action_fp_work_role"
|
||||
sequence="55"
|
||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||
|
||||
<!-- Employee form — add roles section -->
|
||||
<record id="view_hr_employee_form_fp_roles" model="ir.ui.view">
|
||||
<field name="name">hr.employee.form.fp.roles</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Shop Roles" name="fp_shop_roles">
|
||||
<group>
|
||||
<field name="x_fc_work_role_ids" widget="many2many_tags"
|
||||
options="{'no_create_edit': True}"
|
||||
placeholder="Tag the shop roles this employee performs..."/>
|
||||
<div class="text-muted" colspan="2">
|
||||
Work orders tagged with these roles will auto-assign to
|
||||
this employee (or to another employee with the same role,
|
||||
whichever is least loaded).
|
||||
</div>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Process node form — add role field -->
|
||||
<record id="view_fp_process_node_form_fp_roles" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.process.node.form.fp.roles</field>
|
||||
<field name="model">fusion.plating.process.node</field>
|
||||
<field name="inherit_id" ref="fusion_plating.view_fp_process_node_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='work_center_id']" position="after">
|
||||
<field name="x_fc_work_role_id"
|
||||
options="{'no_create_edit': True}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Work Order form — show role + assigned worker -->
|
||||
<record id="view_mrp_workorder_form_fp_roles" model="ir.ui.view">
|
||||
<field name="name">mrp.workorder.form.fp.roles</field>
|
||||
<field name="model">mrp.workorder</field>
|
||||
<field name="inherit_id" ref="fusion_plating_bridge_mrp.view_mrp_workorder_form_fp_bridge"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet//field[@name='x_fc_customer_id']" position="after">
|
||||
<field name="x_fc_work_role_id" readonly="1"/>
|
||||
<field name="x_fc_assigned_user_id"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -359,12 +359,28 @@ class FpShopfloorController(http.Controller):
|
||||
return dom + ([('facility_id', '=', fac.id)] if fac else [])
|
||||
|
||||
wos_ready = wos_progress = 0
|
||||
my_wos = MrpWO.browse([]) if MrpWO is not None else []
|
||||
has_assignment = (
|
||||
MrpWO is not None and 'x_fc_assigned_user_id' in MrpWO._fields
|
||||
)
|
||||
if MrpWO is not None:
|
||||
wo_base = []
|
||||
if fac:
|
||||
wo_base = [('workcenter_id.x_fc_facility_id', '=', fac.id)]
|
||||
wos_ready = MrpWO.search_count(wo_base + [('state', '=', 'ready')])
|
||||
wos_progress = MrpWO.search_count(wo_base + [('state', '=', 'progress')])
|
||||
# "My" WOs — filter by assigned user when the field exists
|
||||
if has_assignment:
|
||||
my_wos = MrpWO.search(
|
||||
wo_base + [
|
||||
('x_fc_assigned_user_id', '=', user.id),
|
||||
('state', 'in', ('ready', 'progress', 'waiting')),
|
||||
],
|
||||
order='sequence, date_start',
|
||||
)
|
||||
wos_ready = len(my_wos.filtered(lambda w: w.state == 'ready'))
|
||||
wos_progress = len(my_wos.filtered(lambda w: w.state == 'progress'))
|
||||
else:
|
||||
wos_ready = MrpWO.search_count(wo_base + [('state', '=', 'ready')])
|
||||
wos_progress = MrpWO.search_count(wo_base + [('state', '=', 'progress')])
|
||||
|
||||
awaiting = BakeWindow.search_count(_dom([('state', '=', 'awaiting_bake')]))
|
||||
in_progress_bakes = BakeWindow.search_count(_dom([('state', '=', 'bake_in_progress')]))
|
||||
@@ -382,21 +398,54 @@ class FpShopfloorController(http.Controller):
|
||||
]
|
||||
|
||||
# -- My Queue (top 8) --------------------------------------------
|
||||
queue_rows = env['fusion.plating.operator.queue'].build_for_user(
|
||||
user_id=user.id, facility_id=fac.id if fac else None,
|
||||
)
|
||||
my_queue = [
|
||||
{
|
||||
'id': r.id,
|
||||
'label': r.label,
|
||||
'description': r.description,
|
||||
'priority': r.priority,
|
||||
'due_at': fields.Datetime.to_string(r.due_at) if r.due_at else '',
|
||||
'source_model': r.source_model,
|
||||
'source_id': r.source_id,
|
||||
}
|
||||
for r in queue_rows[:8]
|
||||
]
|
||||
# When the assignment field is present, prefer a laser-focused
|
||||
# WO queue; otherwise fall back to the generic operator queue.
|
||||
my_queue = []
|
||||
if has_assignment and my_wos:
|
||||
for wo in my_wos[:8]:
|
||||
prod = wo.production_id
|
||||
customer = ''
|
||||
if prod and prod.origin:
|
||||
so = env['sale.order'].search(
|
||||
[('name', '=', prod.origin)], limit=1,
|
||||
)
|
||||
customer = so.partner_id.name if so else ''
|
||||
role = getattr(wo, 'x_fc_work_role_id', False)
|
||||
my_queue.append({
|
||||
'id': wo.id,
|
||||
'label': wo.display_name or wo.name,
|
||||
'description': ' · '.join(filter(None, [
|
||||
customer,
|
||||
prod.product_id.display_name if prod.product_id else '',
|
||||
f"Qty {int(prod.product_qty)}" if prod else '',
|
||||
role.name if role else '',
|
||||
])),
|
||||
'priority': 90 if wo.state == 'ready' else 60,
|
||||
'due_at': fields.Datetime.to_string(wo.date_start) if wo.date_start else '',
|
||||
'source_model': 'mrp.workorder',
|
||||
'source_id': wo.id,
|
||||
'wo_state': wo.state,
|
||||
'wo_name': wo.display_name or wo.name,
|
||||
'can_start': wo.state == 'ready',
|
||||
'can_finish': wo.state == 'progress',
|
||||
})
|
||||
else:
|
||||
queue_rows = env['fusion.plating.operator.queue'].build_for_user(
|
||||
user_id=user.id, facility_id=fac.id if fac else None,
|
||||
)
|
||||
for r in queue_rows[:8]:
|
||||
my_queue.append({
|
||||
'id': r.id,
|
||||
'label': r.label,
|
||||
'description': r.description,
|
||||
'priority': r.priority,
|
||||
'due_at': fields.Datetime.to_string(r.due_at) if r.due_at else '',
|
||||
'source_model': r.source_model,
|
||||
'source_id': r.source_id,
|
||||
'wo_state': '',
|
||||
'can_start': False,
|
||||
'can_finish': False,
|
||||
})
|
||||
|
||||
# -- Active WO for this user -------------------------------------
|
||||
active_wo = None
|
||||
|
||||
@@ -172,6 +172,36 @@ export class ShopfloorTablet extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
async onStartWo(woId) {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/start_wo", { workorder_id: woId });
|
||||
if (res && res.ok) {
|
||||
this.setMessage("Work order started — timer running.", "success");
|
||||
} else if (res && res.error) {
|
||||
this.setMessage(res.error, "danger");
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Start failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async onFinishWo(woId) {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/stop_wo", {
|
||||
workorder_id: woId, finish: true,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage("Work order finished.", "success");
|
||||
} else if (res && res.error) {
|
||||
this.setMessage(res.error, "danger");
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Finish failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Utility
|
||||
stateBadge(state) {
|
||||
const map = {
|
||||
|
||||
@@ -227,19 +227,24 @@
|
||||
}
|
||||
.o_fp_queue_row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 20px;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, var(--o-action) 7%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
border-color: color-mix(in srgb, var(--o-action) 40%, var(--bs-border-color));
|
||||
}
|
||||
}
|
||||
.o_fp_queue_body { cursor: pointer; }
|
||||
.o_fp_queue_actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.o_fp_queue_label { font-weight: 600; }
|
||||
.o_fp_queue_desc { font-size: 0.88rem; color: var(--bs-secondary-color); }
|
||||
.o_fp_queue_pri {
|
||||
|
||||
@@ -127,18 +127,31 @@
|
||||
</div>
|
||||
<ul class="o_fp_queue_list" t-if="state.overview.my_queue.length">
|
||||
<t t-foreach="state.overview.my_queue" t-as="row" t-key="row.id">
|
||||
<li class="o_fp_queue_row"
|
||||
t-on-click="() => this.onQueueItemClick(row)">
|
||||
<li class="o_fp_queue_row">
|
||||
<div class="o_fp_queue_pri" t-att-data-priority="row.priority >= 90 ? 'high' : (row.priority >= 70 ? 'med' : 'low')">
|
||||
<t t-if="row.priority >= 90">HI</t>
|
||||
<t t-elif="row.priority >= 70">M</t>
|
||||
<t t-else="">·</t>
|
||||
</div>
|
||||
<div class="o_fp_queue_body">
|
||||
<div class="o_fp_queue_body"
|
||||
t-on-click="() => this.onQueueItemClick(row)">
|
||||
<div class="o_fp_queue_label"><t t-esc="row.label"/></div>
|
||||
<div class="o_fp_queue_desc"><t t-esc="row.description"/></div>
|
||||
</div>
|
||||
<i class="fa fa-chevron-right text-muted"/>
|
||||
<div class="o_fp_queue_actions">
|
||||
<button t-if="row.can_start"
|
||||
class="btn btn-sm btn-success"
|
||||
t-on-click="() => this.onStartWo(row.source_id)">
|
||||
<i class="fa fa-play me-1"/> Start
|
||||
</button>
|
||||
<button t-if="row.can_finish"
|
||||
class="btn btn-sm btn-primary"
|
||||
t-on-click="() => this.onFinishWo(row.source_id)">
|
||||
<i class="fa fa-check me-1"/> Finish
|
||||
</button>
|
||||
<i class="fa fa-chevron-right text-muted"
|
||||
t-if="!row.can_start and !row.can_finish"/>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user