changes
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.24.2.0',
|
||||
'version': '19.0.24.3.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -267,6 +267,22 @@ export class ManagerDashboard extends Component {
|
||||
priorityTone(p) {
|
||||
return ({'0': 'muted', '1': 'warning', '2': 'danger'})[p] || 'muted';
|
||||
}
|
||||
|
||||
// Open a list view of any model with an optional domain — used by
|
||||
// the new compliance KPI tiles (Missed Bakes / Open Holds / Stale
|
||||
// Steps / Locked / Pending QC / Draft Certs) so the manager can
|
||||
// drill in with one tap. v19.0.24.3.0.
|
||||
openModelList(model, domain) {
|
||||
if (!model) return;
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: model,
|
||||
view_mode: "list,form",
|
||||
views: [[false, "list"], [false, "form"]],
|
||||
domain: domain || [],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_manager_dashboard", ManagerDashboard);
|
||||
|
||||
@@ -39,6 +39,10 @@ export class ShopfloorTablet extends Component {
|
||||
messageType: "info",
|
||||
loading: false,
|
||||
showScan: false,
|
||||
// Live-elapsed timer on active step. Re-rendered every 1s
|
||||
// by a separate interval so the operator sees seconds tick
|
||||
// up without waiting for the 30s payload refresh.
|
||||
activeElapsed: "00:00:00",
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -46,13 +50,39 @@ export class ShopfloorTablet extends Component {
|
||||
if (saved) this.state.stationId = saved;
|
||||
await this.refresh();
|
||||
this._interval = setInterval(() => this.refresh(), 30000);
|
||||
this._tickInterval = setInterval(() => this._tickElapsed(), 1000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._interval) clearInterval(this._interval);
|
||||
if (this._tickInterval) clearInterval(this._tickInterval);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Live timer
|
||||
// Compute elapsed = (now - date_started_iso) for the active step. Runs
|
||||
// every second so the operator sees a real clock, not stale seconds.
|
||||
_tickElapsed() {
|
||||
const a = this.state.overview && this.state.overview.active_wo;
|
||||
if (!a || !a.date_started_iso) {
|
||||
this.state.activeElapsed = "—";
|
||||
return;
|
||||
}
|
||||
// Odoo gives "YYYY-MM-DD HH:MM:SS" in UTC; turn into ISO Z.
|
||||
const isoUtc = a.date_started_iso.replace(" ", "T") + "Z";
|
||||
const startMs = Date.parse(isoUtc);
|
||||
if (isNaN(startMs)) {
|
||||
this.state.activeElapsed = "—";
|
||||
return;
|
||||
}
|
||||
let s = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
||||
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
|
||||
s %= 3600;
|
||||
const mm = String(Math.floor(s / 60)).padStart(2, "0");
|
||||
const ss = String(s % 60).padStart(2, "0");
|
||||
this.state.activeElapsed = `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Helpers
|
||||
setMessage(text, type = "info") {
|
||||
this.state.message = text;
|
||||
@@ -223,6 +253,68 @@ export class ShopfloorTablet extends Component {
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Qty / scrap
|
||||
// Bump qty_done from the tablet — operator confirms +1 part finished.
|
||||
// Also bump scrap with a confirm prompt — auto-spawns a Hold via S17.
|
||||
async onBumpQtyDone(jobId) {
|
||||
if (!jobId) return;
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/bump_qty_done", {
|
||||
job_id: jobId,
|
||||
delta: 1,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage(`Qty done: ${res.qty_done} / ${res.qty_total}`, "success");
|
||||
} else if (res && res.error) {
|
||||
this.setMessage(res.error, "danger");
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Bump failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async onBumpScrap(jobId) {
|
||||
if (!jobId) return;
|
||||
// Block-and-prompt — scrap is a quality event, force the operator
|
||||
// to acknowledge and ideally type a brief reason.
|
||||
const reason = window.prompt(
|
||||
"Reason for scrap (e.g. 'dropped during de-rack', 'flash burn'):",
|
||||
"",
|
||||
);
|
||||
if (reason === null) return; // operator hit Cancel
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/bump_qty_scrapped", {
|
||||
job_id: jobId,
|
||||
delta: 1,
|
||||
reason: reason || null,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage(
|
||||
`Scrap recorded: ${res.qty_scrapped}. Hold auto-created.`,
|
||||
"warning",
|
||||
);
|
||||
} else if (res && res.error) {
|
||||
this.setMessage(res.error, "danger");
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Scrap failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
// Open a pending QC directly from the banner — same deep-link the
|
||||
// FP-QC scan path uses.
|
||||
onOpenPendingQc(qcId) {
|
||||
if (!qcId) return;
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_qc_checklist",
|
||||
params: { check_id: qcId },
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Utility
|
||||
stateBadge(state) {
|
||||
const map = {
|
||||
|
||||
@@ -699,4 +699,100 @@
|
||||
color: $fp-ink-faint;
|
||||
font-size: $fp-text-xs;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// S13/S14/S17/S19 — recipe chips, predecessor lock, live clock, scrap bar
|
||||
// -------------------------------------------------------------------------
|
||||
.o_fp_active_wo_left {
|
||||
display: flex; gap: $fp-space-3; align-items: flex-start;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.o_fp_active_wo_body { flex: 1; min-width: 0; }
|
||||
.o_fp_active_wo_right {
|
||||
display: flex; flex-direction: column; gap: $fp-space-2; align-items: flex-end;
|
||||
}
|
||||
.o_fp_active_wo_clock {
|
||||
display: inline-flex; gap: $fp-space-2; align-items: baseline;
|
||||
background: $fp-card; padding: $fp-space-2 $fp-space-4;
|
||||
border-radius: $fp-radius-md;
|
||||
font-family: ui-monospace, "SF Mono", Consolas, "Liberation Mono", monospace;
|
||||
font-weight: $fp-weight-semibold;
|
||||
font-size: $fp-text-lg;
|
||||
small { color: $fp-ink-faint; font-size: $fp-text-xs; }
|
||||
i { color: $fp-ink-faint; }
|
||||
&.o_fp_active_wo_clock_overrun {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
color: var(--bs-danger, #c52131);
|
||||
}
|
||||
}
|
||||
.o_fp_active_wo_actions {
|
||||
display: flex; gap: $fp-space-2; flex-wrap: wrap;
|
||||
.btn {
|
||||
min-height: $fp-touch-min;
|
||||
padding: 0 $fp-space-3;
|
||||
font-size: $fp-text-sm;
|
||||
font-weight: $fp-weight-semibold;
|
||||
}
|
||||
}
|
||||
.o_fp_active_wo_chips,
|
||||
.o_fp_queue_chips {
|
||||
display: flex; flex-wrap: wrap; gap: $fp-space-2;
|
||||
margin-top: $fp-space-2;
|
||||
}
|
||||
.o_fp_active_wo_instructions,
|
||||
.o_fp_queue_instructions {
|
||||
margin-top: $fp-space-2;
|
||||
padding: $fp-space-2 $fp-space-3;
|
||||
background: rgba(13, 110, 253, 0.08);
|
||||
border-radius: $fp-radius-sm;
|
||||
color: $fp-ink-soft;
|
||||
font-size: $fp-text-sm;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.o_fp_chip {
|
||||
display: inline-flex; align-items: center; gap: $fp-space-1;
|
||||
padding: 2px $fp-space-2;
|
||||
font-size: $fp-text-xs;
|
||||
font-weight: $fp-weight-semibold;
|
||||
border-radius: $fp-radius-pill;
|
||||
background: $fp-card-soft;
|
||||
color: $fp-ink-soft;
|
||||
i { font-size: 10px; }
|
||||
&.o_fp_chip_info {
|
||||
background: rgba(13, 110, 253, 0.15);
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
}
|
||||
&.o_fp_chip_warning {
|
||||
background: rgba(255, 193, 7, 0.20);
|
||||
color: #856404;
|
||||
}
|
||||
&.o_fp_chip_danger {
|
||||
background: rgba(220, 53, 69, 0.15);
|
||||
color: var(--bs-danger, #c52131);
|
||||
}
|
||||
&.o_fp_chip_success {
|
||||
background: rgba(25, 135, 84, 0.15);
|
||||
color: var(--bs-success, #198754);
|
||||
}
|
||||
&.o_fp_chip_muted { background: $fp-card-soft; color: $fp-ink-faint; }
|
||||
}
|
||||
.o_fp_queue_step_pos {
|
||||
margin-left: $fp-space-2;
|
||||
color: $fp-ink-faint;
|
||||
font-size: $fp-text-xs;
|
||||
font-weight: 400;
|
||||
}
|
||||
.o_fp_queue_blocked_msg {
|
||||
margin-top: $fp-space-2;
|
||||
padding: $fp-space-2 $fp-space-3;
|
||||
background: rgba(255, 193, 7, 0.18);
|
||||
border-radius: $fp-radius-sm;
|
||||
color: #856404;
|
||||
font-size: $fp-text-sm;
|
||||
i { margin-right: $fp-space-1; }
|
||||
}
|
||||
.o_fp_queue_row_blocked {
|
||||
opacity: 0.65;
|
||||
.o_fp_queue_pri { color: var(--bs-warning, #f59e0b); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,69 @@
|
||||
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.pending_accept_sos"/></div>
|
||||
<div class="o_fp_kpi_label">Awaiting Assignment</div>
|
||||
</div>
|
||||
<!-- v19.0.24.3.0 — compliance + floor-health KPIs. -->
|
||||
<!-- Hidden when 0 to keep the strip clean; light up -->
|
||||
<!-- when something needs attention. Click to drill. -->
|
||||
<div class="o_fp_kpi o_fp_kpi_danger"
|
||||
t-if="state.overview.kpis.missed_bakes"
|
||||
t-on-click="() => this.openModelList('fusion.plating.bake.window', [['state','=','missed_window']])">
|
||||
<i class="fa fa-fire"/>
|
||||
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.missed_bakes"/></div>
|
||||
<div class="o_fp_kpi_label">Missed Bakes</div>
|
||||
</div>
|
||||
<div class="o_fp_kpi o_fp_kpi_danger"
|
||||
t-if="state.overview.kpis.open_holds"
|
||||
t-on-click="() => this.openModelList('fusion.plating.quality.hold', [['state','in',['on_hold','under_review']]])">
|
||||
<i class="fa fa-pause-circle"/>
|
||||
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.open_holds"/></div>
|
||||
<div class="o_fp_kpi_label">Open Holds</div>
|
||||
</div>
|
||||
<div class="o_fp_kpi o_fp_kpi_warning"
|
||||
t-if="state.overview.kpis.stale_paused_steps or state.overview.kpis.stale_inprogress_steps"
|
||||
t-on-click="() => this.openModelList('fp.job.step', [['state','in',['paused','in_progress']]])">
|
||||
<i class="fa fa-hourglass-end"/>
|
||||
<div class="o_fp_kpi_value">
|
||||
<t t-esc="state.overview.kpis.stale_paused_steps + state.overview.kpis.stale_inprogress_steps"/>
|
||||
</div>
|
||||
<div class="o_fp_kpi_label">Stale Steps</div>
|
||||
</div>
|
||||
<div class="o_fp_kpi o_fp_kpi_warning"
|
||||
t-if="state.overview.kpis.predecessor_locked_steps"
|
||||
t-on-click="() => this.openModelList('fp.job.step', [['requires_predecessor_done','=',true],['state','in',['ready','paused']]])">
|
||||
<i class="fa fa-lock"/>
|
||||
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.predecessor_locked_steps"/></div>
|
||||
<div class="o_fp_kpi_label">Locked Steps</div>
|
||||
</div>
|
||||
<div class="o_fp_kpi o_fp_kpi_info"
|
||||
t-if="state.overview.kpis.pending_qcs"
|
||||
t-on-click="() => this.openModelList('fusion.plating.quality.check', [['state','in',['draft','in_progress']]])">
|
||||
<i class="fa fa-clipboard-list"/>
|
||||
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.pending_qcs"/></div>
|
||||
<div class="o_fp_kpi_label">
|
||||
Pending QC
|
||||
<t t-if="state.overview.kpis.qcs_missing_fischerscope">
|
||||
<span class="text-danger">
|
||||
(<t t-esc="state.overview.kpis.qcs_missing_fischerscope"/> need PDF)
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_kpi o_fp_kpi_info"
|
||||
t-if="state.overview.kpis.draft_certs or state.overview.kpis.issued_certs_today"
|
||||
t-on-click="() => this.openModelList('fp.certificate', [['state','in',['draft','issued']]])">
|
||||
<i class="fa fa-certificate"/>
|
||||
<div class="o_fp_kpi_value">
|
||||
<t t-esc="state.overview.kpis.draft_certs"/>
|
||||
</div>
|
||||
<div class="o_fp_kpi_label">
|
||||
Draft Certs
|
||||
<t t-if="state.overview.kpis.issued_certs_today">
|
||||
<span class="text-success">
|
||||
(<t t-esc="state.overview.kpis.issued_certs_today"/> today)
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Workload grid ============ -->
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
t-if="state.overview and state.overview.active_wo">
|
||||
<div class="o_fp_active_wo_left">
|
||||
<span class="o_fp_active_wo_pulse"/>
|
||||
<div>
|
||||
<div class="o_fp_active_wo_body">
|
||||
<div class="o_fp_active_wo_title">
|
||||
Active: <strong t-esc="state.overview.active_wo.name"/>
|
||||
</div>
|
||||
@@ -93,14 +93,78 @@
|
||||
Job <t t-esc="state.overview.active_wo.mo_name"/>
|
||||
· <t t-esc="state.overview.active_wo.product_name"/>
|
||||
· Qty <t t-esc="state.overview.active_wo.qty_done"/>/<t t-esc="state.overview.active_wo.qty_total"/>
|
||||
<t t-if="state.overview.active_wo.qty_scrapped">
|
||||
<span class="text-danger ms-1">
|
||||
(scrap <t t-esc="state.overview.active_wo.qty_scrapped"/>)
|
||||
</span>
|
||||
</t>
|
||||
<t t-if="state.overview.active_wo.workcenter"> @ <t t-esc="state.overview.active_wo.workcenter"/></t>
|
||||
</div>
|
||||
<!-- Recipe-author chips so operator sees the
|
||||
targets without leaving the active card. -->
|
||||
<div class="o_fp_active_wo_chips">
|
||||
<span class="o_fp_chip o_fp_chip_info"
|
||||
t-if="state.overview.active_wo.thickness_target">
|
||||
<i class="fa fa-bullseye"/>
|
||||
Thickness <t t-esc="state.overview.active_wo.thickness_target"/>
|
||||
<t t-esc="state.overview.active_wo.thickness_uom or 'mils'"/>
|
||||
</span>
|
||||
<span class="o_fp_chip o_fp_chip_info"
|
||||
t-if="state.overview.active_wo.dwell_time_minutes">
|
||||
<i class="fa fa-clock-o"/>
|
||||
Dwell <t t-esc="state.overview.active_wo.dwell_time_minutes"/> min
|
||||
</span>
|
||||
<span class="o_fp_chip o_fp_chip_warning"
|
||||
t-if="state.overview.active_wo.bake_setpoint_temp">
|
||||
<i class="fa fa-fire"/>
|
||||
Bake <t t-esc="state.overview.active_wo.bake_setpoint_temp"/>°
|
||||
</span>
|
||||
<span class="o_fp_chip o_fp_chip_info"
|
||||
t-if="state.overview.active_wo.requires_signoff">
|
||||
<i class="fa fa-pencil-square-o"/>
|
||||
Sign-off required
|
||||
</span>
|
||||
</div>
|
||||
<!-- Recipe-author instructions (from S13). Inline
|
||||
collapsible so operator never has to scan to
|
||||
remind themselves of the procedure. -->
|
||||
<div class="o_fp_active_wo_instructions"
|
||||
t-if="state.overview.active_wo.instructions">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
<t t-esc="state.overview.active_wo.instructions"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_active_wo_right">
|
||||
<!-- Live ticking elapsed time. _tickElapsed() updates
|
||||
this every 1s; far better than a stale duration. -->
|
||||
<div class="o_fp_active_wo_clock"
|
||||
t-att-class="{ 'o_fp_active_wo_clock_overrun': state.overview.active_wo.duration_expected
|
||||
and state.overview.active_wo.duration > state.overview.active_wo.duration_expected * 1.5 }">
|
||||
<i class="fa fa-stopwatch"/>
|
||||
<span class="o_fp_active_wo_clock_v"
|
||||
t-esc="state.activeElapsed"/>
|
||||
<small t-if="state.overview.active_wo.duration_expected">
|
||||
of <t t-esc="Math.round(state.overview.active_wo.duration_expected/60)"/>m planned
|
||||
</small>
|
||||
</div>
|
||||
<div class="o_fp_active_wo_actions">
|
||||
<button class="btn btn-success"
|
||||
t-on-click="() => this.onBumpQtyDone(this.state.overview.active_wo.mo_id)"
|
||||
title="Increment qty_done by 1">
|
||||
<i class="fa fa-plus"/> +1 Done
|
||||
</button>
|
||||
<button class="btn btn-outline-danger"
|
||||
t-on-click="() => this.onBumpScrap(this.state.overview.active_wo.mo_id)"
|
||||
title="Record one scrap part — auto-creates a Hold">
|
||||
<i class="fa fa-trash"/> Scrap
|
||||
</button>
|
||||
<button class="o_fp_big_button"
|
||||
t-on-click="() => openRecord('fp.job.step', state.overview.active_wo.id)">
|
||||
Open Step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="o_fp_big_button"
|
||||
t-on-click="() => openRecord('fp.job.step', state.overview.active_wo.id)">
|
||||
Open Step
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ Dashboard ============ -->
|
||||
@@ -118,16 +182,61 @@
|
||||
</div>
|
||||
<ul class="o_fp_queue_list" t-if="state.overview.my_queue.length">
|
||||
<t t-foreach="state.overview.my_queue" t-as="row" t-key="row.id">
|
||||
<li class="o_fp_queue_row">
|
||||
<li class="o_fp_queue_row"
|
||||
t-att-class="{ 'o_fp_queue_row_blocked': row.predecessor_blocked }">
|
||||
<div class="o_fp_queue_pri" t-att-data-priority="row.priority >= 90 ? 'high' : (row.priority >= 70 ? 'med' : 'low')">
|
||||
<t t-if="row.priority >= 90">HI</t>
|
||||
<t t-if="row.predecessor_blocked">
|
||||
<i class="fa fa-lock"/>
|
||||
</t>
|
||||
<t t-elif="row.priority >= 90">HI</t>
|
||||
<t t-elif="row.priority >= 70">M</t>
|
||||
<t t-else="">·</t>
|
||||
</div>
|
||||
<div class="o_fp_queue_body"
|
||||
t-on-click="() => this.onQueueItemClick(row)">
|
||||
<div class="o_fp_queue_label"><t t-esc="row.label"/></div>
|
||||
<div class="o_fp_queue_label">
|
||||
<t t-esc="row.label"/>
|
||||
<t t-if="row.step_sequence and row.job_step_count">
|
||||
<span class="o_fp_queue_step_pos">
|
||||
· step <t t-esc="row.step_sequence/10"/>/<t t-esc="row.job_step_count"/>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_fp_queue_desc"><t t-esc="row.description"/></div>
|
||||
<!-- S14 — predecessor block notice. Replaces -->
|
||||
<!-- the green Start with a clear "wait for X". -->
|
||||
<div class="o_fp_queue_blocked_msg"
|
||||
t-if="row.predecessor_blocked">
|
||||
<i class="fa fa-lock"/>
|
||||
Awaiting <strong t-esc="row.blocked_by_name"/>
|
||||
— finish that step first
|
||||
</div>
|
||||
<!-- S13 — recipe-author chips inline -->
|
||||
<div class="o_fp_queue_chips"
|
||||
t-if="row.thickness_target or row.dwell_time_minutes or row.bake_setpoint_temp or row.requires_signoff">
|
||||
<span class="o_fp_chip o_fp_chip_info"
|
||||
t-if="row.thickness_target">
|
||||
Target <t t-esc="row.thickness_target"/>
|
||||
<t t-esc="row.thickness_uom or 'mils'"/>
|
||||
</span>
|
||||
<span class="o_fp_chip o_fp_chip_info"
|
||||
t-if="row.dwell_time_minutes">
|
||||
<t t-esc="row.dwell_time_minutes"/>m dwell
|
||||
</span>
|
||||
<span class="o_fp_chip o_fp_chip_warning"
|
||||
t-if="row.bake_setpoint_temp">
|
||||
Bake <t t-esc="row.bake_setpoint_temp"/>°
|
||||
</span>
|
||||
<span class="o_fp_chip o_fp_chip_info"
|
||||
t-if="row.requires_signoff">
|
||||
Sign-off
|
||||
</span>
|
||||
</div>
|
||||
<!-- S13 — instructions snippet (first 120 chars) -->
|
||||
<div class="o_fp_queue_instructions"
|
||||
t-if="row.instructions">
|
||||
<t t-esc="row.instructions.length > 120 ? row.instructions.slice(0,120) + '…' : row.instructions"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_queue_actions">
|
||||
<button t-if="row.can_start"
|
||||
@@ -135,6 +244,12 @@
|
||||
t-on-click="() => this.onStartWo(row.source_id)">
|
||||
<i class="fa fa-play"/> Start
|
||||
</button>
|
||||
<button t-if="row.predecessor_blocked"
|
||||
class="btn btn-light"
|
||||
disabled="disabled"
|
||||
title="Finish the earlier step first.">
|
||||
<i class="fa fa-lock"/> Locked
|
||||
</button>
|
||||
<button t-if="row.can_finish"
|
||||
class="btn btn-primary"
|
||||
t-on-click="() => this.onFinishWo(row.source_id)">
|
||||
@@ -275,6 +390,52 @@
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- ===== Pending QC banner (S19 follow-up) ===== -->
|
||||
<!-- Shows whenever Carlos's job has an open QC. Tap -->
|
||||
<!-- the QC name to deep-link straight into Lisa's -->
|
||||
<!-- mobile checklist. Without this Carlos doesn't -->
|
||||
<!-- know to call inspection — QC sits in draft. -->
|
||||
<section class="o_fp_panel"
|
||||
t-if="state.overview.pending_qcs and state.overview.pending_qcs.length">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-clipboard-check text-warning"/>Pending QC</h3>
|
||||
<span class="o_fp_panel_count">
|
||||
<t t-esc="state.overview.pending_qcs.length"/>
|
||||
</span>
|
||||
</div>
|
||||
<ul class="o_fp_bake_list">
|
||||
<t t-foreach="state.overview.pending_qcs" t-as="qc" t-key="qc.id">
|
||||
<li class="o_fp_bake_row" t-att-data-state="qc.state">
|
||||
<div class="o_fp_bake_main">
|
||||
<div class="o_fp_bake_name">
|
||||
<t t-esc="qc.name"/>
|
||||
<span class="text-muted ms-1">
|
||||
— <t t-esc="qc.template_name"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_bake_meta">
|
||||
Job <t t-esc="qc.job_name"/>
|
||||
· <t t-esc="qc.partner_name"/>
|
||||
· <t t-esc="qc.lines_pending"/>/<t t-esc="qc.line_count"/> pending
|
||||
<t t-if="qc.require_thickness_report_pdf">
|
||||
<span t-att-class="qc.has_thickness_pdf ? 'text-success ms-1' : 'text-danger ms-1'">
|
||||
<i t-att-class="qc.has_thickness_pdf ? 'fa fa-check-circle' : 'fa fa-exclamation-circle'"/>
|
||||
Fischerscope PDF
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_bake_actions">
|
||||
<button class="btn btn-warning"
|
||||
t-on-click="() => this.onOpenPendingQc(qc.id)">
|
||||
<i class="fa fa-clipboard-list"/> Open QC
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="o_fp_panel" t-if="state.overview.holds.length">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-pause-circle text-danger"/>Quality Holds</h3>
|
||||
|
||||
Reference in New Issue
Block a user