feat(shopfloor): rich Tablet Station dashboard + full shop-floor demo data
Tablet Station rebuilt as a live dashboard (not just a QR scanner):
* KPI strip — WOs Ready/Progress, Awaiting/Missed bakes,
First-Piece pending, Quality Holds (each tinted by state)
* Active WO banner with pulsing indicator when a WO is running
* My Queue panel (left) — priority-badged operator next-up list,
clickable rows that jump to the WO/bake/gate form
* Baths tile grid (right) — last-log status chips, MTO count,
hover jump to chemistry log
* Bake Windows list — inline Start/End/Open actions, colour-coded
by state (awaiting / in-progress / missed)
* First-Piece Gates — Pass/Fail buttons for pending inspections
* Quality Holds — Review jump when any open holds exist
* Station picker + scan drawer (collapsed by default)
* 30s auto-refresh, persists picked station in localStorage
New controller endpoints: /fp/shopfloor/tablet_overview,
/fp/shopfloor/pair_station, /fp/shopfloor/mark_gate.
Demo seeder (Phase 12.5) now populates:
* 5 shop-floor stations (Plating, Bake, Inspection, Shipping, Receiving)
* +3 bake windows (awaiting / in-progress / near-due)
* 4 first-piece gates (1 pending, 1 passed+released, 1 passed-holding, 1 failed)
* 2 quality holds on active MOs (one on_hold, one under_review)
All four Shop Floor menu pages (Plant Overview, Tablet Station, Bake
Windows, First-Piece Gates) now have meaningful content.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -313,6 +313,248 @@ class FpShopfloorController(http.Controller):
|
||||
'state': hold.state,
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Tablet Overview — one-shot dashboard payload
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/tablet_overview', type='jsonrpc', auth='user')
|
||||
def tablet_overview(self, station_id=None, facility_id=None):
|
||||
"""Return a rich dashboard snapshot for the Tablet Station page.
|
||||
|
||||
Shape:
|
||||
{
|
||||
station: {...} or None,
|
||||
facility: {id, name} or None,
|
||||
kpis: [{label, value, tone}], # top strip
|
||||
my_queue: [...], # top 8 queue rows
|
||||
active_wo: {...} or None, # the one WO currently in progress
|
||||
baths: [...], # chemistry quick view (top 6)
|
||||
bake_windows: [...], # top 6 awaiting/in-progress, soonest first
|
||||
gates: [...], # pending first-piece gates
|
||||
holds: [...], # open quality holds
|
||||
stations: [...], # all stations, for the picker
|
||||
}
|
||||
"""
|
||||
env = request.env
|
||||
user = env.user
|
||||
|
||||
# -- Resolve station / facility -----------------------------------
|
||||
station = None
|
||||
if station_id:
|
||||
stn = env['fusion.plating.shopfloor.station'].browse(int(station_id))
|
||||
if stn.exists():
|
||||
station = stn
|
||||
if not facility_id:
|
||||
facility_id = stn.facility_id.id
|
||||
fac = None
|
||||
if facility_id:
|
||||
fac = env['fusion.plating.facility'].browse(int(facility_id))
|
||||
|
||||
# -- KPI counts ---------------------------------------------------
|
||||
BakeWindow = env['fusion.plating.bake.window']
|
||||
Gate = env['fusion.plating.first.piece.gate']
|
||||
Hold = env['fusion.plating.quality.hold']
|
||||
MrpWO = env.get('mrp.workorder')
|
||||
|
||||
def _dom(dom):
|
||||
return dom + ([('facility_id', '=', fac.id)] if fac else [])
|
||||
|
||||
wos_ready = wos_progress = 0
|
||||
if MrpWO is not None:
|
||||
wo_base = []
|
||||
if fac:
|
||||
wo_base = [('workcenter_id.x_fc_facility_id', '=', fac.id)]
|
||||
wos_ready = MrpWO.search_count(wo_base + [('state', '=', 'ready')])
|
||||
wos_progress = MrpWO.search_count(wo_base + [('state', '=', 'progress')])
|
||||
|
||||
awaiting = BakeWindow.search_count(_dom([('state', '=', 'awaiting_bake')]))
|
||||
in_progress_bakes = BakeWindow.search_count(_dom([('state', '=', 'bake_in_progress')]))
|
||||
missed = BakeWindow.search_count(_dom([('state', '=', 'missed_window')]))
|
||||
pending_gates = Gate.search_count(_dom([('result', '=', 'pending')]))
|
||||
open_holds = Hold.search_count([('state', 'in', ('on_hold', 'under_review'))])
|
||||
|
||||
kpis = [
|
||||
{'label': 'WOs Ready', 'value': wos_ready, 'tone': 'info', 'icon': 'fa-hourglass-half'},
|
||||
{'label': 'WOs In Progress', 'value': wos_progress, 'tone': 'success', 'icon': 'fa-cogs'},
|
||||
{'label': 'Awaiting Bake', 'value': awaiting, 'tone': 'warning', 'icon': 'fa-fire'},
|
||||
{'label': 'Missed Windows', 'value': missed, 'tone': 'danger' if missed else 'muted', 'icon': 'fa-exclamation-triangle'},
|
||||
{'label': 'First-Piece', 'value': pending_gates, 'tone': 'info', 'icon': 'fa-flag-checkered'},
|
||||
{'label': 'Quality Holds', 'value': open_holds, 'tone': 'danger' if open_holds else 'muted', 'icon': 'fa-pause-circle'},
|
||||
]
|
||||
|
||||
# -- My Queue (top 8) --------------------------------------------
|
||||
queue_rows = env['fusion.plating.operator.queue'].build_for_user(
|
||||
user_id=user.id, facility_id=fac.id if fac else None,
|
||||
)
|
||||
my_queue = [
|
||||
{
|
||||
'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 queue_rows[:8]
|
||||
]
|
||||
|
||||
# -- Active WO for this user -------------------------------------
|
||||
active_wo = None
|
||||
if MrpWO is not None:
|
||||
wo = MrpWO.search([('state', '=', 'progress')], limit=1)
|
||||
if wo:
|
||||
prod = wo.production_id
|
||||
active_wo = {
|
||||
'id': wo.id,
|
||||
'name': wo.display_name or wo.name,
|
||||
'workcenter': wo.workcenter_id.name or '',
|
||||
'mo_name': prod.name or '',
|
||||
'product_name': prod.product_id.display_name if prod.product_id else '',
|
||||
'qty_done': int(getattr(prod, 'qty_produced', None) or 0),
|
||||
'qty_total': int(prod.product_qty or 0),
|
||||
'duration': wo.duration or 0,
|
||||
'step_display': getattr(wo, 'x_fc_step_display', '') or '',
|
||||
'customer': prod.origin or '',
|
||||
}
|
||||
|
||||
# -- Baths chemistry quick view ----------------------------------
|
||||
bath_domain = [('state', 'in', ('operational', 'low', 'out_of_spec'))]
|
||||
if fac:
|
||||
bath_domain.append(('facility_id', '=', fac.id))
|
||||
baths = env['fusion.plating.bath'].search(bath_domain, order='last_log_date desc, id', limit=6)
|
||||
baths_data = [
|
||||
{
|
||||
'id': b.id,
|
||||
'name': b.display_name or b.name,
|
||||
'tank': b.tank_id.name or '',
|
||||
'state': b.state or '',
|
||||
'last_log_date': fields.Datetime.to_string(b.last_log_date) if b.last_log_date else '',
|
||||
'last_log_status': b.last_log_status or '',
|
||||
'mto': round(b.mto_count or 0, 2),
|
||||
}
|
||||
for b in baths
|
||||
]
|
||||
|
||||
# -- Bake windows (top 6 awaiting/in-progress, soonest first) -----
|
||||
bw_domain = _dom([('state', 'in', ('awaiting_bake', 'bake_in_progress', 'missed_window'))])
|
||||
bws = BakeWindow.search(bw_domain, order='bake_required_by asc', limit=6)
|
||||
bw_data = [
|
||||
{
|
||||
'id': bw.id,
|
||||
'name': bw.name,
|
||||
'part_ref': bw.part_ref or '',
|
||||
'lot_ref': bw.lot_ref or '',
|
||||
'customer': bw.customer_ref or '',
|
||||
'state': bw.state,
|
||||
'remaining': bw.time_remaining_display or '',
|
||||
'required_by': fields.Datetime.to_string(bw.bake_required_by) if bw.bake_required_by else '',
|
||||
'quantity': bw.quantity or 0,
|
||||
}
|
||||
for bw in bws
|
||||
]
|
||||
|
||||
# -- First-piece gates (top 6 pending or recently failed) ---------
|
||||
gate_domain = _dom([('result', 'in', ('pending', 'fail'))])
|
||||
gates = Gate.search(gate_domain, order='first_piece_produced desc', limit=6)
|
||||
gates_data = [
|
||||
{
|
||||
'id': g.id,
|
||||
'name': g.name,
|
||||
'part_ref': g.part_ref or '',
|
||||
'customer': g.customer_ref or '',
|
||||
'bath': g.bath_id.name or '',
|
||||
'result': g.result,
|
||||
'first_piece': fields.Datetime.to_string(g.first_piece_produced) if g.first_piece_produced else '',
|
||||
'inspector': g.inspector_id.name or '',
|
||||
}
|
||||
for g in gates
|
||||
]
|
||||
|
||||
# -- Quality holds (top 6 open) -----------------------------------
|
||||
hold_domain = [('state', 'in', ('on_hold', 'under_review'))]
|
||||
holds = Hold.search(hold_domain, order='create_date desc', limit=6)
|
||||
holds_data = [
|
||||
{
|
||||
'id': h.id,
|
||||
'name': h.name,
|
||||
'part_ref': h.part_ref or '',
|
||||
'qty': h.qty_on_hold or 0,
|
||||
'reason': dict(h._fields['hold_reason'].selection).get(h.hold_reason, h.hold_reason or ''),
|
||||
'state': h.state,
|
||||
'operator': h.operator_id.name or '',
|
||||
}
|
||||
for h in holds
|
||||
]
|
||||
|
||||
# -- All stations for picker --------------------------------------
|
||||
station_list = env['fusion.plating.shopfloor.station'].search([], order='facility_id, name')
|
||||
stations = [
|
||||
{
|
||||
'id': s.id,
|
||||
'name': s.name,
|
||||
'code': s.code or '',
|
||||
'facility': s.facility_id.name or '',
|
||||
'work_center': s.work_center_id.name or '',
|
||||
'current_operator': s.current_operator_id.name or '',
|
||||
}
|
||||
for s in station_list
|
||||
]
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'station': {
|
||||
'id': station.id,
|
||||
'name': station.name,
|
||||
'code': station.code or '',
|
||||
'facility': station.facility_id.name or '',
|
||||
'work_center': station.work_center_id.name or '',
|
||||
} if station else None,
|
||||
'facility': {
|
||||
'id': fac.id,
|
||||
'name': fac.name,
|
||||
} if fac else None,
|
||||
'user_name': user.name,
|
||||
'kpis': kpis,
|
||||
'my_queue': my_queue,
|
||||
'active_wo': active_wo,
|
||||
'baths': baths_data,
|
||||
'bake_windows': bw_data,
|
||||
'gates': gates_data,
|
||||
'holds': holds_data,
|
||||
'stations': stations,
|
||||
'server_time': fields.Datetime.to_string(fields.Datetime.now()),
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Pair a station (bumps current_operator_id + last_ping)
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/pair_station', type='jsonrpc', auth='user')
|
||||
def pair_station(self, station_id):
|
||||
stn = request.env['fusion.plating.shopfloor.station'].browse(int(station_id))
|
||||
if not stn.exists():
|
||||
return {'ok': False, 'error': 'Station not found.'}
|
||||
stn.write({
|
||||
'current_operator_id': request.env.user.id,
|
||||
'last_ping': fields.Datetime.now(),
|
||||
})
|
||||
return {'ok': True}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Mark a first-piece gate result from the tablet
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user')
|
||||
def mark_gate(self, gate_id, result):
|
||||
gate = request.env['fusion.plating.first.piece.gate'].browse(int(gate_id))
|
||||
if not gate.exists():
|
||||
return {'ok': False, 'error': 'Gate not found.'}
|
||||
if result == 'pass':
|
||||
gate.action_mark_pass()
|
||||
elif result == 'fail':
|
||||
gate.action_mark_fail()
|
||||
else:
|
||||
return {'ok': False, 'error': f'Unknown result {result}'}
|
||||
return {'ok': True, 'state': gate.result}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Operator queue snapshot
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -5,14 +5,12 @@
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL component using `static template` + `static props = []`
|
||||
// (note: empty array, NOT empty object).
|
||||
// * RPC via standalone `rpc()` from @web/core/network/rpc — NOT useService.
|
||||
// * Registered under registry.category("actions") so the menu / record
|
||||
// action can launch it as a client action ("fp_shopfloor_tablet").
|
||||
// * Backend OWL component using `static template` + `static props = ["*"]`.
|
||||
// * RPC via standalone `rpc()` from @web/core/network/rpc.
|
||||
// * Registered under registry.category("actions") as "fp_shopfloor_tablet".
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, useRef } from "@odoo/owl";
|
||||
import { Component, useState, onMounted, onWillUnmount, useRef } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
@@ -23,155 +21,179 @@ export class ShopfloorTablet extends Component {
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.scanInput = useRef("scanInput");
|
||||
|
||||
this.state = useState({
|
||||
overview: null,
|
||||
stationId: null,
|
||||
scannedCode: "",
|
||||
station: null,
|
||||
currentTank: null,
|
||||
currentBath: null,
|
||||
currentJob: null,
|
||||
queueRows: [],
|
||||
message: "",
|
||||
messageType: "info", // info | success | warning | danger
|
||||
messageType: "info",
|
||||
loading: false,
|
||||
showScan: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this.refreshQueue();
|
||||
if (this.scanInput.el) {
|
||||
this.scanInput.el.focus();
|
||||
}
|
||||
const saved = parseInt(localStorage.getItem("fp_tablet_station_id") || "0") || null;
|
||||
if (saved) this.state.stationId = saved;
|
||||
await this.refresh();
|
||||
this._interval = setInterval(() => this.refresh(), 30000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._interval) clearInterval(this._interval);
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Helpers --------------------------------------------------------
|
||||
// ---------------------------------------------------------- Helpers
|
||||
setMessage(text, type = "info") {
|
||||
this.state.message = text;
|
||||
this.state.messageType = type;
|
||||
}
|
||||
|
||||
clearTargets() {
|
||||
this.state.currentTank = null;
|
||||
this.state.currentBath = null;
|
||||
this.state.currentJob = null;
|
||||
async refresh() {
|
||||
try {
|
||||
const payload = await rpc("/fp/shopfloor/tablet_overview", {
|
||||
station_id: this.state.stationId || null,
|
||||
});
|
||||
if (payload && payload.ok) {
|
||||
this.state.overview = payload;
|
||||
}
|
||||
} catch (err) {
|
||||
// silent — next tick will retry
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Station pairing
|
||||
async onPickStation(ev) {
|
||||
const id = parseInt(ev.target.value) || null;
|
||||
this.state.stationId = id;
|
||||
if (id) {
|
||||
localStorage.setItem("fp_tablet_station_id", String(id));
|
||||
try {
|
||||
await rpc("/fp/shopfloor/pair_station", { station_id: id });
|
||||
this.setMessage("Station paired.", "success");
|
||||
} catch (err) {
|
||||
this.setMessage(`Pair failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem("fp_tablet_station_id");
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Scan drawer
|
||||
toggleScan() {
|
||||
this.state.showScan = !this.state.showScan;
|
||||
if (this.state.showScan) {
|
||||
setTimeout(() => this.scanInput.el && this.scanInput.el.focus(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- QR scan --------------------------------------------------------
|
||||
async onScan() {
|
||||
const code = (this.state.scannedCode || "").trim();
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
if (!code) return;
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const result = await rpc("/fp/shopfloor/scan", { qr_code: code });
|
||||
if (!result || !result.ok) {
|
||||
this.setMessage(
|
||||
(result && result.error) || "Unrecognised QR code",
|
||||
"danger",
|
||||
);
|
||||
this.setMessage((result && result.error) || "Unrecognised QR code", "danger");
|
||||
this.state.loading = false;
|
||||
return;
|
||||
}
|
||||
this.clearTargets();
|
||||
switch (result.model) {
|
||||
case "fusion.plating.tank":
|
||||
this.state.currentTank = result;
|
||||
this.setMessage(
|
||||
`Tank ${result.name} — ${result.queue_size} in queue`,
|
||||
"info",
|
||||
);
|
||||
break;
|
||||
case "fusion.plating.bath":
|
||||
this.state.currentBath = result;
|
||||
this.setMessage(`Bath ${result.name}`, "info");
|
||||
break;
|
||||
case "fusion.plating.bake.window":
|
||||
this.state.currentJob = result;
|
||||
this.setMessage(
|
||||
`Job ${result.name} — ${result.time_remaining || ""} remaining`,
|
||||
result.state === "missed_window" ? "danger" : "warning",
|
||||
);
|
||||
break;
|
||||
case "fusion.plating.shopfloor.station":
|
||||
this.state.station = result;
|
||||
this.setMessage(
|
||||
`Station paired: ${result.name}`,
|
||||
"success",
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.setMessage(`Scanned ${result.model}`, "info");
|
||||
// If a station was scanned, pair it
|
||||
if (result.model === "fusion.plating.shopfloor.station") {
|
||||
this.state.stationId = result.id;
|
||||
localStorage.setItem("fp_tablet_station_id", String(result.id));
|
||||
this.setMessage(`Station paired: ${result.name}`, "success");
|
||||
} else {
|
||||
this.setMessage(`Scanned ${result.model} — ${result.name || ""}`, "info");
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Scan error: ${err.message || err}`, "danger");
|
||||
} finally {
|
||||
this.state.scannedCode = "";
|
||||
this.state.loading = false;
|
||||
if (this.scanInput.el) {
|
||||
this.scanInput.el.focus();
|
||||
}
|
||||
await this.refreshQueue();
|
||||
await this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
onScanKey(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
this.onScan();
|
||||
}
|
||||
if (ev.key === "Enter") this.onScan();
|
||||
}
|
||||
|
||||
// ----- Bake controls --------------------------------------------------
|
||||
async onStartBake() {
|
||||
if (!this.state.currentJob) {
|
||||
return;
|
||||
}
|
||||
// ---------------------------------------------------------- Actions
|
||||
async openRecord(model, id) {
|
||||
if (!model || !id) return;
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: model,
|
||||
res_id: id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
async onStartBake(bwId) {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/start_bake", {
|
||||
bake_window_id: this.state.currentJob.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage("Bake started", "success");
|
||||
this.state.currentJob.state = res.state;
|
||||
}
|
||||
await rpc("/fp/shopfloor/start_bake", { bake_window_id: bwId });
|
||||
this.setMessage("Bake started.", "success");
|
||||
} catch (err) {
|
||||
this.setMessage(`Start bake failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refreshQueue();
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async onEndBake() {
|
||||
if (!this.state.currentJob) {
|
||||
return;
|
||||
}
|
||||
async onEndBake(bwId) {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/end_bake", {
|
||||
bake_window_id: this.state.currentJob.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage(
|
||||
`Bake complete — ${res.bake_duration_hours.toFixed(2)} h`,
|
||||
"success",
|
||||
);
|
||||
this.state.currentJob.state = res.state;
|
||||
}
|
||||
await rpc("/fp/shopfloor/end_bake", { bake_window_id: bwId });
|
||||
this.setMessage("Bake complete.", "success");
|
||||
} catch (err) {
|
||||
this.setMessage(`End bake failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refreshQueue();
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
// ----- Queue ----------------------------------------------------------
|
||||
async refreshQueue() {
|
||||
async onGateResult(gateId, result) {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/queue", {});
|
||||
if (res && res.ok) {
|
||||
this.state.queueRows = res.rows || [];
|
||||
}
|
||||
await rpc("/fp/shopfloor/mark_gate", { gate_id: gateId, result });
|
||||
this.setMessage(`First-piece marked ${result.toUpperCase()}.`,
|
||||
result === "pass" ? "success" : "danger");
|
||||
} catch (err) {
|
||||
// Non-fatal: queue refresh shouldn't block scanning
|
||||
this.setMessage(`Gate update failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async onQueueItemClick(row) {
|
||||
if (row.source_model && row.source_id) {
|
||||
this.openRecord(row.source_model, row.source_id);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Utility
|
||||
stateBadge(state) {
|
||||
const map = {
|
||||
awaiting_bake: "warning",
|
||||
bake_in_progress: "info",
|
||||
baked: "success",
|
||||
missed_window: "danger",
|
||||
pending: "warning",
|
||||
pass: "success",
|
||||
fail: "danger",
|
||||
operational: "success",
|
||||
low: "warning",
|
||||
out_of_spec: "danger",
|
||||
on_hold: "danger",
|
||||
under_review: "warning",
|
||||
released: "success",
|
||||
scrapped: "muted",
|
||||
reworked: "info",
|
||||
ok: "success",
|
||||
warning: "warning",
|
||||
};
|
||||
return map[state] || "muted";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,192 +36,309 @@
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
font-size: 1.1rem;
|
||||
padding: 20px 24px;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
gap: 14px;
|
||||
|
||||
// ---------- Header -------------------------------------------------------
|
||||
.o_fp_tablet_header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_title {
|
||||
font-size: 1.6rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_station {
|
||||
.o_fp_tablet_subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.o_fp_tablet_scan_row {
|
||||
.o_fp_tablet_chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 10px;
|
||||
background-color: color-mix(in srgb, var(--o-action) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--o-action) 35%, transparent);
|
||||
color: var(--o-action);
|
||||
border-radius: 999px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.o_fp_tablet_header_actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.o_fp_station_picker {
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
}
|
||||
.o_fp_scan_toggle {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.o_fp_tablet_message {
|
||||
padding: 14px 18px;
|
||||
// ---------- Scan drawer --------------------------------------------------
|
||||
.o_fp_scan_drawer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background-color: color-mix(in srgb, var(--o-action) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
border: 1px dashed color-mix(in srgb, var(--o-action) 40%, transparent);
|
||||
border-radius: 10px;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&.o_fp_msg_info { @include fp-shop-tint(--bs-info); }
|
||||
// ---------- Flash message ------------------------------------------------
|
||||
.o_fp_tablet_message {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
&.o_fp_msg_info { @include fp-shop-tint(--bs-info); }
|
||||
&.o_fp_msg_success { @include fp-shop-tint(--bs-success); }
|
||||
&.o_fp_msg_warning { @include fp-shop-tint(--bs-warning); }
|
||||
&.o_fp_msg_danger { @include fp-shop-tint(--bs-danger); }
|
||||
&.o_fp_msg_danger { @include fp-shop-tint(--bs-danger); }
|
||||
}
|
||||
|
||||
.o_fp_tablet_grid {
|
||||
// ---------- KPI strip ----------------------------------------------------
|
||||
.o_fp_kpi_strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.o_fp_kpi {
|
||||
position: relative;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
> .fa {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
font-size: 1.4rem;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.o_fp_kpi_value { font-size: 1.8rem; font-weight: 700; line-height: 1; }
|
||||
.o_fp_kpi_label { font-size: 0.85rem; color: var(--bs-secondary-color); text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
|
||||
&.o_fp_kpi_info { border-left: 4px solid var(--bs-info); }
|
||||
&.o_fp_kpi_success { border-left: 4px solid var(--bs-success); }
|
||||
&.o_fp_kpi_warning { border-left: 4px solid var(--bs-warning); }
|
||||
&.o_fp_kpi_danger {
|
||||
border-left: 4px solid var(--bs-danger);
|
||||
background-color: color-mix(in srgb, var(--bs-danger) 5%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
.o_fp_kpi_value { color: var(--bs-danger); }
|
||||
}
|
||||
&.o_fp_kpi_muted { border-left: 4px solid var(--bs-border-color); opacity: 0.8; }
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue {
|
||||
// ---------- Active WO banner --------------------------------------------
|
||||
.o_fp_active_wo {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--bs-success) 40%, var(--bs-border-color));
|
||||
background-color: color-mix(in srgb, var(--bs-success) 7%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
border-radius: 10px;
|
||||
}
|
||||
.o_fp_active_wo_left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.o_fp_active_wo_title { font-weight: 600; font-size: 1.05rem; }
|
||||
.o_fp_active_wo_meta { color: var(--bs-secondary-color); font-size: 0.9rem; }
|
||||
.o_fp_active_wo_pulse {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background-color: var(--bs-success);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--bs-success) 60%, transparent);
|
||||
animation: o_fp_pulse 1.4s infinite;
|
||||
}
|
||||
@keyframes o_fp_pulse {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--bs-success) 60%, transparent); }
|
||||
70% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--bs-success) 0%, transparent); }
|
||||
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--bs-success) 0%, transparent); }
|
||||
}
|
||||
|
||||
// ---------- Dashboard layout --------------------------------------------
|
||||
.o_fp_tablet_dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.o_fp_tablet_dashboard { grid-template-columns: 1fr; }
|
||||
}
|
||||
.o_fp_right_col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
// ---------- Panel (reusable card) ---------------------------------------
|
||||
.o_fp_panel {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
|
||||
.o_fp_tablet_queue_title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--bs-border-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_item {
|
||||
background-color: color-mix(in srgb, var(--bs-body-color) 4%, transparent);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
|
||||
.o_fp_tablet_queue_label {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_desc {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
padding: 14px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Large card surface used for tank / bath info on the tablet
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_tablet_card {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
min-height: 140px;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--o-action) 50%, var(--bs-border-color));
|
||||
box-shadow: 0 2px 10px color-mix(in srgb, var(--bs-body-color) 8%, transparent);
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_meta {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Bake window card — colour shifts with state
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_bake_window_card {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-left-width: 6px;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
min-height: 160px;
|
||||
|
||||
.o_fp_tablet_card_label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.o_fp_tablet_card_value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.o_fp_tablet_card_meta {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.o_fp_tablet_card_actions {
|
||||
.o_fp_panel_head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--bs-border-color);
|
||||
h3 { font-size: 1.05rem; font-weight: 600; margin: 0; }
|
||||
}
|
||||
.o_fp_panel_count {
|
||||
background-color: color-mix(in srgb, var(--bs-body-color) 6%, transparent);
|
||||
color: var(--bs-body-color);
|
||||
border-radius: 999px;
|
||||
padding: 1px 10px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.o_fp_empty {
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
&[data-status="awaiting_bake"] {
|
||||
border-left-color: var(--bs-warning);
|
||||
background-color: color-mix(in srgb, var(--bs-warning) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
// ---------- My Queue list -----------------------------------------------
|
||||
.o_fp_queue_list {
|
||||
list-style: none;
|
||||
margin: 0; padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
&[data-status="bake_in_progress"] {
|
||||
border-left-color: var(--bs-info, var(--o-action));
|
||||
background-color: color-mix(in srgb, var(--bs-info, var(--o-action)) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
.o_fp_queue_row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 20px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, var(--o-action) 7%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
border-color: color-mix(in srgb, var(--o-action) 40%, var(--bs-border-color));
|
||||
}
|
||||
}
|
||||
&[data-status="baked"] {
|
||||
border-left-color: var(--bs-success);
|
||||
background-color: color-mix(in srgb, var(--bs-success) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
.o_fp_queue_label { font-weight: 600; }
|
||||
.o_fp_queue_desc { font-size: 0.88rem; color: var(--bs-secondary-color); }
|
||||
.o_fp_queue_pri {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
&[data-priority="high"] { background-color: color-mix(in srgb, var(--bs-danger) 15%, transparent); color: var(--bs-danger); }
|
||||
&[data-priority="med"] { background-color: color-mix(in srgb, var(--bs-warning) 15%, transparent); color: var(--bs-warning); }
|
||||
&[data-priority="low"] { background-color: color-mix(in srgb, var(--bs-body-color) 8%, transparent); color: var(--bs-secondary-color); }
|
||||
}
|
||||
&[data-status="missed_window"],
|
||||
&[data-status="scrapped"] {
|
||||
border-left-color: var(--bs-danger);
|
||||
background-color: color-mix(in srgb, var(--bs-danger) 8%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
|
||||
// ---------- Bath tiles --------------------------------------------------
|
||||
.o_fp_tile_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.o_fp_tile {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background-color 120ms ease;
|
||||
|
||||
&:hover { border-color: color-mix(in srgb, var(--o-action) 40%, var(--bs-border-color)); }
|
||||
&[data-tone="danger"] { border-left: 4px solid var(--bs-danger); }
|
||||
&[data-tone="warning"] { border-left: 4px solid var(--bs-warning); }
|
||||
&[data-tone="success"] { border-left: 4px solid var(--bs-success); }
|
||||
&[data-tone="info"] { border-left: 4px solid var(--bs-info); }
|
||||
}
|
||||
.o_fp_tile_title { font-weight: 600; margin-bottom: 2px; }
|
||||
.o_fp_tile_meta { font-size: 0.85rem; color: var(--bs-secondary-color); margin-bottom: 6px; }
|
||||
.o_fp_tile_chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
|
||||
// ---------- Chips -------------------------------------------------------
|
||||
.o_fp_chip {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
|
||||
&.o_fp_chip_info { @include fp-shop-tint(--bs-info); }
|
||||
&.o_fp_chip_success { @include fp-shop-tint(--bs-success); }
|
||||
&.o_fp_chip_warning { @include fp-shop-tint(--bs-warning); }
|
||||
&.o_fp_chip_danger { @include fp-shop-tint(--bs-danger); }
|
||||
&.o_fp_chip_muted {
|
||||
background-color: color-mix(in srgb, var(--bs-body-color) 6%, transparent);
|
||||
color: var(--bs-secondary-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Bake / Gate / Hold rows -------------------------------------
|
||||
.o_fp_bake_list {
|
||||
list-style: none;
|
||||
margin: 0; padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.o_fp_bake_row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
|
||||
&[data-state="awaiting_bake"], &[data-state="pending"] {
|
||||
border-left: 4px solid var(--bs-warning);
|
||||
}
|
||||
&[data-state="bake_in_progress"], &[data-state="under_review"] {
|
||||
border-left: 4px solid var(--bs-info);
|
||||
}
|
||||
&[data-state="missed_window"], &[data-state="fail"], &[data-state="on_hold"] {
|
||||
border-left: 4px solid var(--bs-danger);
|
||||
background-color: color-mix(in srgb, var(--bs-danger) 4%, transparent);
|
||||
}
|
||||
&[data-state="baked"], &[data-state="pass"] {
|
||||
border-left: 4px solid var(--bs-success);
|
||||
}
|
||||
}
|
||||
.o_fp_bake_name { font-weight: 600; }
|
||||
.o_fp_bake_meta { font-size: 0.85rem; }
|
||||
.o_fp_bake_actions { display: flex; gap: 6px; }
|
||||
|
||||
// ---------- Footer ------------------------------------------------------
|
||||
.o_fp_tablet_footer {
|
||||
text-align: right;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--bs-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,11 +348,11 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_scan_input {
|
||||
flex: 1 1 auto;
|
||||
min-height: 56px;
|
||||
padding: 12px 18px;
|
||||
font-size: 1.3rem;
|
||||
min-height: 44px;
|
||||
padding: 8px 14px;
|
||||
font-size: 1.05rem;
|
||||
border: 2px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
|
||||
@@ -244,10 +361,7 @@
|
||||
border-color: var(--o-action);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--o-action) 25%, transparent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
&::placeholder { color: var(--bs-secondary-color); }
|
||||
}
|
||||
|
||||
|
||||
@@ -255,26 +369,19 @@
|
||||
// Big touch-friendly action button
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_big_button {
|
||||
min-height: 56px;
|
||||
min-width: 120px;
|
||||
padding: 12px 24px;
|
||||
font-size: 1.1rem;
|
||||
min-height: 44px;
|
||||
min-width: 100px;
|
||||
padding: 8px 18px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--o-action);
|
||||
background-color: var(--o-action);
|
||||
color: var(--o-we-text-on-action, #fff);
|
||||
cursor: pointer;
|
||||
transition: filter 120ms ease, transform 80ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
&:active:not(:disabled) { transform: translateY(1px); }
|
||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
@@ -3,111 +3,326 @@
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Tablet Station dashboard — KPI strip, My Queue, Active WO,
|
||||
Baths, Bake Windows, First-Piece Gates, Quality Holds.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ShopfloorTablet">
|
||||
<div class="o_fp_tablet">
|
||||
|
||||
<!-- ===== Header — title, station picker, scan toggle ===== -->
|
||||
<div class="o_fp_tablet_header">
|
||||
<div class="o_fp_tablet_title">Fusion Plating — Shop Floor</div>
|
||||
<div class="o_fp_tablet_station" t-if="state.station">
|
||||
Station: <strong t-esc="state.station.name"/>
|
||||
<div>
|
||||
<div class="o_fp_tablet_title">
|
||||
<i class="fa fa-tablet me-2"/>Shop Floor Tablet
|
||||
</div>
|
||||
<div class="o_fp_tablet_subtitle" t-if="state.overview">
|
||||
<span t-esc="state.overview.user_name"/>
|
||||
<t t-if="state.overview.station">
|
||||
<span class="o_fp_tablet_chip ms-2">
|
||||
<i class="fa fa-desktop me-1"/>
|
||||
<span t-esc="state.overview.station.name"/>
|
||||
<span t-if="state.overview.station.work_center" class="text-muted">
|
||||
— <span t-esc="state.overview.station.work_center"/>
|
||||
</span>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_tablet_header_actions">
|
||||
<select class="form-select o_fp_station_picker"
|
||||
t-on-change="onPickStation"
|
||||
t-if="state.overview">
|
||||
<option value="">— Pick station —</option>
|
||||
<t t-foreach="state.overview.stations" t-as="s" t-key="s.id">
|
||||
<option t-att-value="s.id"
|
||||
t-att-selected="state.stationId === s.id">
|
||||
<t t-esc="s.name"/>
|
||||
<t t-if="s.work_center"> · <t t-esc="s.work_center"/></t>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<button class="btn btn-outline-primary o_fp_scan_toggle"
|
||||
t-on-click="toggleScan">
|
||||
<i class="fa fa-qrcode me-1"/>Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_scan_row">
|
||||
<input
|
||||
type="text"
|
||||
class="o_fp_scan_input"
|
||||
placeholder="Scan QR code"
|
||||
t-ref="scanInput"
|
||||
t-model="state.scannedCode"
|
||||
t-on-keydown="onScanKey"
|
||||
/>
|
||||
<button class="o_fp_big_button" t-on-click="onScan" t-att-disabled="state.loading">
|
||||
<!-- ===== Optional scan drawer ===== -->
|
||||
<div t-if="state.showScan" class="o_fp_scan_drawer">
|
||||
<input type="text"
|
||||
class="o_fp_scan_input"
|
||||
placeholder="Scan QR code (FP-STATION:…, FP-TANK:…, FP-BATH:…, FP-WO:…)"
|
||||
t-ref="scanInput"
|
||||
t-model="state.scannedCode"
|
||||
t-on-keydown="onScanKey"/>
|
||||
<button class="o_fp_big_button"
|
||||
t-on-click="onScan"
|
||||
t-att-disabled="state.loading">
|
||||
Scan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div t-if="state.message" t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
|
||||
<!-- ===== Flash message ===== -->
|
||||
<div t-if="state.message"
|
||||
t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
|
||||
<span t-esc="state.message"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_grid">
|
||||
<div class="o_fp_tablet_card" t-if="state.currentTank">
|
||||
<div class="o_fp_tablet_card_label">Tank</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentTank.name"/>
|
||||
<!-- ===== KPI strip ===== -->
|
||||
<div class="o_fp_kpi_strip" t-if="state.overview">
|
||||
<t t-foreach="state.overview.kpis" t-as="k" t-key="k.label">
|
||||
<div t-att-class="'o_fp_kpi o_fp_kpi_' + k.tone">
|
||||
<i t-att-class="'fa ' + k.icon"/>
|
||||
<div class="o_fp_kpi_value"><t t-esc="k.value"/></div>
|
||||
<div class="o_fp_kpi_label"><t t-esc="k.label"/></div>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentTank.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta" t-if="state.currentTank.current_bath_name">
|
||||
Bath: <t t-esc="state.currentTank.current_bath_name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
Queue: <t t-esc="state.currentTank.queue_size"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- ===== Active WO banner (only when one is running) ===== -->
|
||||
<div class="o_fp_active_wo"
|
||||
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_title">
|
||||
<i class="fa fa-play-circle me-1"/>
|
||||
Active Work Order: <strong t-esc="state.overview.active_wo.name"/>
|
||||
</div>
|
||||
<div class="o_fp_active_wo_meta">
|
||||
MO <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"/>
|
||||
<span t-if="state.overview.active_wo.workcenter" class="ms-2">
|
||||
@ <t t-esc="state.overview.active_wo.workcenter"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
t-on-click="() => openRecord('mrp.workorder', state.overview.active_wo.id)">
|
||||
Open WO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_card" t-if="state.currentBath">
|
||||
<div class="o_fp_tablet_card_label">Bath</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentBath.name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentBath.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta" t-if="state.currentBath.tank_name">
|
||||
Tank: <t t-esc="state.currentBath.tank_name"/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ===== Main grid ===== -->
|
||||
<div class="o_fp_tablet_dashboard" t-if="state.overview">
|
||||
|
||||
<div class="o_fp_bake_window_card"
|
||||
t-if="state.currentJob"
|
||||
t-att-data-status="state.currentJob.state">
|
||||
<div class="o_fp_tablet_card_label">Bake Job</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentJob.name"/>
|
||||
<!-- === LEFT column: My Queue (wide) === -->
|
||||
<section class="o_fp_panel o_fp_panel_queue">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-list-ol me-2"/>My Queue</h3>
|
||||
<span class="o_fp_panel_count">
|
||||
<t t-esc="state.overview.my_queue.length"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentJob.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
Remaining: <t t-esc="state.currentJob.time_remaining"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_actions">
|
||||
<button class="o_fp_big_button"
|
||||
t-if="state.currentJob.state === 'awaiting_bake'"
|
||||
t-on-click="onStartBake">
|
||||
Start Bake
|
||||
</button>
|
||||
<button class="o_fp_big_button"
|
||||
t-if="state.currentJob.state === 'bake_in_progress'"
|
||||
t-on-click="onEndBake">
|
||||
End Bake
|
||||
</button>
|
||||
<div t-if="!state.overview.my_queue.length"
|
||||
class="o_fp_empty">
|
||||
<i class="fa fa-check-circle text-success me-2"/>
|
||||
All caught up.
|
||||
</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"
|
||||
t-on-click="() => this.onQueueItemClick(row)">
|
||||
<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-elif="row.priority >= 70">M</t>
|
||||
<t t-else="">·</t>
|
||||
</div>
|
||||
<div class="o_fp_queue_body">
|
||||
<div class="o_fp_queue_label"><t t-esc="row.label"/></div>
|
||||
<div class="o_fp_queue_desc"><t t-esc="row.description"/></div>
|
||||
</div>
|
||||
<i class="fa fa-chevron-right text-muted"/>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- === RIGHT column: Baths + Bakes + Gates + Holds === -->
|
||||
<div class="o_fp_right_col">
|
||||
|
||||
<!-- Baths -->
|
||||
<section class="o_fp_panel">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-flask me-2"/>Baths</h3>
|
||||
<span class="o_fp_panel_count"><t t-esc="state.overview.baths.length"/></span>
|
||||
</div>
|
||||
<div t-if="!state.overview.baths.length" class="o_fp_empty">
|
||||
No baths configured.
|
||||
</div>
|
||||
<div class="o_fp_tile_grid" t-if="state.overview.baths.length">
|
||||
<t t-foreach="state.overview.baths" t-as="b" t-key="b.id">
|
||||
<div class="o_fp_tile"
|
||||
t-att-data-tone="stateBadge(b.last_log_status || b.state)"
|
||||
t-on-click="() => this.openRecord('fusion.plating.bath', b.id)">
|
||||
<div class="o_fp_tile_title"><t t-esc="b.name"/></div>
|
||||
<div class="o_fp_tile_meta">
|
||||
Tank <t t-esc="b.tank || '—'"/>
|
||||
</div>
|
||||
<div class="o_fp_tile_chips">
|
||||
<span t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(b.state)">
|
||||
<t t-esc="b.state"/>
|
||||
</span>
|
||||
<span t-if="b.last_log_status"
|
||||
t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(b.last_log_status)">
|
||||
log: <t t-esc="b.last_log_status"/>
|
||||
</span>
|
||||
<span t-if="b.mto" class="o_fp_chip o_fp_chip_muted">
|
||||
MTO <t t-esc="b.mto"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bake Windows -->
|
||||
<section class="o_fp_panel">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-fire me-2"/>Bake Windows</h3>
|
||||
<span class="o_fp_panel_count"><t t-esc="state.overview.bake_windows.length"/></span>
|
||||
</div>
|
||||
<div t-if="!state.overview.bake_windows.length" class="o_fp_empty">
|
||||
No bakes pending.
|
||||
</div>
|
||||
<ul class="o_fp_bake_list" t-if="state.overview.bake_windows.length">
|
||||
<t t-foreach="state.overview.bake_windows" t-as="bw" t-key="bw.id">
|
||||
<li class="o_fp_bake_row" t-att-data-state="bw.state">
|
||||
<div class="o_fp_bake_main">
|
||||
<div class="o_fp_bake_name">
|
||||
<strong t-esc="bw.name"/>
|
||||
<span class="text-muted ms-1">— <t t-esc="bw.part_ref"/></span>
|
||||
</div>
|
||||
<div class="o_fp_bake_meta text-muted">
|
||||
<t t-esc="bw.customer"/>
|
||||
· Qty <t t-esc="bw.quantity"/>
|
||||
· Lot <t t-esc="bw.lot_ref"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_bake_time">
|
||||
<span t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(bw.state)">
|
||||
<t t-esc="bw.remaining || bw.state"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_bake_actions">
|
||||
<button t-if="bw.state === 'awaiting_bake'"
|
||||
class="btn btn-sm btn-warning"
|
||||
t-on-click="() => this.onStartBake(bw.id)">
|
||||
Start
|
||||
</button>
|
||||
<button t-if="bw.state === 'bake_in_progress'"
|
||||
class="btn btn-sm btn-success"
|
||||
t-on-click="() => this.onEndBake(bw.id)">
|
||||
End
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
t-on-click="() => this.openRecord('fusion.plating.bake.window', bw.id)">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- First-Piece Gates -->
|
||||
<section class="o_fp_panel">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-flag-checkered me-2"/>First-Piece Gates</h3>
|
||||
<span class="o_fp_panel_count"><t t-esc="state.overview.gates.length"/></span>
|
||||
</div>
|
||||
<div t-if="!state.overview.gates.length" class="o_fp_empty">
|
||||
No pending first-piece inspections.
|
||||
</div>
|
||||
<ul class="o_fp_bake_list" t-if="state.overview.gates.length">
|
||||
<t t-foreach="state.overview.gates" t-as="g" t-key="g.id">
|
||||
<li class="o_fp_bake_row" t-att-data-state="g.result">
|
||||
<div class="o_fp_bake_main">
|
||||
<div class="o_fp_bake_name">
|
||||
<strong t-esc="g.name"/>
|
||||
<span class="text-muted ms-1">— <t t-esc="g.part_ref"/></span>
|
||||
</div>
|
||||
<div class="o_fp_bake_meta text-muted">
|
||||
<t t-esc="g.customer"/>
|
||||
<t t-if="g.bath"> · Bath <t t-esc="g.bath"/></t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_bake_time">
|
||||
<span t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(g.result)">
|
||||
<t t-esc="g.result"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_bake_actions">
|
||||
<button t-if="g.result === 'pending'"
|
||||
class="btn btn-sm btn-success"
|
||||
t-on-click="() => this.onGateResult(g.id, 'pass')">
|
||||
Pass
|
||||
</button>
|
||||
<button t-if="g.result === 'pending'"
|
||||
class="btn btn-sm btn-danger"
|
||||
t-on-click="() => this.onGateResult(g.id, 'fail')">
|
||||
Fail
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
t-on-click="() => this.openRecord('fusion.plating.first.piece.gate', g.id)">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Quality Holds -->
|
||||
<section class="o_fp_panel" t-if="state.overview.holds.length">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3 class="text-danger"><i class="fa fa-pause-circle me-2"/>Quality Holds</h3>
|
||||
<span class="o_fp_panel_count"><t t-esc="state.overview.holds.length"/></span>
|
||||
</div>
|
||||
<ul class="o_fp_bake_list">
|
||||
<t t-foreach="state.overview.holds" t-as="h" t-key="h.id">
|
||||
<li class="o_fp_bake_row" t-att-data-state="h.state">
|
||||
<div class="o_fp_bake_main">
|
||||
<div class="o_fp_bake_name">
|
||||
<strong t-esc="h.name"/>
|
||||
<span class="text-muted ms-1">— <t t-esc="h.part_ref"/></span>
|
||||
</div>
|
||||
<div class="o_fp_bake_meta text-muted">
|
||||
Qty <t t-esc="h.qty"/>
|
||||
· <t t-esc="h.reason"/>
|
||||
<t t-if="h.operator"> · <t t-esc="h.operator"/></t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_bake_actions">
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
t-on-click="() => this.openRecord('fusion.plating.quality.hold', h.id)">
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_queue">
|
||||
<div class="o_fp_tablet_queue_title">Next Up</div>
|
||||
<div t-if="!state.queueRows.length" class="text-muted">
|
||||
Queue is empty.
|
||||
</div>
|
||||
<ul class="o_fp_tablet_queue_list" t-if="state.queueRows.length">
|
||||
<t t-foreach="state.queueRows" t-as="row" t-key="row.id">
|
||||
<li class="o_fp_tablet_queue_item">
|
||||
<div class="o_fp_tablet_queue_label">
|
||||
<strong t-esc="row.label"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_queue_desc text-muted">
|
||||
<t t-esc="row.description"/>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
<!-- ===== Loading / initial state ===== -->
|
||||
<div t-if="!state.overview" class="o_fp_empty">
|
||||
<i class="fa fa-spinner fa-spin me-2"/>Loading shop-floor data…
|
||||
</div>
|
||||
|
||||
<!-- ===== Footer — server time ===== -->
|
||||
<div class="o_fp_tablet_footer text-muted" t-if="state.overview">
|
||||
<small>
|
||||
Auto-refresh every 30 s · Last sync
|
||||
<t t-esc="state.overview.server_time"/>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
@@ -993,6 +993,143 @@ else:
|
||||
LOG(f" Already has {bw_count} bake windows — skipping")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Phase 12.5: Shop-floor stations + first-piece gates + variety
|
||||
# Feeds the Tablet Station + First-Piece Gates + bake Kanban demos.
|
||||
# ============================================================
|
||||
LOG("Phase 12.5: Shop-floor stations + first-piece gates")
|
||||
|
||||
Station = env['fusion.plating.shopfloor.station']
|
||||
stn_count = Station.search_count([])
|
||||
if stn_count < 5:
|
||||
station_defs = [
|
||||
('Plating Room Tablet 1', 'TAB-PL-01', 'tablet', 'Plating'),
|
||||
('Bake Oven Tablet', 'TAB-BK-01', 'tablet', 'Bake Oven'),
|
||||
('Inspection Kiosk', 'TAB-QA-01', 'kiosk', 'Inspection'),
|
||||
('Shipping Desktop', 'TAB-SH-01', 'desktop', 'Shipping'),
|
||||
('Receiving Mobile', 'TAB-RC-01', 'mobile', 'Receiving'),
|
||||
]
|
||||
for sname, scode, stype, wc_name in station_defs:
|
||||
if Station.search_count([('code', '=', scode)]):
|
||||
continue
|
||||
fp_wc = env['fusion.plating.work.center'].search(
|
||||
[('name', '=', wc_name)], limit=1,
|
||||
) if wc_name else False
|
||||
Station.create({
|
||||
'name': sname,
|
||||
'code': scode,
|
||||
'station_type': stype,
|
||||
'facility_id': facility.id,
|
||||
'work_center_id': fp_wc.id if fp_wc else False,
|
||||
'last_ping': datetime.now() - timedelta(minutes=random.randint(0, 45)),
|
||||
})
|
||||
LOG(f" 5 shop-floor stations created")
|
||||
else:
|
||||
LOG(f" Already has {stn_count} stations — skipping")
|
||||
|
||||
# More bake windows — add a couple active ones for realism
|
||||
if env['fusion.plating.bake.window'].search_count([]) < 6:
|
||||
env['fusion.plating.bake.window'].create({
|
||||
'bath_id': bath_en.id,
|
||||
'part_ref': 'HW-TOR-5501', 'lot_ref': 'LOT-BW-HW-01',
|
||||
'customer_ref': 'Honeywell Toronto',
|
||||
'quantity': 120, 'window_hours': 4.0,
|
||||
'plate_exit_time': datetime.now() - timedelta(hours=2, minutes=15),
|
||||
})
|
||||
env['fusion.plating.bake.window'].create({
|
||||
'bath_id': bath_en.id,
|
||||
'part_ref': 'AP-WGL-7200', 'lot_ref': 'LOT-BW-AP-01',
|
||||
'customer_ref': 'Amphenol Canada',
|
||||
'quantity': 300, 'window_hours': 4.0,
|
||||
'plate_exit_time': datetime.now() - timedelta(hours=3, minutes=30),
|
||||
'bake_start_time': datetime.now() - timedelta(minutes=40),
|
||||
'state': 'bake_in_progress',
|
||||
})
|
||||
env['fusion.plating.bake.window'].create({
|
||||
'bath_id': bath_en.id,
|
||||
'part_ref': 'CY-STR-240', 'lot_ref': 'LOT-BW-CY-02',
|
||||
'customer_ref': 'Cyclone Manufacturing',
|
||||
'quantity': 60, 'window_hours': 4.0,
|
||||
'plate_exit_time': datetime.now() - timedelta(minutes=45),
|
||||
})
|
||||
LOG(" +3 additional bake windows (awaiting / in-progress)")
|
||||
|
||||
# First-piece inspection gates — seed 4 variants
|
||||
Gate = env['fusion.plating.first.piece.gate']
|
||||
if Gate.search_count([]) < 4:
|
||||
Gate.create({
|
||||
'bath_id': bath_en.id,
|
||||
'part_ref': 'HW-TOR-5501',
|
||||
'customer_ref': 'Honeywell Toronto',
|
||||
'routing_first_run': True,
|
||||
'first_piece_produced': datetime.now() - timedelta(minutes=35),
|
||||
'result': 'pending',
|
||||
})
|
||||
Gate.create({
|
||||
'bath_id': bath_en.id,
|
||||
'part_ref': 'AP-WGL-7200',
|
||||
'customer_ref': 'Amphenol Canada',
|
||||
'routing_first_run': False,
|
||||
'first_piece_produced': datetime.now() - timedelta(hours=2),
|
||||
'first_piece_inspected': datetime.now() - timedelta(hours=1, minutes=40),
|
||||
'inspector_id': env.user.id,
|
||||
'result': 'pass',
|
||||
'rest_of_lot_released': True,
|
||||
})
|
||||
Gate.create({
|
||||
'bath_id': bath_en.id,
|
||||
'part_ref': 'MG-WG-8801',
|
||||
'customer_ref': 'Magellan Aerospace',
|
||||
'routing_first_run': True,
|
||||
'first_piece_produced': datetime.now() - timedelta(hours=4),
|
||||
'first_piece_inspected': datetime.now() - timedelta(hours=3, minutes=30),
|
||||
'inspector_id': env.user.id,
|
||||
'result': 'pass',
|
||||
'rest_of_lot_released': False, # passed but awaiting release
|
||||
'notes': '<p>Thickness 1.95 mils — within tolerance. Lot released pending planner signoff.</p>',
|
||||
})
|
||||
Gate.create({
|
||||
'bath_id': bath_en.id,
|
||||
'part_ref': 'CY-STR-240',
|
||||
'customer_ref': 'Cyclone Manufacturing',
|
||||
'routing_first_run': True,
|
||||
'first_piece_produced': datetime.now() - timedelta(hours=6),
|
||||
'first_piece_inspected': datetime.now() - timedelta(hours=5, minutes=30),
|
||||
'inspector_id': env.user.id,
|
||||
'result': 'fail',
|
||||
'notes': '<p>Thickness 0.8 mils — below spec (min 1.2). Rework required.</p>',
|
||||
})
|
||||
LOG(" 4 first-piece gates: 1 pending / 1 passed+released / 1 passed-holding / 1 failed")
|
||||
else:
|
||||
LOG(f" Already has {Gate.search_count([])} first-piece gates — skipping")
|
||||
|
||||
# Quality holds on active MOs — gives the Shop Floor quality-holds panel content
|
||||
Hold = env['fusion.plating.quality.hold']
|
||||
if Hold.search_count([]) < 2:
|
||||
active_mos = env['mrp.production'].search(
|
||||
[('state', 'in', ('progress', 'confirmed'))], limit=3,
|
||||
)
|
||||
hold_reasons = ['out_of_spec', 'damaged', 'contamination']
|
||||
for i, mo in enumerate(active_mos[:2]):
|
||||
wo = mo.workorder_ids[:1]
|
||||
Hold.create({
|
||||
'part_ref': mo.product_id.default_code or mo.product_id.name,
|
||||
'qty_on_hold': random.randint(3, 8),
|
||||
'qty_original': int(mo.product_qty or 10),
|
||||
'hold_reason': hold_reasons[i % len(hold_reasons)],
|
||||
'description': f'Demo hold — flagged during in-process inspection on {mo.name}.',
|
||||
'production_id': mo.id,
|
||||
'workorder_id': wo.id if wo else False,
|
||||
'portal_job_id': mo.x_fc_portal_job_id.id if mo.x_fc_portal_job_id else False,
|
||||
'facility_id': facility.id,
|
||||
'operator_id': env.user.id,
|
||||
'state': 'on_hold' if i == 0 else 'under_review',
|
||||
})
|
||||
LOG(f" {Hold.search_count([])} quality holds seeded")
|
||||
else:
|
||||
LOG(f" Already has {Hold.search_count([])} quality holds — skipping")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Phase 13: Quote configurator + win/loss variety
|
||||
# ============================================================
|
||||
@@ -1236,6 +1373,9 @@ LOG(f" Payments: {env['account.payment'].search_count([('payment_type', '
|
||||
LOG(f" Bath logs: {env['fusion.plating.bath.log'].search_count([])}")
|
||||
LOG(f" Replenishments: {env['fusion.plating.bath.replenishment.suggestion'].search_count([])}")
|
||||
LOG(f" Bake windows: {env['fusion.plating.bake.window'].search_count([])}")
|
||||
LOG(f" Stations: {env['fusion.plating.shopfloor.station'].search_count([])}")
|
||||
LOG(f" First-piece: {env['fusion.plating.first.piece.gate'].search_count([])}")
|
||||
LOG(f" Quality holds: {env['fusion.plating.quality.hold'].search_count([])}")
|
||||
LOG(f" Racks: {env['fusion.plating.rack'].search_count([])}")
|
||||
LOG(f" Operator certs: {env['fp.operator.certification'].search_count([])}")
|
||||
LOG("")
|
||||
|
||||
Reference in New Issue
Block a user