fix(audit-trail): 3 production bugs found via end-to-end Anodize battle test

Battle-tested complete workflow on entech: ABC Manufacturing + Anodize
recipe (id=136) cloned to part-variant (id=1775) → SO S00276 confirmed →
fp.job 1234 with 17 steps → recorded 56 measurement values exercising all
13 input types (incl. all 4 new types) → CoC chronological report renders
69KB with all values incl. photo thumbnails.

Bugs found and fixed:

1. fp.process.node.input_ids missing copy=True — when a master recipe
   was cloned per-part (the standard variant pattern), the operator
   prompts on each step did NOT get copied to the variant. Result: jobs
   built from variants ran with zero prompts even though the master had
   them. Fixed: input_ids now copy=True so cloning auto-duplicates.

2. CoC chronological template read dest.input_ids where dest is
   fp.job.step. Steps don't carry input_ids — that field lives on the
   recipe node. Result: AttributeError aborted the entire CoC render.
   Fixed: walk via dest.recipe_node_id.input_ids; preserves the existing
   collect=True filter.

3. CoC chronological template used hasattr() in a t-value expression.
   QWeb's expression engine doesn't expose Python builtins, raised
   KeyError: 'hasattr'. Fixed: use 'collect' in i._fields instead.

Also enhanced photo rendering in CoC: was just "[Attachment]" placeholder;
now renders an actual <img> thumbnail (max 80px tall) plus the filename.

Battle-test script saved to fusion_plating/scripts/bt_e2e_anodize_v2.py
for re-runs / regression testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-29 22:53:59 -04:00
parent b187192c58
commit ec0a07fbe9
13 changed files with 1246 additions and 3 deletions

View File

