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

@@ -108,6 +108,7 @@ These modules have **source code in this repo** but are **intentionally NOT inst
11. **XML data ordering**: Window actions must be defined BEFORE `<menuitem>` elements that reference them in the same file.
12. **Module install on new modules**: Use `--update=base` alongside `-i MODULE` to ensure Odoo rescans the addons path and finds the new module directory.
13. **Implied group cascade**: `implied_ids` on `res.groups` does NOT reliably propagate to users on module install. Always include `user_ids` to explicitly assign admin, or fix via SQL post-install.
14. **Recipe editor parity**: Step-level UX features (image attachments, prompt editing, settings toggles, preview affordances, etc.) MUST be implemented in BOTH the **Simple Editor** (`fusion_plating/static/src/{js,xml,scss}/simple_recipe_editor.*` + `controllers/simple_recipe_controller.py`) AND the **Tree Editor** (`fusion_plating/static/src/{js,xml,scss}/recipe_tree_editor.*` + `controllers/recipe_controller.py`). Authors choose between editors per-recipe via `preferred_editor`; if a feature only lands in one, half the userbase silently misses it. Default assumption: most clients use the Simple Editor — when in doubt, ship Simple first, then port to Tree in the same change. Backend model + view changes (e.g. new fields on `fusion.plating.process.node`, new tabs on the node form) automatically reach both editors via the related model — only the editor-specific JS/XML/SCSS needs duplicating.
## Naming
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)

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"

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.18.8.0',
'version': '19.0.18.12.9',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -59,6 +59,7 @@ Provides:
'wizard/fp_add_from_quote_wizard_views.xml',
'wizard/fp_quote_promote_wizard_views.xml',
'wizard/fp_part_catalog_import_wizard_views.xml',
'wizard/fp_part_revision_bump_wizard_views.xml',
'wizard/fp_serial_bulk_add_wizard_views.xml',
'views/fp_configurator_menu.xml',
'data/fp_sale_description_template_data.xml',

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
"""Drop the redundant ``revision_number`` Integer column on fp.part.catalog.
The model historically carried two revision fields:
* ``revision`` (Char, required) — the customer's actual revision label
* ``revision_number`` (Integer) — an internal counter
The Integer counter duplicated information already in ``revision`` and
got out of sync whenever the customer used a non-numeric scheme
(A/B/C, A1/A2, "ECO-2024-014" etc.). This migration drops the column.
``action_create_revision`` and the auto-rev path on 3D-model upload now
use ``_bump_revision_label`` which best-effort bumps the alphanumeric
label and lets the user adjust to the customer's actual scheme.
"""
def migrate(cr, version):
cr.execute("""
ALTER TABLE fp_part_catalog DROP COLUMN IF EXISTS revision_number;
""")

View File

