diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py index 05709e63..2a087688 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py @@ -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, diff --git a/fusion_plating/fusion_plating_bridge_mrp/data/fp_work_role_data.xml b/fusion_plating/fusion_plating_bridge_mrp/data/fp_work_role_data.xml new file mode 100644 index 00000000..b50c5ca8 --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/data/fp_work_role_data.xml @@ -0,0 +1,76 @@ + + + + + + Masking + masking + 10 + fa-scissors + Applies masking tape/lacquer before plating and removes after. + + + + Racking + racking + 20 + fa-cogs + Fixtures parts onto racks/barrels for processing. + + + + Plating Operator + plating_op + 30 + fa-flask + Runs the plating line — chemistry checks, dwell, thickness. + + + + De-Mask + demask + 40 + fa-scissors + Removes masking material after plating. + + + + Oven / Bake + oven + 50 + fa-fire + Loads and operates embrittlement-relief ovens. + + + + De-Rack + derack + 60 + fa-cogs + Removes parts from racks/barrels for inspection. + + + + Inspection / QA + inspection + 70 + fa-search + Post-plate inspection, Fischerscope, first-piece sign-off. + + + + Rework + rework + 80 + fa-wrench + Strips bad plating; routes parts back for re-processing. + + + diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py b/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py index 180afc4c..d2015fef 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/fp_process_node.py b/fusion_plating/fusion_plating_bridge_mrp/models/fp_process_node.py new file mode 100644 index 00000000..58784235 --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/models/fp_process_node.py @@ -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.', + ) diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/fp_work_role.py b/fusion_plating/fusion_plating_bridge_mrp/models/fp_work_role.py new file mode 100644 index 00000000..7d9e32e9 --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/models/fp_work_role.py @@ -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.'), + ] diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py b/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py new file mode 100644 index 00000000..bf5d4acc --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/models/hr_employee.py @@ -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.', + ) diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py index 8df85933..3a2bf7f0 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_production.py @@ -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 diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py index 3e8a79f4..eefa3152 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py @@ -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 diff --git a/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv b/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv index 61911f24..65167f51 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating_bridge_mrp/security/ir.model.access.csv @@ -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 diff --git a/fusion_plating/fusion_plating_bridge_mrp/views/fp_work_role_views.xml b/fusion_plating/fusion_plating_bridge_mrp/views/fp_work_role_views.xml new file mode 100644 index 00000000..12075356 --- /dev/null +++ b/fusion_plating/fusion_plating_bridge_mrp/views/fp_work_role_views.xml @@ -0,0 +1,125 @@ + + + + + + fp.work.role.list + fp.work.role + + + + + + + + + + + + + + fp.work.role.form + fp.work.role + +
+ +
+
+ + + + + + + + + + + + + +
+
+
+
+ + + Shop Roles + fp.work.role + list,form + +

+ Define the roles on your shop floor +

+

+ 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. +

+
+
+ + + + + + hr.employee.form.fp.roles + hr.employee + + + + + + +
+ Work orders tagged with these roles will auto-assign to + this employee (or to another employee with the same role, + whichever is least loaded). +
+
+
+
+
+
+ + + + fusion.plating.process.node.form.fp.roles + fusion.plating.process.node + + + + + + + + + + + mrp.workorder.form.fp.roles + mrp.workorder + + + + + + + + + +
diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py index 75e9e7fe..8e6450b9 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py @@ -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 diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js index 34dc897c..7400622c 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js @@ -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 = { diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss index 13351080..3a70f981 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss @@ -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 { diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml index 82ab5866..87005dab 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml @@ -127,18 +127,31 @@