changes
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
40
fusion_plating/fusion_plating_jobs/models/res_users.py
Normal file
40
fusion_plating/fusion_plating_jobs/models/res_users.py
Normal 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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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.005–0.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.005–0.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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user