This commit is contained in:
gsinghpal
2026-05-10 10:25:12 -04:00
parent 6c6a59ceef
commit 6b7b44264a
59 changed files with 2461 additions and 324 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.18.13.8',
'version': '19.0.18.13.13',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

View File

@@ -37,6 +37,25 @@ _INPUT_SNAPSHOT_FIELDS = [
]
def _copy_snapshot_fields(source, fields):
"""Copy ``fields`` from ``source`` record into a write-ready dict.
Many2one values must be unwrapped to their integer id — passing a
recordset to ``create`` triggers psycopg2 ``can't adapt type X``
because the SQL adapter doesn't know how to serialize a recordset.
Scalar fields pass through untouched.
"""
out = {}
for f in fields:
field = source._fields[f]
val = source[f]
if field.type == 'many2one':
out[f] = val.id if val else False
else:
out[f] = val
return out
class SimpleRecipeController(http.Controller):
# ------------------------------------------------------------------ load
@@ -115,6 +134,18 @@ class SimpleRecipeController(http.Controller):
),
'measurements_badge_text': badge_text,
'measurements_badge_class': badge_class,
# Reference images attached to the step. Operators see
# these in the Record Inputs dialog and the step quick-look
# modal — recipe authors upload via the inline edit panel.
'instruction_images': [
{
'id': att.id,
'name': att.name or '',
'mimetype': att.mimetype or '',
'url': '/web/image/%s' % att.id,
}
for att in step.instruction_attachment_ids
],
'inputs': [
{
'id': i.id,
@@ -457,8 +488,7 @@ class SimpleRecipeController(http.Controller):
tpl = False
if template_id:
tpl = request.env['fp.step.template'].browse(template_id)
for f in _SNAPSHOT_FIELDS:
new_vals[f] = tpl[f]
new_vals.update(_copy_snapshot_fields(tpl, _SNAPSHOT_FIELDS))
if tpl.process_type_id:
new_vals['process_type_id'] = tpl.process_type_id.id
if tpl.tank_ids:
@@ -598,8 +628,7 @@ class SimpleRecipeController(http.Controller):
'sequence': src_node.sequence,
'source_template_id': src_node.source_template_id.id or False,
}
for f in _SNAPSHOT_FIELDS:
new_vals[f] = src_node[f]
new_vals.update(_copy_snapshot_fields(src_node, _SNAPSHOT_FIELDS))
if src_node.process_type_id:
new_vals['process_type_id'] = src_node.process_type_id.id
if src_node.tank_ids:
@@ -690,6 +719,69 @@ class SimpleRecipeController(http.Controller):
rec.unlink()
return {'ok': True}
# ============================================================
# Step instruction images — recipe authors attach reference photos
# / screenshots / diagrams to a step from the Simple Editor's inline
# edit panel. Operators see them on the Record Inputs dialog and
# the step quick-look modal at runtime.
# ============================================================
@http.route('/fp/simple_recipe/step/image/add', type='jsonrpc', auth='user')
def step_image_add(self, node_id, filename, datas, mimetype=None):
"""Upload a new instruction image to a recipe step.
Args:
node_id: recipe node (fusion.plating.process.node) id
filename: display name (with extension) for the attachment
datas: base64-encoded image payload (no data: URL prefix)
mimetype: optional override; falls back to image/png
Returns the new attachment metadata so the JS can append it to
the step's gallery without a full reload.
"""
node = request.env['fusion.plating.process.node'].browse(int(node_id))
node.check_access('write')
att = request.env['ir.attachment'].create({
'name': filename or 'image.png',
'datas': datas,
'res_model': 'fusion.plating.process.node',
'res_id': node.id,
'mimetype': mimetype or 'image/png',
})
node.instruction_attachment_ids = [(4, att.id)]
return {
'ok': True,
'image': {
'id': att.id,
'name': att.name,
'mimetype': att.mimetype or '',
'url': '/web/image/%s' % att.id,
},
}
@http.route('/fp/simple_recipe/step/image/remove', type='jsonrpc', auth='user')
def step_image_remove(self, node_id, attachment_id):
"""Unlink an instruction image from a recipe step.
Soft-removes from the M2M; the underlying ir.attachment is
deleted only if it isn't referenced by any other recipe node.
"""
node = request.env['fusion.plating.process.node'].browse(int(node_id))
node.check_access('write')
Att = request.env['ir.attachment']
att = Att.browse(int(attachment_id))
if not att.exists():
return {'ok': False, 'error': 'not_found'}
node.instruction_attachment_ids = [(3, att.id)]
# Drop the attachment file too if no other node still links to it.
Node = request.env['fusion.plating.process.node']
still_used = Node.search_count([
('instruction_attachment_ids', '=', att.id),
])
if not still_used:
att.sudo().unlink()
return {'ok': True}
@http.route('/fp/simple_recipe/step/reset_to_library', type='jsonrpc', auth='user')
def step_reset_to_library(self, node_id):
"""Re-sync the recipe step's input_ids + description from the linked

View File

@@ -129,19 +129,34 @@ class FpProcessNode(models.Model):
('fa-th', 'Grid / Racking'),
('fa-fire', 'Fire / Bake'),
('fa-bolt', 'Bolt / Electric'),
('fa-flash', 'Flash / Discharge'),
('fa-diamond', 'Diamond / Plating'),
('fa-tint', 'Tint / Rinse'),
('fa-shower', 'Shower / Clean'),
('fa-bullseye', 'Target / Blast'),
('fa-search', 'Search / Inspect'),
('fa-check-circle', 'Check / Approve'),
('fa-check-square-o', 'Checklist / QC'),
('fa-clock-o', 'Clock / Wait'),
('fa-pause-circle', 'Pause / Hold'),
('fa-sun-o', 'Sun / Dry'),
('fa-thermometer-half', 'Temp / Heat'),
('fa-cloud', 'Cloud / Atmosphere'),
('fa-eye', 'Eye / Visual'),
('fa-eye-slash', 'Eye-Slash / Hidden'),
('fa-hand-paper-o', 'Hand / Manual'),
('fa-cube', 'Cube / Part'),
('fa-shield', 'Shield / Protect'),
('fa-inbox', 'Inbox / Receiving'),
('fa-archive', 'Archive / Storage'),
('fa-truck', 'Truck / Ship'),
('fa-paper-plane', 'Paper-Plane / Send'),
('fa-link', 'Link / Chain'),
('fa-scissors', 'Scissors / Cut'),
('fa-server', 'Server / Stack'),
('fa-tachometer', 'Tachometer / Gauge'),
('fa-file-text-o', 'Document / Form'),
('fa-plus-circle', 'Plus / Add'),
],
string='Icon',
default='fa-cog',
@@ -151,6 +166,32 @@ class FpProcessNode(models.Model):
default=0,
)
# ---- Reference images / instruction screenshots -------------------------
# Recipe authors attach photos and screenshots here so operators see
# them on the shop floor when running the step. Anything from a
# process diagram, masking-line photo, or annotated screenshot of the
# WI document. Many2many — supports zero, one, or many images.
instruction_attachment_ids = fields.Many2many(
'ir.attachment',
'fp_node_instruction_attachment_rel',
'node_id', 'attachment_id',
string='Instruction Images',
domain=[('mimetype', 'ilike', 'image/')],
help='Reference photos and screenshots that operators see at '
'runtime. Anything visual that helps them execute the step '
'correctly — fixture orientation, masking pattern, gauge '
'reading. Supports multiple images per step.',
)
instruction_attachment_count = fields.Integer(
string='Instruction Image Count',
compute='_compute_instruction_attachment_count',
)
@api.depends('instruction_attachment_ids')
def _compute_instruction_attachment_count(self):
for rec in self:
rec.instruction_attachment_count = len(rec.instruction_attachment_ids)
# ---- Timing --------------------------------------------------------------
estimated_duration = fields.Float(
@@ -722,11 +763,16 @@ class FpProcessNodeInput(models.Model):
)
target_min = fields.Float(
string='Target Min',
help='Lower bound of the acceptable range, expressed in Target Unit.',
digits=(16, 6),
help='Lower bound of the acceptable range, expressed in Target Unit. '
'Stored to 6 decimal places to support plating thicknesses '
'(e.g. 0.000050 in / 50 micro-inches).',
)
target_max = fields.Float(
string='Target Max',
help='Upper bound of the acceptable range, expressed in Target Unit.',
digits=(16, 6),
help='Upper bound of the acceptable range, expressed in Target Unit. '
'Stored to 6 decimal places.',
)
target_unit = fields.Selection(
FP_UOM_SELECTION,

View File

@@ -690,6 +690,86 @@ export class FpSimpleRecipeEditor extends Component {
this._fpResetStepEdit();
}
// -------------------- Instruction images -------------------------------
//
// Recipe authors drop reference photos / screenshots into this list
// while editing a step. Operators see the gallery at runtime in the
// Record Inputs dialog and the step quick-look modal. Backed by
// /fp/simple_recipe/step/image/{add,remove}; mirrors the upload
// affordance available on the tree-editor side.
async onUploadStepImages(stepId, ev) {
const files = Array.from(ev.target.files || []);
if (!files.length) return;
for (const file of files) {
if (!file.type.startsWith("image/")) {
this.notification.add(
_t("%s isn't an image — skipped.").replace("%s", file.name),
{ type: "warning" },
);
continue;
}
// Read as base64 (strip the "data:...;base64," prefix).
// eslint-disable-next-line no-await-in-loop
const datas = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result || "";
resolve(String(result).split(",")[1] || "");
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
// eslint-disable-next-line no-await-in-loop
const result = await rpc("/fp/simple_recipe/step/image/add", {
node_id: stepId,
filename: file.name,
datas: datas,
mimetype: file.type,
});
if (!result.ok) {
this.notification.add(
_t("Could not upload %s.").replace("%s", file.name),
{ type: "danger" },
);
continue;
}
// Append directly to the in-memory step so the gallery
// updates without re-loading the whole recipe tree.
const step = this.state.steps.find((s) => s.id === stepId);
if (step) {
step.instruction_images = [
...(step.instruction_images || []),
result.image,
];
}
}
// Clear the file input so the same file can be uploaded again
// after a remove + re-add cycle.
ev.target.value = "";
this.notification.add(_t("Image(s) attached"), { type: "success" });
}
async onRemoveStepImage(stepId, attachmentId) {
const result = await rpc("/fp/simple_recipe/step/image/remove", {
node_id: stepId,
attachment_id: attachmentId,
});
if (!result.ok) {
this.notification.add(
_t("Could not remove image."),
{ type: "danger" },
);
return;
}
const step = this.state.steps.find((s) => s.id === stepId);
if (step) {
step.instruction_images = (step.instruction_images || []).filter(
(img) => img.id !== attachmentId,
);
}
}
// -------------------- Sub 12d — measurements config --------------------
async onToggleStepCollect(stepId, collect) {

View File

@@ -54,17 +54,61 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
.o_fp_simple_editor_meta {
background: $fp-se-card;
border: 1px solid $fp-se-border;
border-radius: 4px;
padding: 1rem;
border-radius: 6px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
.o_fp_import_row {
display: flex;
align-items: center;
gap: .75rem;
label { font-weight: 500; margin: 0; min-width: 14rem; }
select { flex: 1; max-width: 30rem; }
.o_fp_import_label {
margin: 0;
font-weight: 600;
color: $fp-se-accent;
white-space: nowrap;
display: inline-flex;
align-items: center;
.fa {
color: $fp-se-accent;
opacity: .8;
}
}
// Bootstrap's form-select gives us the chevron + base styling;
// we just tighten the colours to the card tokens so the field
// sits flush in our themed panel instead of fighting it.
.o_fp_import_select {
flex: 1;
max-width: 32rem;
min-height: 2.25rem;
background-color: $fp-se-card;
color: inherit;
border-color: $fp-se-border;
transition: border-color .15s ease, box-shadow .15s ease;
&:hover:not(:focus):not(:disabled) {
border-color: $fp-se-accent;
}
&:focus {
border-color: $fp-se-accent;
box-shadow: 0 0 0 .15rem rgba(46, 125, 107, .18);
outline: none;
}
}
.o_fp_import_btn {
white-space: nowrap;
min-height: 2.25rem;
.fa {
opacity: .9;
}
}
}
}
@@ -492,3 +536,109 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
justify-content: flex-end;
}
// =============================================================================
// Instruction images gallery — recipe-author upload + thumbnail strip in
// the Simple Editor's inline step edit panel. Mirrors what the Record
// Inputs dialog renders at runtime so authors can preview the same way
// the operator will see it.
// =============================================================================
.o_fp_step_images {
.o_fp_step_images_gallery {
display: flex;
flex-wrap: wrap;
gap: .5rem;
margin: .5rem 0;
}
.o_fp_step_image_card {
position: relative;
width: 110px;
background: $fp-se-card;
border: 1px solid $fp-se-border;
border-radius: 6px;
overflow: hidden;
transition: border-color .12s ease, box-shadow .12s ease;
&:hover {
border-color: $fp-se-accent;
box-shadow: 0 2px 6px rgba(0, 0, 0, .12);
}
a {
display: block;
width: 100%;
height: 90px;
cursor: zoom-in;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.o_fp_step_image_remove {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
padding: 0;
border-radius: 50%;
background: rgba(0, 0, 0, .55);
color: #fff;
border: 0;
cursor: pointer;
opacity: 0;
transition: opacity .12s ease, background-color .12s ease;
display: flex;
align-items: center;
justify-content: center;
font-size: .75rem;
}
.o_fp_step_image_card:hover .o_fp_step_image_remove,
.o_fp_step_image_remove:focus {
opacity: 1;
}
.o_fp_step_image_remove:hover {
background: #c0392b;
}
.o_fp_step_image_caption {
font-size: .7rem;
padding: 4px 6px;
color: $fp-se-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-top: 1px solid $fp-se-border;
}
.o_fp_step_image_uploader {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
margin-top: .25rem;
background: $fp-se-card;
border: 1px dashed $fp-se-border;
border-radius: 6px;
cursor: pointer;
color: $fp-se-accent;
font-weight: 500;
transition: border-color .12s ease, background-color .12s ease;
&:hover {
border-color: $fp-se-accent;
border-style: solid;
background: rgba(46, 125, 107, .06);
}
}
}

View File

@@ -23,8 +23,13 @@
<div class="o_fp_simple_editor_meta" t-if="state.recipe">
<div class="o_fp_import_row">
<label>Import starter from template:</label>
<select t-model="state.selectedTemplate">
<label class="o_fp_import_label" for="fp_import_template_select">
<i class="fa fa-download me-2"/>
Import starter from template
</label>
<select id="fp_import_template_select"
class="form-select o_fp_import_select"
t-model="state.selectedTemplate">
<option value="">— Select template —</option>
<t t-foreach="state.templateOptions" t-as="tpl" t-key="tpl.id">
<option t-att-value="tpl.id">
@@ -32,8 +37,10 @@
</option>
</t>
</select>
<button class="btn btn-primary" t-on-click="onImportTemplate"
<button class="btn btn-primary o_fp_import_btn"
t-on-click="onImportTemplate"
t-att-disabled="!state.selectedTemplate">
<i class="fa fa-plus me-1"/>
Import
</button>
</div>
@@ -193,6 +200,56 @@
</small>
</label>
</div>
<!-- Instruction images — recipe author drops
photos / screenshots / diagrams here.
Operators see the gallery at runtime in
the Record Inputs dialog and the step
quick-look modal. -->
<div class="o_fp_edit_field o_fp_step_images">
<label>
<i class="fa fa-camera me-1"/>
<strong>Instruction Images</strong>
</label>
<p class="o_fp_edit_hint">
Reference photos / screenshots / diagrams shown
to operators while running this step. Drop
multiple images for masking patterns, fixture
orientation, gauge readings, etc.
</p>
<div class="o_fp_step_images_gallery"
t-if="step.instruction_images and step.instruction_images.length">
<t t-foreach="step.instruction_images"
t-as="img" t-key="img.id">
<div class="o_fp_step_image_card">
<a t-att-href="img.url"
target="_blank"
t-att-title="img.name">
<img t-att-src="img.url"
t-att-alt="img.name"/>
</a>
<button type="button"
class="o_fp_step_image_remove"
title="Remove image"
t-on-click="() => this.onRemoveStepImage(step.id, img.id)">
<i class="fa fa-times"/>
</button>
<div class="o_fp_step_image_caption"
t-esc="img.name"/>
</div>
</t>
</div>
<label class="o_fp_step_image_uploader">
<i class="fa fa-plus me-1"/>
Upload images
<input type="file"
accept="image/*"
multiple="multiple"
hidden="hidden"
t-on-change="(ev) => this.onUploadStepImages(step.id, ev)"/>
</label>
</div>
<!-- Sub 12d — Measurements config -->
<div class="o_fp_edit_field o_fp_measurements_config">
<label>

View File

@@ -136,6 +136,22 @@
<page string="Description" name="description">
<field name="description" widget="html"/>
</page>
<page string="Instruction Images"
name="instruction_images">
<p class="text-muted">
Photos and screenshots operators see while
running this step — masking patterns,
fixture orientation, annotated diagrams,
gauge readings. Drop one or many images
here; they appear on the shop-floor
step-detail panel and the Record Inputs
dialog.
</p>
<field name="instruction_attachment_ids"
widget="many2many_binary"
options="{'accepted_file_extensions': 'image/*'}"
nolabel="1"/>
</page>
<page string="Operator Inputs" name="inputs">
<group>
<field name="collect_measurements"