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

View File

@@ -834,6 +834,7 @@ Each script is self-contained — builds a fresh SO + job, walks the scenario, a
| **S17** | Operator drops parts, bumps `qty_scrapped` 0→2 | Silent — no AS9100 disposition record | `fp.job.write` hook auto-spawns `fusion.plating.quality.hold` for the scrap delta. Operator updates description with cause | `fusion_plating_jobs 19.0.6.18.0` | `bt_s17_scrap_ncr.py` |
| **S18** | CoC issuance broken in 4 places — operator can't actually email a cert | (a) auto-spawn left every useful field blank → Issue blocked on missing spec_reference; (b) Issue button never generated PDF → `attachment_id` stayed empty; (c) Send to Customer opened email composer with no attachment; (d) auto-spawn had no idempotency → dupes on `button_mark_done` retry | `_fp_create_certificates` now pre-fills `spec_reference` (from coating), `part_number`, `quantity_shipped` (qty scrap), `po_number`, `customer_job_no`, `process_description`, `entech_wo_number`, `sale_order_id`. Idempotency check skips dupes. `action_issue` now renders the EN CoC PDF via new `_fp_render_and_attach_pdf` and sets `attachment_id` so Send to Customer attaches it automatically. Smart button "Certificates" already on the job form (visible when count > 0) so Tom finds the cert from the job he just closed | `fusion_plating_certificates 19.0.5.1.0`, `fusion_plating_jobs 19.0.6.19.0` | `bt_s18_cert_flow.py` |
| **S19** | Lisa uploads Fischerscope X-Ray thickness PDF to QC; CoC ships without it as page 2 — and even after the back-end merge worked, operators couldn't *see* in the cert form whether the merge would happen | Existing merge logic lived in uninstalled `fusion_plating_bridge_mrp` (keyed off `mrp.production` — gone with Sub 11). Post-Sub-11 cert path rendered CoC only; Fischerscope PDF stayed orphaned on the QC record. Even after Phase 1 fix shipped, the cert form had **zero** indicator that a thickness PDF was on file or had been merged → user reported "I did not see anything in the certification issue" | **Phase 1 (back-end merge):** Ported merge to `fp.certificate._fp_merge_thickness_into_pdf`. New `_fp_render_and_attach_pdf` wraps cert PDF generation: renders the CoC via QWeb, then looks up the linked `fusion.plating.quality.check` (`x_fc_job_id → fp.job → QC`), finds the most recent passed QC with `thickness_report_pdf_id`, merges via `pypdf.PdfWriter.append()` (PyPDF2 `PdfMerger` fallback), posts chatter audit `Fischerscope thickness report from QC <name> appended to CoC PDF.`. Hooked into `action_issue` so the multi-page PDF lands on `attachment_id` automatically. **Phase 2 (UI surface):** Added 3 computed fields on `fp.certificate` (in `fusion_plating_jobs`): `x_fc_thickness_qc_id` (linked QC), `x_fc_thickness_pdf_id` (Fischerscope PDF), `x_fc_thickness_status` (`none` / `pending` / `merged`). Cert form now shows: (1) coloured banner above the title — blue "Will Append on Issue" / green "Merged" / amber "No PDF — operator action required"; (2) two new smart buttons (Plating Job, Fischerscope status); (3) new "Thickness Report (Fischerscope)" notebook tab with clickable PDF preview + step-by-step instructions when none uploaded | `fusion_plating_certificates 19.0.5.2.0`, `fusion_plating_jobs 19.0.6.20.0` | `bt_s19_fischer_merge.py` (asserts both pre-Issue `pending` + post-Issue `merged` status flips) |
| **S20** | **Tablet usability pass** — operators were squinting at the tablet, scanning back-and-forth between recipe binders and the screen because the tablet showed step names but no targets, no live timer, no predecessor visibility. QC fail left parts in limbo with no Hold record. Manager Desk showed feel-good KPIs but hid the compliance bombs (missed bakes, stale steps, locked steps, holds, pending QC missing PDF) | Tablet `My Queue` rows had no `instructions`, `thickness_target`, `dwell_time_minutes`, `bake_setpoint_temp`, `requires_signoff` — operators kept scanning the QR code just to read the bake temperature. Steps with `requires_predecessor_done=True` (S14) showed a green Start that always failed with a UserError. Active step "duration" was a stale number that only refreshed every 30s. Holds and bake windows showed plant-wide noise from other crews. **No banner alerted Carlos when his job had a pending QC** (Lisa was not called → QC sat for hours). **No way to bump qty_done or scrap from the tablet** → S17 hold auto-spawn never fired because operators didn't update the field. **`action_fail` on QC marked the check failed but spawned no Hold** — AS9100 disposition trail broken. **Manager Desk KPIs were missing 7 compliance metrics**: stale paused/in-progress steps (cron data), missed bake windows, open holds, predecessor-locked steps, pending QCs, QCs missing Fischerscope PDF, draft cert pipeline | **Carlos's Shopfloor Tablet** — every queue row now carries the recipe-author fields (instructions snippet, thickness target chip, dwell-time chip, bake-temp chip, sign-off badge) so operators read the targets inline. Predecessor-blocked steps render with a 🔒 lock icon, an "Awaiting [step name]" notice, and a disabled `Locked` button (no more Start-then-fail). Active step now shows a **live ticking HH:MM:SS clock** (1s interval, computed from `date_started_iso` JS-side; flips to red on >1.5× planned duration) plus `+1 Done` and `Scrap` buttons that hit two new endpoints (`/fp/shopfloor/bump_qty_done`, `/fp/shopfloor/bump_qty_scrapped` — scrap prompts for reason and S17 auto-spawns the Hold). New **Pending QC banner** lists open QCs for my jobs with line-progress + Fischerscope-PDF status badge, and a tap deep-links into Lisa's mobile QC checklist. Holds and bake windows are now **scoped to my jobs first** (fall back to facility-wide for managers). **QC checklist** — `action_fail` now auto-creates a `fusion.plating.quality.hold` with `hold_reason='qc_failure'` (new selection value), populated description listing the failed checks, idempotent on retry. **Manager Desk** — 7 new clickable compliance KPI tiles: Missed Bakes (S15), Open Holds (S17 + QC fail), Stale Steps (S10/S16 cron data), Locked Steps (S14), Pending QC + "X need PDF" (S19 + missing-Fischerscope), Draft Certs + "Y today" (cert pipeline). Each tile drills into a list filtered to the relevant exception | `fusion_plating_shopfloor 19.0.24.3.0`, `fusion_plating_quality 19.0.4.8.0` | `sim_tablet_walk.py`, `sim_timer_pred_test.py`, `sim_qc_fail_hold.py`, `sim_manager_qc_fail.py` (one-off persona walkthroughs) |
### Manager-bypass context flags
@@ -858,13 +859,24 @@ When you need to override a guard (documented customer deviation, emergency rewo
### Open scenarios — flagged for next session
- **S20** — Operator clocks two steps simultaneously across different jobs (multi-tasking conflict)
- **S21** — Bath chemistry drift mid-step — operator measures bath while plating, value out of spec; no alert on the step
- **S22** — Wrong recipe attached — Carlos sees mismatch with the part he's holding; recovery path?
- **S23** — Customer orders 100 parts spread across 3 jobs; one job's recipe gets edited — does it propagate to siblings?
- **S24** — Hold-aging cron + 3-day escalation (flagged in original audit, not yet built)
- **S25** — Calibration + permit-expiry cron (flagged in original audit, not yet built)
- **S26** — FAIR detection on first-shipment to a new customer/part combo (flagged in original audit, not yet built)
- **S21** — Operator clocks two steps simultaneously across different jobs (multi-tasking conflict)
- **S22** — Bath chemistry drift mid-step — operator measures bath while plating, value out of spec; no alert on the step
- **S23** — Wrong recipe attached — Carlos sees mismatch with the part he's holding; recovery path?
- **S24** — Customer orders 100 parts spread across 3 jobs; one job's recipe gets edited — does it propagate to siblings?
- **S25** — Hold-aging cron + 3-day escalation (flagged in original audit, not yet built)
- **S26** — Calibration + permit-expiry cron (flagged in original audit, not yet built)
- **S27** — FAIR detection on first-shipment to a new customer/part combo (flagged in original audit, not yet built)
### Tablet UI / persona-coverage gaps (S20 audit follow-ups)
The S20 walkthrough mapped 6 OWL apps (`fp_shopfloor_tablet`, `fp_plant_overview`, `fp_process_tree`, `fp_manager_dashboard`, `fp_qc_checklist`, `fp_quality_dashboard`) and surfaced these missing pieces. Each is a separate scenario for a future session:
- **S28 — Bake Oven Operator dedicated tablet.** HE-bake operators currently work from Carlos's tablet (Bake Windows panel). Real shops have a separate oven station; needs: oven-scoped queue (ovens they're certified on), countdown to `bake_required_by`, one-tap Start/End, photo of chart recorder, daily history. `/fp/shopfloor/start_bake` + `end_bake` already exist — only a focused OWL action + menu item needed.
- **S29 — Tank-side chemistry logger.** `/fp/shopfloor/log_chemistry` endpoint exists in shopfloor controller but has no UI calling it. Plating tech walks the line, takes Hull Cell + concentration readings, has nowhere to log them on the tablet. Needs a "Log Bath Reading" action that hits the existing endpoint.
- **S30 — Receiving dock tablet.** Mike works from desktop list/form. A tablet-friendly view at the dock would let him scan PO QR → counted → staged → closed without typing. Existing `fp.receiving` state machine + actions are tablet-ready; only OWL view missing.
- **S31 — Maintenance technician mobile work-orders.** `fusion_plating_bridge_maintenance` shows kanban / list views but no tablet UI. Maintenance walks to broken equipment with a phone — needs "My Open Work Orders" mobile view with photo + start/finish + parts checkout.
- **S32 — Shipping/Logistics tablet.** Tom uses cert form + delivery list. A "Today's Shipments" tablet would let him scan job QR → pull cert → mark delivered. The cert PDF + Send-to-Customer flow is already built in S18 — only a packaging/dispatch view is missing.
- **S33 — Operator landing page after clock-in.** When Carlos clocks in, the system has no "where do I go?" prompt. Should auto-route to Tablet Station with their station pre-paired (currently relies on manual scan or last-localStorage value).
### Where the test scripts live

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.4.7.0',
'version': '19.0.4.8.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',

