feat(fusion_plating): partial order handling on the shop floor
Operators can now see and advance a job's parts across multiple stages
at once (e.g. 10 Masking / 20 Plating / 20 Baking on one 50-part job).
Tracking model C (fluid per-stage quantities + existing hold/scrap/
rework records for exceptions); board option 2 (a card per occupied
stage); wait-to-reconverge close. Additive only — no new model, no
migration, no change to the close/cert/ship lifecycle.
Board (fusion_plating_shopfloor/controllers/plant_kanban.py):
- One card PER (job, stage), composite key "{job_id}:{area}". Unsplit
jobs render exactly as before. _job_presences/_render_presence;
primary presence keeps full job card_state, secondary presences
derive state from their focus step.
Card (plant_card.js/.xml/.scss):
- "20 of 50 here" badge; tap opens the workspace focused on that
stage's step (focus_step_id, already accepted by the workspace).
Move + light-up (move_controller.py, fusion_plating_jobs/fp_job_step.py):
- Availability/pre-fill now from qty_at_step (step had no qty_done/
qty_scrapped fields — the old read was always 0, dead path).
- Forward move auto-flips destination pending->ready (no auto-start;
labour timer stays explicit) and auto-finishes a drained source
(best-effort). Predecessor gate is qty-aware: a step with real
arrived parts is startable regardless of upstream completion
(_fp_has_real_incoming, single source of truth for can_start /
blocker / button_start / move blockers).
Operator advance (job_workspace.js):
- "Send -> <next>" action on in_progress/paused steps opens the slimmed
Move dialog (qty steppers, no keyboard; advanced fields collapsed).
Was only wired into the deprecated shopfloor_tablet before.
Close (fp_job.py):
- button_mark_done counts move-based scrap (_fp_scrapped_via_moves) into
qty_scrapped and derives qty_done = qty - scrapped (was blindly
= job.qty, over-counting). Reconciliation gate unchanged.
Static-validated: pyflakes (py), lxml parse (xml), node --check (js).
Dynamic tests + browser check need an installed env (entech/trial) —
plating modules can't install on the local Community DB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,11 +60,15 @@ export class FpPlantCard extends Component {
|
||||
onCardClick() {
|
||||
const c = this.props.card;
|
||||
if (!c.job_id) return;
|
||||
// Open the workspace focused on THIS stage's step (partial-order
|
||||
// handling) — tapping the Baking card lands on the Baking step,
|
||||
// not the job's global active step. The workspace already accepts
|
||||
// focus_step_id (see the FP-STEP scan path in plant_kanban.js).
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_job_workspace",
|
||||
target: "current",
|
||||
params: { job_id: c.job_id },
|
||||
params: { job_id: c.job_id, focus_step_id: c.focus_step_id || false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,12 @@ import { FpTabletLock } from "./tablet_lock";
|
||||
import { FpRackPartsDialog } from "./rack_parts_dialog";
|
||||
import { FpDamageDialog } from "./fp_damage_dialog";
|
||||
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
|
||||
import { FpMovePartsDialog } from "./move_parts_dialog";
|
||||
|
||||
export class FpJobWorkspace extends Component {
|
||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||
static props = ["*"];
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog };
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, FpMovePartsDialog };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
@@ -225,7 +226,21 @@ export class FpJobWorkspace extends Component {
|
||||
if (step.override_excluded) return [];
|
||||
|
||||
const actions = [];
|
||||
// Partial-order handling — "Send to next →" advances parts parked
|
||||
// at this step to the next stage. Only shown when parts are here
|
||||
// AND a next stage exists. The destination name is on the button
|
||||
// so there's nothing to guess; qty defaults to all parked here.
|
||||
const advanceAction = () => {
|
||||
const nxt = this.nextStepFor(step);
|
||||
if (nxt && (step.qty_at_step || 0) > 0) {
|
||||
return { key: "advance", label: "Send → " + nxt.name,
|
||||
icon: "fa fa-arrow-right", cssClass: "btn btn-primary" };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
if (step.state === "in_progress") {
|
||||
const adv = advanceAction();
|
||||
if (adv) actions.push(adv);
|
||||
actions.push({ key: "record_inputs", label: "Record Inputs",
|
||||
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
||||
actions.push({ key: "pause", label: "Pause",
|
||||
@@ -240,6 +255,8 @@ export class FpJobWorkspace extends Component {
|
||||
if (step.state === "paused") {
|
||||
actions.push({ key: "resume", label: "Resume",
|
||||
icon: "fa fa-play", cssClass: "btn btn-primary" });
|
||||
const adv = advanceAction();
|
||||
if (adv) actions.push(adv);
|
||||
actions.push({ key: "record_inputs", label: "Record Inputs",
|
||||
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
||||
actions.push({
|
||||
@@ -281,6 +298,7 @@ export class FpJobWorkspace extends Component {
|
||||
case "mark_passed": return this.onMarkPassed(step);
|
||||
case "open_contract_review": return this.onOpenContractReview(step);
|
||||
case "start_with_rack": return this.onStartWithRack(step);
|
||||
case "advance": return this.onAdvanceStep(step);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,6 +481,44 @@ export class FpJobWorkspace extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Partial-order advance (2026-06-02) -------------------------------
|
||||
// "Send to next →" — moves parts parked at this step to the next stage.
|
||||
// The destination auto-readies server-side (move_controller), so the
|
||||
// receiving operator sees a Ready card immediately; the source
|
||||
// auto-finishes when it drains to zero. Pure client-side next-step
|
||||
// resolution off the loaded step list — no extra RPC.
|
||||
|
||||
nextStepFor(step) {
|
||||
// The next stage parts flow into: lowest-sequence non-terminal step
|
||||
// after this one. Returns null at the end of the line (parts finish
|
||||
// in place there and close out at job mark-done).
|
||||
const steps = (this.state.data && this.state.data.steps) || [];
|
||||
const candidates = steps
|
||||
.filter((s) => s.sequence > step.sequence
|
||||
&& ["pending", "ready", "paused", "in_progress"].includes(s.state))
|
||||
.sort((a, b) => a.sequence - b.sequence);
|
||||
return candidates.length ? candidates[0] : null;
|
||||
}
|
||||
|
||||
onAdvanceStep(step) {
|
||||
const nxt = this.nextStepFor(step);
|
||||
if (!nxt) {
|
||||
this.notification.add(
|
||||
"This is the last stage — parts finish here and close out at job completion.",
|
||||
{ type: "warning" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Open the slim Move dialog pre-set to advance to the next stage.
|
||||
// Qty defaults to all parked here (qty_at_step) via the preview
|
||||
// endpoint; the operator confirms or trims it with the steppers.
|
||||
this.dialog.add(FpMovePartsDialog, {
|
||||
fromStepId: step.id,
|
||||
toStepId: nxt.id,
|
||||
onCommit: async () => { await this.refresh(); },
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Receiving handlers (Spec C1+C2 2026-05-24) -----------------------
|
||||
// The receiver card at the top of the workspace lets the dock receiver
|
||||
// count boxes, set per-line received quantities + condition, log damage
|
||||
|
||||
@@ -40,6 +40,11 @@ export class FpMovePartsDialog extends Component {
|
||||
promptValues: {},
|
||||
blockers: [],
|
||||
committing: false,
|
||||
// Advanced fields (Transfer Type, To Location) stay collapsed
|
||||
// by default — the everyday flow is "advance all to the next
|
||||
// stage", which needs none of them. Keeps the dialog to a qty
|
||||
// confirm + SEND for the 95% case.
|
||||
showAdvanced: false,
|
||||
});
|
||||
onWillStart(async () => {
|
||||
await this.loadPreview();
|
||||
@@ -152,4 +157,20 @@ export class FpMovePartsDialog extends Component {
|
||||
{ type: "warning" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Qty steppers (no keyboard) ---------------------------------------
|
||||
// The operator taps − / + or "All". Clamped to [1, qtyAvailable] so the
|
||||
// count can never exceed what's parked here.
|
||||
incQty() {
|
||||
if (this.state.qty < this.state.qtyAvailable) this.state.qty += 1;
|
||||
}
|
||||
decQty() {
|
||||
if (this.state.qty > 1) this.state.qty -= 1;
|
||||
}
|
||||
setQtyAll() {
|
||||
this.state.qty = this.state.qtyAvailable;
|
||||
}
|
||||
toggleAdvanced() {
|
||||
this.state.showAdvanced = !this.state.showAdvanced;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,21 @@
|
||||
.card-sub-em { color: $plant-text; font-weight: 600; }
|
||||
.card-meta { font-size: 11px; color: $plant-muted; }
|
||||
.card-step { font-size: 14px; font-weight: 600; color: $plant-text; margin-top: 2px; }
|
||||
// Partial-order handling — "20 of 50 here" per-stage count. The big
|
||||
// number pops so an operator scanning their column instantly sees how
|
||||
// many of a job's parts are at their station. Uses existing tokens so
|
||||
// dark mode is handled at compile time by _plant_tokens.scss.
|
||||
.card-qty-here {
|
||||
font-size: 12px;
|
||||
color: $plant-muted;
|
||||
margin-top: 1px;
|
||||
.qty-here-num {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: $plant-mine-border;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
}
|
||||
.card-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
|
||||
.chip {
|
||||
|
||||
@@ -173,3 +173,89 @@ $fp-md-page: var(--fp-page-bg, #{$_fp_md_page_hex});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ Partial-order handling — easy-advance layout
|
||||
// "Send Parts Forward" dialog: destination banner + big-tap qty stepper
|
||||
// (no keyboard) + collapsed advanced fields. Reuses the $fp-md-* tokens so
|
||||
// dark mode is handled at compile time.
|
||||
.o_fp_move_dialog {
|
||||
.o_fp_move_route {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: .6rem .75rem;
|
||||
background: $fp-md-page;
|
||||
border: 1px solid $fp-md-border;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
|
||||
.route-from { color: $fp-md-muted; }
|
||||
.route-arrow { color: $fp-md-accent; font-weight: 800; }
|
||||
.route-to { color: $fp-md-accent; }
|
||||
}
|
||||
|
||||
.o_fp_move_qty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
|
||||
label { font-weight: 600; margin: 0; }
|
||||
}
|
||||
|
||||
.o_fp_qty_stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
|
||||
.qty-btn {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
border: 1px solid $fp-md-border;
|
||||
border-radius: 8px;
|
||||
background: $fp-md-card;
|
||||
color: $fp-md-accent;
|
||||
|
||||
&:disabled { opacity: .4; }
|
||||
}
|
||||
.qty-value {
|
||||
min-width: 3.5rem;
|
||||
text-align: center;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.qty-all {
|
||||
margin-left: .5rem;
|
||||
padding: .5rem .9rem;
|
||||
border: 1px solid $fp-md-border;
|
||||
border-radius: 8px;
|
||||
background: $fp-md-card;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_qty_hint {
|
||||
color: $fp-md-muted;
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
.o_fp_move_advanced_toggle {
|
||||
text-align: center;
|
||||
|
||||
.btn-link { color: $fp-md-muted; text-decoration: none; }
|
||||
}
|
||||
|
||||
.o_fp_move_advanced {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
padding: .6rem .75rem;
|
||||
border: 1px dashed $fp-md-border;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@
|
||||
<!-- Step name -->
|
||||
<div class="card-step" t-esc="props.card.step_name"/>
|
||||
|
||||
<!-- Parts at THIS stage (partial-order handling). "20 of 50"
|
||||
so a per-stage presence is never mistaken for a whole job.
|
||||
Hidden when nothing is parked here (post-shop / empty). -->
|
||||
<div t-if="props.card.qty_here" class="card-qty-here">
|
||||
<span class="qty-here-num" t-esc="props.card.qty_here"/>
|
||||
<span class="qty-here-of"> of <t t-esc="props.card.job_qty"/> here</span>
|
||||
</div>
|
||||
|
||||
<!-- Tank + state chip -->
|
||||
<div class="card-chips">
|
||||
<span t-if="props.card.tank_label" class="chip tank" t-esc="props.card.tank_label"/>
|
||||
|
||||
@@ -2,75 +2,48 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.FpMovePartsDialog">
|
||||
<Dialog title.translate="Move Parts" size="'lg'">
|
||||
<Dialog title.translate="Send Parts Forward" size="'md'">
|
||||
<div class="o_fp_move_dialog" t-if="!state.loading">
|
||||
|
||||
<div class="o_fp_move_field">
|
||||
<label>Part Count</label>
|
||||
<input type="number" t-model.number="state.qty"
|
||||
t-att-min="1" t-att-max="state.qtyAvailable"/>
|
||||
<span class="text-muted">Available: <t t-esc="state.qtyAvailable"/></span>
|
||||
<!-- Destination banner — operator sees exactly where parts go,
|
||||
nothing to guess. -->
|
||||
<div class="o_fp_move_route">
|
||||
<span class="route-from" t-esc="state.fromStep.name"/>
|
||||
<span class="route-arrow"> → </span>
|
||||
<span class="route-to" t-esc="state.toStep.name"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_move_field">
|
||||
<label>From Node</label>
|
||||
<span t-esc="state.fromStep.name"/>
|
||||
<span/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_move_field" t-if="state.fromStep.tank_name">
|
||||
<label>From Station</label>
|
||||
<span t-esc="state.fromStep.tank_name"/>
|
||||
<span/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_move_field">
|
||||
<label>Transfer Type</label>
|
||||
<select t-model="state.transferType">
|
||||
<option value="step">Step</option>
|
||||
<option value="hold">Hold</option>
|
||||
<option value="scrap">Scrap</option>
|
||||
<option value="rework">Rework</option>
|
||||
<option value="split">Split</option>
|
||||
<option value="return">Return</option>
|
||||
</select>
|
||||
<span/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_move_field">
|
||||
<label>To Node</label>
|
||||
<span t-esc="state.toStep.name"/>
|
||||
<span/>
|
||||
<!-- Qty stepper — no keyboard. Defaults to all parked here. -->
|
||||
<div class="o_fp_move_qty">
|
||||
<label>How many to send?</label>
|
||||
<div class="o_fp_qty_stepper">
|
||||
<button class="qty-btn" t-on-click="decQty"
|
||||
t-att-disabled="state.qty <= 1">−</button>
|
||||
<span class="qty-value" t-esc="state.qty"/>
|
||||
<button class="qty-btn" t-on-click="incQty"
|
||||
t-att-disabled="state.qty >= state.qtyAvailable">+</button>
|
||||
<button class="qty-all" t-on-click="setQtyAll">
|
||||
All (<t t-esc="state.qtyAvailable"/>)
|
||||
</button>
|
||||
</div>
|
||||
<span class="o_fp_qty_hint"><t t-esc="state.qtyAvailable"/> parked here</span>
|
||||
</div>
|
||||
|
||||
<!-- To Station (tank) — only when the recipe offers a choice -->
|
||||
<div class="o_fp_move_field"
|
||||
t-if="state.toStep.tank_options and state.toStep.tank_options.length > 1">
|
||||
<label>To Station</label>
|
||||
<select t-model.number="state.toTankId">
|
||||
<t t-foreach="state.toStep.tank_options"
|
||||
t-as="tk" t-key="tk.id">
|
||||
<t t-foreach="state.toStep.tank_options" t-as="tk" t-key="tk.id">
|
||||
<option t-att-value="tk.id"><t t-esc="tk.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
<span/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_move_field">
|
||||
<label>To Location</label>
|
||||
<select t-model="state.toLocation">
|
||||
<option value="global">Global</option>
|
||||
<option value="quarantine">Quarantine</option>
|
||||
<option value="staging_a">Staging A</option>
|
||||
<option value="staging_b">Staging B</option>
|
||||
<option value="shipping_dock">Shipping Dock</option>
|
||||
<option value="scrap_bin">Scrap Bin</option>
|
||||
</select>
|
||||
<span/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_compliance_prompts"
|
||||
t-if="state.transitionPrompts.length">
|
||||
<h5>Compliance Prompts</h5>
|
||||
<!-- Compliance prompts — only when the recipe author required
|
||||
them. Pickers/checkboxes, minimal free text. -->
|
||||
<div class="o_fp_compliance_prompts" t-if="state.transitionPrompts.length">
|
||||
<h5>Required before sending</h5>
|
||||
<t t-foreach="state.transitionPrompts" t-as="p" t-key="p.id">
|
||||
<div class="o_fp_move_field">
|
||||
<label>
|
||||
@@ -94,13 +67,12 @@
|
||||
</t>
|
||||
</select>
|
||||
<span class="text-muted" t-if="p.hint"><t t-esc="p.hint"/></span>
|
||||
<span t-else=""/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Blockers — inline resolve where possible -->
|
||||
<div class="o_fp_blockers" t-if="state.blockers.length">
|
||||
<h5>Blockers</h5>
|
||||
<t t-foreach="state.blockers" t-as="b" t-key="b_index">
|
||||
<div class="o_fp_blocker_row"
|
||||
t-att-class="b.severity === 'hard' ? 'o_fp_blocker_hard' : 'o_fp_blocker_soft'">
|
||||
@@ -114,6 +86,39 @@
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- More options (advanced) — hold / scrap / rework / location.
|
||||
Collapsed by default so the everyday "advance all" flow is
|
||||
a qty confirm + SEND. -->
|
||||
<div class="o_fp_move_advanced_toggle">
|
||||
<button class="btn btn-link btn-sm" t-on-click="toggleAdvanced">
|
||||
<t t-if="state.showAdvanced">▾ Hide options</t>
|
||||
<t t-else="">▸ More options (hold / scrap / location)</t>
|
||||
</button>
|
||||
</div>
|
||||
<div t-if="state.showAdvanced" class="o_fp_move_advanced">
|
||||
<div class="o_fp_move_field">
|
||||
<label>Transfer Type</label>
|
||||
<select t-model="state.transferType">
|
||||
<option value="step">Send to next step</option>
|
||||
<option value="hold">Hold</option>
|
||||
<option value="scrap">Scrap</option>
|
||||
<option value="rework">Rework</option>
|
||||
<option value="return">Return</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="o_fp_move_field">
|
||||
<label>To Location</label>
|
||||
<select t-model="state.toLocation">
|
||||
<option value="global">Global</option>
|
||||
<option value="quarantine">Quarantine</option>
|
||||
<option value="staging_a">Staging A</option>
|
||||
<option value="staging_b">Staging B</option>
|
||||
<option value="shipping_dock">Shipping Dock</option>
|
||||
<option value="scrap_bin">Scrap Bin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="state.loading">Loading…</div>
|
||||
@@ -126,7 +131,7 @@
|
||||
t-att-disabled="!canCommit"
|
||||
t-att-title="blockerTooltip"
|
||||
t-on-click="onCommit">
|
||||
MOVE (<t t-esc="state.qty"/>)
|
||||
SEND (<t t-esc="state.qty"/>)
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user