@@ -3,11 +3,56 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import re
from markupsafe import Markup
from odoo import api, fields, models, _
def _bump_revision_label(label):
"""Best-effort next revision after ``label``.
Customers use varied revision schemes (A/B/C, A1/A2, "Rev 1"/"Rev 2",
custom strings). This helper handles the common ones; for unrecognised
formats it returns ``label + '*'`` so the user knows they need to
fix the label manually.
- 'A''B' ... 'Y''Z''AA'
- 'a''b' (case preserved on single letter)
- 'A1''A2', 'B12''B13', 'Rev 1''Rev 2'
- 'AB''AC' (last letter incremented)
- everything else → ``label + '*'``
"""
if not label:
return 'A'
label = label.strip()
# Trailing digits — "Rev 1" → "Rev 2", "A1" → "A2".
# Preserve zero-padding when the original was padded ("014" → "015").
m = re.match(r'^(.*?)(\d+)$', label)
if m:
prefix, digits = m.group(1), m.group(2)
bumped = int(digits) + 1
if digits.startswith('0') and len(str(bumped)) <= len(digits):
return f"{prefix}{str(bumped).zfill(len(digits))}"
return f"{prefix}{bumped}"
# Single letter
if len(label) == 1 and label.isalpha():
if label.upper() == 'Z':
return 'AA' if label.isupper() else 'aa'
return chr(ord(label) + 1)
# Multi-char ending in letter — "AB" → "AC"
m = re.match(r'^(.*?)([A-Za-z])$', label)
if m and m.group(2).upper() != 'Z':
return m.group(1) + chr(ord(m.group(2)) + 1)
# Unknown format — caller must edit
return label + '*'
class FpPartCatalog(models.Model):
"""Customer part library.
@@ -36,8 +81,12 @@ class FpPartCatalog(models.Model):
tracking=True, domain="[('customer_rank', '>', 0)]",
)
part_number = fields.Char(string='Part Number', required=True, tracking=True, help="Customer's part number (e.g. VS-R392007E01).")
revision = fields.Char(string='Revision', required=True, default='A', help='Revision letter or number (e.g. Rev: 1B).')
revision_number = fields.Integer(string='Rev #', default=1)
revision = fields.Char(
string='Revision', required=True, default='A',
help="Customer's drawing revision label. Free-text — accepts any "
"format the customer uses (A, B, C / A1, B2 / Rev 1, Rev 2 / "
"ECO-2024-014 etc.).",
)
revision_note = fields.Char(string='Revision Note', help='What changed in this revision.')
revision_date = fields.Datetime(string='Revision Date', default=fields.Datetime.now)
parent_part_id = fields.Many2one(
@@ -643,21 +692,46 @@ class FpPartCatalog(models.Model):
'target': 'current',
}
def action_create_revision(self):
"""Create a new revision of this part. Copies all data, increments revision number."""
def action_open_revision_wizard(self):
"""Open the interactive Create-New-Revision wizard.
This is what the form-header button calls. The wizard asks
the user for the revision label, note, and optionally a new
drawing/3D file BEFORE the new record is created — which is
what most users want.
For non-interactive callers (auto-rev on 3D upload, direct
order line bump) use ``action_create_revision`` directly.
"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Create New Revision'),
'res_model': 'fp.part.revision.bump.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_part_id': self.id,
'active_id': self.id,
'active_model': 'fp.part.catalog',
},
}
def action_create_revision(self):
"""Programmatic, non-interactive revision bump.
Copies the part with a best-effort label bump via
``_bump_revision_label``. Used by code paths that don't have
a user prompt (auto-rev when a new 3D model is uploaded on a
quote, direct-order line bump). User-facing flows should call
``action_open_revision_wizard`` instead.
"""
self.ensure_one()
# Mark current as no longer latest
self.is_latest_revision = False
# Determine the root part for the chain
root = self.parent_part_id or self
# Find highest revision number in chain
all_revs = self.env['fp.part.catalog'].search([
'|', ('id', '=', root.id), ('parent_part_id', '=', root.id),
])
max_rev = max(all_revs.mapped('revision_number') or [0])
new_label = _bump_revision_label(self.revision)
new_rev = self.copy({
'revision_number': max_rev + 1,
'revision': f'Rev {max_rev + 1}',
'revision': new_label,
'revision_date': fields.Datetime.now(),
'revision_note': False,
'parent_part_id': root.id,

View File

@@ -697,13 +697,10 @@ class FpQuoteConfigurator(models.Model):
old_part = self.part_catalog_id
old_part.is_latest_revision = False
root = old_part.parent_part_id or old_part
all_revs = self.env['fp.part.catalog'].search([
'|', ('id', '=', root.id), ('parent_part_id', '=', root.id),
])
max_rev = max(all_revs.mapped('revision_number') or [0])
from .fp_part_catalog import _bump_revision_label
new_label = _bump_revision_label(old_part.revision)
new_part = old_part.copy({
'revision_number': max_rev + 1,
'revision': f'Rev {max_rev + 1}',
'revision': new_label,
'revision_date': fields.Datetime.now(),
'revision_note': f'Updated 3D model: {fname}',
'parent_part_id': root.id,

View File

@@ -46,6 +46,8 @@ access_fp_serial_estimator,fp.serial.estimator,model_fp_serial,fusion_plating_co
access_fp_serial_manager,fp.serial.manager,model_fp_serial,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial_bulk_add_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_revision_bump_estimator,fp.part.revision.bump.estimator,model_fp_part_revision_bump_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part_revision_bump_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
46 access_fp_serial_manager fp.serial.manager model_fp_serial fusion_plating.group_fusion_plating_manager 1 1 1 1
47 access_fp_serial_bulk_add_estimator fp.serial.bulk.add.estimator model_fp_serial_bulk_add_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
48 access_fp_serial_bulk_add_manager fp.serial.bulk.add.manager model_fp_serial_bulk_add_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
49 access_fp_part_revision_bump_estimator fp.part.revision.bump.estimator model_fp_part_revision_bump_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
50 access_fp_part_revision_bump_manager fp.part.revision.bump.manager model_fp_part_revision_bump_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
51 access_fp_coating_thickness_user fp.coating.thickness.user model_fp_coating_thickness base.group_user 1 0 0 0
52 access_fp_coating_thickness_estimator fp.coating.thickness.estimator model_fp_coating_thickness fusion_plating_configurator.group_fp_estimator 1 1 1 0
53 access_fp_coating_thickness_manager fp.coating.thickness.manager model_fp_coating_thickness fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -41,10 +41,22 @@ $fp-composer-muted: var(--fp-composer-muted, $_fp-composer-muted-hex);
.o_fp_part_composer {
padding: 16px;
max-width: 900px;
max-width: 1500px;
margin: 0 auto;
color: $fp-composer-text;
// Variants table — keep the 5 action buttons (Tree / Simple /
// Duplicate / Rename / Delete) on a single row. Without this the
// Delete button wraps even on wide screens because Bootstrap's
// `.table` lets cells shrink to content+wrap.
.o_fp_part_composer_variants {
td:last-child,
th:last-child {
white-space: nowrap;
width: 1%; // shrink-to-fit so buttons stay tight on the right
}
}
&_state {
padding: 32px;
text-align: center;

View File

@@ -32,7 +32,7 @@
<field name="arch" type="xml">
<form string="Part Catalog">
<header>
<button name="action_create_revision"
<button name="action_open_revision_wizard"
string="Create New Revision"
type="object"
class="btn-secondary"
@@ -110,40 +110,26 @@
<div class="oe_title">
<label for="part_number" string="Part Number"/>
<h1><field name="part_number" placeholder="e.g. VS-R392007E01"/></h1>
<field name="name" placeholder="Descriptive part name (e.g. Valve Body Housing)"/>
</div>
<group>
<group>
<group string="Identity">
<field name="name" string="Part Name"
placeholder="Descriptive part name (e.g. Valve Body Housing)"/>
<field name="partner_id"/>
<field name="revision"/>
<field name="revision_number"/>
<field name="material_id"
options="{'no_quick_create': True}"/>
<field name="substrate_material" invisible="1"/>
<field name="geometry_source"/>
<field name="is_latest_revision" invisible="1"/>
<field name="parent_part_id" invisible="not parent_part_id"/>
</group>
<group>
<label for="surface_area"/>
<div class="d-flex align-items-center gap-2">
<field name="surface_area" class="oe_inline"/>
<button name="action_calculate_surface_area" type="object"
string="Calculate from 3D Model"
class="btn-link" icon="fa-calculator"
invisible="not model_attachment_id"/>
</div>
<field name="surface_area_uom"/>
<field name="masking_area_sqin"/>
<field name="effective_area_sqin" readonly="1"/>
<field name="weight"/>
<field name="material_weight_kg" readonly="1"/>
<group string="Manufacturing Defaults">
<field name="material_id"
options="{'no_quick_create': True}"/>
<field name="substrate_material" invisible="1"/>
<field name="x_fc_default_lead_time_days"/>
<field name="certificate_requirement"/>
</group>
</group>
<group string="Quality &amp; Delivery" name="quality_delivery">
<field name="certificate_requirement"/>
</group>
<!-- Quality & Delivery moved into its own notebook tab below
(was a top-level group above the notebook). -->
<!-- Auto-extracted geometry from 3D model -->
<group string="3D Model Analysis"
invisible="not volume_mm3 and not bbox_summary_in and hole_count == 0">
@@ -215,15 +201,48 @@
class="btn-link"/>
</list>
</field>
<separator string="Default Treatments" class="mt-4"/>
<group>
<field name="x_fc_default_coating_config_id"
string="Default Treatment"
options="{'no_create_edit': True}"/>
<field name="x_fc_default_treatment_ids"
string="Default Additional Treatments"
widget="many2many_tags"
options="{'no_create_edit': True}"/>
</group>
<p class="text-muted">
Seeds the treatment fields on new direct-order
lines for this part. Updated whenever "Save as
Default" is ticked while placing an order.
</p>
</page>
<page string="Dimensions &amp; Complexity" name="dimensions">
<group>
<group>
<field name="geometry_source"/>
</group>
<group>
<group string="Surface &amp; Weight">
<label for="surface_area"/>
<div class="d-flex align-items-center gap-2">
<field name="surface_area" class="oe_inline"/>
<button name="action_calculate_surface_area" type="object"
string="Calculate from 3D Model"
class="btn-link" icon="fa-calculator"
invisible="not model_attachment_id"/>
</div>
<field name="surface_area_uom"/>
<field name="masking_area_sqin"/>
<field name="effective_area_sqin" readonly="1"/>
<field name="weight"/>
<field name="material_weight_kg" readonly="1"/>
</group>
<group string="Bounding Box">
<field name="dimensions_length"/>
<field name="dimensions_width"/>
<field name="dimensions_height"/>
</group>
<group>
<group string="Complexity">
<field name="complexity"/>
<field name="masking_zones"/>
<field name="has_blind_holes"/>
@@ -284,8 +303,7 @@
<page string="Revision History" name="revisions"
invisible="not parent_part_id and not revision_ids">
<field name="revision_ids" mode="list">
<list default_order="revision_number desc">
<field name="revision_number" string="Rev #"/>
<list default_order="revision_date desc">
<field name="revision"/>
<field name="revision_note"/>
<field name="revision_date"/>
@@ -295,20 +313,6 @@
</list>
</field>
</page>
<page string="Defaults" name="direct_order_defaults">
<group>
<field name="x_fc_default_coating_config_id"
options="{'no_create_edit': True}"/>
<field name="x_fc_default_treatment_ids"
widget="many2many_tags"
options="{'no_create_edit': True}"/>
</group>
<p class="text-muted">
Seeds the treatment fields on new direct-order
lines. Updated whenever "Save as Default" is
ticked while placing an order.
</p>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Additional notes about this part..."/>
</page>

View File

@@ -43,7 +43,6 @@
<field name="part_number"/>
<field name="name"/>
<field name="revision"/>
<field name="revision_number" string="Rev #"/>
<field name="substrate_material"/>
<field name="surface_area"/>
<field name="surface_area_uom" string="UoM"/>

View File

@@ -8,4 +8,5 @@ from . import fp_add_from_so_wizard
from . import fp_add_from_quote_wizard
from . import fp_quote_promote_wizard
from . import fp_part_catalog_import_wizard
from . import fp_part_revision_bump_wizard
from . import fp_serial_bulk_add_wizard

View File

@@ -55,7 +55,10 @@ class FpDirectOrderLine(models.Model):
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Primary Treatment',
required=True,
help='Optional. Some orders are non-coating work (re-inspection, '
'rework, masking-only, etc.) and the operator picks the '
'workflow downstream — leaving this blank lets that path '
'through.',
)
treatment_ids = fields.Many2many(
'fp.treatment',
@@ -665,7 +668,7 @@ class FpDirectOrderLine(models.Model):
new_rev = self.env['fp.part.catalog'].search([
('parent_part_id', '=', (part.parent_part_id or part).id),
('is_latest_revision', '=', True),
], limit=1, order='revision_number desc')
], limit=1, order='revision_date desc')
if not new_rev:
return part

View File

@@ -189,21 +189,23 @@ class FpDirectOrderWizard(models.Model):
rec.total_qty = sum(rec.line_ids.mapped('quantity'))
rec.total_line_count = len(rec.line_ids)
@api.depends('line_ids.part_catalog_id', 'line_ids.coating_config_id',
@api.depends('line_ids.part_catalog_id',
'line_ids.unit_price', 'line_ids.quantity')
def _compute_missing_info_msg(self):
for rec in self:
has_missing = False
for line in rec.line_ids:
# coating_config_id intentionally NOT in the gate —
# it's optional now (rework / inspection-only / masking
# work doesn't need a primary treatment).
if (not line.part_catalog_id
or not line.coating_config_id
or not line.unit_price
or not line.quantity):
has_missing = True
break
rec.missing_info_msg = (
'Some lines are missing quote information '
'(part / treatment / price / qty). '
'(part / price / qty). '
'Verify before confirming the order.'
if has_missing else False
)
@@ -272,7 +274,10 @@ class FpDirectOrderWizard(models.Model):
# Account-hold early warning. Hard block lives in action_confirm
# but Sarah deserves to know NOW before she builds 5 lines.
if getattr(self.partner_id, 'x_fc_account_hold', False):
# Resolve via commercial_partner so a hold on the company is
# caught even when an Acme-AP child contact is selected.
commercial = self.partner_id.commercial_partner_id
if getattr(commercial, 'x_fc_account_hold', False):
return {
'warning': {
'title': _('Customer on Account Hold'),
@@ -280,7 +285,7 @@ class FpDirectOrderWizard(models.Model):
'%s is currently on account hold. You can still '
'build the quotation, but it cannot be confirmed '
'until the hold is cleared by accounting.'
) % self.partner_id.display_name,
) % commercial.display_name,
}
}
@@ -438,14 +443,24 @@ class FpDirectOrderWizard(models.Model):
# Account-hold hard block — same policy as sale.order.action_confirm
# but enforced earlier so the wizard doesn't waste Sarah's time.
# Manager override allowed via context key fp_skip_account_hold=True.
if (getattr(self.partner_id, 'x_fc_account_hold', False)
# Resolved through commercial_partner so a hold on the company
# blocks every child-contact entry too.
commercial = self.partner_id.commercial_partner_id
# Bypass: Plating Manager OR Plating Administrator. Both checked
# because Odoo's implied_ids cascade (Administrator → Manager)
# doesn't always propagate to existing users on upgrade. See
# CLAUDE.md "Implied group cascade" rule.
can_override = (
self.env.user.has_group('fusion_plating.group_fusion_plating_manager')
or self.env.user.has_group('fusion_plating.group_fusion_plating_administrator')
)
if (getattr(commercial, 'x_fc_account_hold', False)
and not self.env.context.get('fp_skip_account_hold')
and not self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager')):
and not can_override):
raise UserError(_(
'Customer %s is on account hold. Have a manager clear the '
'hold (or override) before creating the order.'
) % self.partner_id.display_name)
) % commercial.display_name)
# Accept EITHER a PO (document + number) OR the PO Pending
# flag. Customers who haven't sent paperwork yet use Pending;
@@ -535,10 +550,14 @@ class FpDirectOrderWizard(models.Model):
for line in self.line_ids:
part = line._get_or_bump_revision()
resolved_parts[line.id] = part
# Build the line header. Primary treatment is optional now;
# when missing, drop it from the header rather than printing
# "False - PartName Rev A".
treatment_label = line.coating_config_id.name or _('No coating')
header = '%s - %s Rev %s (x%d)' % (
line.coating_config_id.name,
treatment_label,
part.name,
part.revision or part.revision_number,
part.revision,
line.quantity,
)
extended = (line.line_description or '').strip()

View File

@@ -154,7 +154,8 @@
optional="hide"/>
<field name="internal_description"
optional="hide"/>
<field name="coating_config_id"/>
<field name="coating_config_id"
optional="show"/>
<field name="process_variant_id"
string="Process / Recipe"
options="{'no_quick_create': True}"
@@ -196,10 +197,12 @@
<field name="treatment_ids"
widget="many2many_tags"
optional="hide"/>
<field name="quantity"/>
<field name="quantity"
optional="show"/>
<field name="unit_price"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
options="{'currency_field': 'currency_id'}"
optional="show"/>
<field name="tax_ids"
widget="many2many_tags"
options="{'no_create': True}"

View File

@@ -22,7 +22,6 @@ CSV_COLUMNS = [
'name', # required
'customer', # required unless wizard.partner_id set
'revision',
'revision_number',
'substrate_material',
'surface_area',
'surface_area_uom',
@@ -266,7 +265,6 @@ class FpPartCatalogImportWizard(models.TransientModel):
'part_number': part_number,
'name': name,
'revision': (row.get('revision') or '').strip() or False,
'revision_number': _to_int(row.get('revision_number'), 1),
'substrate_material': substrate,
'surface_area': _to_float(row.get('surface_area')),
'surface_area_uom': uom,

View File

@@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from ..models.fp_part_catalog import _bump_revision_label
class FpPartRevisionBumpWizard(models.TransientModel):
"""Interactive wizard for creating a new revision of a part.
Replaces the old "click Create New Revision and immediately get a
half-blank form" UX. Now the user supplies the revision label,
revision note, and optionally a new drawing file BEFORE the
revision is created. The wizard pre-fills a best-effort label
via ``_bump_revision_label`` so the common case (A → B, A1 → A2,
Rev 1 → Rev 2, ECO-2024-014 → ECO-2024-015) is one click.
"""
_name = 'fp.part.revision.bump.wizard'
_description = 'Create Part Revision Wizard'
part_id = fields.Many2one(
'fp.part.catalog', string='Source Part', required=True,
readonly=True, ondelete='cascade',
)
current_revision = fields.Char(
string='Current Revision', related='part_id.revision', readonly=True,
)
new_revision = fields.Char(
string='New Revision', required=True,
help="The revision label for the new copy. Pre-filled with a "
"best-effort guess; edit to match the customer's actual "
"revision scheme (A/B/C, A1/A2, Rev 2, ECO-2024-015, etc.).",
)
revision_note = fields.Char(
string='Revision Note',
help='What changed in this revision? (e.g. "Updated tolerance on '
'feature B per ECN-2024-014".)',
)
revision_date = fields.Datetime(
string='Revision Date', default=fields.Datetime.now, required=True,
)
new_drawing_file = fields.Binary(
string='New Drawing (PDF, optional)',
help='Drop a PDF drawing here. It will be added to the new '
'revision\'s drawing list. Leave empty to inherit the '
'existing drawings.',
)
new_drawing_filename = fields.Char(string='Drawing Filename')
new_model_file = fields.Binary(
string='New 3D Model (STEP/STL/IGES, optional)',
help='Drop a STEP, STP, STL, IGES, IGS, BREP, or BRP file here. '
'Replaces the 3D model on the new revision. Leave empty to '
'inherit the existing 3D model.',
)
new_model_filename = fields.Char(string='Model Filename')
# ------------------------------------------------------------------
# Defaults
# ------------------------------------------------------------------
@api.model
def default_get(self, fields_list):
vals = super().default_get(fields_list)
# Resolve part_id from context (button passes active_id).
part_id = vals.get('part_id') or self.env.context.get('default_part_id') \
or self.env.context.get('active_id')
if part_id and self.env.context.get('active_model') in (
'fp.part.catalog', None,
):
part = self.env['fp.part.catalog'].browse(part_id)
if part.exists():
vals['part_id'] = part.id
if 'new_revision' in fields_list and not vals.get('new_revision'):
vals['new_revision'] = _bump_revision_label(part.revision or '')
return vals
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
@api.constrains('new_revision', 'part_id')
def _check_new_revision_unique(self):
for wiz in self:
label = (wiz.new_revision or '').strip()
if not label:
raise ValidationError(_('New revision label cannot be empty.'))
if not wiz.part_id:
continue
if label == (wiz.part_id.revision or '').strip():
raise ValidationError(_(
'New revision label must differ from the current '
'revision (%s).'
) % wiz.part_id.revision)
# Uniqueness within the part chain (same root + same label).
root = wiz.part_id.parent_part_id or wiz.part_id
sibling = self.env['fp.part.catalog'].search([
'|',
('id', '=', root.id),
('parent_part_id', '=', root.id),
('revision', '=', label),
], limit=1)
if sibling:
raise ValidationError(_(
'A revision "%(rev)s" already exists for this part '
'(part %(pn)s). Pick a different label.'
) % {'rev': label, 'pn': wiz.part_id.part_number or ''})
# ------------------------------------------------------------------
# Action
# ------------------------------------------------------------------
def action_create_revision(self):
"""Create the new revision and navigate to it."""
self.ensure_one()
part = self.part_id
if not part:
raise UserError(_('No source part selected.'))
new_label = (self.new_revision or '').strip()
part.is_latest_revision = False
root = part.parent_part_id or part
new_part = part.copy({
'revision': new_label,
'revision_date': self.revision_date or fields.Datetime.now(),
'revision_note': self.revision_note or False,
'parent_part_id': root.id,
'is_latest_revision': True,
'model_attachment_id': part.model_attachment_id.id,
})
# Optional new PDF drawing — appended to the drawing list.
if self.new_drawing_file:
drawing_att = self.env['ir.attachment'].create({
'name': self.new_drawing_filename or 'drawing.pdf',
'datas': self.new_drawing_file,
'res_model': 'fp.part.catalog',
'res_id': new_part.id,
})
new_part.drawing_attachment_ids = [(4, drawing_att.id)]
# Optional new 3D model — replaces the model attachment.
if self.new_model_file:
model_att = self.env['ir.attachment'].create({
'name': self.new_model_filename or 'model.step',
'datas': self.new_model_file,
'res_model': 'fp.part.catalog',
'res_id': new_part.id,
})
new_part.model_attachment_id = model_att.id
new_part.message_post(body=_(
'Revision %(new)s created from %(old)s. %(note)s'
) % {
'new': new_label,
'old': part.revision or '',
'note': self.revision_note or '',
})
return {
'type': 'ir.actions.act_window',
'name': _('Part Revision'),
'res_model': 'fp.part.catalog',
'res_id': new_part.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<record id="view_fp_part_revision_bump_wizard_form" model="ir.ui.view">
<field name="name">fp.part.revision.bump.wizard.form</field>
<field name="model">fp.part.revision.bump.wizard</field>
<field name="arch" type="xml">
<form string="Create New Revision">
<sheet>
<div class="oe_title">
<h2>Create New Revision</h2>
<p class="text-muted">
Bump the revision label for
<strong><field name="part_id" readonly="1" nolabel="1" options="{'no_open': True}"/></strong>.
The pre-filled label is a best-effort guess —
adjust it to match the customer's actual scheme.
</p>
</div>
<group>
<group string="Revision">
<field name="current_revision"/>
<field name="new_revision" placeholder="e.g. B, A2, Rev 2, ECO-2024-015"/>
<field name="revision_date"/>
</group>
<group string="Details">
<field name="revision_note"
placeholder="What changed? (e.g. tolerance update on feature B)"/>
</group>
</group>
<group string="Updated Files (optional)">
<group string="2D Drawing (PDF)">
<field name="new_drawing_filename" invisible="1"/>
<field name="new_drawing_file"
filename="new_drawing_filename"
widget="binary"
nolabel="1"/>
<div class="text-muted small">
Added to the new revision's drawing list.
Leave empty to inherit the current drawings.
</div>
</group>
<group string="3D Model (STEP/STL/IGES)">
<field name="new_model_filename" invisible="1"/>
<field name="new_model_file"
filename="new_model_filename"
widget="binary"
nolabel="1"/>
<div class="text-muted small">
Replaces the 3D model on the new revision.
Leave empty to inherit the current model.
</div>
</group>
</group>
</sheet>
<footer>
<button name="action_create_revision"
string="Create Revision"
type="object"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fp_part_revision_bump_wizard" model="ir.actions.act_window">
<field name="name">Create New Revision</field>
<field name="res_model">fp.part.revision.bump.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Invoicing',
'version': '19.0.3.3.0',
'version': '19.0.3.5.0',
'category': 'Manufacturing/Plating',
'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.',
'description': """

View File

@@ -3,13 +3,22 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, models, _
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class AccountMove(models.Model):
_inherit = 'account.move'
# Mirrors the SO-side related field. See sale_order.py for the
# rationale (dotted refs in view modifiers are fragile + hold lives
# on the commercial partner).
x_fc_partner_account_hold = fields.Boolean(
string='Customer on Account Hold',
related='partner_id.commercial_partner_id.x_fc_account_hold',
store=True, readonly=True,
)
@api.model_create_multi
def create(self, vals_list):
"""Auto-inherit payment terms + customer PO# at creation time.
@@ -55,17 +64,16 @@ class AccountMove(models.Model):
"""
for move in self:
if move.move_type in ('out_invoice', 'out_refund') and move.partner_id:
if move.partner_id.x_fc_account_hold:
is_manager = self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'
)
hold_partner = move.partner_id.commercial_partner_id
if hold_partner.x_fc_account_hold:
is_manager = self.env['res.partner']._fp_user_can_override_account_hold()
if not is_manager:
raise UserError(_(
'Cannot post invoice — customer "%s" is on account hold.\n'
'Reason: %s\n\n'
'Contact a manager to override.'
) % (move.partner_id.name,
move.partner_id.x_fc_account_hold_reason or 'No reason specified'))
) % (hold_partner.name,
hold_partner.x_fc_account_hold_reason or 'No reason specified'))
if not move.invoice_payment_term_id:
raise UserError(_(
'Cannot post invoice "%s" — no payment terms set.\n\n'

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
from odoo import api, fields, models
class ResPartner(models.Model):
@@ -14,6 +14,25 @@ class ResPartner(models.Model):
string='Account Hold', tracking=True,
help='When active, blocks SO confirmation, invoicing, and shipping.',
)
@api.model
def _fp_user_can_override_account_hold(self):
"""True when the current user is allowed to override an account hold.
Plating Manager OR Plating Administrator qualifies. Administrator
is checked explicitly (in addition to the implied chain) because
Odoo's ``implied_ids`` cascade does NOT reliably propagate to
existing users on module upgrade — admin (uid 1) typically lands
in Administrator only, with no Manager membership. Without this
defensive check, the highest-privileged user can't bypass holds.
See CLAUDE.md "Implied group cascade" rule.
"""
user = self.env.user
return (
user.has_group('fusion_plating.group_fusion_plating_manager')
or user.has_group('fusion_plating.group_fusion_plating_administrator')
)
x_fc_account_hold_reason = fields.Text(string='Hold Reason')
x_fc_account_hold_date = fields.Datetime(
string='Hold Date', help='When the hold was placed.',

View File

@@ -15,6 +15,18 @@ _logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
# Explicit related field — dotted refs like `partner_id.x_fc_account_hold`
# in `invisible=` modifiers are fragile in Odoo 19 (the related field
# has to be in the record cache for the evaluator). Surfacing it as a
# plain field on sale.order makes the banner condition deterministic.
# We resolve through `commercial_partner_id` so a hold placed on the
# company also blocks SOs entered against any of its child contacts.
x_fc_partner_account_hold = fields.Boolean(
string='Customer on Account Hold',
related='partner_id.commercial_partner_id.x_fc_account_hold',
store=True, readonly=True,
)
@api.onchange('partner_id')
def _onchange_partner_id_invoice_strategy(self):
"""Auto-fill plating defaults from customer profile.
@@ -119,24 +131,27 @@ class SaleOrder(models.Model):
) % {'so': order.name})
# --- Account hold check ---
if order.partner_id.x_fc_account_hold:
is_manager = self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'
)
# Hold lives on the commercial_partner (the company). Resolve
# through that so a hold on the parent applies to every child
# contact too — typical case is "all of Acme is on hold", not
# "specifically the AP clerk's contact card".
hold_partner = order.partner_id.commercial_partner_id
if hold_partner.x_fc_account_hold:
is_manager = self.env['res.partner']._fp_user_can_override_account_hold()
if not is_manager:
raise UserError(_(
'Cannot confirm — customer "%s" is on account hold.\n'
'Reason: %s\n\n'
'Contact a manager to override.'
) % (order.partner_id.name,
order.partner_id.x_fc_account_hold_reason or 'No reason specified'))
) % (hold_partner.name,
hold_partner.x_fc_account_hold_reason or 'No reason specified'))
else:
order.message_post(
body=_(
'Warning: Customer "%s" is on account hold (reason: %s). '
'Order confirmed by manager override.'
) % (order.partner_id.name,
order.partner_id.x_fc_account_hold_reason or 'N/A'),
) % (hold_partner.name,
hold_partner.x_fc_account_hold_reason or 'N/A'),
)
res = super().action_confirm()

