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

@@ -2,3 +2,4 @@
from . import models
from . import report
from . import controllers
from . import wizards

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.7.0.0',
'version': '19.0.8.8.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
@@ -62,8 +62,11 @@ full design rationale and §6.2 of the implementation plan for task list.
'views/fp_step_priority_views.xml',
'views/jobs_in_shopfloor_menu.xml',
'views/legacy_menu_hide.xml',
'wizards/fp_job_step_move_wizard_views.xml',
'wizards/fp_job_step_input_wizard_views.xml',
'report/report_fp_job_sticker.xml',
'report/report_fp_job_traveller.xml',
'report/report_fp_job_wo_detail.xml',
'report/report_fp_job_margin.xml',
],
'assets': {

View File

@@ -155,6 +155,79 @@ class FpJob(models.Model):
'name': self.sale_order_id.name,
}
# All time logs across every step on this job — backs the Time Logs
# tab on the form so the manager sees the full labour audit without
# clicking into each step.
time_log_ids = fields.One2many(
'fp.job.step.timelog',
'job_id',
string='All Time Logs',
readonly=True,
)
# 2026-04-28 — link to the auto-created Sub 8 racking inspection so
# the job form can show a smart button + the manager can route into
# the inspection without leaving the job screen.
racking_inspection_ids = fields.One2many(
'fp.racking.inspection',
'x_fc_job_id',
string='Racking Inspections',
)
racking_inspection_id = fields.Many2one(
'fp.racking.inspection',
string='Racking Inspection',
compute='_compute_racking_inspection',
store=False,
help='The single racking inspection scoped to this job (Sub 8 '
'enforces uniqueness). Smart button on the form routes here.',
)
# Computed alongside racking_inspection_id so views can render the
# state badge without needing a related-on-non-stored field (which
# the ORM rejects). Selection mirrors fp.racking.inspection.state.
racking_inspection_state = fields.Selection(
[('draft', 'Draft'),
('inspecting', 'Inspecting'),
('done', 'Done'),
('discrepancy_flagged', 'Discrepancy Flagged')],
string='Racking Inspection Status',
compute='_compute_racking_inspection',
store=False,
)
@api.depends('racking_inspection_ids', 'racking_inspection_ids.state')
def _compute_racking_inspection(self):
for job in self:
ri = job.racking_inspection_ids[:1]
job.racking_inspection_id = ri
job.racking_inspection_state = ri.state if ri else False
def action_view_racking_inspection(self):
"""Open the racking inspection. Auto-create if missing (e.g. job
was created before Sub 8 shipped, or auto-create silently failed
at action_confirm time)."""
self.ensure_one()
if 'fp.racking.inspection' not in self.env:
from odoo.exceptions import UserError
raise UserError(_(
'Sub 8 racking inspection module not installed. '
'Install fusion_plating_receiving to enable.'
))
if not self.racking_inspection_id:
self._fp_create_racking_inspection()
self.invalidate_recordset(['racking_inspection_ids'])
ri = self.racking_inspection_id or self.racking_inspection_ids[:1]
if not ri:
from odoo.exceptions import UserError
raise UserError(_('Could not auto-create racking inspection.'))
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.racking.inspection',
'res_id': ri.id,
'view_mode': 'form',
'target': 'current',
'name': _('Racking Inspection — %s') % self.name,
}
def action_view_steps(self):
self.ensure_one()
return {
@@ -166,6 +239,53 @@ class FpJob(models.Model):
'context': {'default_job_id': self.id},
}
def action_open_move_wizard(self):
"""Header button — opens the Move wizard pre-filled with the
currently in-progress (or most recently in-progress) step as the
from-step. Lets the manager move the job forward without first
clicking into a specific step row.
"""
self.ensure_one()
active_step = self.step_ids.filtered(
lambda s: s.state == 'in_progress'
)[:1]
if not active_step:
active_step = self.step_ids.filtered(
lambda s: s.state in ('paused', 'ready')
).sorted('sequence')[:1]
if not active_step:
raise UserError(_(
'No in-progress, paused, or ready step found on this job. '
'Either every step is done or the job is still in draft.'
))
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.job.step.move.wizard',
'view_mode': 'form',
'target': 'new',
'name': _('Move Step — %s') % active_step.name,
'context': {
'default_from_step_id': active_step.id,
'default_job_id': self.id,
},
}
def action_print_traveller(self):
self.ensure_one()
return self.env.ref(
'fusion_plating_jobs.action_report_fp_job_traveller'
).report_action(self)
def action_print_wo_detail(self):
"""Print the Steelhead-style Work Order Detail PDF — chronological
chain-of-custody + per-step inputs + Certified By page. Use this
as the AS9100/Nadcap shippable audit document.
"""
self.ensure_one()
return self.env.ref(
'fusion_plating_jobs.action_report_fp_job_wo_detail'
).report_action(self)
def action_view_deliveries(self):
self.ensure_one()
if not self.delivery_id:
@@ -497,6 +617,38 @@ class FpJob(models.Model):
instructions.append(line)
step_num += 1
# Map recipe_node.default_kind → step.kind so the
# downstream gates (Sub 8 racking soft-gate, Policy B
# contract-review gate) work even when the step gets
# renamed by the customer (e.g. "Hang on Bar" instead
# of "Racking"). Without this, gate detection falls
# back to fragile name matching.
_NODE_KIND_TO_STEP_KIND = {
'cleaning': 'wet',
'etch': 'wet',
'rinse': 'wet',
'plate': 'wet',
'dry': 'wet',
'wbf_test': 'wet',
'bake': 'bake',
'mask': 'mask',
'demask': 'mask',
'racking': 'rack',
'derack': 'rack',
'inspect': 'inspect',
'final_inspect': 'inspect',
'contract_review': 'other',
'gating': 'other',
'ship': 'other',
}
step_kind = 'other'
node_kind = (
node.default_kind
if 'default_kind' in node._fields else None
)
if node_kind and node_kind in _NODE_KIND_TO_STEP_KIND:
step_kind = _NODE_KIND_TO_STEP_KIND[node_kind]
vals = {
'job_id': job.id,
'name': node.name,
@@ -504,6 +656,7 @@ class FpJob(models.Model):
'duration_expected': node.estimated_duration or 0.0,
'sequence': seq_counter[0],
'recipe_node_id': node.id,
'kind': step_kind,
}
if node.estimated_duration:
vals['dwell_time_minutes'] = node.estimated_duration
@@ -636,12 +789,79 @@ class FpJob(models.Model):
)
if pending_steps:
pending_steps.write({'state': 'ready'})
# 2026-04-28 — auto-populate facility_id + manager_id so the
# job header surfaces them on the form. Page-1 audit found
# both empty on confirmed jobs.
job._fp_autofill_facility_and_manager()
job._fp_create_portal_job()
job._fp_create_qc_check_if_needed()
job._fp_create_racking_inspection()
job._fp_fire_notification('job_confirmed')
return result
def _fp_autofill_facility_and_manager(self):
"""Populate facility_id + manager_id on confirm if empty.
Resolution order:
facility_id —
1. Already set → leave alone.
2. First step with a work_centre that has a facility → use it.
3. Recipe's process_type → facility (if process_type carries one).
4. Single-facility company → use that one.
manager_id —
1. Already set → leave alone.
2. Confirming user IS in the Plating Manager group → use them.
3. Sale order user_id (the salesperson who confirmed the SO).
4. The customer's account manager (partner.user_id).
5. Leave blank — no sensible default.
"""
self.ensure_one()
# ---- facility_id ----
if not self.facility_id:
facility = False
for s in self.step_ids:
if s.work_centre_id and 'facility_id' in s.work_centre_id._fields:
facility = s.work_centre_id.facility_id
if facility:
break
if not facility and self.recipe_id and 'process_type_id' in self.recipe_id._fields:
pt = self.recipe_id.process_type_id
if pt and 'facility_id' in pt._fields:
facility = pt.facility_id
if not facility:
Facility = self.env.get('fusion.plating.facility')
if Facility is not None:
facilities = Facility.search([
('company_id', '=', self.company_id.id),
])
if len(facilities) == 1:
facility = facilities
if facility:
self.facility_id = facility.id
self.message_post(body=_(
'Facility auto-set on confirm: %s'
) % facility.display_name)
# ---- manager_id ----
if not self.manager_id:
mgr = False
ManagerGroup = self.env.ref(
'fusion_plating.group_fusion_plating_manager',
raise_if_not_found=False,
)
if ManagerGroup and self.env.user in ManagerGroup.user_ids:
mgr = self.env.user
elif self.sale_order_id and self.sale_order_id.user_id:
mgr = self.sale_order_id.user_id
elif self.partner_id and self.partner_id.user_id:
mgr = self.partner_id.user_id
if mgr:
self.manager_id = mgr.id
self.message_post(body=_(
'Plating Manager auto-set on confirm: %s'
) % mgr.name)
def _fp_create_racking_inspection(self):
"""Auto-create a draft racking inspection on job confirm.

View File

@@ -413,3 +413,370 @@ class FpJobStep(models.Model):
'plate exit. Required by %s.'
)) % (bw.name, window_hrs, bw.bake_required_by))
return result
# ==================================================================
# Phase 2 multi-serial — auto-promote serials on step transitions
# ==================================================================
def _fp_promote_serials_on_start(self):
"""When this step transitions to in_progress, lift any serial
attached to the parent SO line out of `received` / `racked` and
into `in_process`. Idempotent — already-promoted serials are
skipped.
"""
for step in self:
job = step.job_id
if not job.sale_order_line_ids:
continue
serials = job.sale_order_line_ids.mapped('x_fc_serial_ids')
to_promote = serials.filtered(
lambda s: s.state in ('received', 'racked')
)
if to_promote:
# Use sudo on the helper so operator-tier users can promote
# serial state without needing direct write on fp.serial.
to_promote.sudo()._set_state('in_process', message=_(
'Promoted to In Process on step "%s" start by %s.'
) % (step.name, self.env.user.name))
def _fp_promote_serials_on_finish(self):
"""When the LAST step of this step's job finishes (sequenced
terminal step OR an explicit inspect/final-inspect kind), bump
in-flight serials to `inspected` so the shipper sees them ready
for packing. Conservative — only promotes from `in_process`."""
for step in self:
job = step.job_id
if not job.sale_order_line_ids:
continue
# Is this the highest-sequence non-cancelled step on the job?
siblings = job.step_ids.filtered(
lambda s: s.state not in ('cancelled', 'skipped')
)
if not siblings:
continue
last_seq = max(siblings.mapped('sequence'))
is_terminal = (step.sequence == last_seq) or (
step.kind == 'inspect' or 'final' in (step.name or '').lower()
)
if not is_terminal:
continue
serials = job.sale_order_line_ids.mapped('x_fc_serial_ids')
to_promote = serials.filtered(lambda s: s.state == 'in_process')
if to_promote:
to_promote.sudo()._set_state('inspected', message=_(
'Promoted to Inspected on step "%s" finish by %s.'
) % (step.name, self.env.user.name))
# ==================================================================
# Policy B (2026-04-28) — Contract Review enforcement
# ==================================================================
# When a recipe author drops a "Contract Review" step into a recipe,
# button_start opens the QA-005 audit form for the linked part (auto-
# creates one if missing) and button_finish blocks completion until
# the form is `complete` AND the current user is on the recipe's
# contract_review_user_ids approver list (when configured).
#
# Detection — case-insensitive match on the step name OR
# recipe_node_id mapped from a step.template with default_kind ==
# 'contract_review' (the simple-editor library entry).
def _fp_is_contract_review_step(self):
self.ensure_one()
if (self.name or '').strip().lower() in ('contract review', 'qa-005'):
return True
node = self.recipe_node_id
if not node:
return False
# Source template kind (when authored via simple editor library)
if 'source_template_id' in node._fields and node.source_template_id:
if node.source_template_id.default_kind == 'contract_review':
return True
if 'default_kind' in node._fields and node.default_kind == 'contract_review':
return True
return False
def _fp_resolve_contract_review_part(self):
"""Find the fp.part.catalog this step's job is for. Used by the
Contract Review hooks to auto-create / look up the QA-005 form.
Falls through to None when no part can be resolved (no SO line,
SO line without x_fc_part_catalog_id, etc.)."""
self.ensure_one()
for so_line in self.job_id.sale_order_line_ids:
if (so_line.x_fc_part_catalog_id
and 'fp.contract.review' in self.env):
return so_line.x_fc_part_catalog_id
return None
def _fp_open_contract_review(self):
"""Auto-create the QA-005 form for this step's part if missing,
return the act_window pointing at it. Called from button_start
on Contract Review steps."""
self.ensure_one()
part = self._fp_resolve_contract_review_part()
if not part:
return None
Review = self.env.get('fp.contract.review')
if Review is None:
return None # quality module not installed — skip
review = part.x_fc_contract_review_id
if not review:
review = Review.sudo().create({
'part_id': part.id,
'state': 'assistant_review',
})
part.sudo().write({
'x_fc_contract_review_id': review.id,
'x_fc_contract_review_dismissed': False,
})
self.job_id.message_post(body=_(
'Contract Review (QA-005) auto-created for %(part)s on '
'Contract Review step start by %(user)s.'
) % {
'part': part.display_name or part.part_number or '',
'user': self.env.user.name,
})
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.contract.review',
'res_id': review.id,
'view_mode': 'form',
'target': 'current',
'name': _('Contract Review — %s') % (
part.display_name or part.part_number or ''
),
}
def _fp_check_contract_review_complete(self):
"""Block button_finish on a Contract Review step until QA-005 is
signed off. Only enforced when the customer has
partner.x_fc_contract_review_required=True. Manager bypass via
context fp_skip_contract_review_gate=True."""
if self.env.context.get('fp_skip_contract_review_gate'):
return
for step in self:
if not step._fp_is_contract_review_step():
continue
part = step._fp_resolve_contract_review_part()
if not part or not part.partner_id.x_fc_contract_review_required:
continue
review = part.x_fc_contract_review_id
if not review or review.state != 'complete':
state_label = (
review.state if review else _('not started')
)
raise UserError(_(
'Contract Review for %(part)s is %(state)s — must be '
'"complete" before this step can finish. Open the '
'QA-005 form (smart button on the part), get both '
'sections signed off, then retry. Manager bypass: '
'fp_skip_contract_review_gate=True in context.'
) % {
'part': part.display_name or part.part_number or '',
'state': state_label,
})
# Approver-list gate (restored from pre-Sub-11). When the
# recipe author named approvers on the recipe root, only those
# users can finish the Contract Review step.
recipe = step.recipe_node_id and step.recipe_node_id.recipe_root_id
approvers = (recipe.contract_review_user_ids
if (recipe and 'contract_review_user_ids' in recipe._fields)
else False)
if approvers and self.env.user not in approvers:
raise UserError(_(
'Only authorised Contract Review approvers can finish '
'this step. Approvers: %s.\n\nContact your Plating '
'Manager to add yourself if this is wrong, or hand '
'the step to one of the approvers.'
) % ', '.join(approvers.mapped('name')))
# ==================================================================
# Sub 8 follow-up (2026-04-28) — Racking Inspection enforcement
# ==================================================================
# When the recipe-side "Racking" step starts, auto-promote the linked
# fp.racking.inspection from draft → inspecting and route the operator
# straight into the inspection form. When the same step finishes,
# block unless the inspection is in `done` or `discrepancy_flagged`
# (operator cleared every line). Manager bypass via context
# `fp_skip_racking_inspection_gate=True`.
def _fp_is_racking_step(self):
self.ensure_one()
if (self.name or '').strip().lower() in ('racking', 'rack'):
return True
node = self.recipe_node_id
if not node:
return False
if 'source_template_id' in node._fields and node.source_template_id:
if node.source_template_id.default_kind == 'racking':
return True
if 'default_kind' in node._fields and node.default_kind == 'racking':
return True
if self.kind == 'rack':
return True
return False
def _fp_open_racking_inspection(self):
"""Auto-promote draft → inspecting + return act_window for the
linked racking inspection. Auto-creates one if missing."""
self.ensure_one()
if 'fp.racking.inspection' not in self.env:
return None
# Reach the job's existing inspection (auto-created on action_confirm)
# or trigger a fresh create if none exists.
ri = self.job_id.racking_inspection_id
if not ri:
self.job_id._fp_create_racking_inspection()
self.job_id.invalidate_recordset(['racking_inspection_ids'])
ri = self.job_id.racking_inspection_id
if not ri:
return None
# Promote draft → inspecting. action_start raises if state isn't
# draft, so guard.
if ri.state == 'draft':
ri.sudo().action_start()
self.job_id.message_post(body=_(
'Racking inspection auto-promoted to "Inspecting" on '
'%(step)s start by %(user)s.'
) % {'step': self.name, 'user': self.env.user.name})
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.racking.inspection',
'res_id': ri.id,
'view_mode': 'form',
'target': 'current',
'name': _('Racking Inspection — %s') % self.job_id.name,
}
def _fp_check_racking_inspection_complete(self):
"""Soft gate — block button_finish on a Racking step until the
linked inspection is in a terminal state. discrepancy_flagged
counts as complete (the operator finished but flagged issues —
the discrepancy activity will route to the manager separately)."""
if self.env.context.get('fp_skip_racking_inspection_gate'):
return
for step in self:
if not step._fp_is_racking_step():
continue
ri = step.job_id.racking_inspection_id
if not ri:
# No inspection at all — still let it finish, but log a
# chatter warning so the manager sees the gap.
step.job_id.message_post(body=_(
'⚠️ Racking step "%s" finished without a racking '
'inspection on file. Sub 8 expected one to be '
'auto-created on job confirm.'
) % step.name)
continue
if ri.state not in ('done', 'discrepancy_flagged'):
state_label = dict(ri._fields['state'].selection).get(
ri.state, ri.state)
raise UserError(_(
'Racking inspection for %(job)s is "%(state)s" — must '
'be Done or Discrepancy Flagged before this step can '
'finish. Click the Racking Insp. smart button on the '
'job, complete the line check-off, then retry. '
'Manager bypass: fp_skip_racking_inspection_gate=True.'
) % {
'job': step.job_id.name,
'state': state_label,
})
def button_start(self):
# Policy B — Contract Review takes priority (auto-opens QA-005).
for step in self:
if step._fp_is_contract_review_step():
action = step._fp_open_contract_review()
if action:
super(FpJobStep, step).button_start()
if step.state == 'in_progress':
step._fp_promote_serials_on_start()
return action
# Sub 8 — Racking step auto-opens the inspection form.
for step in self:
if step._fp_is_racking_step():
action = step._fp_open_racking_inspection()
if action:
super(FpJobStep, step).button_start()
if step.state == 'in_progress':
step._fp_promote_serials_on_start()
return action
result = super().button_start()
for step in self:
if step.state == 'in_progress':
step._fp_promote_serials_on_start()
return result
def button_finish(self):
# Policy B — block until QA-005 complete (when customer requires it).
self._fp_check_contract_review_complete()
# Sub 8 — block until racking inspection is Done / Flagged.
self._fp_check_racking_inspection_complete()
result = super().button_finish()
for step in self:
if step.state == 'done':
step._fp_promote_serials_on_finish()
return result
# ==================================================================
# Per-row shortcut actions used by the job form's inline action column
# ==================================================================
def action_open_move_wizard(self):
"""Open the Move wizard with this step pre-filled as the from-step."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.job.step.move.wizard',
'view_mode': 'form',
'target': 'new',
'name': _('Move from %s') % self.name,
'context': {
'default_from_step_id': self.id,
'default_job_id': self.job_id.id,
},
}
def action_open_input_wizard(self):
"""Open the Input Recording wizard for this step."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.job.step.input.wizard',
'view_mode': 'form',
'target': 'new',
'name': _('Record Inputs — %s') % self.name,
'context': {
'default_step_id': self.id,
},
}
# ------------------------------------------------------------------
# Live duration helper — view binds to a non-stored compute that
# ticks each time the form re-reads. For a true live ticking clock
# we'd need an OWL widget; this gives "minutes since start" that's
# accurate at every record refresh, which is good enough for a
# backend manager's view.
# ------------------------------------------------------------------
duration_running_minutes = fields.Float(
string='Running Min',
compute='_compute_duration_running',
help='Minutes since the step\'s current open timelog started. '
'Re-reads on every form refresh; equals duration_actual once '
'the step is finished.',
)
@api.depends('state', 'date_started', 'time_log_ids',
'time_log_ids.date_started', 'time_log_ids.date_finished',
'duration_actual')
def _compute_duration_running(self):
now = fields.Datetime.now()
for step in self:
if step.state == 'in_progress':
# Sum closed intervals + (now - open interval start)
closed = sum(step.time_log_ids.mapped('duration_minutes'))
open_log = step.time_log_ids.filtered(
lambda l: not l.date_finished
)[:1]
running = 0.0
if open_log and open_log.date_started:
delta = (now - open_log.date_started).total_seconds() / 60.0
running = max(0.0, delta)
step.duration_running_minutes = closed + running
else:
step.duration_running_minutes = step.duration_actual or 0.0

View File

@@ -202,6 +202,14 @@
</tr>
</table>
<!-- ===== ROUTING TABLE =====
Continues on subsequent pages — paperformat handles
page break automatically. Footer (Ship Order To +
Additional Notes) closes the document. -->
<!-- inline routing follows; footer appears below -->
<!-- (placed after the routing table — see end-of-template) -->
<!-- ===== ROUTING TABLE ===== -->
<table class="bordered" style="margin-top: 4px;">
<thead>
@@ -284,6 +292,37 @@
</t>
</tbody>
</table>
<!-- ===== FOOTER — SHIP ORDER + NOTES ===== -->
<table class="bordered" style="margin-top: 6px;">
<tr>
<th style="width: 30%;">Ship Order To</th>
<th style="width: 70%;">Additional Notes</th>
</tr>
<tr>
<td style="vertical-align: top;">
<strong t-esc="(job.partner_id and job.partner_id.name) or '—'"/><br/>
<span t-esc="(job.partner_id and job.partner_id.street) or ''"/><br/>
<span t-if="job.partner_id and job.partner_id.street2"
t-esc="job.partner_id.street2"/>
<t t-if="job.partner_id and job.partner_id.street2"><br/></t>
<span t-esc="(job.partner_id and job.partner_id.city) or ''"/>,
<span t-esc="(job.partner_id and job.partner_id.state_id and job.partner_id.state_id.code) or ''"/>
<span t-esc="(job.partner_id and job.partner_id.zip) or ''"/><br/>
<span t-esc="(job.partner_id and job.partner_id.country_id and job.partner_id.country_id.code) or ''"/>
</td>
<td class="fp-trav-stamp" style="min-height: 18mm;">
<t t-if="'special_requirements' in job._fields and job.special_requirements">
<span t-esc="job.special_requirements"
style="white-space: pre-wrap; font-size: 7.5pt;"/>
</t>
</td>
</tr>
</table>
<div style="text-align: center; margin-top: 4px; font-size: 7pt; color: #666;">
<span t-esc="job.name"/>
</div>
</div>
</t>
</t>

View File

@@ -0,0 +1,281 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Steelhead-style "Work Order Detail" PDF — the post-job audit cert
that walks fp.job.step.move records chronologically, lists captured
inputs per step, and ends with a Certified By + Cert Statement
page. Bound to fp.job directly (not fp.certificate) so a manager
can print the audit document straight from the job form.
Layout mirrors the customer-shared `job card.pdf` (see CLAUDE.md
Sub 12c). Reuses the same per-customer cert-statement resolution
chain (partner.x_fc_cert_statement → company.x_fc_default_cert_statement
→ hardcoded boilerplate) so we don't fork two cert templates.
-->
<odoo>
<record id="paperformat_fp_wo_detail" model="report.paperformat">
<field name="name">FP Work Order Detail — A4 portrait</field>
<field name="format">A4</field>
<field name="orientation">Portrait</field>
<field name="margin_top">15</field>
<field name="margin_bottom">15</field>
<field name="margin_left">12</field>
<field name="margin_right">12</field>
<field name="header_spacing">8</field>
<field name="dpi">90</field>
</record>
<record id="action_report_fp_job_wo_detail" model="ir.actions.report">
<field name="name">Work Order Detail</field>
<field name="model">fp.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_jobs.report_fp_job_wo_detail_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_wo_detail_template</field>
<field name="print_report_name">'WO Detail - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_wo_detail"/>
</record>
<template id="report_fp_job_wo_detail_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<t t-call="web.external_layout">
<t t-set="company" t-value="job.company_id"/>
<t t-set="moves" t-value="job.move_ids.sorted('move_datetime')"/>
<div class="page fp-wo-detail">
<style>
.fp-wo-detail { font-family: Arial, sans-serif; font-size: 9pt; color: #000; }
.fp-wo-detail h1 { text-align: center; font-size: 18pt; margin: 0 0 6px 0; font-weight: bold; color: #1a4d80; }
.fp-wo-detail h3 { font-size: 11pt; margin: 8px 0 2px 0; font-weight: bold; }
.fp-wo-detail .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 4px; }
.fp-wo-detail table.bordered,
.fp-wo-detail table.bordered th,
.fp-wo-detail table.bordered td { border: 1px solid #000; border-collapse: collapse; }
.fp-wo-detail table.bordered { width: 100%; margin-bottom: 8px; }
.fp-wo-detail table.bordered th { background: #ededed; padding: 4px 6px; font-size: 8.5pt; text-align: left; }
.fp-wo-detail table.bordered td { padding: 4px 6px; vertical-align: top; font-size: 8.5pt; }
.fp-wo-detail .text-center { text-align: center; }
.fp-wo-detail hr.heavy { border: 0; border-top: 2px solid #000; margin: 8px 0; }
.fp-wo-detail .fp-spec { font-size: 10pt; font-weight: bold; margin: 8px 0 4px 0; }
.fp-wo-detail .fp-step-block { page-break-inside: avoid; margin-bottom: 6px; }
</style>
<h1>Work Order Detail</h1>
<!-- ===== HEADER — Prepared For + summary table ===== -->
<div style="margin-bottom: 8px;">
<strong>Prepared For:</strong>
<span style="font-size: 11pt;"
t-esc="(job.partner_id and job.partner_id.name) or '—'"/>
</div>
<table class="bordered">
<tr>
<th style="width: 20%;">Part Number</th>
<th style="width: 30%;">Description</th>
<th style="width: 8%;">Quantity</th>
<th style="width: 10%;">Work Order</th>
<th style="width: 14%;">PO Number</th>
<th style="width: 8%;">Packing List No</th>
<th style="width: 10%;">Date</th>
</tr>
<tr>
<td>
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
<span t-esc="job.part_catalog_id.part_number or '—'"/>
</t>
<t t-else="">
<span t-esc="(job.product_id and job.product_id.default_code) or '—'"/>
</t>
</td>
<td style="white-space: pre-wrap;">
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
<span t-esc="job.part_catalog_id.name or job.product_id.name or '—'"/>
</t>
<t t-else="">
<span t-esc="(job.product_id and job.product_id.name) or '—'"/>
</t>
<t t-if="'special_requirements' in job._fields and job.special_requirements">
<br/>
<span style="font-size: 7.5pt;"
t-esc="job.special_requirements"/>
</t>
</td>
<td class="text-center">
<span t-esc="job.qty"/>
</td>
<td class="text-center">
<span t-esc="job.name"/>
</td>
<td>
<span t-esc="(job.sale_order_id and job.sale_order_id.client_order_ref) or '—'"/>
</td>
<td/>
<td>
<span t-esc="(job.date_finished or job.date_started or job.create_date) and (job.date_finished or job.date_started or job.create_date).strftime('%Y-%m-%d') or ''"/>
</td>
</tr>
</table>
<div class="fp-spec">Specification(s):
<span style="font-weight: normal;"
t-esc="(job.recipe_id and job.recipe_id.name) or '—'"/>
</div>
<hr class="heavy"/>
<!-- ===== CHAIN-OF-CUSTODY WALK ===== -->
<t t-foreach="moves" t-as="mv">
<t t-set="dest" t-value="mv.to_step_id"/>
<t t-set="tank_code" t-value="(mv.to_tank_id and mv.to_tank_id.code) or (dest and dest.tank_id and dest.tank_id.code) or ''"/>
<div class="fp-step-block">
<h3>
<span t-esc="(dest and dest.name) or '—'"/>
<t t-if="tank_code"> (<span t-esc="tank_code"/>)</t>
</h3>
<div class="fp-meta">
<strong>Part Number:</strong>
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
<span t-esc="job.part_catalog_id.part_number or ''"/>
<t t-if="job.part_catalog_id.name">
<span> </span><span t-esc="job.part_catalog_id.name"/>
</t>
</t>
<t t-else="">
<span t-esc="(job.product_id and (job.product_id.default_code or job.product_id.name)) or ''"/>
</t>
<br/>
<strong>Moved By:</strong> <span t-esc="mv.moved_by_user_id.name"/>
<span> </span>
<strong>Time:</strong>
<span t-esc="mv.move_datetime and mv.move_datetime.strftime('%b %d, %Y %I:%M:%S %p') or ''"/>
</div>
<!-- Captured input values for this move -->
<t t-set="captured_values_by_input"
t-value="{v.node_input_id.id: v for v in mv.transition_input_value_ids}"/>
<t t-set="prompts" t-value="False"/>
<t t-if="dest and dest.recipe_node_id">
<t t-set="prompts"
t-value="dest.recipe_node_id.input_ids.filtered(lambda i: (i.kind or 'step_input') == 'step_input').sorted('sequence')"/>
</t>
<t t-if="not prompts and mv.transition_input_value_ids">
<t t-set="prompts"
t-value="mv.transition_input_value_ids.mapped('node_input_id')"/>
</t>
<t t-if="prompts and mv.transition_input_value_ids">
<table class="bordered">
<thead>
<tr>
<th style="width: 24%;">Name</th>
<th style="width: 30%;">Description</th>
<th style="width: 18%;">Value</th>
<th style="width: 28%;">Recorded By</th>
</tr>
</thead>
<tbody>
<t t-foreach="prompts" t-as="inp">
<t t-set="cv" t-value="captured_values_by_input.get(inp.id)"/>
<t t-if="cv">
<t t-set="actual_str" t-value="''"/>
<t t-if="cv.value_text">
<t t-set="actual_str" t-value="cv.value_text"/>
</t>
<t t-elif="cv.value_number">
<t t-set="actual_str"
t-value="('%s %s' % (cv.value_number, (inp.target_unit if 'target_unit' in inp._fields and inp.target_unit else ''))).strip()"/>
</t>
<t t-elif="cv.value_boolean is not False">
<t t-set="actual_str" t-value="'PASS' if cv.value_boolean else 'FAIL'"/>
</t>
<t t-elif="cv.value_date">
<t t-set="actual_str" t-value="cv.value_date.strftime('%Y-%m-%d %H:%M')"/>
</t>
<tr>
<td><span t-esc="inp.name"/></td>
<td>
<t t-if="'hint' in inp._fields and inp.hint">
<span t-esc="inp.hint"/>
</t>
</td>
<td>
<strong t-esc="actual_str"/>
</td>
<td>
<span t-esc="(mv.moved_by_user_id and mv.moved_by_user_id.name) or ''"/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
</t>
</div>
</t>
<t t-if="not moves">
<p style="color: #888; font-style: italic;">
No move log entries yet — this job hasn't progressed
through any steps. Operators move the job forward
via the tablet or the backend Move wizard.
</p>
</t>
<!-- ===== CERTIFIED BY + CERT STATEMENT ===== -->
<p style="page-break-before: always;"/>
<t t-set="owner_sig" t-value="False"/>
<t t-if="'x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id">
<t t-set="_emp" t-value="company.x_fc_owner_user_id.employee_ids[:1]"/>
<t t-if="_emp and 'signature' in _emp._fields">
<t t-set="owner_sig" t-value="_emp['signature']"/>
</t>
</t>
<t t-set="sig_override" t-value="('x_fc_coc_signature_override' in company._fields and company.x_fc_coc_signature_override) or False"/>
<t t-set="signature_img" t-value="sig_override or owner_sig"/>
<t t-set="signer_name" t-value="(job.manager_id and job.manager_id.name) or ('x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id and company.x_fc_owner_user_id.name) or ''"/>
<t t-set="_cust_stmt" t-value="(job.partner_id and 'x_fc_cert_statement' in job.partner_id._fields and job.partner_id.x_fc_cert_statement) or False"/>
<t t-set="_co_stmt" t-value="('x_fc_default_cert_statement' in company._fields and company.x_fc_default_cert_statement) or False"/>
<t t-set="cert_statement" t-value="_cust_stmt or _co_stmt or 'We certify that the parts listed above have been processed in accordance with the specifications referenced and that all required tests have been performed. Records on file at our facility per AS9100 / ISO 9001 retention policy.'"/>
<table class="bordered">
<tr>
<td style="width: 50%; vertical-align: top; height: 40mm;">
<strong>Certified By:</strong><br/>
<t t-if="signature_img">
<img t-att-src="'data:image/png;base64,%s' % signature_img.decode()"
style="max-height: 22mm; max-width: 70mm;"/>
</t><br/>
<strong>Name:</strong> <span t-esc="signer_name"/>
</td>
<td style="width: 50%; vertical-align: top;">
<strong>Certification Statement:</strong>
<span style="font-size: 8.5pt;">
Ref. WO# <span t-esc="job.name"/>
</span>
<p style="font-size: 8pt; margin-top: 4px; white-space: pre-wrap;"
t-esc="cert_statement"/>
</td>
</tr>
<tr>
<td colspan="2" style="height: 25mm;">
<strong>Other Comments:</strong>
</td>
</tr>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -5,3 +5,15 @@ access_fp_job_node_override_manager,fp.job.node.override.manager,model_fp_job_no
access_fp_job_consumption_operator,fp.job.consumption.operator,model_fp_job_consumption,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_consumption,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_step_move_wiz_op,fp.job.step.move.wiz.operator,model_fp_job_step_move_wizard,fusion_plating.group_fusion_plating_operator,1,1,1,1
access_fp_job_step_move_wiz_sup,fp.job.step.move.wiz.supervisor,model_fp_job_step_move_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_job_step_move_wiz_mgr,fp.job.step.move.wiz.manager,model_fp_job_step_move_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_step_move_wiz_in_op,fp.job.step.move.wiz.in.operator,model_fp_job_step_move_wizard_input,fusion_plating.group_fusion_plating_operator,1,1,1,1
access_fp_job_step_move_wiz_in_sup,fp.job.step.move.wiz.in.supervisor,model_fp_job_step_move_wizard_input,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_job_step_move_wiz_in_mgr,fp.job.step.move.wiz.in.manager,model_fp_job_step_move_wizard_input,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_step_input_wiz_op,fp.job.step.input.wiz.operator,model_fp_job_step_input_wizard,fusion_plating.group_fusion_plating_operator,1,1,1,1
access_fp_job_step_input_wiz_sup,fp.job.step.input.wiz.supervisor,model_fp_job_step_input_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_job_step_input_wiz_mgr,fp.job.step.input.wiz.manager,model_fp_job_step_input_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_step_input_wiz_l_op,fp.job.step.input.wiz.l.operator,model_fp_job_step_input_wizard_line,fusion_plating.group_fusion_plating_operator,1,1,1,1
access_fp_job_step_input_wiz_l_sup,fp.job.step.input.wiz.l.supervisor,model_fp_job_step_input_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_job_step_input_wiz_l_mgr,fp.job.step.input.wiz.l.manager,model_fp_job_step_input_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_fp_job_consumption_operator fp.job.consumption.operator model_fp_job_consumption fusion_plating.group_fusion_plating_operator 1 1 1 0
6 access_fp_job_consumption_supervisor fp.job.consumption.supervisor model_fp_job_consumption fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_job_consumption_manager fp.job.consumption.manager model_fp_job_consumption fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_job_step_move_wiz_op fp.job.step.move.wiz.operator model_fp_job_step_move_wizard fusion_plating.group_fusion_plating_operator 1 1 1 1
9 access_fp_job_step_move_wiz_sup fp.job.step.move.wiz.supervisor model_fp_job_step_move_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 1
10 access_fp_job_step_move_wiz_mgr fp.job.step.move.wiz.manager model_fp_job_step_move_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
11 access_fp_job_step_move_wiz_in_op fp.job.step.move.wiz.in.operator model_fp_job_step_move_wizard_input fusion_plating.group_fusion_plating_operator 1 1 1 1
12 access_fp_job_step_move_wiz_in_sup fp.job.step.move.wiz.in.supervisor model_fp_job_step_move_wizard_input fusion_plating.group_fusion_plating_supervisor 1 1 1 1
13 access_fp_job_step_move_wiz_in_mgr fp.job.step.move.wiz.in.manager model_fp_job_step_move_wizard_input fusion_plating.group_fusion_plating_manager 1 1 1 1
14 access_fp_job_step_input_wiz_op fp.job.step.input.wiz.operator model_fp_job_step_input_wizard fusion_plating.group_fusion_plating_operator 1 1 1 1
15 access_fp_job_step_input_wiz_sup fp.job.step.input.wiz.supervisor model_fp_job_step_input_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 1
16 access_fp_job_step_input_wiz_mgr fp.job.step.input.wiz.manager model_fp_job_step_input_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
17 access_fp_job_step_input_wiz_l_op fp.job.step.input.wiz.l.operator model_fp_job_step_input_wizard_line fusion_plating.group_fusion_plating_operator 1 1 1 1
18 access_fp_job_step_input_wiz_l_sup fp.job.step.input.wiz.l.supervisor model_fp_job_step_input_wizard_line fusion_plating.group_fusion_plating_supervisor 1 1 1 1
19 access_fp_job_step_input_wiz_l_mgr fp.job.step.input.wiz.l.manager model_fp_job_step_input_wizard_line fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -49,9 +49,8 @@
<field name="logged_by_id"/>
</group>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
<separator string="Notes"/>
<field name="notes"/>
</sheet>
</form>
</field>

View File

@@ -25,6 +25,110 @@
class="btn-secondary"
icon="fa-sitemap"
invisible="state == 'draft'"/>
<button name="action_open_move_wizard" type="object"
string="Move to Next Step"
class="btn-primary"
icon="fa-arrow-right"
invisible="state not in ('confirmed', 'in_progress')"/>
<button name="action_print_traveller" type="object"
string="Print Traveller"
class="btn-secondary"
icon="fa-print"
invisible="state == 'draft'"/>
<button name="action_print_wo_detail" type="object"
string="Print WO Detail"
class="btn-secondary"
icon="fa-file-text-o"
invisible="state in ('draft', 'cancelled')"/>
</xpath>
<!-- Replace the bare-bones Steps list with the action-rich
manager view. Per-row buttons mirror what an operator
sees on the tablet; Running Min ticks on every refresh
for the active step. -->
<xpath expr="//page[@name='steps']/field[@name='step_ids']" position="replace">
<field name="step_ids" mode="list">
<list editable="bottom"
decoration-info="state in ('ready', 'in_progress')"
decoration-success="state == 'done'"
decoration-warning="state == 'paused'"
decoration-muted="state in ('skipped', 'cancelled')">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="work_centre_id" optional="show"/>
<field name="tank_id" optional="hide"/>
<field name="kind" optional="hide"/>
<field name="state" widget="badge"
decoration-info="state in ('ready', 'in_progress')"
decoration-success="state == 'done'"
decoration-warning="state == 'paused'"
decoration-muted="state in ('skipped', 'cancelled')"/>
<field name="assigned_user_id" optional="show"/>
<field name="duration_expected" optional="show"/>
<field name="duration_running_minutes" string="Running Min" optional="show"/>
<field name="duration_actual" optional="show"/>
<button name="button_start" type="object"
string="Start" icon="fa-play"
class="btn-link text-success"
invisible="state not in ('ready', 'pending')"/>
<button name="button_resume" type="object"
string="Resume" icon="fa-play-circle"
class="btn-link text-success"
invisible="state != 'paused'"/>
<button name="button_pause" type="object"
string="Pause" icon="fa-pause"
class="btn-link text-warning"
invisible="state != 'in_progress'"/>
<button name="button_finish" type="object"
string="Finish" icon="fa-check"
class="btn-link text-primary"
invisible="state != 'in_progress'"/>
<button name="action_open_move_wizard" type="object"
string="Move" icon="fa-arrow-right"
class="btn-link"
invisible="state in ('done', 'cancelled', 'skipped')"/>
<button name="action_open_input_wizard" type="object"
string="Record Inputs" icon="fa-pencil-square-o"
class="btn-link"
invisible="state in ('cancelled', 'skipped')"/>
<button name="button_skip" type="object"
string="Skip" icon="fa-step-forward"
class="btn-link text-muted"
invisible="state not in ('pending', 'ready')"/>
</list>
</field>
</xpath>
<!-- New tabs in the notebook: Move Log + Time Logs.
Both read-only — operators write via the wizards;
these tabs are the audit window. -->
<xpath expr="//page[@name='source']" position="before">
<page string="Move Log" name="move_log">
<field name="move_ids" readonly="1">
<list create="false" edit="false" delete="false"
decoration-info="transfer_type == 'step'"
decoration-warning="transfer_type in ('hold', 'rework')"
decoration-danger="transfer_type == 'scrap'">
<field name="move_datetime"/>
<field name="from_step_id"/>
<field name="to_step_id"/>
<field name="transfer_type" widget="badge"/>
<field name="qty_moved"/>
<field name="moved_by_user_id"/>
</list>
</field>
</page>
<page string="Time Logs" name="time_logs">
<field name="time_log_ids" readonly="1">
<list create="false" edit="false" delete="false">
<field name="step_id"/>
<field name="user_id"/>
<field name="date_started"/>
<field name="date_finished"/>
<field name="duration_minutes"/>
</list>
</field>
</page>
</xpath>
<!-- Inject a button_box at the top of the sheet, before the
@@ -67,6 +171,22 @@
<field name="quality_hold_count" widget="statinfo"
string="Holds"/>
</button>
<button name="action_view_racking_inspection" type="object"
class="oe_stat_button" icon="fa-clipboard-check">
<div class="o_stat_info">
<field name="racking_inspection_state"
widget="badge"
class="o_stat_value"
decoration-success="racking_inspection_state == 'done'"
decoration-info="racking_inspection_state == 'inspecting'"
decoration-warning="racking_inspection_state == 'discrepancy_flagged'"
decoration-muted="racking_inspection_state == 'draft'"
invisible="not racking_inspection_state"/>
<span class="o_stat_value"
invisible="racking_inspection_state"></span>
<span class="o_stat_text">Racking Insp.</span>
</div>
</button>
<button name="action_view_certificates" type="object"
class="oe_stat_button" icon="fa-certificate"
invisible="certificate_count == 0">

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>