This commit is contained in:
gsinghpal
2026-04-27 08:16:20 -04:00
parent f08f328688
commit 2a4909be25
12 changed files with 788 additions and 20 deletions

View File

@@ -254,6 +254,88 @@ class FpManagerDashboardController(http.Controller):
# been marked ready_to_ship yet (or no portal mirror at all).
ready_to_ship_jobs = Job.search_count([('state', '=', 'done')])
# ---- Compliance / floor-health KPIs ---------------------------
# v19.0.24.3.0 — added the things a manager actually loses sleep
# over: stale steps (S10/S16), missed bake windows (S15), open
# holds (S17 + QC fail), pending QCs, predecessor-locked steps
# (S14), and the cert pipeline. Without these the Manager Desk
# is a feel-good "stuff is happening" view; with them it becomes
# an exception list that drives action.
from datetime import timedelta
from odoo import fields as _flds
now = _flds.Datetime.now()
stale_paused_threshold = now - timedelta(hours=24)
stale_inprogress_threshold = now - timedelta(hours=8)
BakeWindow = env['fusion.plating.bake.window']
Hold = env['fusion.plating.quality.hold']
QC = env.get('fusion.plating.quality.check')
Cert = env.get('fp.certificate')
# Stale steps — same domains the crons use
stale_paused = Step.search_count([
('state', '=', 'paused'),
('write_date', '<=', stale_paused_threshold),
])
stale_inprogress = Step.search_count([
('state', '=', 'in_progress'),
('date_started', '<=', stale_inprogress_threshold),
])
# Missed bake windows — compliance bomb
missed_bakes = BakeWindow.search_count([
('state', '=', 'missed_window'),
])
awaiting_bakes = BakeWindow.search_count([
('state', '=', 'awaiting_bake'),
])
# Open holds (QC failure, scrap, contamination, etc.)
open_holds = Hold.search_count([
('state', 'in', ('on_hold', 'under_review')),
])
# Predecessor-locked steps — operators stuck waiting on others
# Only count when the step is ready/paused AND has the lock flag
# AND has at least one earlier-sequence step still open.
pred_locked = 0
if 'requires_predecessor_done' in Step._fields:
cand_steps = Step.search([
('requires_predecessor_done', '=', True),
('state', 'in', ('ready', 'paused')),
])
for st in cand_steps:
if st.job_id.step_ids.filtered(lambda x: (
x.id != st.id and x.sequence < st.sequence
and x.state not in ('done', 'skipped', 'cancelled')
)):
pred_locked += 1
# Pending QCs (draft/in_progress) split out by Fischerscope state
pending_qcs = 0
qcs_missing_pdf = 0
if QC is not None:
pending_qcs = QC.search_count([
('state', 'in', ('draft', 'in_progress')),
])
# QCs that REQUIRE a Fischerscope PDF but don't have one
qcs_missing_pdf = QC.search_count([
('state', 'in', ('draft', 'in_progress')),
('thickness_report_pdf_id', '=', False),
('template_id.require_thickness_report_pdf', '=', True),
])
# Certs in pipeline
draft_certs = 0
issued_certs_today = 0
if Cert is not None:
draft_certs = Cert.search_count([('state', '=', 'draft')])
today = now.date()
issued_certs_today = Cert.search_count([
('state', '=', 'issued'),
('issue_date', '=', today),
])
kpis = {
'unassigned_steps': len(all_steps.filtered(
lambda s: not readiness_by_step.get(s.id, (False, ''))[0],
@@ -264,6 +346,17 @@ class FpManagerDashboardController(http.Controller):
)),
'ready_to_ship_jobs': ready_to_ship_jobs,
'pending_accept_sos': pending_accept_sos,
# New compliance / health metrics
'stale_paused_steps': stale_paused,
'stale_inprogress_steps': stale_inprogress,
'missed_bakes': missed_bakes,
'awaiting_bakes': awaiting_bakes,
'open_holds': open_holds,
'predecessor_locked_steps': pred_locked,
'pending_qcs': pending_qcs,
'qcs_missing_fischerscope': qcs_missing_pdf,
'draft_certs': draft_certs,
'issued_certs_today': issued_certs_today,
}
payload = {

View File

@@ -374,6 +374,64 @@ class FpShopfloorController(http.Controller):
'duration': step.duration_actual,
}
# ----------------------------------------------------------------------
# Quick qty / scrap bumps from the tablet (v19.0.24.3.0)
# ----------------------------------------------------------------------
# Without these endpoints Carlos has to leave the tablet, navigate
# to the back-office job form, and edit qty_done / qty_scrapped
# there. Most operators won't bother — scrap goes unrecorded and
# the AS9100 trail breaks. These two endpoints let the tablet bump
# both with a single tap. Scrap auto-spawns a hold via fp.job.write
# (S17 hook, no extra wiring needed here).
@http.route('/fp/shopfloor/bump_qty_done', type='jsonrpc', auth='user')
def bump_qty_done(self, job_id, delta=1):
"""Increment job.qty_done by `delta` (defaults to +1).
Returns the new totals so the tablet can update without a full refresh.
"""
job = request.env['fp.job'].browse(int(job_id))
if not job.exists():
return {'ok': False, 'error': 'Job not found'}
try:
new_qty = (job.qty_done or 0) + int(delta)
if new_qty < 0:
new_qty = 0
job.qty_done = new_qty
except Exception as e:
return {'ok': False, 'error': str(e)}
return {
'ok': True,
'qty_done': job.qty_done,
'qty_total': job.qty,
'qty_scrapped': job.qty_scrapped,
}
@http.route('/fp/shopfloor/bump_qty_scrapped', type='jsonrpc', auth='user')
def bump_qty_scrapped(self, job_id, delta=1, reason=None):
"""Increment job.qty_scrapped by `delta`. The S17 write-hook on
fp.job auto-spawns a fusion.plating.quality.hold for the delta;
the operator can edit the description on that hold later.
`reason` is optional — passed through to the hold's description.
"""
job = request.env['fp.job'].browse(int(job_id))
if not job.exists():
return {'ok': False, 'error': 'Job not found'}
try:
new_scrap = (job.qty_scrapped or 0) + int(delta)
if new_scrap < 0:
new_scrap = 0
ctx = {}
if reason:
ctx['fp_scrap_reason'] = reason
job.with_context(**ctx).qty_scrapped = new_scrap
except Exception as e:
return {'ok': False, 'error': str(e)}
return {
'ok': True,
'qty_done': job.qty_done,
'qty_total': job.qty,
'qty_scrapped': job.qty_scrapped,
}
# ----------------------------------------------------------------------
# Thickness reading — Fischerscope log entry from inspection station
# ----------------------------------------------------------------------
@@ -594,11 +652,37 @@ class FpShopfloorController(http.Controller):
]
# -- My Queue (top 8) --------------------------------------------
# v19.0.24.3.0 — every step row now carries the recipe-author
# fields (instructions / thickness_target / dwell_time_minutes /
# bake_setpoint_temp / requires_signoff) plus a S14 predecessor-
# block flag so the tablet can:
# 1. show the instructions inline (operator never had to scan)
# 2. grey-out Start with "Awaiting [step]" when predecessor not done
# 3. flag thickness/dwell/bake values as chips
# Without these the tablet shows a step name and nothing else;
# the operator scans the QR every time just to read the bake temp.
my_queue = []
for step in my_steps[:8]:
job = step.job_id
partner_name = job.partner_id.name or ''
product_name = job.product_id.display_name if job.product_id else ''
# S14 — predecessor block. Same domain the model enforces.
predecessor_blocked = False
blocked_by_name = ''
if (getattr(step, 'requires_predecessor_done', False)
and step.state in ('ready', 'paused')):
earlier_open = job.step_ids.filtered(lambda x: (
x.id != step.id
and x.sequence < step.sequence
and x.state not in ('done', 'skipped', 'cancelled')
))
if earlier_open:
predecessor_blocked = True
blocked_by_name = earlier_open.sorted('sequence')[0].name or ''
can_start = _step_can_start(step) and not predecessor_blocked
my_queue.append({
'id': step.id,
'label': step.name or '',
@@ -614,8 +698,21 @@ class FpShopfloorController(http.Controller):
'source_id': step.id,
'wo_state': step.state, # legacy key the template reads
'wo_name': step.name or '',
'can_start': _step_can_start(step),
'can_start': can_start,
'can_finish': _step_can_finish(step),
# S13 — recipe-author metadata so operator sees it inline
'instructions': step.instructions or '',
'thickness_target': step.thickness_target or 0,
'thickness_uom': step.thickness_uom or '',
'dwell_time_minutes': step.dwell_time_minutes or 0,
'bake_setpoint_temp': step.bake_setpoint_temp or 0,
'requires_signoff': bool(getattr(step, 'requires_signoff', False)),
# S14 — predecessor block reason
'predecessor_blocked': predecessor_blocked,
'blocked_by_name': blocked_by_name,
# Sequence so the operator can see "Step 3 of 9"
'step_sequence': step.sequence or 0,
'job_step_count': len(job.step_ids),
})
# -- Active step (the one in_progress) for this user -------------
@@ -628,17 +725,35 @@ class FpShopfloorController(http.Controller):
if active_step:
step = active_step
job = step.job_id
# `date_started` of the current in-progress run (seed for the
# JS-side live ticker so the operator sees seconds counting up
# in real time, not just the stale duration field).
started_iso = ''
if step.date_started:
started_iso = fields.Datetime.to_string(step.date_started)
active_wo = {
'id': step.id,
'name': step.name or '',
'workcenter': step.work_centre_id.name or '',
'mo_id': job.id,
'mo_name': job.name or '',
'product_name': job.product_id.display_name if job.product_id else '',
'qty_done': int(job.qty_done or 0),
'qty_total': int(job.qty or 0),
'qty_scrapped': int(job.qty_scrapped or 0),
'duration': step.duration_actual or 0,
'duration_expected': step.duration_expected or 0,
'date_started_iso': started_iso,
'step_display': step.kind or '',
'customer': job.partner_id.name or '',
# Same recipe-author metadata so operator never has to
# navigate away to remind themselves of the target.
'instructions': step.instructions or '',
'thickness_target': step.thickness_target or 0,
'thickness_uom': step.thickness_uom or '',
'dwell_time_minutes': step.dwell_time_minutes or 0,
'bake_setpoint_temp': step.bake_setpoint_temp or 0,
'requires_signoff': bool(getattr(step, 'requires_signoff', False)),
}
# -- Baths chemistry quick view ----------------------------------
@@ -660,7 +775,20 @@ class FpShopfloorController(http.Controller):
]
# -- Bake windows ------------------------------------------------
bw_domain = _fac_dom([('state', 'in', ('awaiting_bake', 'bake_in_progress', 'missed_window'))])
# v19.0.24.3.0 — scope to the operator's own jobs first so Carlos
# doesn't see 6 unrelated plant-wide bakes in his sidebar.
# Fallback: facility-wide for managers / when the user has no
# active steps. Order missed_window first so accountability
# bubbles up.
my_job_ids = my_steps.mapped('job_id').ids
bw_states = ('awaiting_bake', 'bake_in_progress', 'missed_window')
if my_job_ids and 'job_id' in BakeWindow._fields:
bw_domain = [
('state', 'in', bw_states),
('job_id', 'in', my_job_ids),
]
else:
bw_domain = _fac_dom([('state', 'in', bw_states)])
bws = BakeWindow.search(bw_domain, order='bake_required_by asc', limit=6)
bw_data = [
{
@@ -695,7 +823,17 @@ class FpShopfloorController(http.Controller):
]
# -- Quality holds -----------------------------------------------
hold_domain = [('state', 'in', ('on_hold', 'under_review'))]
# v19.0.24.3.0 — scope holds to operator's jobs so Carlos's
# sidebar isn't flooded with plant-wide HOLD-XXXX from other
# crews. Falls back to all-open for managers.
hold_states = ('on_hold', 'under_review')
if my_job_ids and 'x_fc_job_id' in Hold._fields:
hold_domain = [
('state', 'in', hold_states),
('x_fc_job_id', 'in', my_job_ids),
]
else:
hold_domain = [('state', 'in', hold_states)]
holds = Hold.search(hold_domain, order='create_date desc', limit=6)
holds_data = [
{
@@ -710,6 +848,43 @@ class FpShopfloorController(http.Controller):
for h in holds
]
# -- Pending QC alerts (v19.0.24.3.0, S19 follow-up) -------------
# When Carlos's job has finished its plating steps and a QC has
# auto-spawned (or was manually created), he needs to know to
# call Lisa or hand off the parts. Without this banner the QC
# sits in draft for hours.
pending_qcs = []
QC = env.get('fusion.plating.quality.check')
if QC is not None and my_job_ids:
# The QC's link to fp.job is `job_id` (defined natively on
# fusion.plating.quality.check). The cert side uses
# x_fc_job_id; don't confuse the two.
qc_field = 'job_id' if 'job_id' in QC._fields else None
if qc_field:
qcs = QC.search([
(qc_field, 'in', my_job_ids),
('state', 'in', ('draft', 'in_progress')),
], order='create_date desc', limit=6)
for qc in qcs:
job = qc[qc_field]
pending_qcs.append({
'id': qc.id,
'name': qc.name,
'state': qc.state,
'job_id': job.id if job else False,
'job_name': job.name if job else '',
'partner_name': qc.partner_id.name or '',
'template_name': qc.template_id.name or '',
'line_count': len(qc.line_ids),
'lines_pending': len(qc.line_ids.filtered(
lambda l: l.result == 'pending'
)),
'has_thickness_pdf': bool(qc.thickness_report_pdf_id),
'require_thickness_report_pdf': bool(
qc.template_id.require_thickness_report_pdf
),
})
# -- All stations for picker -------------------------------------
station_list = env['fusion.plating.shopfloor.station'].search([], order='facility_id, name')
stations = [
@@ -745,6 +920,7 @@ class FpShopfloorController(http.Controller):
'bake_windows': bw_data,
'gates': gates_data,
'holds': holds_data,
'pending_qcs': pending_qcs,
'stations': stations,
'server_time': fp_format(request.env, fields.Datetime.now(), fmt='%Y-%m-%d %H:%M:%S'),
}