changes
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.8.7.1',
|
||||
'version': '19.0.9.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
@@ -100,6 +100,8 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'views/fp_job_views.xml',
|
||||
'views/fp_job_step_views.xml',
|
||||
'views/fp_jobs_menu.xml',
|
||||
'data/fp_work_role_data.xml',
|
||||
'views/fp_work_role_views.xml',
|
||||
'data/fp_recipe_enp_alum_basic.xml',
|
||||
'data/fp_recipe_enp_steel_basic.xml',
|
||||
'data/fp_recipe_enp_sp.xml',
|
||||
|
||||
76
fusion_plating/fusion_plating/data/fp_work_role_data.xml
Normal file
76
fusion_plating/fusion_plating/data/fp_work_role_data.xml
Normal file
@@ -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>
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
#
|
||||
# Phase 1 (Sub 11) — relocate fp.work.role, fp.operator.proficiency,
|
||||
# and the hr.employee shop-roles inherit from fusion_plating_bridge_mrp
|
||||
# into fusion_plating core. Re-key all related ir.model.data so the
|
||||
# new module owner picks up the existing records cleanly.
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return # Fresh install — nothing to migrate
|
||||
|
||||
patterns = [
|
||||
'model_fp_work_role',
|
||||
'model_fp_operator_proficiency',
|
||||
'access_fp_work_role_%',
|
||||
'access_fp_proficiency_%',
|
||||
'view_fp_work_role_%',
|
||||
'action_fp_work_role%',
|
||||
'menu_fp_work_role%',
|
||||
'role_%', # data records seeded by fp_work_role_data.xml
|
||||
]
|
||||
for pat in patterns:
|
||||
cr.execute(
|
||||
"""
|
||||
UPDATE ir_model_data
|
||||
SET module = 'fusion_plating'
|
||||
WHERE module = 'fusion_plating_bridge_mrp'
|
||||
AND name LIKE %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ir_model_data d2
|
||||
WHERE d2.module = 'fusion_plating'
|
||||
AND d2.name = ir_model_data.name
|
||||
)
|
||||
""",
|
||||
(pat,),
|
||||
)
|
||||
if cr.rowcount:
|
||||
_logger.info(
|
||||
"Sub 11: re-keyed %d row(s) for %s -> fusion_plating",
|
||||
cr.rowcount, pat,
|
||||
)
|
||||
@@ -23,3 +23,12 @@ from . import fp_operator_certification
|
||||
from . import fp_tz
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
|
||||
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp via
|
||||
# fusion_plating_jobs to core, so other downstream modules
|
||||
# (fusion_plating_cgp, etc.) that touch hr.employee can see the
|
||||
# shop-roles fields without a transitive dep on jobs.
|
||||
from . import fp_work_role
|
||||
from . import fp_proficiency
|
||||
from . import hr_employee
|
||||
from . import fp_process_node_inherit
|
||||
|
||||
@@ -40,3 +40,15 @@ class FpJobStepTimeLog(models.Model):
|
||||
log.duration_minutes = delta.total_seconds() / 60.0
|
||||
else:
|
||||
log.duration_minutes = 0.0
|
||||
|
||||
@api.depends('user_id', 'date_started', 'duration_minutes')
|
||||
def _compute_display_name(self):
|
||||
for log in self:
|
||||
user = log.user_id.name or 'User'
|
||||
when = log.date_started.strftime('%Y-%m-%d %H:%M') if log.date_started else ''
|
||||
mins = ('%.0f min' % log.duration_minutes) if log.duration_minutes else 'open'
|
||||
rec_bits = [user]
|
||||
if when:
|
||||
rec_bits.append(when)
|
||||
rec_bits.append(mins)
|
||||
log.display_name = ' · '.join(rec_bits)
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
163
fusion_plating/fusion_plating/models/fp_proficiency.py
Normal file
163
fusion_plating/fusion_plating/models/fp_proficiency.py
Normal file
@@ -0,0 +1,163 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
#
|
||||
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp. The model
|
||||
# never had MRP fields; the bridge module was just its initial home.
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class FpOperatorProficiency(models.Model):
|
||||
"""Operator proficiency tracker — counts successful step completions
|
||||
per (employee, role) pair and auto-promotes the employee once the
|
||||
role's mastery threshold is crossed.
|
||||
|
||||
The promotion mechanic lets managers casually train workers on the job:
|
||||
they assign someone a task they've never done, the worker finishes it
|
||||
successfully, and after N successes the role is added to the
|
||||
employee's Shop Roles automatically. The operator never has to fill
|
||||
in a form; their growing skill set just unlocks itself.
|
||||
"""
|
||||
_name = 'fp.operator.proficiency'
|
||||
_description = 'Fusion Plating — Operator Task Proficiency'
|
||||
_rec_name = 'display_name'
|
||||
_order = 'employee_id, role_id'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee', string='Operator',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
role_id = fields.Many2one(
|
||||
'fp.work.role', string='Role',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
completed_count = fields.Integer(
|
||||
string='Completions',
|
||||
default=0,
|
||||
help='Number of times this operator has successfully finished a '
|
||||
'step that required this role.',
|
||||
)
|
||||
first_completed_at = fields.Datetime(
|
||||
string='First Success',
|
||||
help='When the operator finished their first step for this role.',
|
||||
)
|
||||
last_completed_at = fields.Datetime(
|
||||
string='Last Success',
|
||||
help='Most recent step completion against this role.',
|
||||
)
|
||||
promoted = fields.Boolean(
|
||||
string='Promoted',
|
||||
default=False,
|
||||
index=True,
|
||||
help='True once the role has been added to the operator\'s Shop '
|
||||
'Roles automatically. Stays True even if a manager removes '
|
||||
'the role afterwards — the count and promotion history are '
|
||||
'preserved as a training record.',
|
||||
)
|
||||
promoted_at = fields.Datetime(
|
||||
string='Promoted On',
|
||||
help='When the auto-promotion fired (count crossed the role\'s '
|
||||
'mastery threshold).',
|
||||
)
|
||||
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name', store=True,
|
||||
)
|
||||
progress_label = fields.Char(
|
||||
compute='_compute_progress_label',
|
||||
help='"3 / 5" style indicator of how close this operator is to '
|
||||
'mastery.',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_proficiency_uniq',
|
||||
'unique(employee_id, role_id)',
|
||||
'There is already a proficiency record for this operator and role.'),
|
||||
]
|
||||
|
||||
@api.depends('employee_id.name', 'role_id.name')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
rec.display_name = (
|
||||
f'{rec.employee_id.name or "?"} — {rec.role_id.name or "?"}'
|
||||
)
|
||||
|
||||
@api.depends('completed_count', 'role_id.mastery_required')
|
||||
def _compute_progress_label(self):
|
||||
for rec in self:
|
||||
target = rec.role_id.mastery_required or 0
|
||||
rec.progress_label = (
|
||||
f'{rec.completed_count} / {target}' if target
|
||||
else str(rec.completed_count)
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _record_completion(self, employee, role):
|
||||
"""Increment the (employee, role) tally and promote if at threshold.
|
||||
|
||||
Idempotent for the (employee, role) pair — if no record exists,
|
||||
we create one. Always uses sudo() because the worker may not
|
||||
have write access to their own profile.
|
||||
"""
|
||||
if not employee or not role:
|
||||
return self.browse()
|
||||
|
||||
rec = self.sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('role_id', '=', role.id),
|
||||
], limit=1)
|
||||
now = fields.Datetime.now()
|
||||
if rec:
|
||||
new_count = rec.completed_count + 1
|
||||
rec.write({
|
||||
'completed_count': new_count,
|
||||
'last_completed_at': now,
|
||||
})
|
||||
else:
|
||||
rec = self.sudo().create({
|
||||
'employee_id': employee.id,
|
||||
'role_id': role.id,
|
||||
'completed_count': 1,
|
||||
'first_completed_at': now,
|
||||
'last_completed_at': now,
|
||||
})
|
||||
rec._maybe_promote()
|
||||
return rec
|
||||
|
||||
def _maybe_promote(self):
|
||||
for rec in self:
|
||||
if rec.promoted:
|
||||
continue
|
||||
target = rec.role_id.mastery_required or 0
|
||||
if target <= 0:
|
||||
continue
|
||||
if rec.completed_count < target:
|
||||
continue
|
||||
employee = rec.employee_id
|
||||
role = rec.role_id
|
||||
already_assigned = role in employee.x_fc_work_role_ids
|
||||
rec.sudo().write({
|
||||
'promoted': True,
|
||||
'promoted_at': fields.Datetime.now(),
|
||||
})
|
||||
if already_assigned:
|
||||
continue
|
||||
employee.sudo().write({
|
||||
'x_fc_work_role_ids': [(4, role.id)],
|
||||
})
|
||||
employee.message_post(
|
||||
body=Markup(_(
|
||||
'<b>%(name)s promoted</b> — qualified for '
|
||||
'<b>%(role)s</b> after %(count)s successful '
|
||||
'completions.'
|
||||
)) % {
|
||||
'name': employee.name,
|
||||
'role': role.name,
|
||||
'count': rec.completed_count,
|
||||
},
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
62
fusion_plating/fusion_plating/models/fp_work_role.py
Normal file
62
fusion_plating/fusion_plating/models/fp_work_role.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
#
|
||||
# Phase 1 (Sub 11) — relocated from fusion_plating_bridge_mrp. The model
|
||||
# never had MRP fields; the bridge module was just its initial home.
|
||||
|
||||
from odoo import api, 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 step 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)
|
||||
|
||||
mastery_required = fields.Integer(
|
||||
string='Mastery Threshold',
|
||||
default=lambda self: self._default_mastery_required(),
|
||||
help='Number of successful step completions a worker needs on this '
|
||||
"role before they're added to its qualified-operators list "
|
||||
'automatically. 1 = promote on first success; 3 = solid '
|
||||
"default for everyday roles; 5+ for tasks that need real "
|
||||
'practice. Defaults from Settings > Fusion Plating > '
|
||||
'Default Mastery Threshold.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _default_mastery_required(self):
|
||||
return self.env.company.x_fc_default_mastery_threshold or 3
|
||||
161
fusion_plating/fusion_plating/models/hr_employee.py
Normal file
161
fusion_plating/fusion_plating/models/hr_employee.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# -*- 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 api, 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.
|
||||
|
||||
Lead hands are a separate per-role list — they don't have to be
|
||||
primary owners of those roles, but they're authorised to step in
|
||||
when the regular owner is absent or behind. The Manager Desk
|
||||
promotes lead hands above other workers in its dropdown for any
|
||||
role they cover.
|
||||
"""
|
||||
_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. '
|
||||
'Roles are added automatically when an employee completes '
|
||||
'a task that meets the role mastery threshold.',
|
||||
)
|
||||
# Per-role lead-hand list. Sarah might be a lead hand for masking +
|
||||
# racking but not for plating; Mike might cover everything during
|
||||
# a graveyard shift. Stored on a separate relation table so the
|
||||
# primary "Shop Roles" list stays distinct from the cover-anything
|
||||
# authority.
|
||||
x_fc_lead_hand_role_ids = fields.Many2many(
|
||||
'fp.work.role', 'fp_employee_lead_hand_role_rel',
|
||||
'employee_id', 'role_id', string='Lead Hand For',
|
||||
help='Roles where this employee is authorised to lead or cover '
|
||||
'for an absent operator. Lead hands are surfaced first in '
|
||||
'the Manager Desk worker picker for these roles.',
|
||||
)
|
||||
|
||||
x_fc_proficiency_ids = fields.One2many(
|
||||
'fp.operator.proficiency', 'employee_id',
|
||||
string='Task Proficiency',
|
||||
help='Per-role completion tally. Workers earn one count per WO '
|
||||
'they finish on a given role. Once the count crosses the '
|
||||
"role's mastery threshold the role is added to their "
|
||||
'Shop Roles list automatically.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attendance helpers — used by the Manager Desk to show who is
|
||||
# currently clocked in. Works with vanilla hr_attendance or the
|
||||
# full fusion_clock module — both store an open record (no
|
||||
# check_out) for as long as the employee is on shift.
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_is_clocked_in = fields.Boolean(
|
||||
string='Clocked In',
|
||||
compute='_compute_x_fc_is_clocked_in',
|
||||
search='_search_x_fc_is_clocked_in',
|
||||
help='True if this employee currently has an open hr.attendance '
|
||||
'record (clocked in but not clocked out).',
|
||||
)
|
||||
|
||||
def _compute_x_fc_is_clocked_in(self):
|
||||
"""Compute attendance status from hr.attendance.
|
||||
|
||||
Batched so the manager dashboard doesn't issue one query per
|
||||
employee — important when the shop has dozens of operators.
|
||||
"""
|
||||
if not self:
|
||||
return
|
||||
Att = self.env.get('hr.attendance')
|
||||
if Att is None:
|
||||
for emp in self:
|
||||
emp.x_fc_is_clocked_in = False
|
||||
return
|
||||
# One read for the whole recordset.
|
||||
open_emp_ids = set(Att.sudo().search([
|
||||
('employee_id', 'in', self.ids),
|
||||
('check_out', '=', False),
|
||||
]).mapped('employee_id').ids)
|
||||
for emp in self:
|
||||
emp.x_fc_is_clocked_in = emp.id in open_emp_ids
|
||||
|
||||
def _search_x_fc_is_clocked_in(self, *args):
|
||||
"""Lets `[('x_fc_is_clocked_in', '=', True)]` work as a domain.
|
||||
|
||||
Two compounding gotchas surfaced after fusion_clock auto-closed
|
||||
the demo open attendances:
|
||||
|
||||
1. Odoo 19 normalises ``('=', True)`` into
|
||||
``('in', OrderedSet([True]))`` before invoking the search
|
||||
method. The previous code only handled ``=`` / ``!=`` and
|
||||
fell through to ``return []`` for ``in`` / ``not in`` —
|
||||
which Odoo treats as "no constraint" and matches every
|
||||
row.
|
||||
|
||||
2. ``('id', 'in', [])`` is also treated as no-constraint in
|
||||
some Odoo versions; replaced with a ``[0]`` sentinel so
|
||||
the empty-open-list case correctly matches nothing.
|
||||
|
||||
Strategy: reduce caller intent to a *match_set* of booleans
|
||||
(which values of ``x_fc_is_clocked_in`` should match), flip on
|
||||
negative operators, then translate into ``id IN`` / ``NOT IN``
|
||||
on the cached open-attendance employee ids. Variable signature
|
||||
future-proofs against Odoo's compute-field API shifting again.
|
||||
"""
|
||||
# Variable signature — Odoo 19 may pass (records, op, val).
|
||||
if len(args) == 3:
|
||||
_records, operator, value = args
|
||||
elif len(args) == 2:
|
||||
operator, value = args
|
||||
else:
|
||||
return [('id', '=', False)]
|
||||
|
||||
Att = self.env.get('hr.attendance')
|
||||
if Att is None:
|
||||
return [('id', '=', False)]
|
||||
|
||||
if operator in ('=', '!='):
|
||||
match_set = {bool(value)}
|
||||
elif operator in ('in', 'not in'):
|
||||
match_set = set(map(bool, value))
|
||||
else:
|
||||
return [('id', '=', False)]
|
||||
|
||||
# Negated operators flip the match set.
|
||||
if operator in ('!=', 'not in'):
|
||||
match_set = {True, False} - match_set
|
||||
|
||||
if not match_set:
|
||||
return [('id', '=', False)]
|
||||
if match_set == {True, False}:
|
||||
return [] # every row matches
|
||||
|
||||
open_emp_ids = Att.sudo().search(
|
||||
[('check_out', '=', False)]
|
||||
).employee_id.ids
|
||||
ids_term = open_emp_ids or [0]
|
||||
return [('id', 'in' if True in match_set else 'not in', ids_term)]
|
||||
|
||||
@api.model
|
||||
def _fp_clocked_in_user_ids(self):
|
||||
"""Return the set of res.users.ids whose linked employee is on shift.
|
||||
|
||||
Used by the Manager Desk controller to short-circuit the worker
|
||||
dropdown to "present today" without an N+1 attendance query
|
||||
per worker.
|
||||
"""
|
||||
Att = self.env.get('hr.attendance')
|
||||
if Att is None:
|
||||
return set()
|
||||
emps = Att.sudo().search([
|
||||
('check_out', '=', False),
|
||||
]).mapped('employee_id')
|
||||
return set(emps.user_id.ids)
|
||||
@@ -56,3 +56,8 @@ access_fp_job_step_manager,fp.job.step.manager,model_fp_job_step,fusion_plating.
|
||||
access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_job_step_timelog_supervisor,fp.job.step.timelog.supervisor,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
165
fusion_plating/fusion_plating/views/fp_work_role_views.xml
Normal file
165
fusion_plating/fusion_plating/views/fp_work_role_views.xml
Normal file
@@ -0,0 +1,165 @@
|
||||
<?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"/>
|
||||
<field name="mastery_required"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description"
|
||||
placeholder="Short operator-facing description of what this role covers."/>
|
||||
</group>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
<strong>Mastery Threshold</strong> controls auto-promotion: when an
|
||||
operator has finished this many WOs against this role, the role is
|
||||
added to their Shop Roles automatically and a chatter line is
|
||||
posted to their employee record. Defaults from
|
||||
<em>Settings > Fusion Plating > Default Mastery Threshold</em>.
|
||||
</div>
|
||||
</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 — Shop Roles + Lead Hand For + Proficiency tracker -->
|
||||
<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"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor">
|
||||
<group>
|
||||
<group string="Tasks This Operator Can Do">
|
||||
<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 small" colspan="2">
|
||||
Work orders tagged with these roles auto-assign to
|
||||
this employee (or to whoever has the same role and
|
||||
the lighter open queue).
|
||||
</div>
|
||||
</group>
|
||||
<group string="Lead Hand For"
|
||||
groups="fusion_plating.group_fusion_plating_manager">
|
||||
<field name="x_fc_lead_hand_role_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create_edit': True}"
|
||||
placeholder="Roles where this employee can cover for absent operators..."/>
|
||||
<div class="text-muted small" colspan="2">
|
||||
Lead hands appear at the top of the Manager Desk
|
||||
worker dropdown for these roles, even when they
|
||||
aren't the primary owner. Use for cross-trained
|
||||
workers who can step in during absences.
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<separator string="Task Proficiency"/>
|
||||
<p class="text-muted small">
|
||||
Auto-tracked: every successfully completed WO bumps the
|
||||
count for its role. When the count crosses the role's
|
||||
mastery threshold the role is added to <em>Tasks This
|
||||
Operator Can Do</em> automatically.
|
||||
</p>
|
||||
<field name="x_fc_proficiency_ids" nolabel="1"
|
||||
readonly="1">
|
||||
<list>
|
||||
<field name="role_id"/>
|
||||
<field name="completed_count"/>
|
||||
<field name="progress_label" string="Progress"/>
|
||||
<field name="promoted" widget="boolean_toggle"
|
||||
readonly="1"/>
|
||||
<field name="first_completed_at"/>
|
||||
<field name="last_completed_at"/>
|
||||
<field name="promoted_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</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>
|
||||
|
||||
<!--
|
||||
NOTE: the WO form already shows x_fc_work_role_id + x_fc_assigned_user_id
|
||||
via mrp_workorder_views.xml (after production_id). The earlier inherit
|
||||
here would cause the fields to render twice.
|
||||
-->
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user