feat(fusion_plating): Express masking reference images → mask step + workstation viewer
Order-entry shortcut: when masking is toggled ON for an Express order line, an amber "MASK" button appears to attach reference image(s)/PDF(s). The files ride the existing _fp_apply_express_overrides_to_job path onto the job's masking step, so the operator sees exactly what to mask — no recipe edit or custom prompt needed. - configurator: masking_attachment_ids on the wizard line + SO line; action_upload_masking_ref; override branch writes refs onto mask steps; amber multi-file MASK button (express_action_btns) shown when masking is on. - jobs: x_fc_masking_attachment_ids on fp.job.step (per-step) + computed rollup on fp.job; office "Masking Refs" form page (readonly preview). - shopfloor: workspace step payload carries masking_refs (sudo'd attachment read, rule 13m); operator sees thumbnail/PDF tiles on the mask step that open in Odoo's full-screen FileViewer (zoom + swipe). Verified end-to-end on entech: SO-line refs land on the mask step + job rollup (WO-30091); payload mask_refs shape correct (is_image, /web/image). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.22.11.0',
|
||||
'version': '19.0.22.13.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
|
||||
@@ -350,6 +350,14 @@ class SaleOrderLine(models.Model):
|
||||
'steps run, with this text shown on the operator tablet under '
|
||||
'fp.job.step.instructions.',
|
||||
)
|
||||
x_fc_masking_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'sale_order_line_masking_att_rel', 'line_id', 'attachment_id',
|
||||
string='Masking Reference(s)',
|
||||
help='Masking reference image(s)/PDF(s) captured at Express order '
|
||||
'entry; applied to the job\'s masking step at job creation so '
|
||||
'the operator sees what to mask.',
|
||||
)
|
||||
x_fc_revision_snapshot = fields.Char(
|
||||
string='Revision (snapshot)',
|
||||
copy=False,
|
||||
@@ -840,6 +848,19 @@ class SaleOrderLine(models.Model):
|
||||
})
|
||||
if nodes:
|
||||
msgs.append(_('Masking + de-masking steps opted out (per SO line)'))
|
||||
elif self.x_fc_masking_attachment_ids:
|
||||
# Masking ON + Express reference file(s) attached → surface them on
|
||||
# the mask step so the operator sees what to mask. Lands on the
|
||||
# second call (after steps exist), same as bake below.
|
||||
mask_steps = job.step_ids.filtered(
|
||||
lambda s: s.recipe_node_id.default_kind == 'mask'
|
||||
)
|
||||
if mask_steps:
|
||||
mask_steps.sudo().write({
|
||||
'x_fc_masking_attachment_ids': [(6, 0, self.x_fc_masking_attachment_ids.ids)],
|
||||
})
|
||||
msgs.append(_('Masking reference(s) attached to the mask step: %d file(s)')
|
||||
% len(self.x_fc_masking_attachment_ids))
|
||||
|
||||
# 2. Bake — empty = opt out; non-empty = keep + write step.instructions
|
||||
bake_text = (self.x_fc_bake_instructions or '').strip()
|
||||
|
||||
@@ -91,6 +91,67 @@ export class FpExpressActionBtns extends Component {
|
||||
);
|
||||
if (action) await this.action.doAction(action);
|
||||
}
|
||||
|
||||
// ---- Masking reference upload (2026-06-03) ----
|
||||
// Visible only when masking is toggled ON for this line. Accepts MULTIPLE
|
||||
// image/PDF files; each is attached to the line and (on order confirm)
|
||||
// copied onto the job's masking step so the operator sees it in the
|
||||
// workstation. Mirrors onUpload but loops over the file list.
|
||||
get maskingEnabled() {
|
||||
return !!this.props.record.data.masking_enabled;
|
||||
}
|
||||
|
||||
get maskCount() {
|
||||
const m = this.props.record.data.masking_attachment_ids;
|
||||
return (m && m.count) || 0;
|
||||
}
|
||||
|
||||
async onUploadMask(ev) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept = ".pdf,.png,.jpg,.jpeg,application/pdf,image/*";
|
||||
input.onchange = async () => {
|
||||
const files = Array.from(input.files || []);
|
||||
if (!files.length) return;
|
||||
if (!(await this._ensureSaved())) return;
|
||||
let ok = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
const base64 = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result.split(",")[1]);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
await this.orm.call(
|
||||
this.props.record.resModel,
|
||||
"action_upload_masking_ref",
|
||||
[[this.props.record.resId]],
|
||||
{
|
||||
context: {
|
||||
fp_masking_file: base64,
|
||||
fp_masking_filename: file.name,
|
||||
},
|
||||
},
|
||||
);
|
||||
ok += 1;
|
||||
} catch (e) {
|
||||
this.notification.add(
|
||||
`Masking upload failed for "${file.name}": ${e.message || e}`,
|
||||
{ type: "danger" },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
this.notification.add(`${ok} masking reference(s) added.`, { type: "success" });
|
||||
await this.props.record.load();
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
|
||||
export const fpExpressActionBtns = {
|
||||
|
||||
@@ -441,6 +441,21 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// MASK upload — amber so order-entry notices the "attach reference"
|
||||
// affordance the moment masking is toggled on. Solid amber works on
|
||||
// both the light and dark backend bundles (dark text on amber fill).
|
||||
.o_fp_xpr_mask_btn {
|
||||
color: #1f2937;
|
||||
border-color: #d97706;
|
||||
background: #fbbf24;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: #1f2937;
|
||||
border-color: #b45309;
|
||||
background: #f59e0b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
title="Open the part record in a modal">
|
||||
OPEN
|
||||
</button>
|
||||
<button t-if="maskingEnabled"
|
||||
class="o_fp_xpr_action_stack_btn o_fp_xpr_mask_btn"
|
||||
t-on-click="onUploadMask"
|
||||
t-att-disabled="!hasPart"
|
||||
title="Attach masking reference image(s)/PDF(s) — shown to the operator on the masking step">
|
||||
MASK<t t-if="maskCount"> (<t t-esc="maskCount"/>)</t>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
<field name="customer_line_ref" string="Line Job #" placeholder="ABC" width="80px"/>
|
||||
<field name="thickness_range" string="Thickness" placeholder=".0005-.0010" width="100px"/>
|
||||
<field name="masking_enabled" string="Mask" widget="boolean_toggle" width="55px"/>
|
||||
<field name="masking_attachment_ids" column_invisible="1"/>
|
||||
<!-- Bake pill — click to edit -->
|
||||
<field name="bake_instructions"
|
||||
string="Bake"
|
||||
|
||||
@@ -573,6 +573,14 @@ class FpDirectOrderLine(models.Model):
|
||||
help='Free-text bake instructions. Empty = bake steps are opted out. '
|
||||
'Non-empty = bake step instructions on the operator tablet.',
|
||||
)
|
||||
masking_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fp_direct_order_line_masking_att_rel', 'line_id', 'attachment_id',
|
||||
string='Masking Reference(s)',
|
||||
help='Image(s)/PDF(s) of what to mask. Carried to the SO line and '
|
||||
'shown to the operator on the job\'s masking step. Only relevant '
|
||||
'when Masking is enabled.',
|
||||
)
|
||||
|
||||
# ---- Computes ----
|
||||
@api.depends('quantity', 'unit_price')
|
||||
@@ -766,6 +774,29 @@ class FpDirectOrderLine(models.Model):
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_upload_masking_ref(self):
|
||||
"""Attach a masking reference (image/PDF) to this line.
|
||||
|
||||
Called by the Express 'MASK REF' button — once per file (multi-select
|
||||
loops in JS), via context keys fp_masking_file + fp_masking_filename.
|
||||
Stored on the line's masking_attachment_ids; carried to the SO line
|
||||
and the job's masking step at order confirm.
|
||||
"""
|
||||
self.ensure_one()
|
||||
from odoo.exceptions import UserError
|
||||
file_data = self.env.context.get('fp_masking_file')
|
||||
filename = self.env.context.get('fp_masking_filename', 'masking-ref')
|
||||
if not file_data:
|
||||
raise UserError(_('No file data received.'))
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': filename,
|
||||
'datas': file_data,
|
||||
'res_model': 'fp.direct.order.line',
|
||||
'res_id': self.id,
|
||||
})
|
||||
self.write({'masking_attachment_ids': [(4, att.id)]})
|
||||
return True
|
||||
|
||||
def action_upload_drawing(self):
|
||||
"""Attach a file (via context) to the line's part as a drawing.
|
||||
|
||||
|
||||
@@ -956,6 +956,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
'x_fc_customer_line_ref': line.customer_line_ref or False,
|
||||
'x_fc_masking_enabled': line.masking_enabled,
|
||||
'x_fc_bake_instructions': line.bake_instructions or False,
|
||||
'x_fc_masking_attachment_ids': [(6, 0, line.masking_attachment_ids.ids)],
|
||||
# Sub 9 — explicit tax override from the wizard line.
|
||||
# When blank, Odoo will compute taxes from the product
|
||||
# defaults at SO-line save time (the standard behaviour).
|
||||
|
||||
Reference in New Issue
Block a user