feat(plating): session 2026-05-23 deploys — F1/F7/S22/S23 + UI fixes
Consolidated commit of session work already deployed to entech and verified via the deep audit + the persona walk: S22 — Signoff gate (fp.job.step.requires_signoff was 100% unenforced, 42/42 done steps had NULL signoff_user_id). Three-piece fix: _fp_autosign_if_required (captures finisher on button_finish), _fp_check_signoff_complete (raises UserError if NULL after autosign), action_signoff (explicit supervisor pre-sign). Bypass: fp_skip_signoff_gate=True. S23 — Transition-form gate (same dormant-field shape as S22, caught preventively before recipe authors flipped requires_transition_form on). Model helpers on fp.job.step.move + controller gate in move_controller (parts commit) + pre-reject in rack commit. F7 — Chatter standardization: _fp_create_qc_check_if_needed, _fp_fire_notification, _fp_create_delivery silent failures now also post to job chatter instead of only logging to file. UI fixes: - Critical Rule 20 documented + applied: OWL templates only expose Math as a global. Calling String(d) inside t-on-click throws 'v2 is not a function'. Fixed pin_pad.xml (string array instead of number array with String() coercion). Also swept parseInt/ parseFloat in recipe_tree_editor + simple_recipe_editor. - Notes panel HTML escape fix: chatter messages off /fp/workspace/load were rendered via t-out, escaping the HTML. Wrap with markup() in job_workspace.js refresh() before assigning to state. Versions: fusion_plating 19.0.20.8.0 → 19.0.20.9.0 fusion_plating_jobs 19.0.10.20.0 → 19.0.10.23.0 fusion_plating_shopfloor 19.0.30.2.0 → 19.0.30.5.0 All deployed to entech (LXC 111) and verified live. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.20.8.0',
|
||||
'version': '19.0.20.10.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpJobStepMove(models.Model):
|
||||
@@ -74,6 +77,92 @@ class FpJobStepMove(models.Model):
|
||||
string='Transition Input Values',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# S23 — required transition-input gate
|
||||
# ------------------------------------------------------------------
|
||||
# When the destination step has requires_transition_form=True, the
|
||||
# recipe author wants chain-of-custody attestations captured on the
|
||||
# move (location, photo, customer WO #, etc.). Same dormant-field
|
||||
# shape as S22's signoff bug — the field existed but nothing enforced
|
||||
# it. Callers (tablet controllers, future backend wizards) MUST call
|
||||
# _fp_check_transition_inputs_complete() after writing values to
|
||||
# transition_input_value_ids.
|
||||
#
|
||||
# We can't gate on create() because values are written in a separate
|
||||
# call after the move row. Model-level enforcement would require
|
||||
# either a deferred-commit pattern or a write hook; explicit caller
|
||||
# invocation is the simplest contract.
|
||||
|
||||
def _fp_missing_required_transition_inputs(self):
|
||||
"""Return the recordset of required transition_input prompts on
|
||||
the to_step's recipe node that have NO captured value on this
|
||||
move. Centralised helper — used by the gate below and by future
|
||||
diagnostics."""
|
||||
self.ensure_one()
|
||||
Prompt = self.env['fusion.plating.process.node.input']
|
||||
to_step = self.to_step_id
|
||||
if not to_step or not to_step.recipe_node_id:
|
||||
return Prompt
|
||||
if not to_step.requires_transition_form:
|
||||
return Prompt
|
||||
prompts = to_step.recipe_node_id.input_ids
|
||||
if 'kind' in prompts._fields:
|
||||
prompts = prompts.filtered(
|
||||
lambda i: i.kind == 'transition_input')
|
||||
if 'collect' in prompts._fields:
|
||||
prompts = prompts.filtered(lambda i: i.collect)
|
||||
required_prompts = prompts.filtered(lambda i: i.required)
|
||||
if not required_prompts:
|
||||
return Prompt
|
||||
recorded_input_ids = set(
|
||||
self.transition_input_value_ids.mapped('node_input_id.id')
|
||||
)
|
||||
return required_prompts.filtered(
|
||||
lambda p: p.id not in recorded_input_ids
|
||||
)
|
||||
|
||||
def _fp_check_transition_inputs_complete(self):
|
||||
"""Raise UserError when the destination step has
|
||||
requires_transition_form=True and required transition_input
|
||||
prompts haven't been recorded on this move. Audit gate — same
|
||||
shape as fp.job.step._fp_check_step_inputs_complete (S21) and
|
||||
._fp_check_signoff_complete (S22).
|
||||
|
||||
Manager bypass via context fp_skip_transition_form=True
|
||||
(consistent with the existing audit-trail flag on the tablet
|
||||
controllers). Bypasses are posted to chatter on the move
|
||||
record naming the user.
|
||||
"""
|
||||
if self.env.context.get('fp_skip_transition_form'):
|
||||
for move in self:
|
||||
if not move.to_step_id.requires_transition_form:
|
||||
continue
|
||||
move.message_post(body=Markup(_(
|
||||
'Transition-form gate bypassed by %s. '
|
||||
'Documented deviation — required prompts not '
|
||||
'recorded on this move.'
|
||||
)) % self.env.user.name)
|
||||
return
|
||||
for move in self:
|
||||
missing = move._fp_missing_required_transition_inputs()
|
||||
if not missing:
|
||||
continue
|
||||
names = ', '.join(
|
||||
'"%s"' % (p.name or '').strip() for p in missing
|
||||
)
|
||||
raise UserError(_(
|
||||
'Move to step "%(step)s" cannot be committed — '
|
||||
'%(n)s required transition prompt(s) not recorded: '
|
||||
'%(names)s. Fill them in the Move dialog before '
|
||||
'committing. Managers can override via context flag '
|
||||
'fp_skip_transition_form=True for documented '
|
||||
'deviations.'
|
||||
) % {
|
||||
'step': move.to_step_id.name,
|
||||
'n': len(missing),
|
||||
'names': names,
|
||||
})
|
||||
|
||||
|
||||
class FpJobStepMoveInputValue(models.Model):
|
||||
"""Captured value for one transition-input prompt.
|
||||
|
||||
@@ -321,7 +321,7 @@
|
||||
<label>Estimated Duration (min)</label>
|
||||
<input type="number" class="form-control" min="0" step="1"
|
||||
t-att-value="state.selectedNode.estimated_duration || 0"
|
||||
t-on-change="(ev) => { state.selectedNode.estimated_duration = parseFloat(ev.target.value) || 0; }"/>
|
||||
t-on-change="(ev) => { state.selectedNode.estimated_duration = (+ev.target.value) || 0; }"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_re_field">
|
||||
@@ -380,7 +380,7 @@
|
||||
<label for="fp_re_workflow_state">Triggers Workflow State</label>
|
||||
<select id="fp_re_workflow_state"
|
||||
class="form-select"
|
||||
t-on-change="(ev) => { state.selectedNode.triggers_workflow_state_id = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
|
||||
t-on-change="(ev) => { state.selectedNode.triggers_workflow_state_id = ev.target.value ? (+ev.target.value) : false; }">
|
||||
<option value=""
|
||||
t-att-selected="!state.selectedNode.triggers_workflow_state_id">
|
||||
— None (use default-kind matching) —
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
t-if="state.workflowStates and state.workflowStates.length">
|
||||
<label>Triggers Workflow State</label>
|
||||
<select class="form-select"
|
||||
t-on-change="(ev) => { state.editTriggersWorkflowStateId = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
|
||||
t-on-change="(ev) => { state.editTriggersWorkflowStateId = ev.target.value ? (+ev.target.value) : false; }">
|
||||
<option value="" t-att-selected="!state.editTriggersWorkflowStateId">— None (use Step Type) —</option>
|
||||
<t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id">
|
||||
<option t-att-value="ws.id"
|
||||
@@ -598,7 +598,7 @@
|
||||
t-if="state.workflowStates and state.workflowStates.length">
|
||||
<label class="form-label">Triggers Workflow State</label>
|
||||
<select class="form-select"
|
||||
t-on-change="(ev) => { state.libraryEditor.triggers_workflow_state_id = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
|
||||
t-on-change="(ev) => { state.libraryEditor.triggers_workflow_state_id = ev.target.value ? (+ev.target.value) : false; }">
|
||||
<option value=""
|
||||
t-att-selected="!state.libraryEditor.triggers_workflow_state_id">
|
||||
— None (use default-kind matching) —
|
||||
|
||||
Reference in New Issue
Block a user