diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index 33b85fc6..c5ae9abe 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -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 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 diff --git a/fusion_plating/fusion_plating_quality/__manifest__.py b/fusion_plating/fusion_plating_quality/__manifest__.py index b1685747..91d06e6f 100644 --- a/fusion_plating/fusion_plating_quality/__manifest__.py +++ b/fusion_plating/fusion_plating_quality/__manifest__.py @@ -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.', diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_check.py b/fusion_plating/fusion_plating_quality/models/fp_quality_check.py index ce3ebced..9c1d8bf5 100644 --- a/fusion_plating/fusion_plating_quality/models/fp_quality_check.py +++ b/fusion_plating/fusion_plating_quality/models/fp_quality_check.py @@ -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( 'QC FAILED — 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 %s 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: diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py b/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py index 84204ecb..c5d86712 100644 --- a/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py +++ b/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py @@ -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', diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index e28779f1..7a0fd4fe 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -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.', diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py index 85ffdc2d..3d336228 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py @@ -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 = { diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py index 9ada5a3f..7ee66efe 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py @@ -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'), } diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js index 7feac04e..83dcbd7a 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js @@ -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); diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js index 98f6ab31..bcf51157 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js @@ -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 = { diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss index 2466f13d..fc05c8ba 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss @@ -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); } + } } diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml index 68a14526..29d9b5e3 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml @@ -90,6 +90,69 @@
Awaiting Assignment
+ + + +
+ +
+
Missed Bakes
+
+
+ +
+
Open Holds
+
+
+ +
+ +
+
Stale Steps
+
+
+ +
+
Locked Steps
+
+
+ +
+
+ Pending QC + + + ( need PDF) + + +
+
+
+ +
+ +
+
+ Draft Certs + + + ( today) + + +
+
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml index 3e9cbc67..763532a1 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml @@ -85,7 +85,7 @@ t-if="state.overview and state.overview.active_wo">
-
+
Active:
@@ -93,14 +93,78 @@ Job · · Qty / + + + (scrap ) + + @
+ +
+ + + Thickness + + + + + Dwell min + + + + Bake ° + + + + Sign-off required + +
+ +
+ + +
+
+
+
+ +
+ + + + of m planned + +
+
+ + +
- @@ -118,16 +182,61 @@
    -
  • +
  • - HI + + + + HI M ·
    -
    +
    + + + + · step / + + +
    + + +
    + + Awaiting + — finish that step first +
    + +
    + + Target + + + + m dwell + + + Bake ° + + + Sign-off + +
    + +
    + +
    +
+ + + + + +
+
+

Pending QC

+ + + +
+
    + +
  • +
    +
    + + + — + +
    +
    + Job + · + · / pending + + + + Fischerscope PDF + + +
    +
    +
    + +
    +
  • +
    +
+
+

Quality Holds