View File

@@ -237,6 +237,14 @@ class FpQualityCheck(models.Model):
) % self.env.user.name)
def action_fail(self):
"""Mark QC failed AND auto-spawn a fusion.plating.quality.hold
so the parts have an AS9100 disposition record. Without this
spawning the parts are in limbo — operator can't ship and
nothing tracks scrap/rework/use-as-is decisions.
v19.0.4.x — Hold auto-spawn added per the tablet usability pass.
"""
Hold = self.env.get('fusion.plating.quality.hold')
for rec in self:
rec.write({
'state': 'failed',
@@ -247,6 +255,53 @@ class FpQualityCheck(models.Model):
rec.message_post(body=Markup(
'<b>QC FAILED</b> — inspector %s.'
) % self.env.user.name)
# Auto-spawn the Hold (best-effort; QC failure stands even
# if Hold creation fails for some odd reason).
if Hold is not None and rec.job_id:
# Avoid dupes if the manager calls fail twice
existing = Hold.sudo().search([
('x_fc_job_id', '=', rec.job_id.id),
('hold_reason', '=', 'qc_failure'),
('state', 'in', ('on_hold', 'under_review')),
], limit=1) if 'x_fc_job_id' in Hold._fields else False
if not existing:
fail_lines = ', '.join(
l.name for l in rec.line_ids.filtered(
lambda l: l.result == 'fail'
)
) or '(see checklist)'
vals = {
'part_ref': rec.job_id.name or '',
'qty_on_hold': int(rec.job_id.qty or 1),
'qty_original': int(rec.job_id.qty or 1),
'hold_reason': (
'qc_failure'
if 'qc_failure' in dict(
Hold._fields['hold_reason'].selection
)
else 'other'
),
'description': (
'QC %s failed. Failed checks: %s. '
'Inspector: %s. Manager: review and decide '
'rework / scrap / use-as-is.'
) % (rec.name, fail_lines, self.env.user.name),
}
if 'x_fc_job_id' in Hold._fields:
vals['x_fc_job_id'] = rec.job_id.id
if 'partner_id' in Hold._fields and rec.partner_id:
vals['partner_id'] = rec.partner_id.id
try:
hold = Hold.sudo().create(vals)
rec.message_post(body=Markup(
'Hold <b>%s</b> auto-created for failed QC. '
'Manager must dispose.'
) % hold.name)
except Exception as e:
_logger.warning(
'QC %s: failed to auto-spawn hold: %s',
rec.name, e,
)
def action_rework(self):
for rec in self:

View File

@@ -56,6 +56,10 @@ class FpQualityHold(models.Model):
('contamination', 'Contamination'),
('customer_complaint', 'Customer Complaint'),
('process_deviation', 'Process Deviation'),
# v19.0.4.8.0 — Distinct bucket so QA can split QC-failed
# holds (auto-spawned by fusion.plating.quality.check.action_fail)
# from operator-flagged process deviations / contamination.
('qc_failure', 'QC Failure'),
('other', 'Other'),
],
string='Hold Reason',

View File

@@ -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.',

View File

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

View File

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

View File

@@ -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);

View File

@@ -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 = {

View File

@@ -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); }
}
}

View File

@@ -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 ============ -->

View File

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