feat(jobs): Sub 13 sequential step enforcement + Sub 12e v3 wizard
Two coherent feature drops shipping together because their fp_job_step
edits overlap. Both target operator workflow correctness.
## Sub 13 — Sequential step enforcement (recipe + per-step)
Background:
Investigation on WH/JOB/00339 showed operators starting Incoming
Inspection while Contract Review was still in_progress. Audit:
98.7% of recipe operations system-wide had requires_predecessor_done
= false (the legacy per-step opt-in defaults off, recipe authors
rarely tick the box).
Architecture:
Recipe-level toggle + per-step opt-out (Option A from /investigate).
* fusion.plating.process.node.enforce_sequential — Boolean on the
recipe root. Default True. When True, every operation under this
recipe waits for earlier-sequence steps to finish before it can
start.
* fusion.plating.process.node.parallel_start — Boolean on operation
nodes. When True, this step bypasses the sequential gate (e.g.
paperwork or QA review that runs alongside production).
* Mirrored on fp.step.template (parallel_start) so library steps
carry the flag into snapshots.
* fp.job.enforce_sequential — related from recipe_id. Snapshotted
at job creation so a recipe author flipping the recipe's flag
AFTER job generation does NOT change behaviour mid-run.
* fp.job.step.parallel_start — related from recipe_node_id.
* Decision matrix (encapsulated in
fp.job.step._fp_should_block_predecessors):
recipe.enforce_sequential | step.parallel_start | step.req_pred_done | block?
--------------------------|---------------------|--------------------|------
True | False | any | YES
True | True | any | no
False | any | True | YES
False | any | False | no
* Manager bypass via context fp_skip_predecessor_check=True (existing).
Runtime gates:
* fp.job.step.button_start — calls _fp_should_block_predecessors;
raises UserError naming the blocking earlier step(s).
* fp.job.step.can_start — computed Boolean for view-side disable.
* Move wizard predecessor check
(fusion_plating_shopfloor/controllers/move_controller.py) — uses
the same helper so tablet + backend behave identically.
UI surface:
* Recipe form (fp_process_node_views.xml) — enforce_sequential
toggle on recipe root, parallel_start checkbox on operations.
* Step template form — parallel_start checkbox.
* Simple Recipe Editor (inline library form) — Parallel Start
checkbox + legacy flag demoted with muted styling + supervisor
group gate.
* Recipe Tree Editor (properties panel) — both flags exposed,
only-show on the right node_type.
* Controllers updated to allowlist + payload the new fields.
Migration:
fusion_plating/migrations/19.0.18.12.0/post-migrate.py — sets
enforce_sequential = TRUE on every existing recipe-root node.
Idempotent. User confirmed dev-stage data, so retroactive flip
is safe (no production jobs to disrupt).
Tests:
TestSequentialEnforcement (10 tests) covering:
* sequential mode blocks out-of-order start
* first step always startable
* predecessor finish/skip unlocks next
* parallel_start opts out of gate
* free-flow mode bypasses gate
* legacy requires_predecessor_done still honoured in free-flow
* manager bypass via context
* can_start compute reflects state correctly
* library template parallel_start snapshots into recipe-node
## Sub 12e — Record Inputs Wizard v3 (card layout, dark-mode aware)
Background:
v2 wizard was a 17-column wide editable table. Operators got lost
finding which value column applied to their row's type, horizontal
scroll required on tablets, composite types crammed into one row.
New layout:
* Each measurement renders as a stacked card (CSS Grid + display
transformation on the existing list widget — preserves inline
editing, no JS rewrite).
* Card header: prompt name (large, bold) + type/unit pills.
* Card body: ONLY the value widget for this row's type
(number / boolean / date / text / photo / multi-point / panel).
* Composite types (multi-point thickness 5x reading + avg, bath
panel 4 fields) get inline sub-grid inside the card.
* Empty state ("no measurement prompts") with friendly CTA.
Dark mode:
* SCSS branches at compile time on $o-webclient-color-scheme
(per fusion-plating/CLAUDE.md note).
* Tokens: 7 surface colours + 4 ink levels with light/dark hex
pairs, all behind var(--fp-*) custom properties for per-deploy
override.
* Registered in BOTH web.assets_backend AND web.assets_web_dark
so each bundle compiles its own palette.
Tablet polish:
@media (max-width: 900px) — collapse meta below prompt + bump
numeric input min-height to 56px.
Defensive:
* v2 view kept in the XML file (instant rollback by changing one
view_id ref).
* `:has(.o_invisible_modifier)` rule drops empty cells out of the
grid so Odoo's invisible="..." doesn't punch holes in layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.14.6',
|
||||
'version': '19.0.8.16.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -71,15 +71,18 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
'report/report_fp_job_margin.xml',
|
||||
],
|
||||
'assets': {
|
||||
# Sub 12d — Step Details quick-look modal styles. Registered in
|
||||
# both bundles so light + dark mode each compile correctly
|
||||
# Sub 12d — Step Details quick-look modal styles + Sub 12e — Record
|
||||
# Inputs Wizard v3 card layout. Registered in both bundles so
|
||||
# light + dark mode each compile correctly
|
||||
# ($o-webclient-color-scheme branches at compile time per
|
||||
# CLAUDE.md note).
|
||||
'web.assets_backend': [
|
||||
'fusion_plating_jobs/static/src/scss/fp_step_quick_look.scss',
|
||||
'fusion_plating_jobs/static/src/scss/fp_job_step_input_wizard_v3.scss',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
'fusion_plating_jobs/static/src/scss/fp_step_quick_look.scss',
|
||||
'fusion_plating_jobs/static/src/scss/fp_job_step_input_wizard_v3.scss',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
|
||||
@@ -53,6 +53,21 @@ class FpJob(models.Model):
|
||||
'job_id',
|
||||
string='Recipe Overrides',
|
||||
)
|
||||
# Sub 13 — sequential enforcement. Mirrored from the recipe root so
|
||||
# button_start on each step can read the policy without walking the
|
||||
# node tree. Stored so a recipe author flipping the recipe's flag
|
||||
# AFTER job generation does NOT change behaviour mid-run (jobs
|
||||
# snapshot the policy at creation, not on the fly).
|
||||
enforce_sequential = fields.Boolean(
|
||||
related='recipe_id.enforce_sequential',
|
||||
string='Enforce Sequential Order',
|
||||
store=True,
|
||||
readonly=True,
|
||||
help='Snapshotted from the recipe at job creation. When True, '
|
||||
'every step waits for its predecessors before it can start '
|
||||
'(unless the step itself is flagged Parallel Start, or a '
|
||||
'manager bypasses via context).',
|
||||
)
|
||||
# Phase 7 — migration idempotency key. Populated by
|
||||
# scripts/migrate_to_fp_jobs.py to mark a fp.job as the mirror of a
|
||||
# specific mrp.production. Used to skip already-migrated MOs on
|
||||
|
||||
@@ -20,22 +20,93 @@ _logger = logging.getLogger(__name__)
|
||||
class FpJobStep(models.Model):
|
||||
_inherit = 'fp.job.step'
|
||||
|
||||
# ===== Sub 13 — sequential enforcement (recipe + per-step) =============
|
||||
# Decision matrix for whether button_start must verify predecessors:
|
||||
#
|
||||
# recipe.enforce_sequential | step.parallel_start | step.req_pred (legacy) | block?
|
||||
# --------------------------|---------------------|------------------------|------
|
||||
# True | False | any | YES
|
||||
# True | True | any | no
|
||||
# False | any | True | YES
|
||||
# False | any | False | no
|
||||
#
|
||||
# Manager bypass via context fp_skip_predecessor_check=True.
|
||||
# Encapsulated in _fp_should_block_predecessors() so the same gate
|
||||
# is reused by button_start and the Move wizard's predecessor check
|
||||
# (fusion_plating_shopfloor/controllers/move_controller.py).
|
||||
# ======================================================================
|
||||
|
||||
def _fp_should_block_predecessors(self):
|
||||
"""Return True if this step must verify that every earlier-
|
||||
sequence step is in a terminal state before it can start.
|
||||
See decision matrix in the section header above.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Defensive: jobs without a recipe (manual-build jobs) default
|
||||
# to enforce-on so freshly-created records don't accidentally
|
||||
# leak permissive behaviour through a related-field None.
|
||||
if not self.job_id:
|
||||
return True
|
||||
recipe_seq = self.job_id.enforce_sequential
|
||||
if recipe_seq:
|
||||
return not self.parallel_start
|
||||
# Free-flow recipe — only the legacy per-step flag still gates.
|
||||
return bool(self.requires_predecessor_done)
|
||||
|
||||
can_start = fields.Boolean(
|
||||
string='Can Start',
|
||||
compute='_compute_can_start',
|
||||
help='True when this step is in a startable state AND the '
|
||||
'predecessor gate (if any) is currently clear. Drives the '
|
||||
'tablet/job form Start button visibility.',
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'state',
|
||||
'sequence',
|
||||
'parallel_start',
|
||||
'requires_predecessor_done',
|
||||
'job_id.enforce_sequential',
|
||||
'job_id.step_ids.state',
|
||||
'job_id.step_ids.sequence',
|
||||
)
|
||||
def _compute_can_start(self):
|
||||
for step in self:
|
||||
if step.state not in ('pending', 'ready', 'paused'):
|
||||
step.can_start = False
|
||||
continue
|
||||
if not step._fp_should_block_predecessors():
|
||||
step.can_start = True
|
||||
continue
|
||||
blocking = step.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence < step.sequence and s.state not in (
|
||||
'done', 'skipped', 'cancelled',
|
||||
)
|
||||
)
|
||||
step.can_start = not bool(blocking)
|
||||
|
||||
def button_start(self):
|
||||
"""Override — soft gate when parts haven't been received yet,
|
||||
plus hard predecessor gate for steps flagged
|
||||
requires_predecessor_done by the recipe author.
|
||||
plus hard predecessor gate driven by the recipe + per-step
|
||||
sequential enforcement policy (Sub 13).
|
||||
|
||||
Receiving check is soft (logs to chatter) — manager wants the
|
||||
shop to start prep regardless when parts are in-transit late.
|
||||
|
||||
Predecessor check IS hard-blocking — if the recipe author
|
||||
marked this step as serial-required, every earlier-sequence
|
||||
step must be terminal (done / skipped / cancelled) before
|
||||
Start fires. Manager bypass via fp_skip_predecessor_check=True.
|
||||
Predecessor check IS hard-blocking — every earlier-sequence
|
||||
step must be terminal (done / skipped / cancelled) before Start
|
||||
fires, UNLESS:
|
||||
* this step is flagged parallel_start (explicit per-step opt-out), OR
|
||||
* the parent recipe has enforce_sequential=False AND this step
|
||||
is not flagged requires_predecessor_done (legacy free-flow).
|
||||
|
||||
Manager bypass via fp_skip_predecessor_check=True.
|
||||
"""
|
||||
skip_pred = self.env.context.get('fp_skip_predecessor_check')
|
||||
for step in self:
|
||||
if not step.requires_predecessor_done or skip_pred:
|
||||
if skip_pred:
|
||||
continue
|
||||
if not step._fp_should_block_predecessors():
|
||||
continue
|
||||
blocking = step.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence < step.sequence and s.state not in (
|
||||
@@ -44,10 +115,13 @@ class FpJobStep(models.Model):
|
||||
)
|
||||
if blocking:
|
||||
raise UserError(_(
|
||||
"Step '%s' requires predecessors done first. "
|
||||
"Blocking earlier step(s):\n %s\n\nFinish or skip "
|
||||
"those before starting this one (manager can "
|
||||
"override via context fp_skip_predecessor_check=True)."
|
||||
"Step '%s' cannot start until earlier steps are "
|
||||
"finished, skipped, or cancelled.\n\nBlocking step(s):\n %s\n\n"
|
||||
"Options:\n"
|
||||
" * Finish/Skip the blocking step(s) first.\n"
|
||||
" * If this step legitimately runs in parallel, ask "
|
||||
"a manager to flag it as Parallel Start on the recipe.\n"
|
||||
" * Manager override via context fp_skip_predecessor_check=True."
|
||||
) % (
|
||||
step.name,
|
||||
'\n '.join(
|
||||
@@ -412,7 +486,7 @@ class FpJobStep(models.Model):
|
||||
the next one as a single atomic flow."""
|
||||
self.ensure_one()
|
||||
view = self.env.ref(
|
||||
'fusion_plating_jobs.view_fp_job_step_input_wizard_form_v2'
|
||||
'fusion_plating_jobs.view_fp_job_step_input_wizard_form_v3'
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
// =============================================================================
|
||||
// Record Inputs Wizard — v3 card layout (light + dark mode)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
//
|
||||
// Replaces the long-row table layout (v2) with a stacked card layout —
|
||||
// one card per measurement prompt, the right input widget rendered per
|
||||
// type, target range + required indicator visible inline.
|
||||
//
|
||||
// Pattern (per fusion-plating/CLAUDE.md):
|
||||
// * SCSS branches at COMPILE TIME on $o-webclient-color-scheme
|
||||
// * File is registered in BOTH web.assets_backend AND web.assets_web_dark
|
||||
// * No reliance on runtime DOM classes (.o_dark_mode etc) — Odoo 19
|
||||
// does not flip dark mode via runtime; it serves a separate bundle.
|
||||
// * Tokens fall through to CSS custom properties so deployments can
|
||||
// override via :root { --fp-card-bg: ... } without touching SCSS.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// ---------- Surface tokens — branched at compile time ------------------------
|
||||
|
||||
$_fp-iw-card-hex : #ffffff;
|
||||
$_fp-iw-card-hover-hex: #f8f9fa;
|
||||
$_fp-iw-page-hex : #f3f4f6;
|
||||
$_fp-iw-border-hex : #d8dadd;
|
||||
$_fp-iw-border-focus-hex: #714B67; // Odoo brand purple
|
||||
$_fp-iw-ink-hex : #1f2937;
|
||||
$_fp-iw-ink-soft-hex : #4b5563;
|
||||
$_fp-iw-ink-mute-hex : #6b7280;
|
||||
$_fp-iw-ink-faint-hex : #9ca3af;
|
||||
$_fp-iw-required-hex : #dc3545; // red asterisk
|
||||
$_fp-iw-success-hex : #198754;
|
||||
$_fp-iw-pill-bg-hex : #f1f3f5;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fp-iw-card-hex : #22262d !global;
|
||||
$_fp-iw-card-hover-hex: #2a2f37 !global;
|
||||
$_fp-iw-page-hex : #1a1d21 !global;
|
||||
$_fp-iw-border-hex : #343942 !global;
|
||||
$_fp-iw-border-focus-hex: #a78bca !global; // lighter purple for dark
|
||||
$_fp-iw-ink-hex : #e5e7eb !global;
|
||||
$_fp-iw-ink-soft-hex : #c8ccd2 !global;
|
||||
$_fp-iw-ink-mute-hex : #8a909a !global;
|
||||
$_fp-iw-ink-faint-hex : #5a606b !global;
|
||||
$_fp-iw-required-hex : #ea868f !global;
|
||||
$_fp-iw-success-hex : #75b798 !global;
|
||||
$_fp-iw-pill-bg-hex : #1c2027 !global;
|
||||
}
|
||||
|
||||
// CSS-custom-property fallbacks so per-deployment overrides still work.
|
||||
$fp-iw-card : var(--fp-card-bg, #{$_fp-iw-card-hex});
|
||||
$fp-iw-card-hover : var(--fp-card-hover-bg, #{$_fp-iw-card-hover-hex});
|
||||
$fp-iw-page : var(--fp-page-bg, #{$_fp-iw-page-hex});
|
||||
$fp-iw-border : var(--fp-border-color, #{$_fp-iw-border-hex});
|
||||
$fp-iw-border-focus: var(--fp-border-focus, #{$_fp-iw-border-focus-hex});
|
||||
$fp-iw-ink : var(--fp-ink, #{$_fp-iw-ink-hex});
|
||||
$fp-iw-ink-soft : var(--fp-ink-soft, #{$_fp-iw-ink-soft-hex});
|
||||
$fp-iw-ink-mute : var(--fp-ink-mute, #{$_fp-iw-ink-mute-hex});
|
||||
$fp-iw-ink-faint : var(--fp-ink-faint, #{$_fp-iw-ink-faint-hex});
|
||||
$fp-iw-required : var(--fp-required, #{$_fp-iw-required-hex});
|
||||
$fp-iw-success : var(--fp-success, #{$_fp-iw-success-hex});
|
||||
$fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Wizard layout — header + section title + card grid + empty state
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_input_wizard_v3 {
|
||||
background-color: $fp-iw-page;
|
||||
|
||||
.o_fp_input_header {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid $fp-iw-border;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: $fp-iw-ink;
|
||||
}
|
||||
.o_fp_input_subhead {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: $fp-iw-ink-mute;
|
||||
|
||||
// The job_id field renders as an inline anchor; keep the
|
||||
// colour calm so the section title stays the focal point.
|
||||
a, .o_field_widget {
|
||||
color: $fp-iw-ink-soft;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_input_section_title {
|
||||
margin: 8px 0 12px 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: $fp-iw-ink-mute;
|
||||
}
|
||||
|
||||
.o_fp_input_empty_state {
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
color: $fp-iw-ink-mute;
|
||||
background-color: $fp-iw-card;
|
||||
border: 1px dashed $fp-iw-border;
|
||||
border-radius: 12px;
|
||||
|
||||
strong {
|
||||
color: $fp-iw-ink-soft;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// List → cards transformation
|
||||
//
|
||||
// We keep Odoo's <list editable="bottom"> for inline editing semantics
|
||||
// (operators tab through cells, Enter saves the row) and re-render it
|
||||
// as a stack of cards via CSS only. No JS, no OWL component — the
|
||||
// existing wizard model is unchanged.
|
||||
//
|
||||
// Strategy: turn each <table>/<tr>/<td> into block-level / grid
|
||||
// containers. Hide column headers entirely. Use CSS Grid on each row
|
||||
// to position prompt + meta + value into a card layout.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_input_card_list {
|
||||
// Override the default list chrome — no border, no horizontal scroll
|
||||
.o_list_renderer {
|
||||
background: transparent;
|
||||
border: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.o_list_table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-collapse: separate;
|
||||
background: transparent;
|
||||
|
||||
// No column headers — each card carries its own labels
|
||||
> thead { display: none; }
|
||||
|
||||
> tbody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// Each row becomes a card
|
||||
tr.o_data_row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
"prompt meta"
|
||||
"value value"
|
||||
"extras extras";
|
||||
gap: 8px 16px;
|
||||
align-items: start;
|
||||
padding: 16px 20px;
|
||||
background-color: $fp-iw-card;
|
||||
border: 1px solid $fp-iw-border;
|
||||
border-radius: 12px;
|
||||
transition: border-color 150ms ease, background-color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $fp-iw-card-hover;
|
||||
}
|
||||
&:focus-within {
|
||||
border-color: $fp-iw-border-focus;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, #{$fp-iw-border-focus} 18%, transparent);
|
||||
}
|
||||
|
||||
// Per-cell rest — strip table styling; we'll re-position via
|
||||
// grid-area on the cells we actually want visible.
|
||||
> td {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
vertical-align: top;
|
||||
|
||||
// Inputs inherit row width
|
||||
.o_field_widget {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Defensive: any cell whose widget is logically invisible
|
||||
// (Odoo's invisible="..." attr) drops out of the grid so it
|
||||
// doesn't punch an empty slot in our layout.
|
||||
> td:has(.o_invisible_modifier),
|
||||
> td.o_invisible_modifier,
|
||||
> td:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// ---------- Card header — prompt name ----------
|
||||
td.o_fp_iw_prompt {
|
||||
grid-area: prompt;
|
||||
|
||||
input, .o_field_widget {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: $fp-iw-ink;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
box-shadow: none !important;
|
||||
cursor: text;
|
||||
|
||||
&[readonly], &:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
// Required asterisk — driven by data-required attribute
|
||||
// OR a server-side compute. We can't easily inspect the
|
||||
// model field here, so the asterisk is rendered by the
|
||||
// XML view via a span sibling (.o_fp_iw_required_marker).
|
||||
}
|
||||
|
||||
// ---------- Meta — type + unit pill, target range ----------
|
||||
td.o_fp_iw_meta {
|
||||
grid-area: meta;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
font-size: 0.75rem;
|
||||
color: $fp-iw-ink-mute;
|
||||
|
||||
.o_field_widget {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
// Type/unit selection looks like a pill
|
||||
select, input {
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 10px !important;
|
||||
background-color: $fp-iw-pill-bg !important;
|
||||
color: $fp-iw-ink-soft !important;
|
||||
border: 1px solid $fp-iw-border !important;
|
||||
border-radius: 999px !important;
|
||||
line-height: 1.2 !important;
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Value — the live widget for this row's type ----------
|
||||
td.o_fp_iw_value {
|
||||
grid-area: value;
|
||||
max-width: 360px;
|
||||
|
||||
// Numeric / text / date inputs — large + comfortable
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="datetime-local"],
|
||||
input:not([type]) {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
padding: 10px 14px;
|
||||
min-height: 48px;
|
||||
background-color: $fp-iw-card;
|
||||
color: $fp-iw-ink;
|
||||
border: 1px solid $fp-iw-border;
|
||||
border-radius: 8px;
|
||||
box-shadow: none;
|
||||
transition: border-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
|
||||
&:focus {
|
||||
border-color: $fp-iw-border-focus;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb,
|
||||
#{$fp-iw-border-focus} 25%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $fp-iw-ink-faint;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
// Boolean toggle — make the pill bigger, easier to tap
|
||||
.o_boolean_toggle {
|
||||
transform: scale(1.4);
|
||||
transform-origin: left center;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
// Image / photo widget
|
||||
.o_field_image {
|
||||
img, .o_image, .o_form_uri {
|
||||
max-width: 240px;
|
||||
max-height: 180px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid $fp-iw-border;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Extras — composite types (multi-point, panel) ----------
|
||||
td.o_fp_iw_extra {
|
||||
grid-area: extras;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
margin-right: 8px;
|
||||
|
||||
// Compact label-above-input grouping
|
||||
&::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: $fp-iw-ink-mute;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 80px !important;
|
||||
font-size: 1rem;
|
||||
padding: 6px 10px;
|
||||
min-height: 38px;
|
||||
background-color: $fp-iw-card;
|
||||
color: $fp-iw-ink;
|
||||
border: 1px solid $fp-iw-border;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
// Trash button column — small, right-aligned, low contrast
|
||||
td.o_list_record_remove {
|
||||
grid-area: meta;
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
opacity: 0.4;
|
||||
|
||||
&:hover { opacity: 1; }
|
||||
|
||||
button {
|
||||
color: $fp-iw-ink-mute;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 4px;
|
||||
|
||||
&:hover {
|
||||
color: $fp-iw-required;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Add a line" footer — make it a tasteful CTA card
|
||||
tfoot, .o_field_x2many_list_row_add {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
|
||||
a, td {
|
||||
display: inline-block;
|
||||
padding: 10px 18px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: $fp-iw-ink-soft;
|
||||
background-color: $fp-iw-card;
|
||||
border: 1px dashed $fp-iw-border;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, color 120ms ease;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $fp-iw-border-focus;
|
||||
border-color: $fp-iw-border-focus;
|
||||
background-color: $fp-iw-card-hover;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Tablet polish — operators on shop-floor tablets need bigger touch targets
|
||||
// =============================================================================
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.o_fp_input_card_list .o_list_table tr.o_data_row {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"prompt"
|
||||
"meta"
|
||||
"value"
|
||||
"extras";
|
||||
|
||||
td.o_fp_iw_meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
td.o_fp_iw_value {
|
||||
max-width: 100%;
|
||||
|
||||
input { min-height: 56px; }
|
||||
}
|
||||
|
||||
td.o_list_record_remove {
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -826,3 +826,231 @@ class TestContractReviewStepRouting(TransactionCase):
|
||||
'Non-CR steps must NOT be redirected to QA-005, got: %r'
|
||||
% action,
|
||||
)
|
||||
|
||||
|
||||
class TestSequentialEnforcement(TransactionCase):
|
||||
"""Sub 13 — recipe-level + per-step sequential enforcement.
|
||||
|
||||
Decision matrix being verified:
|
||||
recipe.enforce_sequential | step.parallel_start | step.req_pred (legacy) | block?
|
||||
--------------------------|---------------------|------------------------|------
|
||||
True | False | any | YES
|
||||
True | True | any | no
|
||||
False | any | True | YES
|
||||
False | any | False | no
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from odoo.exceptions import UserError
|
||||
self._UserError = UserError
|
||||
self.partner = self.env['res.partner'].create({'name': 'Seq Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Seq W'})
|
||||
self.wc = self.env['fp.work.centre'].create({
|
||||
'name': 'Bench', 'code': 'BENCH', 'kind': 'inspection',
|
||||
})
|
||||
|
||||
def _build_recipe(self, enforce_sequential=True, names=None):
|
||||
"""Build a 3-step recipe with the given enforcement setting."""
|
||||
names = names or ['Step A', 'Step B', 'Step C']
|
||||
recipe = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Seq Recipe (%s)' % (
|
||||
'sequential' if enforce_sequential else 'free-flow'),
|
||||
'node_type': 'recipe',
|
||||
'enforce_sequential': enforce_sequential,
|
||||
})
|
||||
nodes = []
|
||||
for i, n in enumerate(names):
|
||||
nodes.append(self.env['fusion.plating.process.node'].create({
|
||||
'name': n,
|
||||
'node_type': 'operation',
|
||||
'parent_id': recipe.id,
|
||||
'sequence': (i + 1) * 10,
|
||||
}))
|
||||
return recipe, nodes
|
||||
|
||||
def _build_job(self, recipe, nodes):
|
||||
"""Create a job + matching steps in (pending) state.
|
||||
Skips the auto-generator so the test doesn't depend on the SO
|
||||
fixture chain.
|
||||
"""
|
||||
job = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'recipe_id': recipe.id,
|
||||
})
|
||||
steps = []
|
||||
for n in nodes:
|
||||
steps.append(self.env['fp.job.step'].create({
|
||||
'job_id': job.id,
|
||||
'name': n.name,
|
||||
'recipe_node_id': n.id,
|
||||
'work_centre_id': self.wc.id,
|
||||
'sequence': n.sequence,
|
||||
'kind': 'other',
|
||||
'state': 'ready',
|
||||
}))
|
||||
# job.enforce_sequential is a related from recipe.enforce_sequential
|
||||
# — invalidate to force re-read after the fact-of-life writes above.
|
||||
job.invalidate_recordset(['enforce_sequential'])
|
||||
return job, steps
|
||||
|
||||
# ---- Sequential mode (the new default) -----------------------------
|
||||
|
||||
def test_sequential_default_blocks_out_of_order_start(self):
|
||||
recipe, nodes = self._build_recipe(enforce_sequential=True)
|
||||
job, steps = self._build_job(recipe, nodes)
|
||||
a, b, c = steps
|
||||
# Start A first — should succeed
|
||||
a.button_start()
|
||||
self.assertEqual(a.state, 'in_progress')
|
||||
# Now try to start C while A is still in_progress
|
||||
with self.assertRaises(self._UserError):
|
||||
c.button_start()
|
||||
|
||||
def test_sequential_starting_first_step_works(self):
|
||||
recipe, nodes = self._build_recipe(enforce_sequential=True)
|
||||
job, steps = self._build_job(recipe, nodes)
|
||||
a, b, c = steps
|
||||
# First step has no predecessors → should always be allowed
|
||||
a.button_start()
|
||||
self.assertEqual(a.state, 'in_progress')
|
||||
|
||||
def test_sequential_after_predecessor_finishes_unlocks_next(self):
|
||||
recipe, nodes = self._build_recipe(enforce_sequential=True)
|
||||
job, steps = self._build_job(recipe, nodes)
|
||||
a, b, c = steps
|
||||
a.button_start()
|
||||
a.button_finish()
|
||||
# Now B should be startable
|
||||
b.button_start()
|
||||
self.assertEqual(b.state, 'in_progress')
|
||||
|
||||
def test_sequential_skipped_predecessor_unlocks_next(self):
|
||||
recipe, nodes = self._build_recipe(enforce_sequential=True)
|
||||
job, steps = self._build_job(recipe, nodes)
|
||||
a, b, c = steps
|
||||
a.button_skip()
|
||||
b.button_start()
|
||||
self.assertEqual(b.state, 'in_progress')
|
||||
|
||||
# ---- Per-step parallel_start opt-out --------------------------------
|
||||
|
||||
def test_parallel_start_step_can_start_anytime(self):
|
||||
recipe, nodes = self._build_recipe(enforce_sequential=True)
|
||||
nodes[2].parallel_start = True # mark Step C as parallel
|
||||
job, steps = self._build_job(recipe, nodes)
|
||||
a, b, c = steps
|
||||
# Start A so B+C are blocked under normal sequential rules
|
||||
a.button_start()
|
||||
# B is still blocked (default behaviour)
|
||||
with self.assertRaises(self._UserError):
|
||||
b.button_start()
|
||||
# C is parallel — should start fine while A is in_progress
|
||||
c.button_start()
|
||||
self.assertEqual(c.state, 'in_progress')
|
||||
|
||||
# ---- Free-flow mode (legacy escape hatch) ---------------------------
|
||||
|
||||
def test_free_flow_does_not_block(self):
|
||||
recipe, nodes = self._build_recipe(enforce_sequential=False)
|
||||
job, steps = self._build_job(recipe, nodes)
|
||||
a, b, c = steps
|
||||
# All three startable in any order — no enforcement
|
||||
c.button_start()
|
||||
a.button_start()
|
||||
b.button_start()
|
||||
self.assertEqual(a.state, 'in_progress')
|
||||
self.assertEqual(b.state, 'in_progress')
|
||||
self.assertEqual(c.state, 'in_progress')
|
||||
|
||||
def test_free_flow_with_legacy_per_step_flag_still_blocks(self):
|
||||
recipe, nodes = self._build_recipe(enforce_sequential=False)
|
||||
nodes[2].requires_predecessor_done = True # legacy flag
|
||||
job, steps = self._build_job(recipe, nodes)
|
||||
a, b, c = steps
|
||||
a.button_start()
|
||||
# Even in free-flow, the legacy flag forces C to wait
|
||||
with self.assertRaises(self._UserError):
|
||||
c.button_start()
|
||||
|
||||
# ---- Manager bypass --------------------------------------------------
|
||||
|
||||
def test_manager_bypass_via_context(self):
|
||||
recipe, nodes = self._build_recipe(enforce_sequential=True)
|
||||
job, steps = self._build_job(recipe, nodes)
|
||||
a, b, c = steps
|
||||
a.button_start()
|
||||
# Manager override skips the gate
|
||||
c.with_context(fp_skip_predecessor_check=True).button_start()
|
||||
self.assertEqual(c.state, 'in_progress')
|
||||
|
||||
# ---- can_start compute ----------------------------------------------
|
||||
|
||||
def test_can_start_compute_reflects_gate(self):
|
||||
recipe, nodes = self._build_recipe(enforce_sequential=True)
|
||||
job, steps = self._build_job(recipe, nodes)
|
||||
a, b, c = steps
|
||||
# All ready — only first step can start
|
||||
steps.invalidate_recordset(['can_start'])
|
||||
self.assertTrue(a.can_start, 'First step has no predecessor — should be startable')
|
||||
self.assertFalse(b.can_start, 'Step B blocked by Step A (ready, not done)')
|
||||
self.assertFalse(c.can_start, 'Step C blocked by Step A')
|
||||
# After A finishes, B becomes startable (C still blocked by B)
|
||||
a.button_start()
|
||||
a.button_finish()
|
||||
steps.invalidate_recordset(['can_start'])
|
||||
self.assertTrue(b.can_start)
|
||||
self.assertFalse(c.can_start)
|
||||
|
||||
def test_can_start_false_for_done_steps(self):
|
||||
recipe, nodes = self._build_recipe(enforce_sequential=True)
|
||||
job, steps = self._build_job(recipe, nodes)
|
||||
a, b, c = steps
|
||||
a.button_start()
|
||||
a.button_finish()
|
||||
steps.invalidate_recordset(['can_start'])
|
||||
self.assertFalse(a.can_start, 'Done step is not startable')
|
||||
|
||||
def test_can_start_true_for_parallel_step(self):
|
||||
recipe, nodes = self._build_recipe(enforce_sequential=True)
|
||||
nodes[2].parallel_start = True
|
||||
job, steps = self._build_job(recipe, nodes)
|
||||
a, b, c = steps
|
||||
a.button_start()
|
||||
steps.invalidate_recordset(['can_start'])
|
||||
self.assertTrue(c.can_start, 'parallel_start step should always be startable')
|
||||
|
||||
# ---- Library template snapshot --------------------------------------
|
||||
|
||||
def test_parallel_start_snapshots_from_library_template(self):
|
||||
"""When a library template with parallel_start=True is dropped
|
||||
into a recipe via the Simple Editor controller, the new
|
||||
recipe-node should inherit the flag.
|
||||
"""
|
||||
tpl = self.env['fp.step.template'].create({
|
||||
'name': 'Parallel paperwork step',
|
||||
'parallel_start': True,
|
||||
})
|
||||
recipe = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Snap recipe',
|
||||
'node_type': 'recipe',
|
||||
})
|
||||
# Mimic the controller's _SNAPSHOT_FIELDS copy
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import _SNAPSHOT_FIELDS
|
||||
self.assertIn(
|
||||
'parallel_start', _SNAPSHOT_FIELDS,
|
||||
'parallel_start must be in the controller _SNAPSHOT_FIELDS list',
|
||||
)
|
||||
new_node = self.env['fusion.plating.process.node'].create({
|
||||
'parent_id': recipe.id,
|
||||
'node_type': 'operation',
|
||||
'sequence': 10,
|
||||
**{f: tpl[f] for f in _SNAPSHOT_FIELDS},
|
||||
})
|
||||
self.assertTrue(
|
||||
new_node.parallel_start,
|
||||
'parallel_start did not snapshot from library template',
|
||||
)
|
||||
|
||||
@@ -93,11 +93,154 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- v3 — Card-based stacked layout (light + dark mode aware) -->
|
||||
<!-- -->
|
||||
<!-- Replaces v2's wide editable table. Each measurement renders as a -->
|
||||
<!-- card with: prompt name (header), type/unit pills (meta), and ONLY -->
|
||||
<!-- the live input widget for that prompt's type (value). Composite -->
|
||||
<!-- types (multi-point thickness, bath chemistry panel) keep their -->
|
||||
<!-- sub-fields inside the same card via the "extras" grid area. -->
|
||||
<!-- -->
|
||||
<!-- Auto-adapts to custom recipes/steps because default_get on the -->
|
||||
<!-- model already pre-fills line_ids from recipe_node.input_ids. -->
|
||||
<!-- -->
|
||||
<!-- All visual styling lives in -->
|
||||
<!-- static/src/scss/fp_job_step_input_wizard_v3.scss -->
|
||||
<!-- which is registered in BOTH web.assets_backend AND -->
|
||||
<!-- web.assets_web_dark so both themes compile their own palette. -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="view_fp_job_step_input_wizard_form_v3" model="ir.ui.view">
|
||||
<field name="name">fp.job.step.input.wizard.form.v3</field>
|
||||
<field name="model">fp.job.step.input.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Record Step Inputs">
|
||||
<sheet class="o_fp_input_wizard_v3">
|
||||
<div class="o_fp_input_header">
|
||||
<h2><field name="step_id" readonly="1" nolabel="1"/></h2>
|
||||
<p class="o_fp_input_subhead">
|
||||
Job <field name="job_id" readonly="1" nolabel="1"/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_input_section_title">Measurements</div>
|
||||
|
||||
<p class="o_fp_input_empty_state" invisible="line_ids">
|
||||
No measurement prompts on this step yet.
|
||||
Click <strong>Add a line</strong> below to record an
|
||||
ad-hoc reading.
|
||||
</p>
|
||||
|
||||
<field name="line_ids" class="o_fp_input_card_list" nolabel="1">
|
||||
<list editable="bottom" create="true" delete="true">
|
||||
<!-- Hidden flag fields — drive value-cell visibility -->
|
||||
<field name="is_authored" column_invisible="1"/>
|
||||
<field name="is_boolean_type" column_invisible="1"/>
|
||||
<field name="is_date_type" column_invisible="1"/>
|
||||
<field name="is_numeric_type" column_invisible="1"/>
|
||||
<field name="is_photo_type" column_invisible="1"/>
|
||||
<field name="is_multi_point_type" column_invisible="1"/>
|
||||
<field name="is_panel_type" column_invisible="1"/>
|
||||
<field name="point_avg" column_invisible="1"/>
|
||||
<field name="photo_filename" column_invisible="1"/>
|
||||
|
||||
<!-- Card header — prompt name (large, bold via SCSS) -->
|
||||
<field name="name"
|
||||
string="Measurement"
|
||||
readonly="is_authored"
|
||||
placeholder="e.g. Oven Temp, Bath Reading, Operator Initials"
|
||||
class="o_fp_iw_prompt"/>
|
||||
|
||||
<!-- Meta — type + unit rendered as pills (top-right) -->
|
||||
<field name="input_type"
|
||||
string="Type"
|
||||
readonly="is_authored"
|
||||
class="o_fp_iw_meta"/>
|
||||
<field name="target_unit"
|
||||
string="Unit"
|
||||
readonly="is_authored"
|
||||
class="o_fp_iw_meta"
|
||||
optional="show"/>
|
||||
|
||||
<!-- Hidden by default — operator can opt in via the cog menu
|
||||
if they want to see/edit target ranges per row -->
|
||||
<field name="target_min" optional="hide"/>
|
||||
<field name="target_max" optional="hide"/>
|
||||
|
||||
<!-- Mutually exclusive value widgets — only the one
|
||||
matching the row's input_type renders -->
|
||||
<field name="value_number"
|
||||
string="Value"
|
||||
invisible="not is_numeric_type"
|
||||
class="o_fp_iw_value"/>
|
||||
<field name="value_boolean"
|
||||
string="Value"
|
||||
widget="boolean_toggle"
|
||||
invisible="not is_boolean_type"
|
||||
class="o_fp_iw_value"/>
|
||||
<field name="value_date"
|
||||
string="Value"
|
||||
invisible="not is_date_type"
|
||||
class="o_fp_iw_value"/>
|
||||
<field name="value_text"
|
||||
string="Value"
|
||||
invisible="is_numeric_type or is_boolean_type or is_date_type or is_photo_type or is_multi_point_type or is_panel_type"
|
||||
class="o_fp_iw_value"/>
|
||||
<field name="photo_value"
|
||||
string="Photo"
|
||||
widget="image"
|
||||
options="{'preview_image': 'photo_value'}"
|
||||
invisible="not is_photo_type"
|
||||
class="o_fp_iw_value"/>
|
||||
|
||||
<!-- Composite type 1: Multi-Point Thickness — 5 readings -->
|
||||
<field name="point_1" string="R1"
|
||||
invisible="not is_multi_point_type"
|
||||
class="o_fp_iw_extra" optional="show"/>
|
||||
<field name="point_2" string="R2"
|
||||
invisible="not is_multi_point_type"
|
||||
class="o_fp_iw_extra" optional="show"/>
|
||||
<field name="point_3" string="R3"
|
||||
invisible="not is_multi_point_type"
|
||||
class="o_fp_iw_extra" optional="show"/>
|
||||
<field name="point_4" string="R4"
|
||||
invisible="not is_multi_point_type"
|
||||
class="o_fp_iw_extra" optional="hide"/>
|
||||
<field name="point_5" string="R5"
|
||||
invisible="not is_multi_point_type"
|
||||
class="o_fp_iw_extra" optional="hide"/>
|
||||
|
||||
<!-- Composite type 2: Bath Chemistry Panel -->
|
||||
<field name="panel_ph" string="pH"
|
||||
invisible="not is_panel_type"
|
||||
class="o_fp_iw_extra"/>
|
||||
<field name="panel_concentration" string="Conc"
|
||||
invisible="not is_panel_type"
|
||||
class="o_fp_iw_extra"/>
|
||||
<field name="panel_temperature" string="Temp"
|
||||
invisible="not is_panel_type"
|
||||
class="o_fp_iw_extra"/>
|
||||
<field name="panel_bath_id" string="Bath"
|
||||
invisible="not is_panel_type"
|
||||
class="o_fp_iw_extra"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_commit" type="object"
|
||||
string="Save" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_job_step_input_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Record Step Inputs</field>
|
||||
<field name="res_model">fp.job.step.input.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_fp_job_step_input_wizard_form_v2"/>
|
||||
<field name="view_id" ref="view_fp_job_step_input_wizard_form_v3"/>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user