folder rename

This commit is contained in:
gsinghpal
2026-04-16 20:53:53 -04:00
parent 3f3ddcbab4
commit 7c7ef06057
634 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import shopfloor_controller

View File

@@ -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,
}