@@ -321,6 +321,83 @@ export class FpSimpleRecipeEditor extends Component {
this.state.editInstructions = "";
}
// -------------------- Sub 12d — measurements config --------------------
async onToggleStepCollect(stepId, collect) {
await rpc("/fp/simple_recipe/step/toggle_collect", {
node_id: stepId, collect,
});
await this.loadAll();
}
async onToggleInputCollect(inputId, collect) {
await rpc("/fp/simple_recipe/step/edit_input", {
input_id: inputId,
payload: { collect },
});
await this.loadAll();
}
async onEditInputField(inputId, field, value) {
const payload = {};
payload[field] = value;
await rpc("/fp/simple_recipe/step/edit_input", {
input_id: inputId,
payload,
});
await this.loadAll();
}
async onAddCustomInput(stepId) {
await rpc("/fp/simple_recipe/step/add_input", {
node_id: stepId,
payload: { name: _t("New Prompt"), input_type: "text" },
});
await this.loadAll();
}
/**
* Auto-save an input field on blur or change. Skips empty names so
* accidental Tab-out doesn't blank the prompt.
*/
async onInputBlur(inputId, field, ev) {
const value = ev.target.value;
if (field === "name" && !value.trim()) return;
await this.onEditInputField(inputId, field, value);
}
async onRemoveCustomInput(inputId) {
const result = await rpc("/fp/simple_recipe/step/remove_input", {
input_id: inputId,
});
if (result.error === "library_sourced") {
this.notification.add(
_t("Library prompts can't be deleted — toggle Collect off instead."),
{ type: "warning" }
);
return;
}
await this.loadAll();
}
async onResetToLibrary(stepId) {
const result = await rpc("/fp/simple_recipe/step/reset_to_library", {
node_id: stepId,
});
if (!result.ok) {
this.notification.add(
_t("This step has no linked library template."),
{ type: "warning" }
);
return;
}
await this.loadAll();
this.notification.add(
_t("Reset to library defaults — custom prompts preserved"),
{ type: "success" }
);
}
/**
* Render stored HTML as plain text for the textarea. Strips tags,
* collapses block elements to newlines. Good enough for the simple

View File

@@ -233,6 +233,27 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
color: $fp-se-muted;
}
.o_fp_measurements_config {
margin-top: .75rem;
padding-top: .75rem;
border-top: 1px solid #d8dadd;
.o_fp_inputs_table_wrap {
margin-top: .5rem;
}
.o_fp_inputs_table {
font-size: .85rem;
margin-bottom: .25rem;
th { font-weight: 500; }
td { vertical-align: middle; }
}
.o_fp_inputs_actions {
display: flex;
gap: .5rem;
margin-top: .25rem;
}
}
.o_fp_edit_actions {
display: flex;
gap: .5rem;

View File

@@ -71,6 +71,13 @@
t-if="step.tank_ids and step.tank_ids.length">
<t t-esc="step.tank_ids.length"/> stations
</span>
<span class="badge ms-1"
t-att-class="step.measurements_badge_class"
t-if="step.measurements_badge_text"
title="Measurement collection state">
<i class="fa fa-clipboard"/>
<t t-esc="step.measurements_badge_text"/>
</span>
<button class="o_fp_step_edit"
title="Edit name &amp; instructions"
t-on-click="() => this.onToggleEdit(step.id)">
@@ -102,6 +109,113 @@
Shown to operators when running this step at the tank. Use line breaks for separate points.
</p>
</div>
<!-- Sub 12d — Measurements config -->
<div class="o_fp_edit_field o_fp_measurements_config">
<label>
<input type="checkbox"
t-att-checked="step.collect_measurements"
t-on-change="(ev) => this.onToggleStepCollect(step.id, ev.target.checked)"/>
<strong> Collect measurements at this step</strong>
</label>
<p class="o_fp_edit_hint">
Master switch. When off, the operator wizard skips this step entirely
(no input prompts shown at runtime).
</p>
<div t-if="step.collect_measurements"
class="o_fp_inputs_table_wrap">
<table class="table table-sm o_fp_inputs_table"
t-if="step.inputs and step.inputs.length">
<thead>
<tr>
<th style="width:60px;">Collect</th>
<th>Prompt</th>
<th style="width:160px;">Type</th>
<th style="width:90px;">Min</th>
<th style="width:90px;">Max</th>
<th style="width:60px;">Req</th>
<th style="width:36px;"></th>
</tr>
</thead>
<tbody>
<tr t-foreach="step.inputs" t-as="inp" t-key="inp.id">
<td>
<input type="checkbox"
t-att-checked="inp.collect"
t-on-change="(ev) => this.onToggleInputCollect(inp.id, ev.target.checked)"/>
</td>
<td>
<input type="text" class="form-control form-control-sm"
t-att-value="inp.name"
t-on-blur="(ev) => this.onInputBlur(inp.id, 'name', ev)"/>
<small t-if="inp.from_library"
class="text-muted"
title="From library template — toggle Collect off instead of deleting">
from library
</small>
</td>
<td>
<select class="form-select form-select-sm"
t-on-change="(ev) => this.onEditInputField(inp.id, 'input_type', ev.target.value)">
<option value="text" t-att-selected="inp.input_type === 'text'">Text</option>
<option value="number" t-att-selected="inp.input_type === 'number'">Number</option>
<option value="boolean" t-att-selected="inp.input_type === 'boolean'">Yes/No</option>
<option value="selection" t-att-selected="inp.input_type === 'selection'">Selection</option>
<option value="date" t-att-selected="inp.input_type === 'date'">Date / Time</option>
<option value="signature" t-att-selected="inp.input_type === 'signature'">Signature</option>
<option value="time_hms" t-att-selected="inp.input_type === 'time_hms'">Time (HH:MM:SS)</option>
<option value="time_seconds" t-att-selected="inp.input_type === 'time_seconds'">Time (sec)</option>
<option value="temperature" t-att-selected="inp.input_type === 'temperature'">Temperature</option>
<option value="thickness" t-att-selected="inp.input_type === 'thickness'">Thickness</option>
<option value="pass_fail" t-att-selected="inp.input_type === 'pass_fail'">Pass / Fail</option>
<option value="photo" t-att-selected="inp.input_type === 'photo'">Photo</option>
<option value="multi_point_thickness" t-att-selected="inp.input_type === 'multi_point_thickness'">Multi-Point Thickness</option>
<option value="bath_chemistry_panel" t-att-selected="inp.input_type === 'bath_chemistry_panel'">Bath Chemistry Panel</option>
<option value="ph" t-att-selected="inp.input_type === 'ph'">pH</option>
</select>
</td>
<td>
<input type="number" step="any" class="form-control form-control-sm"
t-att-value="inp.target_min"
t-on-blur="(ev) => this.onInputBlur(inp.id, 'target_min', ev)"/>
</td>
<td>
<input type="number" step="any" class="form-control form-control-sm"
t-att-value="inp.target_max"
t-on-blur="(ev) => this.onInputBlur(inp.id, 'target_max', ev)"/>
</td>
<td class="text-center">
<input type="checkbox"
t-att-checked="inp.required"
t-on-change="(ev) => this.onEditInputField(inp.id, 'required', ev.target.checked)"/>
</td>
<td>
<button t-if="!inp.from_library"
class="btn btn-link btn-sm text-danger p-0"
title="Remove custom prompt"
t-on-click="() => this.onRemoveCustomInput(inp.id)">×</button>
</td>
</tr>
</tbody>
</table>
<p t-if="!step.inputs or !step.inputs.length"
class="text-muted">
No measurement prompts on this step yet.
</p>
<div class="o_fp_inputs_actions">
<button class="btn btn-link btn-sm"
t-on-click="() => this.onAddCustomInput(step.id)">
<i class="fa fa-plus"/> Add custom prompt
</button>
<button t-if="step.source_template_id"
class="btn btn-link btn-sm"
t-on-click="() => this.onResetToLibrary(step.id)">
<i class="fa fa-refresh"/> Reset to library defaults
</button>
</div>
</div>
</div>
<div class="o_fp_edit_actions">
<button class="btn btn-primary btn-sm"
t-on-click="() => this.onSaveStep()">