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

@@ -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',