View File

@@ -13,10 +13,11 @@
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//form/header" position="before">
<field name="x_fc_partner_account_hold" invisible="1"/>
<div class="alert alert-danger py-1 px-2 mb-0 small"
role="alert"
invisible="not partner_id or not partner_id.x_fc_account_hold">
<i class="fa fa-ban me-1"/>
invisible="not x_fc_partner_account_hold">
<i class="fa fa-ban me-1" title="Account hold"/>
<strong>Account Hold</strong> — SO confirmation, invoicing
and shipping are blocked for non-managers.
</div>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.18.4',
'version': '19.0.8.20.6',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
@@ -20,10 +20,10 @@ Bridges fp.job and fp.job.step (defined in fusion_plating core, Phase 1 of
the migration spec dated 2026-04-25) to the rest of the Fusion Plating
module family — configurator, portal, logistics, quality, certificates.
Coexists with fusion_plating_bridge_mrp during the migration period.
Activate native jobs via the x_fc_use_native_jobs settings flag (default:
False). When False, SO confirm continues to create mrp.production records
through bridge_mrp. When True, SO confirm creates fp.job records here.
As of Sub 11 (2026-04-26), MRP is uninstalled and fp.job is the only
fulfilment path. SO confirm always creates fp.job records here. The
former x_fc_use_native_jobs migration toggle was removed in 19.0.8.19.0
once the legacy fallback became unreachable.
19.0.4.0.0 (2026-04-24): Operator UI consolidation. The parallel
OWL/controller stack (job_process_tree, job_plant_overview,
@@ -57,7 +57,6 @@ full design rationale and §6.2 of the implementation plan for task list.
# so the statusbar's m2o has its targets available at view-render time).
'data/fp_workflow_state_data.xml',
'views/fp_workflow_state_views.xml',
'views/res_config_settings_views.xml',
'views/fp_job_step_quick_look_views.xml',
'views/fp_job_form_inherit.xml',
'views/fp_job_quality_buttons.xml',

View File

@@ -57,6 +57,37 @@ class FpRecordInputsController(http.Controller):
'is_authored': True,
})
# Operator initials — used by the JS dialog to pre-fill
# signature / "Reviewer Initials" prompts. The user can edit
# the value in the dialog and the new value is persisted back
# via /fp/record_inputs/commit so future jobs and other steps
# automatically pick it up.
user = request.env.user
try:
user_initials = user.fp_get_initials()
except AttributeError:
user_initials = ''
# Instruction images — the recipe author's reference photos /
# screenshots that show the operator HOW to do this step
# (masking patterns, fixture orientation, annotated diagrams).
# Returned as URL pointers so the dialog renders thumbnails
# without bloating the load payload with base64.
instruction_images = []
if node and 'instruction_attachment_ids' in node._fields:
for att in node.instruction_attachment_ids:
instruction_images.append({
'id': att.id,
'name': att.name or '',
'mimetype': att.mimetype or '',
'url': '/web/image/%s' % att.id,
})
# Operator instructions text — shown above the prompts so the
# author's written guidance is visible at runtime.
instructions_html = ''
if node and node.description:
instructions_html = node.description
return {
'ok': True,
'step': {
@@ -68,13 +99,16 @@ class FpRecordInputsController(http.Controller):
'name': step.job_id.name,
},
'prompts': prompts,
'user_initials': user_initials or '',
'instructions_html': instructions_html or '',
'instruction_images': instruction_images,
}
# ------------------------------------------------------------------
# Commit — write values via the existing wizard (reuse semantics)
# ------------------------------------------------------------------
@http.route('/fp/record_inputs/commit', type='jsonrpc', auth='user')
def commit(self, step_id, values, advance_after=False):
def commit(self, step_id, values, advance_after=False, user_initials=None):
"""Commit operator-entered values for this step.
Args:
@@ -148,6 +182,17 @@ class FpRecordInputsController(http.Controller):
if advance_after:
ctx['fp_advance_after_save'] = True
result = wizard.with_context(**ctx).action_commit()
# Persist a changed initials value on the user record so
# the next dialog (any step, any job) auto-fills the new
# value. Only writes when the operator explicitly typed a
# different value than what they had stored.
if user_initials is not None:
cleaned = (user_initials or '').strip()
stored = (request.env.user.x_fc_initials or '').strip()
if cleaned and cleaned != stored:
request.env.user.sudo().write({
'x_fc_initials': cleaned,
})
return {
'ok': True,
'next_action': result if isinstance(result, dict) else False,

View File

@@ -11,9 +11,9 @@ from . import fp_job_step
from . import fp_job_node_override
from . import fp_portal_job
from . import account_move
from . import res_config_settings
from . import sale_order
from . import sale_order_line
from . import res_users
# Phase 3 — parallel job/step links on dependent modules' models.
from . import fp_batch

View File

@@ -367,6 +367,16 @@ class FpJobStep(models.Model):
if cr_action:
return cr_action
# Racking step routing — same idea as Contract Review. If the
# operator clicks Finish on a Racking step but the linked
# racking inspection isn't done yet, route them straight to
# the inspection form instead of throwing a "find the smart
# button" error message. They complete the line check-off,
# mark Done, and re-click Finish & Next to advance.
ri_action = self._fp_racking_inspection_redirect()
if ri_action:
return ri_action
# Prompt-first behaviour: show the Record Inputs dialog when the
# recipe step has authored prompts and nothing has been captured
# in this run. Bypass when context flag is set (i.e. we're being
@@ -631,15 +641,34 @@ class FpJobStep(models.Model):
def _fp_open_contract_review(self):
"""Auto-create the QA-005 form for this step's part if missing,
return the act_window pointing at it. Called from button_start
on Contract Review steps."""
on Contract Review steps.
Returns None when the review is already satisfied (state
'complete' or 'dismissed') — letting button_start fall through
to the standard path so the step starts directly, without an
unnecessary detour through an already-signed form. This mirrors
the Finish & Next redirect behaviour: once contract review is
cleared for a part, neither Start nor Finish stops to ask
about it again.
Also short-circuits when the customer doesn't require contract
review and via the manager-bypass context flag, to keep entry
and finish gates in lockstep.
"""
self.ensure_one()
if self.env.context.get('fp_skip_contract_review_gate'):
return None
part = self._fp_resolve_contract_review_part()
if not part:
return None
if not part.partner_id.x_fc_contract_review_required:
return None
Review = self.env.get('fp.contract.review')
if Review is None:
return None # quality module not installed — skip
review = part.x_fc_contract_review_id
if review and review.state in ('complete', 'dismissed'):
return None # already satisfied — fall through to normal start
if not review:
review = Review.sudo().create({
'part_id': part.id,
@@ -767,6 +796,46 @@ class FpJobStep(models.Model):
'name': _('Racking Inspection — %s') % self.job_id.name,
}
def _fp_racking_inspection_redirect(self):
"""Return an act_window opening the linked racking inspection
form, or False to indicate "no redirect needed".
Mirrors ``_fp_contract_review_redirect``. Triggers when:
* this step is a Racking step (matched by ``_fp_is_racking_step``)
* the linked ``fp.racking.inspection`` exists and is NOT yet in
a terminal state (``done`` / ``discrepancy_flagged``)
When the inspection is already terminal — or doesn't exist at
all — returns False so action_finish_and_advance falls through
to the normal finish path. The hard gate
(``_fp_check_racking_inspection_complete``) still fires from
``button_finish`` for any caller that bypasses the redirect.
Manager bypass via ``fp_skip_racking_inspection_gate=True``.
"""
self.ensure_one()
if self.env.context.get('fp_skip_racking_inspection_gate'):
return False
if not self._fp_is_racking_step():
return False
if 'fp.racking.inspection' not in self.env:
return False
ri = self.job_id.racking_inspection_id
if not ri:
# No inspection record at all — let the soft gate handle
# this with a chatter warning, don't redirect.
return False
if ri.state in ('done', 'discrepancy_flagged'):
return False
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.racking.inspection',
'res_id': ri.id,
'view_mode': 'form',
'target': 'current',
'name': _('Racking Inspection — %s') % self.job_id.name,
}
def _fp_check_racking_inspection_complete(self):
"""Soft gate — block button_finish on a Racking step until the
linked inspection is in a terminal state. discrepancy_flagged
@@ -939,32 +1008,51 @@ class FpJobStep(models.Model):
"""Return an ir.actions.act_window opening the part's QA-005
Contract Review form, or False to indicate "no redirect needed".
Triggers when:
* the recipe node is flagged default_kind='contract_review', AND
* the linked part has no review yet OR the review is still in
a non-terminal state (draft / assistant_review / manager_review).
Triggers when ALL of these are true:
* the step is a Contract Review step (matched via
``_fp_is_contract_review_step`` — name OR template kind OR
node kind, same as the finish-time gate),
* the customer requires contract review
(``partner.x_fc_contract_review_required = True``), AND
* the linked part either has no review yet OR the review is
still in a non-terminal state (draft / assistant_review /
manager_review).
Once the review reaches state 'complete' or 'dismissed' the step
is allowed to finish through the normal path, which is how the
operator clears the contract-review gate after signing QA-005.
Once the review reaches state 'complete' or 'dismissed' the
step is allowed to finish through the normal path. This is how
Finish & Next moves on to the next step automatically once the
contract review is already satisfied for that part — including
when the review was completed on a previous order.
Soft-fail: if the job has no part_catalog_id we cannot route to
a per-part review, so we fall through to the standard wizard
rather than blocking the operator.
Resolution mirrors ``_fp_check_contract_review_complete`` so a
single source of truth governs both ENTRY (this redirect) and
FINISH (the gate) — they always agree on whether a step is a
contract review and which part it's bound to.
Soft-fail: if no part can be resolved we fall through to the
standard wizard rather than blocking the operator.
"""
self.ensure_one()
node = self.recipe_node_id
if not node or node.default_kind != 'contract_review':
# Manager bypass — same context flag the gate honours.
if self.env.context.get('fp_skip_contract_review_gate'):
return False
part = self.job_id.part_catalog_id
if not self._fp_is_contract_review_step():
return False
part = self._fp_resolve_contract_review_part() \
or self.job_id.part_catalog_id
if not part:
_logger.warning(
"Contract-review step '%s' on job %s has no part_catalog_id "
"cannot redirect to QA-005 form, falling through to "
"Contract-review step '%s' on job %s has no part "
"cannot redirect to QA-005 form, falling through to "
"standard wizard.",
self.name, self.job_id.name,
)
return False
# Customer flag check — when the customer doesn't require
# contract review, the redirect doesn't fire and the step
# finishes through the normal path. Matches the gate's policy.
if not part.partner_id.x_fc_contract_review_required:
return False
review = part.x_fc_contract_review_id
if review and review.state in ('complete', 'dismissed'):
return False
@@ -1022,6 +1110,28 @@ class FpJobStep(models.Model):
related='recipe_node_id.collect_measurements',
readonly=True,
)
# Job context related fields — used by the quick-look modal so the
# operator can see which job / customer / part / qty this step
# belongs to without opening the parent job form. Related (not
# stored) so they always reflect the live job record.
quick_look_partner_id = fields.Many2one(
'res.partner', string='Customer',
related='job_id.partner_id', readonly=True,
)
quick_look_part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
related='job_id.part_catalog_id', readonly=True,
)
quick_look_qty = fields.Float(
string='Order Qty',
related='job_id.qty', readonly=True,
)
quick_look_instruction_attachment_ids = fields.Many2many(
'ir.attachment',
string='Instruction Images',
related='recipe_node_id.instruction_attachment_ids',
readonly=True,
)
quick_look_prompt_ids = fields.Many2many(
'fusion.plating.process.node.input',
string='Prompts',

View File

@@ -1,23 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# x_fc_use_native_jobs — company-level setting that controls whether
# SO confirmation creates a native fp.job record (this module) or
# the legacy mrp.production / mrp.workorder records (bridge_mrp).
#
# Default: False (legacy MO flow). Phase 9 cutover flips this to True
# on entech.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
x_fc_use_native_jobs = fields.Boolean(
string='Use Native Plating Jobs',
config_parameter='fusion_plating_jobs.use_native_jobs',
help='When enabled, SO confirmation creates fp.job records '
'instead of mrp.production. Phase-2 migration toggle.',
)

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class ResUsers(models.Model):
_inherit = 'res.users'
x_fc_initials = fields.Char(
string='Plating Initials',
help='Operator / inspector initials used to pre-fill signature '
'and "Reviewer Initials" style prompts in the Record Inputs '
'dialog. Editable in the dialog itself — when the user types '
'a different value and saves, it persists here for every '
'future job and step.',
)
@api.model
def _fp_default_initials(self):
"""Best-effort initials derived from the user's display name.
Used as a fallback when ``x_fc_initials`` is empty so the
operator still gets a sensible pre-fill on their first run.
E.g. "John Doe" -> "JD", "Mary Anne Smith" -> "MAS".
"""
name = (self.name or '').strip()
if not name:
return ''
return ''.join(
piece[0] for piece in name.split() if piece
).upper()[:6]
def fp_get_initials(self):
"""Resolve the user's initials for the dialog: stored override
first, fall back to the auto-derived value from their name."""
self.ensure_one()
return self.x_fc_initials or self._fp_default_initials()

View File

@@ -2,12 +2,10 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# sale.order.action_confirm hook — creates fp.job records when the
# x_fc_use_native_jobs setting is True. Mirrors bridge_mrp's
# _fp_auto_create_mo but creates fp.job instead of mrp.production.
#
# When the setting is False (default), this hook is a no-op and
# bridge_mrp's MO-creation hook handles the flow.
# sale.order.action_confirm hook — creates fp.job records on confirm.
# Sub 11 (2026-04-26) removed MRP entirely; fp.job is the only fulfilment
# path. The former x_fc_use_native_jobs migration toggle was dropped in
# 19.0.8.19.0 once the legacy bridge_mrp fallback became unreachable.
import logging
@@ -82,18 +80,7 @@ class SaleOrder(models.Model):
)
def _compute_workflow_stage(self):
"""Native-jobs override — walks fp.job state instead of mrp.production.
When `use_native_jobs` is on, the SO is fulfilled by `fp.job`
records, not MRP MOs. The bridge_mrp compute reads `mrp.production`
and would falsely stall the banner. We branch at the top: native
mode → fp.job walker; legacy mode → super() (bridge_mrp).
"""
ICP = self.env['ir.config_parameter'].sudo()
native = ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True'
if not native:
return super()._compute_workflow_stage()
"""Walk fp.job state to derive the SO workflow banner."""
Job = self.env['fp.job']
Delivery = self.env.get('fusion.plating.delivery')
for so in self:
@@ -201,27 +188,24 @@ class SaleOrder(models.Model):
def action_confirm(self):
result = super().action_confirm()
# Only run when the native flag is on
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True':
for so in self:
so._fp_auto_create_job()
# Auto-confirm any draft jobs we just created so steps
# generate immediately (no manager click required).
# Best-effort: an exception in side-effects shouldn't
# block the SO confirm itself.
draft_jobs = self.env['fp.job'].sudo().search([
('sale_order_id', '=', so.id),
('state', '=', 'draft'),
])
for job in draft_jobs:
try:
job.action_confirm()
except Exception as exc:
so.message_post(body=_(
'Auto-confirm of fp.job %(job)s failed: %(err)s. '
'Confirm manually from the job form.'
) % {'job': job.name, 'err': exc})
for so in self:
so._fp_auto_create_job()
# Auto-confirm any draft jobs we just created so steps
# generate immediately (no manager click required).
# Best-effort: an exception in side-effects shouldn't
# block the SO confirm itself.
draft_jobs = self.env['fp.job'].sudo().search([
('sale_order_id', '=', so.id),
('state', '=', 'draft'),
])
for job in draft_jobs:
try:
job.action_confirm()
except Exception as exc:
so.message_post(body=_(
'Auto-confirm of fp.job %(job)s failed: %(err)s. '
'Confirm manually from the job form.'
) % {'job': job.name, 'err': exc})
return result
def _fp_auto_create_job(self):

View File

@@ -29,7 +29,31 @@ import { _t } from "@web/core/l10n/translation";
const NUMERIC_TYPES = new Set([
"number", "temperature", "thickness", "time_seconds", "ph",
]);
const BOOLEAN_TYPES = new Set(["boolean", "pass_fail"]);
// Generic boolean only — pass_fail gets its own dedicated PASS/FAIL widget
// because a bare Yes/No toggle gives the operator no context about which
// state is the good outcome.
const BOOLEAN_TYPES = new Set(["boolean"]);
// Human-friendly labels for the type pill in the card header. Without
// this map the pill shows the raw key (e.g. "pass_fail") which looks like
// a developer field name. The recipe author shouldn't see code identifiers.
const TYPE_LABELS = {
text: "Text",
number: "Number",
boolean: "Yes / No",
selection: "Selection",
date: "Date / Time",
signature: "Signature",
time_hms: "Time (HH:MM:SS)",
time_seconds: "Time (sec)",
temperature: "Temperature",
thickness: "Thickness",
pass_fail: "Pass / Fail",
photo: "Photo",
multi_point_thickness: "Thickness (5 readings)",
bath_chemistry_panel: "Bath Chemistry",
ph: "pH",
};
export class FpRecordInputsDialog extends Component {
@@ -46,6 +70,18 @@ export class FpRecordInputsDialog extends Component {
stepName: "",
jobName: "",
rows: [],
// Operator's persisted initials — pre-filled into signature
// / "Reviewer Initials" prompts on load. When the operator
// edits and saves a different value, the controller persists
// it back to res.users.x_fc_initials so it sticks for every
// future step / job.
userInitials: "",
// Recipe-author instructions: the description text and the
// attached reference images (photos / screenshots / diagrams).
// Surfaced at the top of the dialog before the prompt cards
// so the operator sees them BEFORE entering values.
instructionsHtml: "",
instructionImages: [],
});
onWillStart(async () => {
await this.loadPrompts();
@@ -67,23 +103,86 @@ export class FpRecordInputsDialog extends Component {
}
this.state.stepName = data.step.name;
this.state.jobName = data.job.name;
this.state.rows = data.prompts.map((p) => ({
...p,
// value fields — initialized blank, populated as operator types
value_text: "",
value_number: 0,
value_boolean: false,
value_date: "",
photo_value: false,
photo_filename: "",
point_1: 0, point_2: 0, point_3: 0,
point_4: 0, point_5: 0,
panel_ph: 0, panel_concentration: 0,
panel_temperature: 0, panel_bath_id: "",
}));
this.state.userInitials = data.user_initials || "";
this.state.instructionsHtml = data.instructions_html || "";
this.state.instructionImages = data.instruction_images || [];
const nowDt = this._fpNowForDatetimeLocal();
this.state.rows = data.prompts.map((p) => {
const row = {
...p,
// value fields — initialized blank, populated as operator types
value_text: "",
value_number: 0,
value_boolean: false,
value_date: "",
photo_value: false,
photo_filename: "",
point_1: 0, point_2: 0, point_3: 0,
point_4: 0, point_5: 0,
panel_ph: 0, panel_concentration: 0,
panel_temperature: 0, panel_bath_id: "",
// Pass/Fail explicit choice tracking — see onPass/onFail.
_passfail_chosen: "",
// Min / max range entry — see hasRangeEntry().
value_min: 0,
value_max: 0,
};
// ---- Sensible per-type defaults ------------------------------
// Date / time → now. The operator can still adjust before save.
if (this.isDate(row)) {
row.value_date = nowDt;
}
// Pass / Fail defaults:
// - Simple pass_fail (no target range) → default PASS so the
// common "everything good" path is one less click.
// - Range-based pass_fail (Bore A 0.0050.007 etc.) → DO NOT
// pre-select. The verdict must reflect the readings the
// operator enters; pre-selecting PASS would silently
// record PASS even when readings are out of spec.
if (this.isPassFail(row) && !this.hasRangeEntry(row)) {
row.value_boolean = true;
row._passfail_chosen = "pass";
}
// Signature / "Reviewer Initials" / "Inspector Initials" /
// similar prompts → pre-fill with the operator's persisted
// initials so they don't retype the same letters on every
// step. Heuristic: input_type=='signature' OR prompt name
// contains 'initial' (case-insensitive).
if (this._fpIsInitialsField(row)) {
row.value_text = this.state.userInitials;
}
return row;
});
this.state.loading = false;
}
// True when this row should be auto-populated from
// ``state.userInitials``. Driven by input_type or a name keyword
// so it works for "Reviewer Initials" (text), "Inspector Signature"
// (signature), "Operator Initials" (text), etc.
_fpIsInitialsField(row) {
if (this.isSignature(row)) return true;
if ((row.input_type || "") === "text") {
const name = (row.name || "").toLowerCase();
return name.includes("initial");
}
return false;
}
// Current local datetime as "YYYY-MM-DDTHH:MM" (the format the
// <input type="datetime-local"> widget accepts in t-model).
_fpNowForDatetimeLocal() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return [
d.getFullYear(),
"-", pad(d.getMonth() + 1),
"-", pad(d.getDate()),
"T", pad(d.getHours()),
":", pad(d.getMinutes()),
].join("");
}
// ---- Type predicates (used by the OWL template t-if) ----------------
isNumeric(row) { return NUMERIC_TYPES.has(row.input_type); }
isBoolean(row) { return BOOLEAN_TYPES.has(row.input_type); }
@@ -92,12 +191,91 @@ export class FpRecordInputsDialog extends Component {
isMulti(row) { return row.input_type === "multi_point_thickness"; }
isPanel(row) { return row.input_type === "bath_chemistry_panel"; }
isSelection(row) { return row.input_type === "selection"; }
// Fallback to text for anything else (text, signature, time_hms, ...)
isPassFail(row) { return row.input_type === "pass_fail"; }
isSignature(row) { return row.input_type === "signature"; }
// Fallback to text for anything else (text, time_hms, ...)
isText(row) {
return !this.isNumeric(row) && !this.isBoolean(row)
&& !this.isDate(row) && !this.isPhoto(row)
&& !this.isMulti(row) && !this.isPanel(row)
&& !this.isSelection(row);
&& !this.isSelection(row) && !this.isPassFail(row)
&& !this.isSignature(row);
}
// Friendly label for the type pill — defaults to the raw key when no
// mapping exists so a future input_type still renders something.
inputTypeLabel(row) {
return TYPE_LABELS[row.input_type] || row.input_type || "Text";
}
// True when the recipe author defined BOTH target_min and target_max
// on the prompt — the signal that the operator is expected to capture
// a range (multiple readings → record their min and max observation).
//
// Fires for numeric AND pass_fail types: a Bore inspection is a
// canonical example where the prompt is "PASS/FAIL" but the recipe
// sets a target range (e.g. 0.0050.007 in) — operator records the
// observed min and max bore reading AND marks pass/fail.
hasRangeEntry(row) {
if (!row.target_min || !row.target_max) return false;
if (row.target_min === row.target_max) return false;
return this.isNumeric(row) || this.isPassFail(row);
}
// Range hint for the dual-entry case — both bounds must be within
// spec for a green "in range" verdict; otherwise call out which one
// is the offender.
dualRangeHint(row) {
const lo = parseFloat(row.value_min);
const hi = parseFloat(row.value_max);
if (!lo && !hi) return null;
if (hi && lo && hi < lo) {
return { kind: "low", text: _t("max < min — check entry") };
}
if (lo && row.target_min && lo < row.target_min) {
return { kind: "low", text: _t("min below target") };
}
if (hi && row.target_max && hi > row.target_max) {
return { kind: "high", text: _t("max above target") };
}
if (lo && hi) {
return { kind: "ok", text: _t("both in range") };
}
return null;
}
// Pass/Fail handlers — set value_boolean explicitly per button.
// Three states: undecided (false + nothing chosen yet), passed, failed.
// We track the operator's CHOICE separately from the underlying boolean
// so the buttons can show "FAIL" as the active state (which would
// otherwise be indistinguishable from "not yet answered" in a plain
// boolean field).
onPass(row) {
row.value_boolean = true;
row._passfail_chosen = "pass";
}
onFail(row) {
row.value_boolean = false;
row._passfail_chosen = "fail";
}
isPassActive(row) { return row._passfail_chosen === "pass"; }
isFailActive(row) { return row._passfail_chosen === "fail"; }
// Auto-suggested PASS/FAIL outcome when a pass_fail prompt has both
// a target range and at least one reading entered. Returns 'pass',
// 'fail', or '' (no suggestion). Drives the visual hint under the
// dual-entry widget; the operator still has to click a button.
suggestedPassFail(row) {
if (!this.isPassFail(row) || !this.hasRangeEntry(row)) return "";
const lo = parseFloat(row.value_min);
const hi = parseFloat(row.value_max);
if (!lo && !hi) return "";
const tmin = row.target_min;
const tmax = row.target_max;
const minOk = !lo || lo >= tmin;
const maxOk = !hi || hi <= tmax;
const sane = !lo || !hi || hi >= lo;
return (minOk && maxOk && sane) ? "pass" : "fail";
}
// ---- Selection options — recipe author may store as comma-sep ------
@@ -125,7 +303,23 @@ export class FpRecordInputsDialog extends Component {
return { kind: "ok", text: _t("in range") };
}
// ---- Photo upload — file → base64 ----------------------------------
// Convert HTML5 datetime-local "YYYY-MM-DDTHH:MM[:SS]" to Odoo's
// "YYYY-MM-DD HH:MM:SS". Returns false for empty / falsy input so
// the field clears cleanly on the server side.
_fpFormatDatetime(v) {
if (!v) return false;
let s = String(v).replace("T", " ");
if (s.endsWith("Z")) {
s = s.slice(0, -1);
}
// datetime-local without step gives "HH:MM" — pad to "HH:MM:SS".
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(s)) {
s += ":00";
}
return s;
}
// ---- Photo upload — file -> base64 ----------------------------------
async onPhotoChange(row, ev) {
const file = ev.target.files[0];
if (!file) return;
@@ -171,6 +365,7 @@ export class FpRecordInputsDialog extends Component {
point_4: 0, point_5: 0,
panel_ph: 0, panel_concentration: 0,
panel_temperature: 0, panel_bath_id: "",
_passfail_chosen: "",
});
}
@@ -178,6 +373,21 @@ export class FpRecordInputsDialog extends Component {
this.state.rows.splice(idx, 1);
}
// The "current" initials value across all rows — a row counts as a
// signature/initials field when ``_fpIsInitialsField`` is true.
// Returns the most-recently-set value (last write wins) or empty.
// The commit endpoint persists this back to res.users.x_fc_initials
// when it differs from what was loaded.
_fpCollectInitials() {
let latest = "";
for (const r of this.state.rows) {
if (!this._fpIsInitialsField(r)) continue;
const v = (r.value_text || "").trim();
if (v) latest = v;
}
return latest;
}
// ---- Save ----------------------------------------------------------
async onSave() {
// Validate ad-hoc rows have a prompt name
@@ -190,31 +400,74 @@ export class FpRecordInputsDialog extends Component {
return;
}
}
// Validate range-based pass_fail rows: when readings are entered
// (or the prompt is required), the operator must explicitly pick
// PASS or FAIL. Otherwise readings would be recorded with no
// verdict — silent ambiguity that breaks the audit trail.
for (const row of this.state.rows) {
if (!this.isPassFail(row) || !this.hasRangeEntry(row)) continue;
const hasReadings = row.value_min || row.value_max;
const noChoice = !row._passfail_chosen;
if ((hasReadings || row.required) && noChoice) {
this.notification.add(
_t("Mark PASS or FAIL on \"%s\" before saving.")
.replace("%s", row.name || _t("the inspection prompt")),
{ type: "warning" },
);
return;
}
}
this.state.saving = true;
const payload = this.state.rows.map((r) => ({
node_input_id: r.node_input_id || false,
name: r.name,
input_type: r.input_type,
target_unit: r.target_unit,
target_min: r.target_min,
target_max: r.target_max,
value_text: r.value_text || false,
value_number: r.value_number || 0,
value_boolean: r.value_boolean,
value_date: r.value_date || false,
photo_value: r.photo_value || false,
photo_filename: r.photo_filename || false,
point_1: r.point_1, point_2: r.point_2, point_3: r.point_3,
point_4: r.point_4, point_5: r.point_5,
panel_ph: r.panel_ph,
panel_concentration: r.panel_concentration,
panel_temperature: r.panel_temperature,
panel_bath_id: r.panel_bath_id,
}));
const payload = this.state.rows.map((r) => {
// When the prompt expects a range entry (min + max readings),
// pack both into value_text for the audit trail and set
// value_number to the larger reading so existing range checks
// continue to work without a backend schema change. For
// pass_fail prompts with range, the verdict (PASS or FAIL)
// is appended too so the CoC shows the full inspection.
let valueText = r.value_text || false;
let valueNumber = r.value_number || 0;
if (this.hasRangeEntry(r)
&& (r.value_min || r.value_max)) {
const lo = r.value_min || 0;
const hi = r.value_max || 0;
const unit = r.target_unit ? ` ${r.target_unit}` : "";
let txt = `Min: ${lo}, Max: ${hi}${unit}`;
if (this.isPassFail(r) && r._passfail_chosen) {
txt += `${r._passfail_chosen.toUpperCase()}`;
}
valueText = txt;
valueNumber = hi || lo;
}
return {
node_input_id: r.node_input_id || false,
name: r.name,
input_type: r.input_type,
target_unit: r.target_unit,
target_min: r.target_min,
target_max: r.target_max,
value_text: valueText,
value_number: valueNumber,
value_boolean: r.value_boolean,
// datetime-local emits "YYYY-MM-DDTHH:MM" (or "...:SS")
// Odoo's Datetime field needs "YYYY-MM-DD HH:MM:SS".
// Normalise here so the wire payload is always valid.
value_date: this._fpFormatDatetime(r.value_date),
photo_value: r.photo_value || false,
photo_filename: r.photo_filename || false,
point_1: r.point_1, point_2: r.point_2, point_3: r.point_3,
point_4: r.point_4, point_5: r.point_5,
panel_ph: r.panel_ph,
panel_concentration: r.panel_concentration,
panel_temperature: r.panel_temperature,
panel_bath_id: r.panel_bath_id,
};
});
const result = await rpc("/fp/record_inputs/commit", {
step_id: this.props.stepId,
values: payload,
advance_after: !!this.props.advanceAfter,
user_initials: this._fpCollectInitials(),
});
this.state.saving = false;
if (!result.ok) {
@@ -229,9 +482,23 @@ export class FpRecordInputsDialog extends Component {
{ type: "success" },
);
this.props.close();
// If commit returned an action (e.g. Finish & Advance), dispatch it
if (result.next_action && typeof result.next_action === "object") {
await this.action.doAction(result.next_action);
// Dispatch a meaningful next action when the backend returns one
// (e.g. opening another form). Otherwise — and for the no-op
// ir.actions.act_window_close case — soft-reload so the job form
// behind the dialog re-fetches and the operator sees the step
// state flip from In Progress -> Done without manually refreshing.
const next = result.next_action;
const isReal =
next &&
typeof next === "object" &&
next.type !== "ir.actions.act_window_close";
if (isReal) {
await this.action.doAction(next);
} else {
await this.action.doAction({
type: "ir.actions.client",
tag: "soft_reload",
});
}
}

View File

@@ -223,10 +223,42 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
// ---------- Target / hint helpers ------------------------------------------
// Target pill — surfaces the recipe-author's target_min / target_max
// (the "spec") so the operator knows what they're aiming for BEFORE
// they enter readings. Reads as a small inline badge with bullseye
// icon, separated visually from the body / hint copy.
.o_fp_ri_target {
margin: 0 0 8px 0;
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0 0 10px 0;
padding: 4px 10px;
background-color: rgba(46, 125, 107, .10);
border: 1px solid rgba(46, 125, 107, .25);
border-radius: 999px;
font-size: 0.8125rem;
color: $rid-ink-mute;
color: $rid-ok;
.fa-bullseye { color: $rid-ok; }
.o_fp_ri_target_label {
text-transform: uppercase;
letter-spacing: .04em;
font-size: 0.7rem;
font-weight: 600;
opacity: .85;
}
.o_fp_ri_target_value {
color: $rid-ink;
font-variant-numeric: tabular-nums;
}
.o_fp_ri_target_unit {
margin-left: 2px;
color: $rid-ink-mute;
font-size: 0.75rem;
}
}
.o_fp_ri_hint {
margin: 0 0 8px 0;
@@ -236,6 +268,69 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
}
// =============================================================================
// Instructions block — recipe author's narrative text + image gallery,
// rendered above the prompt cards so the operator reads context BEFORE
// entering values. Hidden by the t-if when neither piece is authored.
// =============================================================================
.o_fp_ri_instructions {
margin-bottom: 14px;
padding: 14px 16px;
background-color: $rid-card;
border: 1px solid $rid-border;
border-left: 4px solid $rid-border-focus;
border-radius: 6px;
color: $rid-ink;
box-shadow: 0 1px 2px rgba(0, 0, 0, .03);
.o_fp_ri_instructions_text {
font-size: .95rem;
line-height: 1.5;
margin-bottom: 10px;
// Reset the rich-text fragments coming out of the HTML field
// so they render predictably inside the dialog frame.
:first-child { margin-top: 0; }
:last-child { margin-bottom: 0; }
img { max-width: 100%; height: auto; border-radius: 4px; }
}
.o_fp_ri_instructions_gallery {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.o_fp_ri_instructions_thumb {
display: inline-block;
width: 96px;
height: 96px;
border: 1px solid $rid-border;
border-radius: 4px;
overflow: hidden;
background-color: $rid-page;
cursor: zoom-in;
transition: transform .12s ease, border-color .12s ease,
box-shadow .12s ease;
&:hover {
transform: scale(1.04);
border-color: $rid-border-focus;
box-shadow: 0 2px 8px rgba(0, 0, 0, .12);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
}
// =============================================================================
// Card body — inputs per type
// =============================================================================
@@ -512,3 +607,197 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
grid-template-columns: repeat(2, 1fr);
}
}
// =============================================================================
// Pass / Fail — distinct two-button widget
//
// A bare boolean toggle hid the question's intent ("PASS or FAIL?" → "Yes
// or No?"). Two clearly-coloured buttons mirror the language the operator
// already speaks: green PASS, red FAIL. Active button fills with the
// outcome colour; inactive stays outlined.
// =============================================================================
.o_fp_ri_passfail {
display: flex;
gap: 12px;
.o_fp_ri_pf_btn {
flex: 1;
min-height: 52px;
padding: 10px 16px;
font-size: 1rem;
font-weight: 700;
letter-spacing: .04em;
border-radius: 6px;
background: transparent;
cursor: pointer;
transition: background-color .12s ease, color .12s ease,
border-color .12s ease, transform .04s ease;
&:active {
transform: scale(0.985);
}
.fa {
font-size: 1.05em;
}
}
.o_fp_ri_pf_pass {
border: 1.5px solid $rid-ok;
color: $rid-ok;
&:hover { background-color: rgba(25, 135, 84, .08); }
&.o_fp_ri_pf_active {
background-color: $rid-ok;
color: #ffffff;
border-color: $rid-ok;
box-shadow: 0 1px 0 rgba(0, 0, 0, .08);
}
}
.o_fp_ri_pf_fail {
border: 1.5px solid $rid-required;
color: $rid-required;
&:hover { background-color: rgba(220, 53, 69, .08); }
&.o_fp_ri_pf_active {
background-color: $rid-required;
color: #ffffff;
border-color: $rid-required;
box-shadow: 0 1px 0 rgba(0, 0, 0, .08);
}
}
}
// =============================================================================
// Signature — clearly-affordance'd input so operators know it's an
// initial / signature, not free text.
// =============================================================================
.o_fp_ri_signature {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: $rid-input;
border: 1px solid $rid-border;
border-radius: 6px;
transition: border-color .15s ease, box-shadow .15s ease;
&:focus-within {
border-color: $rid-border-focus;
box-shadow: 0 0 0 .15rem rgba(113, 75, 103, .15);
}
.o_fp_ri_signature_icon {
font-size: 1.1rem;
color: $rid-ink-mute;
}
.o_fp_ri_input_signature {
flex: 1;
border: 0;
background: transparent;
padding: 6px 0;
font-family: "Courier New", "Lucida Console", monospace;
font-size: 1rem;
letter-spacing: .08em;
text-transform: uppercase;
color: $rid-ink;
&:focus {
outline: none;
box-shadow: none;
}
}
}
// =============================================================================
// Selection — empty-state hint when recipe author didn't authoring options
// =============================================================================
.o_fp_ri_select_empty {
padding: 10px 12px;
border: 1px dashed $rid-border-strong;
border-radius: 6px;
background-color: $rid-page;
color: $rid-ink-mute;
font-size: .9rem;
.fa-info-circle {
color: $rid-warn;
}
.o_fp_ri_input_text {
width: 100%;
}
}
// =============================================================================
// Dual-entry numeric — Min Reading + Max Reading side-by-side
//
// Fires when the recipe author authored both target_min AND target_max on
// a numeric prompt (signal: this measurement is a range, not a point).
// Operator records the lowest and highest reading from their inspection
// pass. The hint below verifies BOTH bounds are within spec.
// =============================================================================
.o_fp_ri_dual {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: start;
.o_fp_ri_dual_field {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
}
.o_fp_ri_dual_label {
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .05em;
color: $rid-ink-mute;
}
.o_fp_ri_dual_hint {
grid-column: 1 / -1;
margin-top: -4px;
}
}
// =============================================================================
// PASS/FAIL suggestion banner — fires when a pass_fail prompt has both a
// target range and the operator has entered Min/Max readings. Shows the
// suggested verdict so the operator knows what the system thinks before
// they tap PASS or FAIL.
// =============================================================================
.o_fp_ri_pf_suggest {
margin: 8px 0 6px;
padding: 8px 12px;
border-radius: 6px;
font-size: .9rem;
border: 1px solid transparent;
&.o_fp_ri_pf_suggest_pass {
background-color: rgba(25, 135, 84, .10);
border-color: rgba(25, 135, 84, .35);
color: $rid-ok;
}
&.o_fp_ri_pf_suggest_fail {
background-color: rgba(220, 53, 69, .10);
border-color: rgba(220, 53, 69, .35);
color: $rid-required;
}
}

View File

@@ -20,16 +20,39 @@
<span class="ms-2">Loading prompts...</span>
</div>
<!-- Empty state -->
<div t-elif="!state.rows.length" class="o_fp_ri_empty">
<!-- Instructions block — recipe-author HTML + image gallery shown
above the prompt cards so the operator reads context BEFORE
entering values. Hidden when neither is authored. -->
<div t-if="!state.loading and (state.instructionsHtml or state.instructionImages.length)"
class="o_fp_ri_instructions">
<div t-if="state.instructionsHtml"
class="o_fp_ri_instructions_text"
t-out="state.instructionsHtml"/>
<div t-if="state.instructionImages.length"
class="o_fp_ri_instructions_gallery">
<t t-foreach="state.instructionImages" t-as="img" t-key="img.id">
<a t-att-href="img.url"
target="_blank"
class="o_fp_ri_instructions_thumb"
t-att-title="img.name">
<img t-att-src="img.url" t-att-alt="img.name"/>
</a>
</t>
</div>
</div>
<!-- Empty state. Independent t-if (not t-elif) so the
instructions block above doesn't break the chain — the
cards / empty branch must only depend on loading + rows. -->
<div t-if="!state.loading and !state.rows.length" class="o_fp_ri_empty">
<p>No measurement prompts on this step.</p>
<button class="btn btn-secondary" t-on-click="addAdHocRow">
<i class="fa fa-plus me-1"/> Add a measurement
</button>
</div>
<!-- Cards -->
<div t-else="" class="o_fp_ri_cards">
<!-- Cards. Same fix — independent t-if. -->
<div t-if="!state.loading and state.rows.length" class="o_fp_ri_cards">
<t t-foreach="state.rows" t-as="row" t-key="row_index">
<div class="o_fp_ri_card"
t-att-class="{ 'o_fp_ri_card_required': row.required }">
@@ -53,7 +76,7 @@
<div class="o_fp_ri_meta">
<span class="o_fp_ri_pill o_fp_ri_pill_type"
t-esc="row.input_type"/>
t-esc="inputTypeLabel(row)"/>
<span t-if="row.target_unit"
class="o_fp_ri_pill o_fp_ri_pill_unit"
t-esc="row.target_unit"/>
@@ -67,14 +90,19 @@
</button>
</div>
<!-- Target range hint (if recipe author set one) -->
<div t-if="(row.target_min or row.target_max) and isNumeric(row)"
<!-- Target range hint (any prompt with a target_min /
target_max — numeric, pass_fail, etc.). Renders
as a small "Target: 0.005 0.007 in" pill so the
operator can see the spec before they enter
readings. -->
<div t-if="row.target_min or row.target_max"
class="o_fp_ri_target">
Target:
<strong>
<t t-if="row.target_min" t-esc="row.target_min"/><t t-if="row.target_min and row.target_max"></t><t t-if="row.target_max" t-esc="row.target_max"/>
<i class="fa fa-bullseye me-1"/>
<span class="o_fp_ri_target_label">Target</span>
<strong class="o_fp_ri_target_value">
<t t-if="row.target_min" t-esc="row.target_min"/><t t-if="row.target_min and row.target_max"> </t><t t-if="row.target_max" t-esc="row.target_max"/>
</strong>
<span t-if="row.target_unit" class="ms-1 text-muted" t-esc="row.target_unit"/>
<span t-if="row.target_unit" class="o_fp_ri_target_unit" t-esc="row.target_unit"/>
</div>
<!-- Hint text from recipe author -->
@@ -83,8 +111,9 @@
<!-- Card body — live input widget per type -->
<div class="o_fp_ri_card_body">
<!-- Numeric (number, temperature, thickness, time_seconds, ph) -->
<div t-if="isNumeric(row)" class="o_fp_ri_numeric">
<!-- Numeric — single value (no range defined) -->
<div t-if="isNumeric(row) and !hasRangeEntry(row)"
class="o_fp_ri_numeric">
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
@@ -97,7 +126,109 @@
t-esc="hint.text"/>
</div>
<!-- Boolean / pass-fail toggle -->
<!-- Numeric — dual entry (recipe author defined a
min and max target → operator records both
observed extremes from their measurements).
Constrained to numeric so it doesn't duplicate
the pass_fail+range branch above. -->
<div t-if="isNumeric(row) and hasRangeEntry(row)" class="o_fp_ri_dual">
<label class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Min Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
</label>
<label class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Max Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
</label>
<t t-set="dhint" t-value="dualRangeHint(row)"/>
<span t-if="dhint"
class="o_fp_ri_range_hint o_fp_ri_dual_hint"
t-att-class="'o_fp_ri_range_' + dhint.kind"
t-esc="dhint.text"/>
</div>
<!-- Pass / Fail with range — operator records min
+ max measurements first, system suggests the
verdict, then operator confirms with PASS/FAIL.
This branch fires when the recipe author
defined target_min / target_max on a pass_fail
prompt (e.g. Bore inspection: 0.005-0.007 in). -->
<t t-if="isPassFail(row) and hasRangeEntry(row)">
<div class="o_fp_ri_dual">
<label class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Min Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
</label>
<label class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Max Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
</label>
<t t-set="dhint" t-value="dualRangeHint(row)"/>
<span t-if="dhint"
class="o_fp_ri_range_hint o_fp_ri_dual_hint"
t-att-class="'o_fp_ri_range_' + dhint.kind"
t-esc="dhint.text"/>
</div>
<t t-set="sugg" t-value="suggestedPassFail(row)"/>
<div t-if="sugg" class="o_fp_ri_pf_suggest"
t-att-class="'o_fp_ri_pf_suggest_' + sugg">
<i t-att-class="sugg === 'pass' ? 'fa fa-check-circle me-1' : 'fa fa-exclamation-triangle me-1'"/>
Readings suggest <strong t-esc="sugg.toUpperCase()"/> — confirm below.
</div>
<div class="o_fp_ri_passfail">
<button type="button"
class="o_fp_ri_pf_btn o_fp_ri_pf_pass"
t-att-class="{ 'o_fp_ri_pf_active': isPassActive(row) }"
t-on-click="() => this.onPass(row)">
<i class="fa fa-check me-2"/> PASS
</button>
<button type="button"
class="o_fp_ri_pf_btn o_fp_ri_pf_fail"
t-att-class="{ 'o_fp_ri_pf_active': isFailActive(row) }"
t-on-click="() => this.onFail(row)">
<i class="fa fa-times me-2"/> FAIL
</button>
</div>
</t>
<!-- Pass / Fail without range — distinct two-button
widget so the operator sees the OUTCOME, not a
generic toggle. Active button fills with green
(PASS) or red (FAIL); the inactive one stays
outlined. -->
<div t-if="isPassFail(row) and !hasRangeEntry(row)"
class="o_fp_ri_passfail">
<button type="button"
class="o_fp_ri_pf_btn o_fp_ri_pf_pass"
t-att-class="{ 'o_fp_ri_pf_active': isPassActive(row) }"
t-on-click="() => this.onPass(row)">
<i class="fa fa-check me-2"/> PASS
</button>
<button type="button"
class="o_fp_ri_pf_btn o_fp_ri_pf_fail"
t-att-class="{ 'o_fp_ri_pf_active': isFailActive(row) }"
t-on-click="() => this.onFail(row)">
<i class="fa fa-times me-2"/> FAIL
</button>
</div>
<!-- Generic boolean toggle (Yes / No) -->
<label t-if="isBoolean(row)" class="o_fp_ri_toggle">
<input type="checkbox" t-model="row.value_boolean"/>
<span class="o_fp_ri_toggle_track">
@@ -114,14 +245,36 @@
t-model="row.value_date"/>
<!-- Selection (uses recipe author's selection_options) -->
<select t-if="isSelection(row)"
class="o_fp_ri_input o_fp_ri_input_select"
t-model="row.value_text">
<option value="">— choose —</option>
<t t-foreach="selectionOptions(row)" t-as="opt" t-key="opt">
<option t-att-value="opt" t-esc="opt"/>
</t>
</select>
<t t-if="isSelection(row)">
<t t-set="opts" t-value="selectionOptions(row)"/>
<select t-if="opts.length"
class="o_fp_ri_input o_fp_ri_input_select"
t-model="row.value_text">
<option value="">— choose —</option>
<t t-foreach="opts" t-as="opt" t-key="opt">
<option t-att-value="opt" t-esc="opt"/>
</t>
</select>
<div t-else="" class="o_fp_ri_select_empty">
<i class="fa fa-info-circle me-1"/>
No options configured for this prompt — type a value below.
<input type="text"
class="o_fp_ri_input o_fp_ri_input_text mt-2"
t-model="row.value_text"
placeholder="Enter value…"/>
</div>
</t>
<!-- Signature — distinct affordance so the operator
knows initials are required (not free text). -->
<div t-if="isSignature(row)" class="o_fp_ri_signature">
<i class="fa fa-pencil-square-o o_fp_ri_signature_icon"/>
<input type="text"
class="o_fp_ri_input o_fp_ri_input_signature"
t-model="row.value_text"
placeholder="Type your initials (e.g. JD)"
maxlength="10"/>
</div>
<!-- Photo upload -->
<div t-if="isPhoto(row)" class="o_fp_ri_photo">

View File

@@ -286,16 +286,7 @@ class TestSoConfirmHook(TransactionCase):
self.env['sale.order.line'].create(line_defaults)
return so
def test_flag_off_no_job_created(self):
# Default flag is False
self.ICP.set_param('fusion_plating_jobs.use_native_jobs', 'False')
so = self._make_so_with_plating_line()
so.action_confirm()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertFalse(jobs)
def test_flag_on_creates_job(self):
self.ICP.set_param('fusion_plating_jobs.use_native_jobs', 'True')
def test_so_confirm_creates_job(self):
# Need a plating line — add x_fc_part_catalog_id if available
if 'x_fc_part_catalog_id' in self.env['sale.order.line']._fields:
partner_for_part = self.env['res.partner'].create({'name': 'PartOwner'})
@@ -313,8 +304,7 @@ class TestSoConfirmHook(TransactionCase):
else:
self.skipTest('x_fc_part_catalog_id field not present on sale.order.line')
def test_flag_on_idempotent(self):
self.ICP.set_param('fusion_plating_jobs.use_native_jobs', 'True')
def test_so_confirm_idempotent(self):
if 'x_fc_part_catalog_id' in self.env['sale.order.line']._fields:
partner_for_part = self.env['res.partner'].create({'name': 'PO'})
part = self.env['fp.part.catalog'].create({

View File

@@ -19,12 +19,23 @@
<field name="arch" type="xml">
<form string="Step Details" edit="false" create="false" delete="false">
<sheet>
<!-- Hidden helper fields used by section visibility
conditions below. Without these the empty-state
hides for Equipment / Schedule won't evaluate. -->
<field name="work_centre_id" invisible="1"/>
<field name="tank_id" invisible="1"/>
<field name="bath_id" invisible="1"/>
<field name="rack_id" invisible="1"/>
<field name="duration_expected" invisible="1"/>
<field name="duration_actual" invisible="1"/>
<field name="assigned_user_id" invisible="1"/>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
<div class="text-muted">
<field name="sequence" readonly="1"/> ·
Step #<field name="sequence" readonly="1"/> ·
<field name="kind" readonly="1"/> ·
<field name="state" widget="badge"
decoration-info="state == 'in_progress'"
@@ -34,17 +45,50 @@
</div>
</div>
<group>
<group string="Equipment">
<field name="work_centre_id" readonly="1"/>
<field name="tank_id" readonly="1"/>
<field name="bath_id" readonly="1"/>
<field name="rack_id" readonly="1"/>
<!-- Job context — what job is this step part of, who's
the customer, what part, how many. The single most
useful thing to surface up top so the operator
orients themselves before drilling in. -->
<group string="Job Context">
<group>
<field name="job_id" readonly="1" options="{'no_open': True}"/>
<field name="quick_look_part_catalog_id" readonly="1"
options="{'no_open': True}"
invisible="not quick_look_part_catalog_id"/>
</group>
<group string="Schedule">
<field name="duration_expected" readonly="1"/>
<field name="duration_actual" readonly="1"/>
<field name="assigned_user_id" readonly="1"/>
<group>
<field name="quick_look_partner_id" readonly="1"
options="{'no_open': True}"
invisible="not quick_look_partner_id"/>
<field name="quick_look_qty" readonly="1"
invisible="not quick_look_qty"/>
</group>
</group>
<!-- Equipment / Schedule — only render when there's
actually something to show. An Inspection step with
no tank / bath / time-budget shouldn't display
four empty rows of "—" — that's misleading. -->
<group invisible="not work_centre_id and not tank_id and not bath_id and not rack_id and not duration_expected and not duration_actual and not assigned_user_id">
<group string="Equipment"
invisible="not work_centre_id and not tank_id and not bath_id and not rack_id">
<field name="work_centre_id" readonly="1"
invisible="not work_centre_id"/>
<field name="tank_id" readonly="1"
invisible="not tank_id"/>
<field name="bath_id" readonly="1"
invisible="not bath_id"/>
<field name="rack_id" readonly="1"
invisible="not rack_id"/>
</group>
<group string="Schedule"
invisible="not duration_expected and not duration_actual and not assigned_user_id">
<field name="duration_expected" readonly="1"
invisible="not duration_expected"/>
<field name="duration_actual" readonly="1"
invisible="not duration_actual"/>
<field name="assigned_user_id" readonly="1"
invisible="not assigned_user_id"/>
</group>
</group>
@@ -57,14 +101,24 @@
<strong> Master switch off</strong> — no values will be collected at runtime for this step.
</div>
<separator string="Operator Instructions"/>
<div class="o_fp_quick_look_instructions">
<!-- Operator Instructions — hide the whole section when
the recipe author didn't write any. -->
<separator string="Operator Instructions"
invisible="not quick_look_instructions"/>
<div class="o_fp_quick_look_instructions"
invisible="not quick_look_instructions">
<field name="quick_look_instructions" nolabel="1" readonly="1"/>
</div>
<p class="text-muted small"
invisible="quick_look_instructions">
No instructions authored for this step.
</p>
<!-- Instruction images — visual reference photos /
screenshots the recipe author attached to the
node. Hidden when none. -->
<separator string="Reference Images"
invisible="not quick_look_instruction_attachment_ids"/>
<field name="quick_look_instruction_attachment_ids"
nolabel="1" readonly="1"
widget="many2many_binary"
invisible="not quick_look_instruction_attachment_ids"/>
<separator string="Measurement Prompts"/>
<field name="quick_look_prompt_ids" nolabel="1" readonly="1">

View File

@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_res_config_settings_jobs" model="ir.ui.view">
<field name="name">res.config.settings.fp.jobs</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Plating Jobs" string="Fusion Plating Jobs" name="fusion_plating_jobs">
<block title="Native Job Migration" name="fp_jobs_migration">
<setting id="fp_use_native_jobs"
string="Use Native Plating Jobs"
help="When enabled, SO confirmation creates fp.job records instead of mrp.production. Phase-2 migration toggle.">
<field name="x_fc_use_native_jobs"/>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -228,8 +228,8 @@ class FpJobStepInputWizardLine(models.TransientModel):
_FP_INPUT_TYPE_SELECTION,
string='Type',
)
target_min = fields.Float(string='Min')
target_max = fields.Float(string='Max')
target_min = fields.Float(string='Min', digits=(16, 6))
target_max = fields.Float(string='Max', digits=(16, 6))
target_unit = fields.Selection(
FP_UOM_SELECTION,
string='Unit',

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Logistics',
'version': '19.0.3.3.0',
'version': '19.0.3.5.0',
'category': 'Manufacturing/Plating',
'summary': (
'Pickup & delivery for plating shops: vehicle master, driver '

View File

@@ -183,7 +183,60 @@ class FpDelivery(models.Model):
# ==========================================================================
# Actions
# ==========================================================================
def _fp_check_account_hold(self, action_label):
"""Block shipping when the customer is on account hold.
Enforces the third leg of the SO banner promise ("SO confirmation,
invoicing AND SHIPPING are blocked"). Resolved through
``commercial_partner_id`` so a hold on the parent company applies
even when the delivery is addressed to a child contact.
Manager bypass: ``fp_skip_account_hold=True`` in context (matches
the pattern used in fp_direct_order_wizard and the SO action_confirm
manager-override). Non-managers can't bypass.
``getattr`` is defensive — the hold field lives in
``fusion_plating_invoicing``; this module doesn't dep on it.
"""
for rec in self:
partner = rec.partner_id.commercial_partner_id
if not getattr(partner, 'x_fc_account_hold', False):
continue
if self.env.context.get('fp_skip_account_hold'):
rec.message_post(body=_(
'Account-hold check bypassed via context flag for '
'%(action)s. Customer "%(name)s" is on hold (reason: '
'%(reason)s).'
) % {
'action': action_label,
'name': partner.name,
'reason': getattr(partner, 'x_fc_account_hold_reason', '') or 'N/A',
})
continue
is_manager = self.env['res.partner']._fp_user_can_override_account_hold()
if not is_manager:
raise UserError(_(
'Cannot %(action)s delivery "%(name)s" — customer "%(partner)s" '
'is on account hold.\n'
'Reason: %(reason)s\n\n'
'Contact a manager to override.'
) % {
'action': action_label,
'name': rec.name or rec.display_name,
'partner': partner.name,
'reason': getattr(partner, 'x_fc_account_hold_reason', '') or 'No reason specified',
})
rec.message_post(body=_(
'Warning: Customer "%(name)s" is on account hold (reason: '
'%(reason)s). Delivery %(action)s by manager override.'
) % {
'name': partner.name,
'reason': getattr(partner, 'x_fc_account_hold_reason', '') or 'N/A',
'action': action_label,
})
def action_schedule(self):
self._fp_check_account_hold(_('schedule'))
self.write({'state': 'scheduled'})
def action_start_route(self):
@@ -194,6 +247,7 @@ class FpDelivery(models.Model):
is non-negotiable — without it the chain-of-custody hand-off
has no signed party and the POD can't be linked to a person.
"""
self._fp_check_account_hold(_('dispatch'))
for rec in self:
if not rec.assigned_driver_id:
raise UserError(_(

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Notifications',
'version': '19.0.6.3.0',
'version': '19.0.6.4.0',
'category': 'Manufacturing/Plating',
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
'author': 'Nexa Systems Inc.',

View File

@@ -9,6 +9,7 @@ from . import res_partner
from . import sale_order
from . import fp_receiving
from . import account_move
from . import account_move_send
from . import account_payment
# Phase 5 (Sub 11) — mrp.production hook retired. The native equivalent
# fires from fp.job.button_mark_done -> _fp_fire_notification('job_complete').

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, models
class AccountMoveSend(models.AbstractModel):
_inherit = 'account.move.send'
@api.model
def _get_default_pdf_report_id(self, move):
"""Make the Fusion Plating invoice the official invoice PDF.
Odoo's Send wizard renders the chosen ``pdf_report`` as the
legal/audit-trail PDF (stored on ``move.invoice_pdf_report_id``)
and ALSO renders every extra report listed in the mail
template's ``report_template_ids`` minus the chosen pdf_report
and ``account.account_invoices`` (see
``_get_placeholder_mail_template_dynamic_attachments_data``).
Without this override the wizard picks ``account.account_invoices``
as the official PDF, so the email ships TWO invoices: the stock
Odoo one + our branded plating one (which is in our mail
template's ``report_template_ids``). Returning our report as the
default makes the set-difference cancel out and the customer
receives a single, branded invoice.
Partner-level and journal-level ``invoice_template_pdf_report_id``
overrides still win — admins can opt out per customer or journal.
"""
partner_default = move.commercial_partner_id.with_company(
move.company_id
).invoice_template_pdf_report_id
if partner_default:
return partner_default
journal_default = move.journal_id.with_company(
move.company_id
).invoice_template_pdf_report_id
if journal_default:
return journal_default
if move.move_type == 'out_invoice':
fp_report = self.env.ref(
'fusion_plating_reports.action_report_fp_invoice_portrait',
raise_if_not_found=False,
)
if fp_report and move._is_action_report_available(fp_report):
return fp_report
return super()._get_default_pdf_report_id(move)

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.10.2.0',
'version': '19.0.10.3.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -399,6 +399,7 @@
<field name="print_report_name">'Invoice - %s' % (object.name or '')</field>
<field name="binding_model_id" ref="account.model_account_move"/>
<field name="binding_type">report</field>
<field name="is_invoice_report" eval="True"/>
</record>
<record id="action_report_fp_invoice_landscape" model="ir.actions.report">