This commit is contained in:
gsinghpal
2026-04-28 19:39:37 -04:00
parent 2d42b33d68
commit 13e300d90e
103 changed files with 4959 additions and 331 deletions

View 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

View File

@@ -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,
])

View File

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

View File

@@ -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,
])

View File

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