feat(plating): session 2026-05-23 deploys — F1/F7/S22/S23 + UI fixes

Consolidated commit of session work already deployed to entech and
verified via the deep audit + the persona walk:

S22 — Signoff gate (fp.job.step.requires_signoff was 100% unenforced,
42/42 done steps had NULL signoff_user_id). Three-piece fix:
_fp_autosign_if_required (captures finisher on button_finish),
_fp_check_signoff_complete (raises UserError if NULL after autosign),
action_signoff (explicit supervisor pre-sign). Bypass:
fp_skip_signoff_gate=True.

S23 — Transition-form gate (same dormant-field shape as S22, caught
preventively before recipe authors flipped requires_transition_form
on). Model helpers on fp.job.step.move + controller gate in
move_controller (parts commit) + pre-reject in rack commit.

F7 — Chatter standardization: _fp_create_qc_check_if_needed,
_fp_fire_notification, _fp_create_delivery silent failures now also
post to job chatter instead of only logging to file.

UI fixes:
- Critical Rule 20 documented + applied: OWL templates only expose
  Math as a global. Calling String(d) inside t-on-click throws
  'v2 is not a function'. Fixed pin_pad.xml (string array instead of
  number array with String() coercion). Also swept parseInt/
  parseFloat in recipe_tree_editor + simple_recipe_editor.
- Notes panel HTML escape fix: chatter messages off /fp/workspace/load
  were rendered via t-out, escaping the HTML. Wrap with markup() in
  job_workspace.js refresh() before assigning to state.

Versions:
  fusion_plating         19.0.20.8.0 → 19.0.20.9.0
  fusion_plating_jobs    19.0.10.20.0 → 19.0.10.23.0
  fusion_plating_shopfloor 19.0.30.2.0 → 19.0.30.5.0

All deployed to entech (LXC 111) and verified live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-23 20:37:17 -04:00
parent d6ebcb6233
commit 1a3ca8704e
15 changed files with 597 additions and 142 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.30.2.0',
'version': '19.0.30.6.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',

View File

@@ -203,6 +203,13 @@ class FpTabletMoveController(http.Controller):
for prompt_id, value in (prompt_values or {}).items():
self._capture_prompt_value(move, int(prompt_id), value)
# S23 — required transition-input gate. Runs AFTER value capture
# so the operator gets credit for whatever they filled in. Raises
# UserError if to_step.requires_transition_form=True and any
# required transition_input prompt has no value. Rollback unwinds
# the move + value rows. Manager bypass: fp_skip_transition_form.
move._fp_check_transition_inputs_complete()
# Advance qty_at_step counters
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
from_step.qty_at_step_finish = (from_step.qty_at_step_finish or 0) + qty
@@ -298,6 +305,42 @@ class FpTabletMoveController(http.Controller):
rack = Rack.browse(rack_id)
to_step = Step.browse(to_step_id)
# S23 — pre-check: rack moves don't capture transition prompts
# (no per-move dialog), so if to_step.requires_transition_form
# we must reject up-front and force the operator through Move
# Parts (which has the form UI). Without this check, rack moves
# silently bypass the audit gate that Move Parts enforces.
if (to_step.requires_transition_form
and not request.env.context.get('fp_skip_transition_form')):
# Use the same model helper for consistency — build a dummy
# in-memory move to compute "missing" set, then surface a
# clear message that points operators at the right tool.
recipe_node = to_step.recipe_node_id
required_prompts = recipe_node.input_ids if recipe_node else (
request.env['fusion.plating.process.node.input']
)
if 'kind' in required_prompts._fields:
required_prompts = required_prompts.filtered(
lambda i: i.kind == 'transition_input')
required_prompts = required_prompts.filtered(
lambda i: i.required)
if required_prompts:
names = ', '.join(
'"%s"' % (p.name or '').strip()
for p in required_prompts
)
raise UserError(_(
'Step "%(step)s" requires a transition form '
'(%(n)s required prompt(s): %(names)s). '
'Use Move Parts for one batch at a time so the form '
'can be filled in, or have a manager override with '
'context flag fp_skip_transition_form=True.'
) % {
'step': to_step.name,
'n': len(required_prompts),
'names': names,
})
moves = []
for batch in Step.search([('rack_id', '=', rack.id)]):
qty = (batch.qty_done or 0) - (batch.qty_scrapped or 0)

View File

