changes
This commit is contained in:
6
fusion_plating/fusion_plating_jobs/wizards/__init__.py
Normal file
6
fusion_plating/fusion_plating_jobs/wizards/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import fp_job_step_move_wizard
|
||||
from . import fp_job_step_input_wizard
|
||||
@@ -0,0 +1,217 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
"""Backend Step Input Recording wizard.
|
||||
|
||||
Operator-recorded measurements during a step (Soak Clean Time/Temp,
|
||||
ElectroClean Amperage, E-Nickel Plate Temp, Plating Thickness, etc.)
|
||||
that the customer's WO traveler ends with handwritten in pen.
|
||||
|
||||
These values are the per-step `step_input` prompts authored on the
|
||||
recipe node (fp.step.template.input.kind == 'step_input'). On the
|
||||
tablet they're captured via the QC checklist OWL component; the
|
||||
backend wizard gives the manager the same capability without leaving
|
||||
the job form.
|
||||
|
||||
Captured values land on a synthetic `fp.job.step.move` row with
|
||||
transfer_type='step' (an in-place move, no destination change) so the
|
||||
existing CoC chronological QWeb template renders them in the same
|
||||
format as the tablet-captured values — single source of truth for
|
||||
report rendering.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
|
||||
|
||||
|
||||
# Same selection list as fp.step.template.input.input_type so authored
|
||||
# rows + ad-hoc rows pick from the same vocabulary.
|
||||
_FP_INPUT_TYPE_SELECTION = [
|
||||
('text', 'Text'),
|
||||
('number', 'Number'),
|
||||
('boolean', 'Yes/No'),
|
||||
('selection', 'Selection'),
|
||||
('date', 'Date / Time'),
|
||||
('signature', 'Signature'),
|
||||
('time_hms', 'Time (HH:MM:SS)'),
|
||||
('time_seconds', 'Time (seconds)'),
|
||||
('temperature', 'Temperature'),
|
||||
('thickness', 'Thickness'),
|
||||
('pass_fail', 'Pass / Fail'),
|
||||
]
|
||||
|
||||
|
||||
class FpJobStepInputWizard(models.TransientModel):
|
||||
_name = 'fp.job.step.input.wizard'
|
||||
_description = 'Fusion Plating — Step Input Recording (Backend)'
|
||||
|
||||
step_id = fields.Many2one(
|
||||
'fp.job.step', string='Step', required=True, readonly=True,
|
||||
)
|
||||
job_id = fields.Many2one(
|
||||
related='step_id.job_id', string='Job', store=False, readonly=True,
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fp.job.step.input.wizard.line', 'wizard_id',
|
||||
string='Inputs',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
defaults = super().default_get(fields_list)
|
||||
ctx = self.env.context
|
||||
step_id = ctx.get('default_step_id') or ctx.get('active_id')
|
||||
if not step_id:
|
||||
return defaults
|
||||
step = self.env['fp.job.step'].browse(step_id)
|
||||
if not step.exists() or not step.recipe_node_id:
|
||||
return defaults
|
||||
defaults['step_id'] = step.id
|
||||
node = step.recipe_node_id
|
||||
# Filter to step_input prompts only — transition inputs go on the
|
||||
# Move wizard, not here.
|
||||
inputs = node.input_ids
|
||||
if 'kind' in inputs._fields:
|
||||
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
|
||||
defaults['line_ids'] = [(0, 0, {
|
||||
'node_input_id': inp.id,
|
||||
'name': inp.name,
|
||||
'input_type': inp.input_type,
|
||||
'target_min': getattr(inp, 'target_min', 0.0) or 0.0,
|
||||
'target_max': getattr(inp, 'target_max', 0.0) or 0.0,
|
||||
'target_unit': getattr(inp, 'target_unit', False) or False,
|
||||
}) for inp in inputs]
|
||||
return defaults
|
||||
|
||||
def action_commit(self):
|
||||
self.ensure_one()
|
||||
if not self.line_ids:
|
||||
raise UserError(_(
|
||||
'Add at least one input row before clicking Record. '
|
||||
'Click "Add a line" in the table above to enter an '
|
||||
'ad-hoc measurement.'
|
||||
))
|
||||
# Ad-hoc rows must have a prompt name — otherwise we can't tell
|
||||
# what was being measured on the audit trail.
|
||||
unnamed = self.line_ids.filtered(
|
||||
lambda l: not l.node_input_id and not (l.name or '').strip()
|
||||
)
|
||||
if unnamed:
|
||||
raise UserError(_(
|
||||
'Every ad-hoc input row needs a Prompt label so the '
|
||||
'audit trail captures what was measured. %s row(s) missing '
|
||||
'a prompt.'
|
||||
) % len(unnamed))
|
||||
# Synthetic in-place move so the chronological CoC template picks
|
||||
# up these values alongside transition-input values without a
|
||||
# second QWeb branch.
|
||||
Move = self.env['fp.job.step.move']
|
||||
move = Move.create({
|
||||
'job_id': self.step_id.job_id.id,
|
||||
'from_step_id': self.step_id.id,
|
||||
'to_step_id': self.step_id.id,
|
||||
'transfer_type': 'step',
|
||||
'qty_moved': int(self.step_id.job_id.qty or 1),
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
ValueModel = self.env['fp.job.step.move.input.value']
|
||||
captured = 0
|
||||
for line in self.line_ids:
|
||||
if not line._has_value():
|
||||
continue
|
||||
vals = {
|
||||
'move_id': move.id,
|
||||
'node_input_id': line.node_input_id.id or False,
|
||||
'value_text': line.value_text or False,
|
||||
'value_number': line.value_number or 0.0,
|
||||
'value_boolean': line.value_boolean,
|
||||
'value_date': line.value_date or False,
|
||||
}
|
||||
# For ad-hoc rows (no node_input_id), preserve the operator's
|
||||
# typed prompt label in value_text so the chronological CoC
|
||||
# report still shows what was measured. Format: "Prompt: value"
|
||||
if not line.node_input_id and line.name:
|
||||
if vals['value_text']:
|
||||
vals['value_text'] = f"{line.name}: {vals['value_text']}"
|
||||
elif vals['value_number']:
|
||||
vals['value_text'] = (
|
||||
f"{line.name}: {vals['value_number']}"
|
||||
+ (f" {line.target_unit}" if line.target_unit else '')
|
||||
)
|
||||
else:
|
||||
vals['value_text'] = line.name
|
||||
ValueModel.create(vals)
|
||||
captured += 1
|
||||
if captured == 0:
|
||||
move.unlink()
|
||||
raise UserError(_(
|
||||
'Enter at least one value before saving.'
|
||||
))
|
||||
self.step_id.message_post(body=_(
|
||||
'%(n)s step input(s) recorded by %(user)s'
|
||||
) % {'n': captured, 'user': self.env.user.name})
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
|
||||
class FpJobStepInputWizardLine(models.TransientModel):
|
||||
_name = 'fp.job.step.input.wizard.line'
|
||||
_description = 'Fusion Plating — Step Input Wizard Line'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'fp.job.step.input.wizard', required=True, ondelete='cascade',
|
||||
)
|
||||
# 2026-04-28 fix — node_input_id is optional now so operators can
|
||||
# record ad-hoc measurements when the recipe has no authored prompts
|
||||
# (the screenshot case: a step with zero step_input definitions
|
||||
# rendered an empty wizard with no way to add anything). Authored
|
||||
# prompts pre-fill name + type as readonly; ad-hoc rows are fully
|
||||
# editable.
|
||||
node_input_id = fields.Many2one(
|
||||
'fusion.plating.process.node.input', ondelete='set null',
|
||||
)
|
||||
name = fields.Char(string='Prompt')
|
||||
# 2026-04-28 — convert input_type + target_unit from Char → Selection
|
||||
# so operators pick from the curated dropdown. Free-text led to "kg"
|
||||
# vs "kgs" vs "kilo" inconsistencies on the audit trail.
|
||||
input_type = fields.Selection(
|
||||
_FP_INPUT_TYPE_SELECTION,
|
||||
string='Type',
|
||||
)
|
||||
target_min = fields.Float(string='Min')
|
||||
target_max = fields.Float(string='Max')
|
||||
target_unit = fields.Selection(
|
||||
FP_UOM_SELECTION,
|
||||
string='Unit',
|
||||
help='Pick from the curated list — keeps every step\'s readings '
|
||||
'in the same vocabulary across the shop.',
|
||||
)
|
||||
|
||||
value_text = fields.Char(string='Text')
|
||||
value_number = fields.Float(string='Number')
|
||||
value_boolean = fields.Boolean(string='Yes/No')
|
||||
value_date = fields.Datetime(string='Date / Time')
|
||||
|
||||
is_authored = fields.Boolean(
|
||||
compute='_compute_is_authored',
|
||||
help='True when this row originated from an authored recipe input. '
|
||||
'Drives field readonly state — authored prompts are locked, '
|
||||
'ad-hoc rows are fully editable.',
|
||||
)
|
||||
|
||||
@api.depends('node_input_id')
|
||||
def _compute_is_authored(self):
|
||||
for rec in self:
|
||||
rec.is_authored = bool(rec.node_input_id)
|
||||
|
||||
def _has_value(self):
|
||||
self.ensure_one()
|
||||
return any([
|
||||
self.value_text,
|
||||
self.value_number,
|
||||
self.value_boolean,
|
||||
self.value_date,
|
||||
])
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_job_step_input_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.job.step.input.wizard.form</field>
|
||||
<field name="model">fp.job.step.input.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Record Step Inputs">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="step_id" readonly="1"/>
|
||||
<field name="job_id" readonly="1"/>
|
||||
</group>
|
||||
<separator string="Step Inputs"/>
|
||||
<p class="text-muted" invisible="line_ids">
|
||||
No authored prompts on this recipe step. Click
|
||||
<strong>Add a line</strong> below to record one or
|
||||
more ad-hoc measurements (operator name + value).
|
||||
Authored prompts will appear here automatically once
|
||||
the recipe gets `step_input` rows in the Process
|
||||
Composer.
|
||||
</p>
|
||||
<field name="line_ids">
|
||||
<list editable="bottom">
|
||||
<field name="is_authored" column_invisible="1"/>
|
||||
<field name="name"
|
||||
readonly="is_authored"
|
||||
placeholder="e.g. Oven Temp, Operator Initials, Bath Reading"/>
|
||||
<field name="input_type"
|
||||
readonly="is_authored"
|
||||
placeholder="number / text / boolean / date"
|
||||
optional="show"/>
|
||||
<field name="target_min" readonly="is_authored" optional="hide"/>
|
||||
<field name="target_max" readonly="is_authored" optional="hide"/>
|
||||
<field name="target_unit" readonly="is_authored" optional="show"/>
|
||||
<field name="value_text"/>
|
||||
<field name="value_number"/>
|
||||
<field name="value_boolean" widget="boolean_toggle"/>
|
||||
<field name="value_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_commit" type="object"
|
||||
string="Record" 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="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,344 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
"""Backend Move-to-Next wizard.
|
||||
|
||||
Mirrors the tablet's Move Parts dialog (`fusion_plating_shopfloor`'s
|
||||
`move_parts_dialog.js`) so a manager running the whole job from the
|
||||
backend form on a low-staffing day captures the same chain-of-custody
|
||||
record the operator would create from the tablet — same `fp.job.step.move`
|
||||
row + same `transition_input_value_ids` snapshot, same chatter trail,
|
||||
same downstream report rendering.
|
||||
|
||||
Compliance prompts (transition inputs authored on the recipe node)
|
||||
appear as editable rows on the wizard. Submit creates the move log,
|
||||
finishes the from-step if it's still in_progress, and starts the
|
||||
to-step.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
# Mirror the Selection on the Record Inputs wizard so both dialogs use
|
||||
# the same Type vocabulary.
|
||||
_FP_INPUT_TYPE_SELECTION = [
|
||||
('text', 'Text'),
|
||||
('number', 'Number'),
|
||||
('boolean', 'Yes/No'),
|
||||
('selection', 'Selection'),
|
||||
('date', 'Date / Time'),
|
||||
('signature', 'Signature'),
|
||||
('time_hms', 'Time (HH:MM:SS)'),
|
||||
('time_seconds', 'Time (seconds)'),
|
||||
('temperature', 'Temperature'),
|
||||
('thickness', 'Thickness'),
|
||||
('pass_fail', 'Pass / Fail'),
|
||||
]
|
||||
|
||||
|
||||
class FpJobStepMoveWizard(models.TransientModel):
|
||||
_name = 'fp.job.step.move.wizard'
|
||||
_description = 'Fusion Plating — Move Step Wizard (Backend)'
|
||||
|
||||
job_id = fields.Many2one('fp.job', string='Job', required=True, readonly=True)
|
||||
from_step_id = fields.Many2one(
|
||||
'fp.job.step',
|
||||
string='From Step',
|
||||
required=True,
|
||||
domain="[('job_id', '=', job_id)]",
|
||||
)
|
||||
to_step_id = fields.Many2one(
|
||||
'fp.job.step',
|
||||
string='To Step',
|
||||
required=True,
|
||||
domain="[('job_id', '=', job_id), ('id', '!=', from_step_id)]",
|
||||
help='Defaults to the next sequenced step on this job.',
|
||||
)
|
||||
transfer_type = fields.Selection(
|
||||
[
|
||||
('step', 'Step'),
|
||||
('hold', 'Hold'),
|
||||
('scrap', 'Scrap'),
|
||||
('rework', 'Rework'),
|
||||
('split', 'Split'),
|
||||
('return', 'Return'),
|
||||
],
|
||||
string='Transfer Type', default='step', required=True,
|
||||
)
|
||||
qty_moved = fields.Integer(string='Qty Moved', required=True, default=1)
|
||||
to_location = fields.Selection(
|
||||
[
|
||||
('global', 'Global'),
|
||||
('quarantine', 'Quarantine'),
|
||||
('staging_a', 'Staging A'),
|
||||
('staging_b', 'Staging B'),
|
||||
('shipping_dock', 'Shipping Dock'),
|
||||
('scrap_bin', 'Scrap Bin'),
|
||||
],
|
||||
string='To Location', default='global',
|
||||
)
|
||||
notes = fields.Text(string='Notes')
|
||||
finish_from_step = fields.Boolean(
|
||||
string='Finish From-Step',
|
||||
default=True,
|
||||
help='If the from-step is still in progress, finishing it on move '
|
||||
'closes the timelog and stamps the audit trail.',
|
||||
)
|
||||
start_to_step = fields.Boolean(
|
||||
string='Start To-Step',
|
||||
default=True,
|
||||
help='If the to-step is ready, start it after the move so the '
|
||||
'next operator picks up an in-progress step.',
|
||||
)
|
||||
input_value_ids = fields.One2many(
|
||||
'fp.job.step.move.wizard.input',
|
||||
'wizard_id',
|
||||
string='Compliance Prompts',
|
||||
help='Authored transition inputs from the to-step\'s recipe node. '
|
||||
'Capture the operator\'s answers — they snapshot to '
|
||||
'fp.job.step.move.input.value when the wizard commits.',
|
||||
)
|
||||
|
||||
# ==================================================================
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
defaults = super().default_get(fields_list)
|
||||
ctx = self.env.context
|
||||
from_step_id = ctx.get('default_from_step_id') or ctx.get('active_id')
|
||||
if from_step_id and self.env.context.get('active_model') != 'fp.job.step':
|
||||
# Came from job form button — active_id is the job, not the step
|
||||
from_step_id = ctx.get('default_from_step_id')
|
||||
if from_step_id:
|
||||
from_step = self.env['fp.job.step'].browse(from_step_id)
|
||||
if from_step.exists():
|
||||
defaults['from_step_id'] = from_step.id
|
||||
defaults['job_id'] = from_step.job_id.id
|
||||
defaults['qty_moved'] = int(from_step.job_id.qty or 1)
|
||||
# Next sequenced step that isn't done/cancelled
|
||||
next_step = self.env['fp.job.step'].search([
|
||||
('job_id', '=', from_step.job_id.id),
|
||||
('sequence', '>', from_step.sequence),
|
||||
('state', 'not in', ('done', 'cancelled', 'skipped')),
|
||||
], order='sequence asc, id asc', limit=1)
|
||||
if next_step:
|
||||
defaults['to_step_id'] = next_step.id
|
||||
# Pre-seed input_value_ids from authored prompts on
|
||||
# both ends of the move so programmatic creators
|
||||
# (script tests, RPC clients) get them too —
|
||||
# @api.onchange only fires in interactive UI.
|
||||
seen = set()
|
||||
rows = []
|
||||
if from_step.recipe_node_id:
|
||||
inputs = from_step.recipe_node_id.input_ids
|
||||
if 'kind' in inputs._fields:
|
||||
inputs = inputs.filtered(
|
||||
lambda i: i.kind == 'step_input')
|
||||
for inp in inputs.sorted('sequence'):
|
||||
if inp.id in seen:
|
||||
continue
|
||||
seen.add(inp.id)
|
||||
rows.append((0, 0, {
|
||||
'node_input_id': inp.id,
|
||||
'name': '%s (Step Input)' % inp.name,
|
||||
'input_type': inp.input_type,
|
||||
}))
|
||||
if next_step.recipe_node_id:
|
||||
inputs = next_step.recipe_node_id.input_ids
|
||||
if 'kind' in inputs._fields:
|
||||
inputs = inputs.filtered(
|
||||
lambda i: i.kind == 'transition_input')
|
||||
for inp in inputs.sorted('sequence'):
|
||||
if inp.id in seen:
|
||||
continue
|
||||
seen.add(inp.id)
|
||||
rows.append((0, 0, {
|
||||
'node_input_id': inp.id,
|
||||
'name': '%s (Transition)' % inp.name,
|
||||
'input_type': inp.input_type,
|
||||
}))
|
||||
if rows:
|
||||
defaults['input_value_ids'] = rows
|
||||
return defaults
|
||||
|
||||
@api.onchange('to_step_id', 'from_step_id')
|
||||
def _onchange_to_step_seed_inputs(self):
|
||||
"""Seed prompt rows from BOTH
|
||||
|
||||
* the to-step's recipe node `transition_input` prompts —
|
||||
authored compliance fields fired on move-in.
|
||||
* the from-step's recipe node `step_input` prompts —
|
||||
measurements that should be captured BEFORE leaving the
|
||||
from-step (operator answers "what did you actually run?"
|
||||
while the data is fresh).
|
||||
|
||||
2026-04-28 fix — previously only transition_input was pulled,
|
||||
which left the section empty for steps where the author only
|
||||
defined step_input prompts. Operators tried to record inputs
|
||||
at move time and got an unfillable form.
|
||||
"""
|
||||
for wiz in self:
|
||||
wiz.input_value_ids = [(5, 0, 0)]
|
||||
seen = set()
|
||||
rows = []
|
||||
# 1. From-step's step_input prompts — measurements captured
|
||||
# while finalising the step.
|
||||
if wiz.from_step_id and wiz.from_step_id.recipe_node_id:
|
||||
from_node = wiz.from_step_id.recipe_node_id
|
||||
inputs = from_node.input_ids
|
||||
if 'kind' in inputs._fields:
|
||||
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
|
||||
for inp in inputs.sorted('sequence'):
|
||||
if inp.id in seen:
|
||||
continue
|
||||
seen.add(inp.id)
|
||||
rows.append((0, 0, {
|
||||
'node_input_id': inp.id,
|
||||
'name': '%s (Step Input)' % inp.name,
|
||||
'input_type': inp.input_type,
|
||||
}))
|
||||
# 2. To-step's transition_input prompts — compliance gates
|
||||
# fired on entry to the next step.
|
||||
if wiz.to_step_id and wiz.to_step_id.recipe_node_id:
|
||||
to_node = wiz.to_step_id.recipe_node_id
|
||||
inputs = to_node.input_ids
|
||||
if 'kind' in inputs._fields:
|
||||
inputs = inputs.filtered(lambda i: i.kind == 'transition_input')
|
||||
for inp in inputs.sorted('sequence'):
|
||||
if inp.id in seen:
|
||||
continue
|
||||
seen.add(inp.id)
|
||||
rows.append((0, 0, {
|
||||
'node_input_id': inp.id,
|
||||
'name': '%s (Transition)' % inp.name,
|
||||
'input_type': inp.input_type,
|
||||
}))
|
||||
wiz.input_value_ids = rows
|
||||
|
||||
# ==================================================================
|
||||
def action_commit(self):
|
||||
self.ensure_one()
|
||||
if not self.from_step_id or not self.to_step_id:
|
||||
raise UserError(_('Pick both From and To steps before moving.'))
|
||||
|
||||
Move = self.env['fp.job.step.move']
|
||||
move = Move.create({
|
||||
'job_id': self.job_id.id,
|
||||
'from_step_id': self.from_step_id.id,
|
||||
'to_step_id': self.to_step_id.id,
|
||||
'transfer_type': self.transfer_type,
|
||||
'qty_moved': self.qty_moved,
|
||||
'qty_available_at_move': self.qty_moved,
|
||||
'to_location': self.to_location,
|
||||
'moved_by_user_id': self.env.user.id,
|
||||
})
|
||||
|
||||
# Snapshot captured prompt values into fp.job.step.move.input.value.
|
||||
ValueModel = self.env['fp.job.step.move.input.value']
|
||||
for line in self.input_value_ids:
|
||||
if not line._has_value():
|
||||
continue
|
||||
vals = {
|
||||
'move_id': move.id,
|
||||
'node_input_id': line.node_input_id.id or False,
|
||||
'value_text': line.value_text or False,
|
||||
'value_number': line.value_number or 0.0,
|
||||
'value_boolean': line.value_boolean,
|
||||
'value_date': line.value_date or False,
|
||||
}
|
||||
# Ad-hoc rows (no node_input_id) — preserve the operator's typed
|
||||
# prompt label in value_text so the chronological CoC report
|
||||
# still shows what was measured.
|
||||
if not line.node_input_id and line.name:
|
||||
if vals['value_text']:
|
||||
vals['value_text'] = f"{line.name}: {vals['value_text']}"
|
||||
elif vals['value_number']:
|
||||
vals['value_text'] = f"{line.name}: {vals['value_number']}"
|
||||
else:
|
||||
vals['value_text'] = line.name
|
||||
ValueModel.create(vals)
|
||||
|
||||
# Finish from-step if requested AND it's still running.
|
||||
if self.finish_from_step and self.from_step_id.state == 'in_progress':
|
||||
self.from_step_id.button_finish()
|
||||
|
||||
# Start to-step if requested AND it's ready/paused.
|
||||
if self.start_to_step and self.to_step_id.state in ('ready', 'paused', 'pending'):
|
||||
# Auto-promote pending → ready when manager moves into it
|
||||
if self.to_step_id.state == 'pending':
|
||||
self.to_step_id.state = 'ready'
|
||||
self.to_step_id.button_start()
|
||||
|
||||
# Surface the new move on the job's chatter so anyone watching
|
||||
# the job form sees the activity in real time.
|
||||
self.job_id.message_post(body=_(
|
||||
'Moved %(qty)s parts: %(from)s → %(to)s by %(user)s'
|
||||
) % {
|
||||
'qty': self.qty_moved,
|
||||
'from': self.from_step_id.name,
|
||||
'to': self.to_step_id.name,
|
||||
'user': self.env.user.name,
|
||||
})
|
||||
|
||||
if self.notes:
|
||||
move.message_post(body=self.notes)
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
|
||||
class FpJobStepMoveWizardInput(models.TransientModel):
|
||||
"""Repeater row mirroring fp.job.step.move.input.value.
|
||||
|
||||
Lives on the wizard so the operator/manager fills these inline,
|
||||
then `action_commit` snapshots them into the real model. Keeping
|
||||
a transient mirror means the wizard form can be filled, cancelled,
|
||||
and reopened without polluting the chain-of-custody audit log.
|
||||
|
||||
2026-04-28 — `node_input_id` is now optional so operators can add
|
||||
ad-hoc input rows directly from the Move dialog (operator types
|
||||
the prompt label + value). Authored prompts still pre-fill
|
||||
name + type as readonly; ad-hoc rows are fully editable. Same
|
||||
pattern as the standalone Record Inputs wizard."""
|
||||
_name = 'fp.job.step.move.wizard.input'
|
||||
_description = 'Fusion Plating — Move Wizard Input Row'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'fp.job.step.move.wizard',
|
||||
required=True, ondelete='cascade',
|
||||
)
|
||||
node_input_id = fields.Many2one(
|
||||
'fusion.plating.process.node.input',
|
||||
string='Prompt',
|
||||
ondelete='set null',
|
||||
)
|
||||
name = fields.Char(string='Prompt')
|
||||
input_type = fields.Selection(
|
||||
_FP_INPUT_TYPE_SELECTION,
|
||||
string='Type',
|
||||
)
|
||||
value_text = fields.Char(string='Text Value')
|
||||
value_number = fields.Float(string='Number Value')
|
||||
value_boolean = fields.Boolean(string='Yes/No')
|
||||
value_date = fields.Datetime(string='Date / Time')
|
||||
|
||||
is_authored = fields.Boolean(
|
||||
compute='_compute_is_authored',
|
||||
help='True when this row originated from an authored recipe input. '
|
||||
'Drives field readonly state — authored prompts are locked, '
|
||||
'ad-hoc rows are fully editable.',
|
||||
)
|
||||
|
||||
@api.depends('node_input_id')
|
||||
def _compute_is_authored(self):
|
||||
for rec in self:
|
||||
rec.is_authored = bool(rec.node_input_id)
|
||||
|
||||
def _has_value(self):
|
||||
self.ensure_one()
|
||||
return any([
|
||||
self.value_text,
|
||||
self.value_number,
|
||||
self.value_boolean,
|
||||
self.value_date,
|
||||
])
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_job_step_move_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.job.step.move.wizard.form</field>
|
||||
<field name="model">fp.job.step.move.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Move Step">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="job_id" readonly="1"/>
|
||||
<field name="from_step_id"/>
|
||||
<field name="to_step_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="transfer_type"/>
|
||||
<field name="qty_moved"/>
|
||||
<field name="to_location"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="finish_from_step"/>
|
||||
<field name="start_to_step"/>
|
||||
</group>
|
||||
<separator string="Inputs (compliance + step measurements)"/>
|
||||
<p class="text-muted" invisible="input_value_ids">
|
||||
No authored prompts on either step. Click
|
||||
<strong>Add a line</strong> below to record an
|
||||
ad-hoc measurement (operator name + value). The
|
||||
capture lands on the chronological CoC alongside
|
||||
any authored prompts.
|
||||
</p>
|
||||
<field name="input_value_ids">
|
||||
<list editable="bottom">
|
||||
<field name="is_authored" column_invisible="1"/>
|
||||
<field name="name"
|
||||
readonly="is_authored"
|
||||
placeholder="e.g. Oven Temp, Bath OK?, Operator Initials"/>
|
||||
<field name="input_type"
|
||||
readonly="is_authored"
|
||||
optional="show"/>
|
||||
<field name="value_text"/>
|
||||
<field name="value_number"/>
|
||||
<field name="value_boolean" widget="boolean_toggle"/>
|
||||
<field name="value_date"/>
|
||||
</list>
|
||||
</field>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes" nolabel="1"
|
||||
placeholder="Optional context — why this move, what to watch for next..."/>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_commit" type="object"
|
||||
string="Move" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_job_step_move_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Move Step</field>
|
||||
<field name="res_model">fp.job.step.move.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user