folder rename
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import shopfloor_controller
|
||||
Binary file not shown.
@@ -0,0 +1,767 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpShopfloorController(http.Controller):
|
||||
"""JSON-RPC endpoints for the shop-floor tablet client.
|
||||
|
||||
NOTE — Odoo 19 requires `type='jsonrpc'`. The legacy `type='json'`
|
||||
decorator was removed.
|
||||
"""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# QR scan dispatch
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/scan', type='jsonrpc', auth='user')
|
||||
def scan(self, qr_code):
|
||||
"""Resolve a scanned QR code to a target record.
|
||||
|
||||
Recognised payloads:
|
||||
FP-TANK:<code> -> fusion.plating.tank
|
||||
FP-BATH:<name> -> fusion.plating.bath
|
||||
FP-STATION:<code> -> fusion.plating.shopfloor.station
|
||||
FP-JOB:<name> -> fusion.plating.bake.window
|
||||
FP-OVEN:<code> -> fusion.plating.bake.oven
|
||||
"""
|
||||
if not qr_code:
|
||||
return {'ok': False, 'error': 'Empty QR code'}
|
||||
|
||||
code = qr_code.strip()
|
||||
|
||||
if code.startswith('FP-TANK:'):
|
||||
tank = request.env['fusion.plating.tank'].search(
|
||||
[('code', '=', code.split(':', 1)[1])], limit=1
|
||||
)
|
||||
if not tank:
|
||||
return {'ok': False, 'error': f'Tank {code} not found'}
|
||||
return {
|
||||
'ok': True,
|
||||
'model': 'fusion.plating.tank',
|
||||
'id': tank.id,
|
||||
'name': tank.name,
|
||||
'state': tank.state,
|
||||
'current_bath_id': tank.current_bath_id.id or False,
|
||||
'current_bath_name': tank.current_bath_id.name or '',
|
||||
'queue_size': tank.x_fp_shopfloor_queue_size,
|
||||
}
|
||||
|
||||
if code.startswith('FP-BATH:'):
|
||||
bath = request.env['fusion.plating.bath'].search(
|
||||
[('name', '=', code.split(':', 1)[1])], limit=1
|
||||
)
|
||||
if not bath:
|
||||
return {'ok': False, 'error': f'Bath {code} not found'}
|
||||
return {
|
||||
'ok': True,
|
||||
'model': 'fusion.plating.bath',
|
||||
'id': bath.id,
|
||||
'name': bath.name,
|
||||
'state': bath.state,
|
||||
'tank_id': bath.tank_id.id or False,
|
||||
'tank_name': bath.tank_id.name or '',
|
||||
}
|
||||
|
||||
if code.startswith('FP-STATION:'):
|
||||
station = request.env['fusion.plating.shopfloor.station'].search(
|
||||
[('code', '=', code.split(':', 1)[1])], limit=1
|
||||
)
|
||||
if not station:
|
||||
return {'ok': False, 'error': f'Station {code} not found'}
|
||||
station.action_ping()
|
||||
return {
|
||||
'ok': True,
|
||||
'model': 'fusion.plating.shopfloor.station',
|
||||
'id': station.id,
|
||||
'name': station.name,
|
||||
'facility_id': station.facility_id.id,
|
||||
'facility_name': station.facility_id.name,
|
||||
'work_center_id': station.work_center_id.id or False,
|
||||
'work_center_name': station.work_center_id.name or '',
|
||||
}
|
||||
|
||||
if code.startswith('FP-JOB:'):
|
||||
bw = request.env['fusion.plating.bake.window'].search(
|
||||
[('name', '=', code.split(':', 1)[1])], limit=1
|
||||
)
|
||||
if not bw:
|
||||
return {'ok': False, 'error': f'Job {code} not found'}
|
||||
return {
|
||||
'ok': True,
|
||||
'model': 'fusion.plating.bake.window',
|
||||
'id': bw.id,
|
||||
'name': bw.name,
|
||||
'state': bw.state,
|
||||
'time_remaining': bw.time_remaining_display,
|
||||
'bake_required_by': fields.Datetime.to_string(bw.bake_required_by) if bw.bake_required_by else '',
|
||||
}
|
||||
|
||||
if code.startswith('FP-OVEN:'):
|
||||
oven = request.env['fusion.plating.bake.oven'].search(
|
||||
[('code', '=', code.split(':', 1)[1])], limit=1
|
||||
)
|
||||
if not oven:
|
||||
return {'ok': False, 'error': f'Oven {code} not found'}
|
||||
return {
|
||||
'ok': True,
|
||||
'model': 'fusion.plating.bake.oven',
|
||||
'id': oven.id,
|
||||
'name': oven.name,
|
||||
}
|
||||
|
||||
if code.startswith('FP-WO:'):
|
||||
MrpWO = request.env.get('mrp.workorder')
|
||||
if MrpWO is None:
|
||||
return {'ok': False, 'error': 'MRP not installed'}
|
||||
wo = MrpWO.search(
|
||||
[('name', '=', code.split(':', 1)[1])], limit=1
|
||||
)
|
||||
if not wo:
|
||||
return {'ok': False, 'error': f'Work order {code} not found'}
|
||||
return {
|
||||
'ok': True,
|
||||
'model': 'mrp.workorder',
|
||||
'id': wo.id,
|
||||
'name': wo.display_name,
|
||||
'state': wo.state,
|
||||
'duration': wo.duration,
|
||||
'production_name': wo.production_id.name or '',
|
||||
'product_name': wo.production_id.product_id.display_name or '',
|
||||
}
|
||||
|
||||
return {'ok': False, 'error': f'Unrecognised QR payload: {code}'}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Quick chemistry log from the tablet
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/log_chemistry', type='jsonrpc', auth='user')
|
||||
def log_chemistry(self, bath_id, readings, shift=None, notes=None):
|
||||
"""Create a fusion.plating.bath.log with one line per reading.
|
||||
|
||||
readings: list of {parameter_id, value} dicts.
|
||||
"""
|
||||
if not bath_id:
|
||||
raise UserError("bath_id required")
|
||||
bath = request.env['fusion.plating.bath'].browse(int(bath_id))
|
||||
if not bath.exists():
|
||||
raise UserError(f"Bath {bath_id} not found")
|
||||
|
||||
line_vals = []
|
||||
for reading in (readings or []):
|
||||
param_id = reading.get('parameter_id')
|
||||
value = reading.get('value')
|
||||
if not param_id:
|
||||
continue
|
||||
line_vals.append((0, 0, {
|
||||
'parameter_id': int(param_id),
|
||||
'value': float(value) if value not in (None, '') else 0.0,
|
||||
}))
|
||||
|
||||
log = request.env['fusion.plating.bath.log'].create({
|
||||
'bath_id': bath.id,
|
||||
'shift': shift or False,
|
||||
'notes': notes or False,
|
||||
'line_ids': line_vals,
|
||||
})
|
||||
return {
|
||||
'ok': True,
|
||||
'log_id': log.id,
|
||||
'log_name': log.name,
|
||||
'status': log.status,
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Bake window controls
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user')
|
||||
def start_bake(self, bake_window_id, oven_id=None):
|
||||
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id))
|
||||
if not bw.exists():
|
||||
raise UserError(f"Bake window {bake_window_id} not found")
|
||||
if oven_id:
|
||||
bw.oven_id = int(oven_id)
|
||||
bw.action_start_bake()
|
||||
return {
|
||||
'ok': True,
|
||||
'state': bw.state,
|
||||
'bake_start_time': fields.Datetime.to_string(bw.bake_start_time) if bw.bake_start_time else '',
|
||||
}
|
||||
|
||||
@http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user')
|
||||
def end_bake(self, bake_window_id):
|
||||
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id))
|
||||
if not bw.exists():
|
||||
raise UserError(f"Bake window {bake_window_id} not found")
|
||||
bw.action_end_bake()
|
||||
return {
|
||||
'ok': True,
|
||||
'state': bw.state,
|
||||
'bake_end_time': fields.Datetime.to_string(bw.bake_end_time) if bw.bake_end_time else '',
|
||||
'bake_duration_hours': bw.bake_duration_hours,
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# MRP work order controls (requires fusion_plating_bridge_mrp)
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user')
|
||||
def start_wo(self, workorder_id):
|
||||
"""Start the MRP timer on a work order."""
|
||||
MrpWO = request.env.get('mrp.workorder')
|
||||
if MrpWO is None:
|
||||
return {'ok': False, 'error': 'MRP not installed'}
|
||||
wo = MrpWO.browse(int(workorder_id))
|
||||
if not wo.exists():
|
||||
return {'ok': False, 'error': f'Work order {workorder_id} not found'}
|
||||
if wo.state == 'ready':
|
||||
wo.button_start()
|
||||
return {
|
||||
'ok': True,
|
||||
'state': wo.state,
|
||||
'duration': wo.duration,
|
||||
}
|
||||
|
||||
@http.route('/fp/shopfloor/stop_wo', type='jsonrpc', auth='user')
|
||||
def stop_wo(self, workorder_id, finish=False):
|
||||
"""Pause or finish the MRP timer on a work order.
|
||||
|
||||
finish=True calls button_finish(), otherwise button_pending().
|
||||
"""
|
||||
MrpWO = request.env.get('mrp.workorder')
|
||||
if MrpWO is None:
|
||||
return {'ok': False, 'error': 'MRP not installed'}
|
||||
wo = MrpWO.browse(int(workorder_id))
|
||||
if not wo.exists():
|
||||
return {'ok': False, 'error': f'Work order {workorder_id} not found'}
|
||||
if finish:
|
||||
wo.button_finish()
|
||||
else:
|
||||
wo.button_pending()
|
||||
return {
|
||||
'ok': True,
|
||||
'state': wo.state,
|
||||
'duration': wo.duration,
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Quality hold — partial qty split
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/quality_hold', type='jsonrpc', auth='user')
|
||||
def quality_hold(self, workorder_id=None, portal_job_id=None,
|
||||
part_ref=None, qty_on_hold=0, qty_original=0,
|
||||
hold_reason='other', description=None,
|
||||
mark_for_scrap=False, facility_id=None,
|
||||
work_center_id=None, current_process_node=None):
|
||||
"""Create a quality hold record, splitting qty from the original lot.
|
||||
|
||||
If the MRP bridge is installed and a workorder_id is provided the
|
||||
work order's qty_producing is reduced to reflect the split.
|
||||
"""
|
||||
if not qty_on_hold or int(qty_on_hold) <= 0:
|
||||
raise UserError("qty_on_hold must be a positive integer.")
|
||||
|
||||
vals = {
|
||||
'part_ref': part_ref or '',
|
||||
'qty_on_hold': int(qty_on_hold),
|
||||
'qty_original': int(qty_original) if qty_original else 0,
|
||||
'hold_reason': hold_reason or 'other',
|
||||
'description': description or '',
|
||||
'mark_for_scrap': bool(mark_for_scrap),
|
||||
'current_process_node': current_process_node or '',
|
||||
}
|
||||
|
||||
if facility_id:
|
||||
vals['facility_id'] = int(facility_id)
|
||||
if work_center_id:
|
||||
vals['work_center_id'] = int(work_center_id)
|
||||
|
||||
# Link to portal job if provided
|
||||
if portal_job_id:
|
||||
job = request.env['fusion.plating.portal.job'].browse(
|
||||
int(portal_job_id),
|
||||
)
|
||||
if job.exists():
|
||||
vals['portal_job_id'] = job.id
|
||||
|
||||
# Link to MRP work order if provided and MRP bridge installed
|
||||
if workorder_id:
|
||||
MrpWO = request.env.get('mrp.workorder')
|
||||
if MrpWO is not None:
|
||||
wo = MrpWO.browse(int(workorder_id))
|
||||
if wo.exists():
|
||||
vals['workorder_id'] = wo.id
|
||||
vals['production_id'] = wo.production_id.id or False
|
||||
# Reduce qty_producing to reflect the split
|
||||
new_qty = wo.qty_producing - int(qty_on_hold)
|
||||
if new_qty >= 0:
|
||||
wo.qty_producing = new_qty
|
||||
|
||||
hold = request.env['fusion.plating.quality.hold'].create(vals)
|
||||
return {
|
||||
'ok': True,
|
||||
'hold_id': hold.id,
|
||||
'hold_name': hold.name,
|
||||
'state': hold.state,
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Operator queue snapshot
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/queue', type='jsonrpc', auth='user')
|
||||
def queue(self, facility_id=None):
|
||||
Queue = request.env['fusion.plating.operator.queue']
|
||||
rows = Queue.build_for_user(
|
||||
user_id=request.env.user.id,
|
||||
facility_id=int(facility_id) if facility_id else None,
|
||||
)
|
||||
return {
|
||||
'ok': True,
|
||||
'count': len(rows),
|
||||
'rows': [
|
||||
{
|
||||
'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 rows
|
||||
],
|
||||
}
|
||||
|
||||
# ==================================================================
|
||||
# Plant Overview Dashboard
|
||||
# ==================================================================
|
||||
|
||||
@http.route('/fp/shopfloor/plant_overview/move_card',
|
||||
type='jsonrpc', auth='user')
|
||||
def plant_overview_move_card(self, card_id, source_model,
|
||||
target_workcenter_id):
|
||||
"""Move a work order card to a different work centre (drag & drop).
|
||||
|
||||
Only mrp.workorder is supported for now — other source models
|
||||
will return an error so the frontend can display it gracefully.
|
||||
"""
|
||||
if source_model != 'mrp.workorder':
|
||||
return {'ok': False,
|
||||
'error': 'Drag & drop is only supported for work orders.'}
|
||||
|
||||
MrpWO = request.env.get('mrp.workorder')
|
||||
if MrpWO is None:
|
||||
return {'ok': False, 'error': 'MRP module not available.'}
|
||||
|
||||
wo = MrpWO.browse(int(card_id))
|
||||
if not wo.exists():
|
||||
return {'ok': False, 'error': f'Work order {card_id} not found.'}
|
||||
|
||||
wc = request.env['mrp.workcenter'].browse(int(target_workcenter_id))
|
||||
if not wc.exists():
|
||||
return {'ok': False,
|
||||
'error': f'Work centre {target_workcenter_id} not found.'}
|
||||
|
||||
try:
|
||||
wo.write({'workcenter_id': wc.id})
|
||||
_logger.info(
|
||||
'Plant Overview: moved WO %s (%s) → WC %s (%s) by uid %s',
|
||||
wo.id, wo.display_name, wc.id, wc.name,
|
||||
request.env.uid,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.exception('Plant Overview move_card failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
return {'ok': True}
|
||||
|
||||
@http.route('/fp/shopfloor/plant_overview', type='jsonrpc', auth='user')
|
||||
def plant_overview(self, facility_id=None, search=None):
|
||||
"""Return work orders grouped by work centre for the plant overview.
|
||||
|
||||
Works in two modes:
|
||||
1. MRP installed (fusion_plating_bridge_mrp) — pulls mrp.workorder
|
||||
records grouped by workcenter_id.
|
||||
2. MRP not installed — falls back to bake windows and first-piece
|
||||
gates as card sources.
|
||||
"""
|
||||
search = (search or '').strip().lower()
|
||||
|
||||
# Determine facility name
|
||||
facility_name = "Plant 1"
|
||||
if facility_id:
|
||||
fac = request.env['fusion.plating.facility'].browse(int(facility_id))
|
||||
if fac.exists():
|
||||
facility_name = fac.name
|
||||
else:
|
||||
fac = request.env['fusion.plating.facility'].search([], limit=1)
|
||||
if fac:
|
||||
facility_name = fac.name
|
||||
|
||||
columns = []
|
||||
|
||||
# ---- MODE 1: MRP bridge available ------------------------------------
|
||||
MrpWO = request.env.get('mrp.workorder')
|
||||
if MrpWO is not None:
|
||||
columns = self._plant_overview_mrp(MrpWO, search)
|
||||
else:
|
||||
# ---- MODE 2: Fallback — bake windows + first-piece gates ---------
|
||||
columns = self._plant_overview_fallback(search)
|
||||
|
||||
return {
|
||||
'facility_name': facility_name,
|
||||
'columns': columns,
|
||||
}
|
||||
|
||||
def _plant_overview_mrp(self, MrpWO, search):
|
||||
"""Build columns from mrp.workorder records."""
|
||||
domain = [('state', 'not in', ['done', 'cancel'])]
|
||||
work_orders = MrpWO.search(domain, order='workcenter_id, sequence, id')
|
||||
|
||||
# Group by work centre
|
||||
wc_map = {} # workcenter_id → {'name': ..., 'cards': [...]}
|
||||
for wo in work_orders:
|
||||
wc_id = wo.workcenter_id.id if wo.workcenter_id else 0
|
||||
wc_name = wo.workcenter_id.name if wo.workcenter_id else 'Unassigned'
|
||||
|
||||
if wc_id not in wc_map:
|
||||
wc_map[wc_id] = {
|
||||
'work_center_id': wc_id,
|
||||
'work_center_name': wc_name,
|
||||
'cards': [],
|
||||
}
|
||||
|
||||
card = self._wo_to_card(wo)
|
||||
|
||||
# Apply search filter
|
||||
if search and not self._card_matches_search(card, search):
|
||||
continue
|
||||
|
||||
wc_map[wc_id]['cards'].append(card)
|
||||
|
||||
# Sort columns by name, put Unassigned last
|
||||
columns = sorted(
|
||||
wc_map.values(),
|
||||
key=lambda c: (c['work_center_id'] == 0, c['work_center_name']),
|
||||
)
|
||||
return columns
|
||||
|
||||
def _wo_to_card(self, wo):
|
||||
"""Convert an mrp.workorder to a card dict."""
|
||||
# Customer name — from sale order if available
|
||||
customer_name = ''
|
||||
so_name = ''
|
||||
production = wo.production_id
|
||||
if production:
|
||||
# Try origin field for SO ref
|
||||
so_name = getattr(production, 'origin', '') or ''
|
||||
# Try to get customer from linked sale order
|
||||
sale_order = None
|
||||
if hasattr(production, 'sale_id') and production.sale_id:
|
||||
sale_order = production.sale_id
|
||||
elif so_name:
|
||||
sale_order = request.env['sale.order'].search([('name', '=', so_name)], limit=1)
|
||||
if sale_order:
|
||||
customer_name = sale_order.partner_id.name or ''
|
||||
elif hasattr(production, 'partner_id') and production.partner_id:
|
||||
customer_name = production.partner_id.name or ''
|
||||
|
||||
# Parts progress
|
||||
parts_done = 0
|
||||
parts_total = 0
|
||||
if production:
|
||||
parts_total = int(production.product_qty or 0)
|
||||
# qty_produced may not exist in all versions; fall back to qty_producing
|
||||
parts_done = int(
|
||||
getattr(production, 'qty_produced', None)
|
||||
or getattr(production, 'qty_producing', None)
|
||||
or 0
|
||||
)
|
||||
|
||||
# Last operator and activity
|
||||
last_operator = ''
|
||||
last_activity = ''
|
||||
if wo.date_start:
|
||||
# user_id may not exist on mrp.workorder in all Odoo versions
|
||||
if hasattr(wo, 'user_id') and wo.user_id:
|
||||
last_operator = wo.user_id.name or ''
|
||||
elif wo.time_ids:
|
||||
last_log = wo.time_ids.sorted('date_start', reverse=True)[:1]
|
||||
if last_log and last_log.user_id:
|
||||
last_operator = last_log.user_id.name or ''
|
||||
last_activity = self._time_ago(wo.write_date or wo.date_start)
|
||||
|
||||
# Tags from priority field
|
||||
tags = []
|
||||
prio = getattr(wo, 'x_fc_priority', '0') or '0'
|
||||
if prio == '2':
|
||||
tags.append('HOT')
|
||||
elif prio == '1':
|
||||
tags.append('Priority')
|
||||
|
||||
# Date display
|
||||
date_display = ''
|
||||
if wo.date_start:
|
||||
date_display = wo.date_start.strftime('%-m/%-d')
|
||||
elif production and production.date_start:
|
||||
date_display = production.date_start.strftime('%-m/%-d')
|
||||
|
||||
# Step info
|
||||
step_display = getattr(wo, 'x_fc_step_display', '') or ''
|
||||
step_number = getattr(wo, 'x_fc_step_number', 0) or 0
|
||||
|
||||
# Customer logo URL (from partner) + product image
|
||||
customer_logo_url = ''
|
||||
product_image_url = ''
|
||||
if sale_order and sale_order.partner_id:
|
||||
customer_logo_url = f'/web/image/res.partner/{sale_order.partner_id.id}/image_128'
|
||||
if production and production.product_id:
|
||||
product_image_url = f'/web/image/product.product/{production.product_id.id}/image_128'
|
||||
|
||||
return {
|
||||
'id': wo.id,
|
||||
'source_model': 'mrp.workorder',
|
||||
'customer_name': customer_name,
|
||||
'so_name': f'SO {so_name}' if so_name else '',
|
||||
'wo_name': wo.display_name or '',
|
||||
'parts_done': parts_done,
|
||||
'parts_total': parts_total,
|
||||
'last_operator': last_operator,
|
||||
'last_activity': last_activity,
|
||||
'tags': tags,
|
||||
'date_display': date_display,
|
||||
'state': wo.state or '',
|
||||
'priority': prio,
|
||||
'step_display': step_display,
|
||||
'step_number': step_number,
|
||||
'customer_logo_url': customer_logo_url,
|
||||
'product_image_url': product_image_url,
|
||||
'product_name': production.product_id.display_name if production and production.product_id else '',
|
||||
}
|
||||
|
||||
def _plant_overview_fallback(self, search):
|
||||
"""Build columns from bake windows + first-piece gates when MRP
|
||||
is not installed."""
|
||||
columns = []
|
||||
|
||||
# Bake windows as one column
|
||||
bw_cards = []
|
||||
BakeWindow = request.env['fusion.plating.bake.window']
|
||||
bake_windows = BakeWindow.search(
|
||||
[('state', 'in', ('awaiting_bake', 'bake_in_progress'))],
|
||||
order='bake_required_by asc',
|
||||
)
|
||||
for bw in bake_windows:
|
||||
card = {
|
||||
'id': bw.id,
|
||||
'source_model': 'fusion.plating.bake.window',
|
||||
'customer_name': bw.customer_ref or '',
|
||||
'so_name': '',
|
||||
'wo_name': bw.name or '',
|
||||
'parts_done': 0,
|
||||
'parts_total': 0,
|
||||
'last_operator': '',
|
||||
'last_activity': self._time_ago(bw.write_date) if bw.write_date else '',
|
||||
'tags': ['HOT'] if bw.state == 'missed_window' else [],
|
||||
'date_display': bw.bake_required_by.strftime('%-m/%-d') if bw.bake_required_by else '',
|
||||
'state': bw.state or '',
|
||||
}
|
||||
if not search or self._card_matches_search(card, search):
|
||||
bw_cards.append(card)
|
||||
|
||||
if bw_cards:
|
||||
columns.append({
|
||||
'work_center_id': -1,
|
||||
'work_center_name': 'Bake Windows',
|
||||
'cards': bw_cards,
|
||||
})
|
||||
|
||||
# First-piece gates as another column
|
||||
fp_cards = []
|
||||
FpGate = request.env['fusion.plating.first.piece.gate']
|
||||
gates = FpGate.search(
|
||||
[('result', 'in', ('pending', 'fail'))],
|
||||
order='create_date desc',
|
||||
)
|
||||
for gate in gates:
|
||||
card = {
|
||||
'id': gate.id,
|
||||
'source_model': 'fusion.plating.first.piece.gate',
|
||||
'customer_name': '',
|
||||
'so_name': '',
|
||||
'wo_name': gate.name or '',
|
||||
'parts_done': 0,
|
||||
'parts_total': 0,
|
||||
'last_operator': gate.inspector_id.name if gate.inspector_id else '',
|
||||
'last_activity': self._time_ago(gate.write_date) if gate.write_date else '',
|
||||
'tags': [],
|
||||
'date_display': gate.create_date.strftime('%-m/%-d') if gate.create_date else '',
|
||||
'state': gate.result or '',
|
||||
}
|
||||
if not search or self._card_matches_search(card, search):
|
||||
fp_cards.append(card)
|
||||
|
||||
if fp_cards:
|
||||
columns.append({
|
||||
'work_center_id': -2,
|
||||
'work_center_name': 'First-Piece Gates',
|
||||
'cards': fp_cards,
|
||||
})
|
||||
|
||||
return columns
|
||||
|
||||
def _card_matches_search(self, card, search):
|
||||
"""Return True if the card matches the search term."""
|
||||
searchable = ' '.join([
|
||||
card.get('customer_name', ''),
|
||||
card.get('so_name', ''),
|
||||
card.get('wo_name', ''),
|
||||
]).lower()
|
||||
return search in searchable
|
||||
|
||||
def _time_ago(self, dt):
|
||||
"""Return a human-readable 'time ago' string from a datetime."""
|
||||
if not dt:
|
||||
return ''
|
||||
now = datetime.now()
|
||||
if hasattr(dt, 'replace'):
|
||||
# Make both naive for comparison
|
||||
dt = dt.replace(tzinfo=None)
|
||||
delta = now - dt
|
||||
total_seconds = int(delta.total_seconds())
|
||||
|
||||
if total_seconds < 60:
|
||||
return 'just now'
|
||||
minutes = total_seconds // 60
|
||||
if minutes < 60:
|
||||
return f'{minutes}m ago'
|
||||
hours = minutes // 60
|
||||
if hours < 24:
|
||||
return f'{hours}h ago'
|
||||
days = hours // 24
|
||||
if days < 7:
|
||||
return f'{days}d ago'
|
||||
weeks = days // 7
|
||||
remaining_days = days % 7
|
||||
if remaining_days:
|
||||
return f'{weeks}w {remaining_days}d ago'
|
||||
return f'{weeks}w ago'
|
||||
|
||||
# ==================================================================
|
||||
# Work Order — Process Flow + Cost Summary
|
||||
# ==================================================================
|
||||
@http.route('/fp/shopfloor/wo_process_flow', type='jsonrpc', auth='user')
|
||||
def wo_process_flow(self, workorder_id):
|
||||
"""Return process flow steps for the horizontal bar."""
|
||||
wo = request.env['mrp.workorder'].browse(int(workorder_id))
|
||||
if not wo.exists():
|
||||
return {'ok': False, 'error': 'Work order not found'}
|
||||
return {'ok': True, 'steps': wo.get_process_flow()}
|
||||
|
||||
@http.route('/fp/shopfloor/wo_cost_summary', type='jsonrpc', auth='user')
|
||||
def wo_cost_summary(self, workorder_id):
|
||||
"""Return cost breakdown for a work order's MO."""
|
||||
wo = request.env['mrp.workorder'].browse(int(workorder_id))
|
||||
if not wo.exists():
|
||||
return {'ok': False, 'error': 'Work order not found'}
|
||||
return {'ok': True, 'data': wo.get_cost_summary()}
|
||||
|
||||
# ==================================================================
|
||||
# Process Tree
|
||||
# ==================================================================
|
||||
@http.route('/fp/shopfloor/process_tree', type='jsonrpc', auth='user')
|
||||
def process_tree(self, production_id):
|
||||
"""Return routing tree for a manufacturing order.
|
||||
|
||||
Each node is an operation/work-order step. Children represent
|
||||
sub-states (ready vs active) within that step.
|
||||
"""
|
||||
MrpWO = request.env.get('mrp.workorder')
|
||||
if MrpWO is None:
|
||||
return {
|
||||
'production_name': '',
|
||||
'product_name': '',
|
||||
'state': '',
|
||||
'nodes': [],
|
||||
}
|
||||
|
||||
MrpProduction = request.env['mrp.production']
|
||||
production = MrpProduction.browse(int(production_id))
|
||||
if not production.exists():
|
||||
raise UserError(f"Manufacturing order {production_id} not found")
|
||||
|
||||
work_orders = MrpWO.search(
|
||||
[('production_id', '=', production.id)],
|
||||
order='sequence, id',
|
||||
)
|
||||
|
||||
nodes = []
|
||||
for wo in work_orders:
|
||||
qty_done = int(wo.qty_produced or 0)
|
||||
qty_total = int(wo.qty_production or production.product_qty or 0)
|
||||
|
||||
# Duration display
|
||||
duration_mins = wo.duration or 0
|
||||
if duration_mins >= 60:
|
||||
duration_display = f'{duration_mins / 60:.1f}h'
|
||||
elif duration_mins > 0:
|
||||
duration_display = f'{int(duration_mins)}m'
|
||||
else:
|
||||
duration_display = ''
|
||||
|
||||
# Build children — sub-state nodes
|
||||
children = []
|
||||
if wo.state in ('ready', 'waiting'):
|
||||
children.append({
|
||||
'id': f'{wo.id}_ready',
|
||||
'name': f'Ready for {wo.workcenter_id.name or wo.name}',
|
||||
'state': 'ready',
|
||||
'qty_done': 0,
|
||||
'qty_total': qty_total,
|
||||
})
|
||||
elif wo.state == 'progress':
|
||||
children.append({
|
||||
'id': f'{wo.id}_active',
|
||||
'name': f'{wo.workcenter_id.name or wo.name}-ing',
|
||||
'state': 'progress',
|
||||
'qty_done': qty_done,
|
||||
'qty_total': qty_total,
|
||||
})
|
||||
# Also show "remaining" child if partial
|
||||
remaining = qty_total - qty_done
|
||||
if remaining > 0:
|
||||
children.append({
|
||||
'id': f'{wo.id}_remaining',
|
||||
'name': f'Ready for {wo.workcenter_id.name or wo.name}',
|
||||
'state': 'ready',
|
||||
'qty_done': 0,
|
||||
'qty_total': remaining,
|
||||
})
|
||||
|
||||
nodes.append({
|
||||
'id': wo.id,
|
||||
'workorder_id': wo.id,
|
||||
'sequence': wo.sequence or 0,
|
||||
'name': wo.display_name or wo.name,
|
||||
'work_center_name': wo.workcenter_id.name if wo.workcenter_id else '',
|
||||
'state': wo.state or '',
|
||||
'qty_done': qty_done,
|
||||
'qty_total': qty_total,
|
||||
'duration_display': duration_display,
|
||||
'children': children,
|
||||
})
|
||||
|
||||
return {
|
||||
'production_name': production.name or '',
|
||||
'product_name': production.product_id.display_name if production.product_id else '',
|
||||
'state': production.state or '',
|
||||
'nodes': nodes,
|
||||
}
|
||||
Reference in New Issue
Block a user