@@ -35,7 +35,12 @@ export class FpPinPad extends Component {
async _press(digit) {
if (this.state.submitting) return;
if (this.state.pin.length >= 4) return;
this.state.pin = this.state.pin + digit;
// Defensive: coerce to string in JS rather than the template
// because OWL templates don't expose `String` as a callable
// (Critical Rule 20 in CLAUDE.md). Callers pass strings already
// via the string array in pin_pad.xml; this is a belt-and-braces
// guard for any future caller passing a numeric digit.
this.state.pin = this.state.pin + String(digit);
this.state.error = "";
if (this.state.pin.length === 4) {
await this._submit();

View File

@@ -17,7 +17,7 @@
// Auto-refresh: every 15s.
// =============================================================================
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { Component, markup, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { fpRpc } from "./services/fp_rpc";
@@ -64,6 +64,19 @@ export class FpJobWorkspace extends Component {
try {
const res = await rpc("/fp/workspace/load", { job_id: this.state.jobId });
if (res && res.ok) {
// Chatter bodies arrive as plain HTML strings off the RPC.
// The template renders them via `t-out="msg.body"`, which
// HTML-ESCAPES plain JS strings unless they're tagged with
// markup() from @odoo/owl. Without this wrap the operator
// sees literal `<p>` and `<b>` tags instead of formatted
// text (caught 2026-05-23 — Notes panel showing raw HTML).
if (res.chatter && res.chatter.length) {
for (const m of res.chatter) {
if (m && typeof m.body === "string") {
m.body = markup(m.body);
}
}
}
this.state.data = res;
} else if (res && res.error) {
this.notification.add(res.error, { type: "danger" });
@@ -75,8 +88,18 @@ export class FpJobWorkspace extends Component {
// ---- Navigation --------------------------------------------------------
onBack() {
// Close workspace; return to whatever spawned the action
this.action.doAction({ type: "ir.actions.act_window_close" });
// The workspace is opened from the Landing kanban with
// target: "current", which REPLACES the current action and
// wipes the backstack. So `act_window_close` did nothing —
// there's no parent action to close to. Navigate explicitly
// to the Shop Floor Landing instead, which works whether the
// workspace was opened from the kanban, a QR scan, the manager
// dashboard, or a direct URL. (Bug caught 2026-05-23.)
this.action.doAction({
type: "ir.actions.client",
tag: "fp_shopfloor_landing",
target: "current",
});
}
// ---- Hand-Off (Phase 6.2) ---------------------------------------------

View File

@@ -176,13 +176,42 @@ $_lan-text-hex: #1d1d1f;
}
// ---- Kanban board ------------------------------------------------------
// Recipe authors keep adding work centres (Anodize, Strip, Etch, Bake,
// Mask, Rack, Inspect, Ship…) so the kanban must accommodate both
// FEW columns (early-shop layouts) AND MANY columns (mature shops with
// 15+ stations). Two design moves to handle both:
// 1. Columns use `flex: 1 0 200px` — basis 200px, GROW into spare
// space (3 cols on a 1200px screen → each becomes 400px), but
// NEVER SHRINK below 200px so 15+ cols stay readable and scroll
// horizontally. Max 320px caps the growth so a single-column
// kanban doesn't span 1200px of empty whitespace.
// 2. Custom-styled horizontal scrollbar — the default browser bar
// is invisible until hover on most platforms; users had no idea
// more columns existed off-screen. Now there's a persistent thin
// bar at the bottom of the board.
.o_fp_landing_board {
flex: 1;
display: flex;
gap: 0.6rem;
padding: 0.6rem 1rem 1rem;
overflow-x: auto;
overflow-y: hidden;
align-items: stretch;
// Custom scrollbar — visible enough that users notice more columns
// exist off-screen without being obnoxiously large.
&::-webkit-scrollbar { height: 10px; }
&::-webkit-scrollbar-track {
background: $_lan-page-hex;
border-radius: 5px;
}
&::-webkit-scrollbar-thumb {
background: $_lan-border-hex;
border-radius: 5px;
&:hover { background: darken(#d8dadd, 10%); }
}
scrollbar-width: thin; // Firefox
scrollbar-color: $_lan-border-hex $_lan-page-hex;
}
.o_fp_landing_empty {
@@ -194,13 +223,16 @@ $_lan-text-hex: #1d1d1f;
}
.o_fp_landing_col {
flex: 0 0 240px;
flex: 1 0 200px; // grow into spare, never shrink below 200px
min-width: 200px;
max-width: 320px; // cap growth so single col doesn't span 1200px
background: $_lan-card-hex;
border: 1px solid $_lan-border-hex;
border-radius: 6px;
display: flex;
flex-direction: column;
max-height: 100%;
overflow: hidden; // contain inner sticky header within border-radius
&.o_fp_drop_target {
outline: 2px dashed #0071e3;
@@ -209,6 +241,14 @@ $_lan-text-hex: #1d1d1f;
}
.o_fp_landing_col_head {
// Sticky inside the column body so as the operator scrolls through
// many cards, they always see WHICH station they're looking at.
// (Caught 2026-05-23 — long card lists in Oven Baking made operators
// lose track of which column they were scrolling.)
position: sticky;
top: 0;
z-index: 2;
background: $_lan-card-hex;
padding: 0.4rem 0.7rem;
border-bottom: 1px solid $_lan-border-hex;
display: flex;
@@ -218,7 +258,14 @@ $_lan-text-hex: #1d1d1f;
font-size: 0.78rem;
}
.o_fp_landing_col_name { flex: 1; }
.o_fp_landing_col_name {
flex: 1;
// Truncate long work-centre names instead of wrapping (which would
// push the count badge to a second line and shift card content).
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.o_fp_landing_col_count {
background: $_lan-page-hex;
@@ -226,6 +273,7 @@ $_lan-text-hex: #1d1d1f;
padding: 0.1rem 0.5rem;
font-size: 0.7rem;
color: var(--text-secondary, #777);
flex-shrink: 0; // don't squeeze the count when the name is long
}
.o_fp_landing_col_body {

View File

@@ -15,9 +15,16 @@
<div t-if="state.error" class="o_fp_pin_error" t-esc="state.error"/>
<div class="o_fp_pin_grid">
<t t-foreach="[1,2,3,4,5,6,7,8,9]" t-as="d" t-key="d">
<!-- IMPORTANT: digits MUST be string literals here.
OWL templates only expose `Math` as a JS global —
`String`, `Number`, `Array`, etc. are NOT in template
scope. Calling `String(d)` throws "v2 is not a
function" because the compiled template references
a global named String that doesn't exist. Keep this
array as strings; do any type coercion in JS. -->
<t t-foreach="['1','2','3','4','5','6','7','8','9']" t-as="d" t-key="d">
<button class="o_fp_pin_key"
t-on-click="() => this._press(String(d))"
t-on-click="() => this._press(d)"
t-att-disabled="state.submitting">
<t t-esc="d"/>
</button>