folder rename
This commit is contained in:
10
fusion_plating/fusion_plating_shopfloor/models/__init__.py
Normal file
10
fusion_plating/fusion_plating_shopfloor/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import fp_shopfloor_station
|
||||
from . import fp_bake_oven
|
||||
from . import fp_bake_window
|
||||
from . import fp_first_piece_gate
|
||||
from . import fp_operator_queue
|
||||
from . import fp_tank
|
||||
@@ -0,0 +1,68 @@
|
||||
# -*- 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 FpBakeOven(models.Model):
|
||||
"""A bake oven master record.
|
||||
|
||||
Used by hydrogen embrittlement relief baking and other post-process bakes.
|
||||
Carries a chart-recorder reference so traceability evidence can be stitched
|
||||
to a bake window record by serial number.
|
||||
"""
|
||||
_name = 'fusion.plating.bake.oven'
|
||||
_description = 'Fusion Plating — Bake Oven'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'facility_id, code'
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Facility',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
work_center_id = fields.Many2one(
|
||||
'fusion.plating.work.center',
|
||||
string='Work Center',
|
||||
domain="[('facility_id','=',facility_id)]",
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
target_temp_min = fields.Float(
|
||||
string='Target Temp Min',
|
||||
help='Lower bound of target oven temperature.',
|
||||
)
|
||||
target_temp_max = fields.Float(
|
||||
string='Target Temp Max',
|
||||
help='Upper bound of target oven temperature.',
|
||||
)
|
||||
chart_recorder_ref = fields.Char(
|
||||
string='Chart Recorder Ref',
|
||||
help='Serial / asset reference of the chart recorder providing trace evidence.',
|
||||
)
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fp_bake_oven_code_facility_uniq',
|
||||
'unique(code, facility_id)',
|
||||
'Bake oven code must be unique within a facility.',
|
||||
),
|
||||
]
|
||||
288
fusion_plating/fusion_plating_shopfloor/models/fp_bake_window.py
Normal file
288
fusion_plating/fusion_plating_shopfloor/models/fp_bake_window.py
Normal file
@@ -0,0 +1,288 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpBakeWindow(models.Model):
|
||||
"""Hydrogen embrittlement relief bake window enforcer.
|
||||
|
||||
When a high-strength-steel part exits a plating tank, a clock starts.
|
||||
The customer / specification defines a window (typically 1 to 4 hours)
|
||||
inside which the relief bake MUST begin. Missing the window requires
|
||||
scrap or rework — there is no retroactive fix.
|
||||
|
||||
This model is the headline differentiator of the shop-floor module.
|
||||
A cron job updates state every 5 minutes so the kanban board on the
|
||||
tablet always reflects current jeopardy.
|
||||
"""
|
||||
_name = 'fusion.plating.bake.window'
|
||||
_description = 'Fusion Plating — Bake Window'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'bake_required_by, id desc'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: self._default_name(),
|
||||
tracking=True,
|
||||
)
|
||||
bath_id = fields.Many2one(
|
||||
'fusion.plating.bath',
|
||||
string='Bath',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
tank_id = fields.Many2one(
|
||||
'fusion.plating.tank',
|
||||
related='bath_id.tank_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
related='bath_id.facility_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
related='bath_id.company_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# ----- Job identity ---------------------------------------------------
|
||||
part_ref = fields.Char(
|
||||
string='Part Reference',
|
||||
tracking=True,
|
||||
)
|
||||
lot_ref = fields.Char(
|
||||
string='Lot Reference',
|
||||
tracking=True,
|
||||
)
|
||||
customer_ref = fields.Char(
|
||||
string='Customer Reference',
|
||||
tracking=True,
|
||||
)
|
||||
quantity = fields.Integer(
|
||||
string='Quantity',
|
||||
)
|
||||
|
||||
# ----- The clock ------------------------------------------------------
|
||||
plate_exit_time = fields.Datetime(
|
||||
string='Plate Exit Time',
|
||||
required=True,
|
||||
default=fields.Datetime.now,
|
||||
tracking=True,
|
||||
help='Moment the part left the plating tank. Starts the bake-window clock.',
|
||||
)
|
||||
window_hours = fields.Float(
|
||||
string='Window (hours)',
|
||||
default=1.0,
|
||||
required=True,
|
||||
tracking=True,
|
||||
help='Customer-specified window inside which the relief bake must begin.',
|
||||
)
|
||||
bake_required_by = fields.Datetime(
|
||||
string='Bake Required By',
|
||||
compute='_compute_required_by',
|
||||
store=True,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ----- The bake -------------------------------------------------------
|
||||
bake_start_time = fields.Datetime(
|
||||
string='Bake Start Time',
|
||||
tracking=True,
|
||||
)
|
||||
bake_end_time = fields.Datetime(
|
||||
string='Bake End Time',
|
||||
tracking=True,
|
||||
)
|
||||
bake_temp = fields.Float(
|
||||
string='Bake Temp',
|
||||
)
|
||||
bake_duration_hours = fields.Float(
|
||||
string='Bake Duration (hours)',
|
||||
)
|
||||
oven_id = fields.Many2one(
|
||||
'fusion.plating.bake.oven',
|
||||
string='Oven',
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
operator_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Operator',
|
||||
default=lambda self: self.env.user,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ----- State / display ------------------------------------------------
|
||||
state = fields.Selection(
|
||||
[
|
||||
('awaiting_bake', 'Awaiting Bake'),
|
||||
('bake_in_progress', 'Bake In Progress'),
|
||||
('baked', 'Baked'),
|
||||
('missed_window', 'Missed Window'),
|
||||
('scrapped', 'Scrapped'),
|
||||
],
|
||||
string='Status',
|
||||
default='awaiting_bake',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
status_color = fields.Integer(
|
||||
string='Status Color',
|
||||
compute='_compute_status_color',
|
||||
help='Kanban colour index. Green if plenty of time, yellow at 75% '
|
||||
'of the window consumed, red if missed.',
|
||||
)
|
||||
time_remaining_display = fields.Char(
|
||||
string='Time Remaining',
|
||||
compute='_compute_time_remaining',
|
||||
help='HH:MM:SS countdown to bake_required_by.',
|
||||
)
|
||||
|
||||
notes = fields.Html(
|
||||
string='Notes',
|
||||
)
|
||||
active = fields.Boolean(
|
||||
default=True,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Defaults
|
||||
# ==========================================================================
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.bake.window')
|
||||
return seq or '/'
|
||||
|
||||
# ==========================================================================
|
||||
# Computes
|
||||
# ==========================================================================
|
||||
@api.depends('plate_exit_time', 'window_hours')
|
||||
def _compute_required_by(self):
|
||||
for rec in self:
|
||||
if rec.plate_exit_time and rec.window_hours:
|
||||
rec.bake_required_by = rec.plate_exit_time + timedelta(
|
||||
hours=rec.window_hours
|
||||
)
|
||||
else:
|
||||
rec.bake_required_by = False
|
||||
|
||||
@api.depends('state', 'plate_exit_time', 'window_hours', 'bake_required_by',
|
||||
'bake_start_time')
|
||||
def _compute_status_color(self):
|
||||
"""Kanban colour index — neutral palette that works in light + dark.
|
||||
|
||||
0=no color, 1=red, 2=orange, 3=yellow, 4=green, 5=purple, 10=grey
|
||||
"""
|
||||
now = fields.Datetime.now()
|
||||
for rec in self:
|
||||
if rec.state == 'baked':
|
||||
rec.status_color = 4 # green
|
||||
elif rec.state == 'scrapped':
|
||||
rec.status_color = 1 # red
|
||||
elif rec.state == 'missed_window':
|
||||
rec.status_color = 1 # red
|
||||
elif rec.state == 'bake_in_progress':
|
||||
rec.status_color = 5 # purple
|
||||
elif rec.state == 'awaiting_bake' and rec.bake_required_by:
|
||||
if now >= rec.bake_required_by:
|
||||
rec.status_color = 1 # red — missed
|
||||
elif rec.plate_exit_time and rec.window_hours:
|
||||
elapsed = (now - rec.plate_exit_time).total_seconds()
|
||||
total = rec.window_hours * 3600.0
|
||||
pct = (elapsed / total) if total else 0.0
|
||||
if pct >= 0.75:
|
||||
rec.status_color = 3 # yellow
|
||||
elif pct >= 0.5:
|
||||
rec.status_color = 2 # orange (warning lean)
|
||||
else:
|
||||
rec.status_color = 4 # green
|
||||
else:
|
||||
rec.status_color = 4
|
||||
else:
|
||||
rec.status_color = 0
|
||||
|
||||
@api.depends('bake_required_by', 'state')
|
||||
def _compute_time_remaining(self):
|
||||
now = fields.Datetime.now()
|
||||
for rec in self:
|
||||
if rec.state in ('baked', 'scrapped'):
|
||||
rec.time_remaining_display = '—'
|
||||
continue
|
||||
if not rec.bake_required_by:
|
||||
rec.time_remaining_display = ''
|
||||
continue
|
||||
delta = rec.bake_required_by - now
|
||||
seconds = int(delta.total_seconds())
|
||||
if seconds <= 0:
|
||||
rec.time_remaining_display = 'OVERDUE'
|
||||
continue
|
||||
hours = seconds // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
secs = seconds % 60
|
||||
rec.time_remaining_display = f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
|
||||
# ==========================================================================
|
||||
# Actions
|
||||
# ==========================================================================
|
||||
def action_start_bake(self):
|
||||
for rec in self:
|
||||
rec.write({
|
||||
'state': 'bake_in_progress',
|
||||
'bake_start_time': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
def action_end_bake(self):
|
||||
for rec in self:
|
||||
vals = {
|
||||
'state': 'baked',
|
||||
'bake_end_time': fields.Datetime.now(),
|
||||
}
|
||||
if rec.bake_start_time:
|
||||
delta = fields.Datetime.now() - rec.bake_start_time
|
||||
vals['bake_duration_hours'] = delta.total_seconds() / 3600.0
|
||||
rec.write(vals)
|
||||
|
||||
def action_scrap(self):
|
||||
self.write({'state': 'scrapped'})
|
||||
|
||||
# ==========================================================================
|
||||
# Cron
|
||||
# ==========================================================================
|
||||
@api.model
|
||||
def _cron_update_states(self):
|
||||
"""Flip awaiting_bake records past their window to missed_window."""
|
||||
now = fields.Datetime.now()
|
||||
candidates = self.search([
|
||||
('state', '=', 'awaiting_bake'),
|
||||
('bake_required_by', '!=', False),
|
||||
('bake_required_by', '<', now),
|
||||
])
|
||||
if candidates:
|
||||
candidates.write({'state': 'missed_window'})
|
||||
for rec in candidates:
|
||||
rec.message_post(
|
||||
body=(
|
||||
f"Bake window missed: required by "
|
||||
f"{fields.Datetime.to_string(rec.bake_required_by)}, "
|
||||
f"now {fields.Datetime.to_string(now)}."
|
||||
)
|
||||
)
|
||||
# Touching status_color/time_remaining is automatic via compute on read,
|
||||
# but we trigger a recompute marker so kanban refreshes pick up colour
|
||||
# changes within the 5-minute window.
|
||||
return True
|
||||
@@ -0,0 +1,128 @@
|
||||
# -*- 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 FpFirstPieceGate(models.Model):
|
||||
"""First-piece inspection gate per routing.
|
||||
|
||||
Aerospace, nuclear and many automotive customers require that the FIRST
|
||||
piece off a routing be inspected and dispositioned BEFORE the rest of
|
||||
the lot is allowed to run. This model captures that gate.
|
||||
"""
|
||||
_name = 'fusion.plating.first.piece.gate'
|
||||
_description = 'Fusion Plating — First-Piece Inspection Gate'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'first_piece_produced desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: self._default_name(),
|
||||
tracking=True,
|
||||
)
|
||||
bath_id = fields.Many2one(
|
||||
'fusion.plating.bath',
|
||||
string='Bath',
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
related='bath_id.facility_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
part_ref = fields.Char(
|
||||
string='Part Reference',
|
||||
tracking=True,
|
||||
)
|
||||
customer_ref = fields.Char(
|
||||
string='Customer Reference',
|
||||
tracking=True,
|
||||
)
|
||||
routing_first_run = fields.Boolean(
|
||||
string='First Run of Routing',
|
||||
help='Tick if this is the first time this routing runs this part.',
|
||||
)
|
||||
|
||||
first_piece_produced = fields.Datetime(
|
||||
string='First Piece Produced',
|
||||
tracking=True,
|
||||
)
|
||||
first_piece_inspected = fields.Datetime(
|
||||
string='First Piece Inspected',
|
||||
tracking=True,
|
||||
)
|
||||
inspector_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Inspector',
|
||||
tracking=True,
|
||||
)
|
||||
result = fields.Selection(
|
||||
[
|
||||
('pending', 'Pending'),
|
||||
('pass', 'Pass'),
|
||||
('fail', 'Fail'),
|
||||
],
|
||||
string='Result',
|
||||
default='pending',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
rest_of_lot_released = fields.Boolean(
|
||||
string='Rest of Lot Released',
|
||||
tracking=True,
|
||||
)
|
||||
notes = fields.Html(
|
||||
string='Notes',
|
||||
)
|
||||
status_color = fields.Integer(
|
||||
compute='_compute_status_color',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# ==========================================================================
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.first.piece.gate')
|
||||
return seq or '/'
|
||||
|
||||
@api.depends('result', 'rest_of_lot_released')
|
||||
def _compute_status_color(self):
|
||||
for rec in self:
|
||||
if rec.result == 'fail':
|
||||
rec.status_color = 1 # red
|
||||
elif rec.result == 'pass' and rec.rest_of_lot_released:
|
||||
rec.status_color = 4 # green
|
||||
elif rec.result == 'pass':
|
||||
rec.status_color = 3 # yellow — passed but not released
|
||||
else:
|
||||
rec.status_color = 5 # purple — pending
|
||||
|
||||
# ==========================================================================
|
||||
# Actions
|
||||
# ==========================================================================
|
||||
def action_mark_pass(self):
|
||||
self.write({
|
||||
'result': 'pass',
|
||||
'first_piece_inspected': fields.Datetime.now(),
|
||||
'inspector_id': self.env.user.id,
|
||||
})
|
||||
|
||||
def action_mark_fail(self):
|
||||
self.write({
|
||||
'result': 'fail',
|
||||
'first_piece_inspected': fields.Datetime.now(),
|
||||
'inspector_id': self.env.user.id,
|
||||
})
|
||||
|
||||
def action_release_lot(self):
|
||||
self.filtered(lambda r: r.result == 'pass').write({
|
||||
'rest_of_lot_released': True,
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
# -*- 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 FpOperatorQueue(models.TransientModel):
|
||||
"""Transient operator next-up queue.
|
||||
|
||||
Built on demand from in-flight bake windows + bath log activities,
|
||||
so the tablet always renders fresh state without persisting a queue
|
||||
table that would drift from reality.
|
||||
"""
|
||||
_name = 'fusion.plating.operator.queue'
|
||||
_description = 'Fusion Plating — Operator Next-Up Queue'
|
||||
_order = 'priority desc, due_at, id'
|
||||
|
||||
operator_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Operator',
|
||||
default=lambda self: self.env.user,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Facility',
|
||||
)
|
||||
work_center_id = fields.Many2one(
|
||||
'fusion.plating.work.center',
|
||||
string='Work Center',
|
||||
)
|
||||
label = fields.Char(string='Label')
|
||||
description = fields.Text(string='Description')
|
||||
priority = fields.Integer(string='Priority', default=0)
|
||||
due_at = fields.Datetime(string='Due At')
|
||||
source_model = fields.Char(string='Source Model')
|
||||
source_id = fields.Integer(string='Source ID')
|
||||
|
||||
@api.model
|
||||
def build_for_user(self, user_id=None, facility_id=None):
|
||||
"""Build (and return) a transient queue snapshot for the given user."""
|
||||
user_id = user_id or self.env.user.id
|
||||
self.search([('operator_id', '=', user_id)]).unlink()
|
||||
|
||||
rows = []
|
||||
bake_domain = [('state', 'in', ('awaiting_bake', 'bake_in_progress'))]
|
||||
if facility_id:
|
||||
bake_domain.append(('facility_id', '=', facility_id))
|
||||
bakes = self.env['fusion.plating.bake.window'].search(
|
||||
bake_domain, order='bake_required_by'
|
||||
)
|
||||
for bw in bakes:
|
||||
rows.append({
|
||||
'operator_id': user_id,
|
||||
'facility_id': bw.facility_id.id,
|
||||
'label': f"Bake: {bw.name}",
|
||||
'description': (
|
||||
f"Part {bw.part_ref or '?'} • Lot {bw.lot_ref or '?'} • "
|
||||
f"Required by {fields.Datetime.to_string(bw.bake_required_by) if bw.bake_required_by else '?'}"
|
||||
),
|
||||
'priority': 100 if bw.state == 'awaiting_bake' else 50,
|
||||
'due_at': bw.bake_required_by,
|
||||
'source_model': 'fusion.plating.bake.window',
|
||||
'source_id': bw.id,
|
||||
})
|
||||
|
||||
gate_domain = [('result', '=', 'pending')]
|
||||
if facility_id:
|
||||
gate_domain.append(('facility_id', '=', facility_id))
|
||||
gates = self.env['fusion.plating.first.piece.gate'].search(gate_domain)
|
||||
for g in gates:
|
||||
rows.append({
|
||||
'operator_id': user_id,
|
||||
'facility_id': g.facility_id.id,
|
||||
'label': f"First piece: {g.name}",
|
||||
'description': f"Part {g.part_ref or '?'}",
|
||||
'priority': 80,
|
||||
'source_model': 'fusion.plating.first.piece.gate',
|
||||
'source_id': g.id,
|
||||
})
|
||||
|
||||
# ----- MRP work orders (if fusion_plating_bridge_mrp installed) -----
|
||||
MrpWO = self.env.get('mrp.workorder')
|
||||
if MrpWO is not None:
|
||||
wo_domain = [('state', 'in', ('ready', 'progress'))]
|
||||
if facility_id:
|
||||
wo_domain.append(('workcenter_id.x_fc_facility_id', '=', facility_id))
|
||||
work_orders = MrpWO.search(wo_domain, order='sequence, date_start')
|
||||
for wo in work_orders:
|
||||
rows.append({
|
||||
'operator_id': user_id,
|
||||
'facility_id': (
|
||||
wo.workcenter_id.x_fc_facility_id.id
|
||||
if hasattr(wo.workcenter_id, 'x_fc_facility_id')
|
||||
and wo.workcenter_id.x_fc_facility_id
|
||||
else False
|
||||
),
|
||||
'work_center_id': (
|
||||
wo.workcenter_id.x_fc_fp_work_center_id.id
|
||||
if hasattr(wo.workcenter_id, 'x_fc_fp_work_center_id')
|
||||
and wo.workcenter_id.x_fc_fp_work_center_id
|
||||
else False
|
||||
),
|
||||
'label': f"WO: {wo.display_name}",
|
||||
'description': (
|
||||
f"{wo.production_id.product_id.display_name or '?'} • "
|
||||
f"MO {wo.production_id.name or '?'} • "
|
||||
f"Qty {getattr(wo, 'qty_remaining', '') or getattr(wo, 'qty_production', '') or wo.production_id.product_qty}"
|
||||
),
|
||||
'priority': 90 if wo.state == 'ready' else 60,
|
||||
'due_at': wo.date_start or False,
|
||||
'source_model': 'mrp.workorder',
|
||||
'source_id': wo.id,
|
||||
})
|
||||
|
||||
if rows:
|
||||
self.create(rows)
|
||||
return self.search([('operator_id', '=', user_id)])
|
||||
@@ -0,0 +1,94 @@
|
||||
# -*- 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 FpShopfloorStation(models.Model):
|
||||
"""A registered shop-floor station: tablet, kiosk, desktop, or mobile.
|
||||
|
||||
Each station is identified by a unique code and a scannable QR identifier
|
||||
so an operator can pair their device to a work centre with a single tap.
|
||||
"""
|
||||
_name = 'fusion.plating.shopfloor.station'
|
||||
_description = 'Fusion Plating — Shop Floor Station'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'facility_id, work_center_id, code'
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
required=True,
|
||||
tracking=True,
|
||||
help='Short unique identifier (e.g. "TAB-EN-01").',
|
||||
)
|
||||
qr_code = fields.Char(
|
||||
string='QR Code',
|
||||
help='Scannable station identifier. Defaults to FP-STATION:<code>.',
|
||||
)
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
string='Facility',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
work_center_id = fields.Many2one(
|
||||
'fusion.plating.work.center',
|
||||
string='Work Center',
|
||||
domain="[('facility_id','=',facility_id)]",
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
station_type = fields.Selection(
|
||||
[
|
||||
('tablet', 'Tablet'),
|
||||
('kiosk', 'Kiosk'),
|
||||
('desktop', 'Desktop'),
|
||||
('mobile', 'Mobile'),
|
||||
],
|
||||
string='Station Type',
|
||||
default='tablet',
|
||||
required=True,
|
||||
)
|
||||
current_operator_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Current Operator',
|
||||
tracking=True,
|
||||
)
|
||||
last_ping = fields.Datetime(
|
||||
string='Last Ping',
|
||||
)
|
||||
notes = fields.Text(
|
||||
string='Notes',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fp_shopfloor_station_code_uniq',
|
||||
'unique(code)',
|
||||
'Shop-floor station code must be unique.',
|
||||
),
|
||||
]
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('qr_code') and vals.get('code'):
|
||||
vals['qr_code'] = f"FP-STATION:{vals['code']}"
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_ping(self):
|
||||
"""Bump the last_ping timestamp (called from the tablet client)."""
|
||||
self.write({'last_ping': fields.Datetime.now()})
|
||||
return True
|
||||
54
fusion_plating/fusion_plating_shopfloor/models/fp_tank.py
Normal file
54
fusion_plating/fusion_plating_shopfloor/models/fp_tank.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# -*- 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 FpTank(models.Model):
|
||||
"""Extend the core tank with shop-floor helpers.
|
||||
|
||||
Adds a queue-size badge so the tank kanban can advertise jeopardy and
|
||||
a one-tap action to launch the tablet view focused on this tank.
|
||||
"""
|
||||
_inherit = 'fusion.plating.tank'
|
||||
|
||||
x_fp_shopfloor_queue_size = fields.Integer(
|
||||
string='Shopfloor Queue',
|
||||
compute='_compute_shopfloor_queue_size',
|
||||
help='Number of bake windows + first-piece gates currently waiting on '
|
||||
'this tank\'s current bath.',
|
||||
)
|
||||
|
||||
def _compute_shopfloor_queue_size(self):
|
||||
BakeWindow = self.env['fusion.plating.bake.window']
|
||||
Gate = self.env['fusion.plating.first.piece.gate']
|
||||
for rec in self:
|
||||
bath_ids = rec.bath_ids.ids
|
||||
if not bath_ids:
|
||||
rec.x_fp_shopfloor_queue_size = 0
|
||||
continue
|
||||
count = BakeWindow.search_count([
|
||||
('bath_id', 'in', bath_ids),
|
||||
('state', 'in', ('awaiting_bake', 'bake_in_progress')),
|
||||
])
|
||||
count += Gate.search_count([
|
||||
('bath_id', 'in', bath_ids),
|
||||
('result', '=', 'pending'),
|
||||
])
|
||||
rec.x_fp_shopfloor_queue_size = count
|
||||
|
||||
def action_open_tablet_view(self):
|
||||
"""Open the tablet client action focused on this tank."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_shopfloor_tablet',
|
||||
'name': 'Shop Floor Tablet',
|
||||
'params': {
|
||||
'tank_id': self.id,
|
||||
'qr_code': self.qr_code or '',
|
||||
},
|
||||
'target': 'fullscreen',
|
||||
}
|
||||
Reference in New Issue
Block a user