This commit is contained in:
gsinghpal
2026-04-29 03:35:33 -04:00
parent 6ac6d24da6
commit a2fe1fcbcc
61 changed files with 4655 additions and 667 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.8.0',
'version': '19.0.8.11.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -202,9 +202,9 @@ class FpJob(models.Model):
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)."""
"""Open the racking inspection. Auto-create if missing, or seed
lines from the SO if it exists but was created before line auto-
seeding shipped (the helper handles both cases idempotently)."""
self.ensure_one()
if 'fp.racking.inspection' not in self.env:
from odoo.exceptions import UserError
@@ -212,9 +212,12 @@ class FpJob(models.Model):
'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'])
# Always call the helper — it short-circuits for already-populated
# draft inspections and creates fresh ones when missing. This is
# also the entry point that backfills lines on inspections that
# pre-date the line-seeding feature.
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
@@ -239,11 +242,39 @@ class FpJob(models.Model):
'context': {'default_job_id': self.id},
}
def action_finish_current_step(self):
"""Steelhead-style header button: finish whatever's currently
in_progress and auto-start the next pending/ready step. If
nothing is running yet, start the lowest-sequence pending step
instead — operator's first click on a fresh job just begins
the line.
"""
self.ensure_one()
running = self.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
if running:
return running.action_finish_and_advance()
# No running step — kick off the first pending/ready one.
first = self.step_ids.filtered(
lambda s: s.state in ('pending', 'ready', 'paused')
).sorted('sequence')[:1]
if not first:
raise UserError(_(
'No runnable step found on this job — either every step '
'is done or the job is still in draft.'
))
first.with_context(fp_skip_predecessor_check=True).button_start()
self.message_post(body=_(
'Started first step "%s".'
) % first.name)
return True
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.
"""Original Move wizard — kept available for cross-station moves
and rework / scrap transfers. The simple "finish current → start
next" flow is now action_finish_current_step (header button).
Opens the wizard pre-filled with the currently in-progress (or
most recently in-progress) step as the from-step.
"""
self.ensure_one()
active_step = self.step_ids.filtered(
@@ -871,6 +902,9 @@ class FpJob(models.Model):
production_id too so legacy reports keep working.
Idempotent — if an inspection already exists for this job, skip.
Either way the inspection's lines are seeded from the SO's
plating order lines so the racker walks into a pre-populated
checklist instead of an empty form.
"""
self.ensure_one()
if 'fp.racking.inspection' not in self.env:
@@ -883,17 +917,62 @@ class FpJob(models.Model):
('x_fc_job_id', '=', self.id),
], limit=1)
if existing:
# Self-heal: pre-existing inspections from before line seeding
# was added show up empty. Top them up now if still empty +
# the inspection isn't already finalised (don't rewrite history).
if not existing.line_ids and existing.state == 'draft':
self._fp_seed_racking_lines(existing)
return
# Phase 6 (Sub 11) — production_id retired; bind by x_fc_job_id only.
vals = {'x_fc_job_id': self.id}
try:
Inspection.create(vals)
insp = Inspection.create(vals)
self._fp_seed_racking_lines(insp)
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create racking inspection: %s",
self.name, e,
)
def _fp_seed_racking_lines(self, inspection):
"""Populate the inspection with one line per SO plating order line.
Walks sale_order_line_ids (the M2M of SO lines tied to this job),
falling back to the linked SO's order_line. Each line carries the
part_catalog and the quoted qty as the expected count — the
racker confirms or amends on the floor.
"""
self.ensure_one()
if not inspection or inspection.line_ids:
return
Line = self.env['fp.racking.inspection.line'].sudo()
# Source preference: explicit M2M of plating lines bound to this
# job (fast-order multi-part jobs), falling back to the SO header.
so_lines = self.sale_order_line_ids
if not so_lines and self.sale_order_id:
so_lines = self.sale_order_id.order_line
plating_lines = so_lines.filtered(
lambda l: l.x_fc_part_catalog_id and not l.display_type
)
if not plating_lines:
return
seq = 10
for sol in plating_lines:
try:
Line.create({
'inspection_id': inspection.id,
'sequence': seq,
'part_catalog_id': sol.x_fc_part_catalog_id.id,
'qty_expected': int(sol.product_uom_qty or 0),
'condition': 'ok',
})
except Exception as e:
_logger.warning(
"Job %s: failed to seed racking line for SO line %s: %s",
self.name, sol.id, e,
)
seq += 10
def _fp_create_portal_job(self):
"""Create the fusion.plating.portal.job mirror record."""
self.ensure_one()

View File

@@ -326,6 +326,98 @@ class FpJobStep(models.Model):
)) % (step.name, old, new, new - old, self.env.user.name))
return True
def action_finish_and_advance(self):
"""Steelhead-style "Finish & Next" — finish this step then auto-
start the next pending/ready step in sequence. Single click
replaces the prior Finish-then-Move-wizard dance.
If the step has authored step_input prompts AND none have been
captured yet, we route through the simplified Record Inputs
wizard first; saving the wizard re-enters here with the
`fp_after_inputs=True` context flag so we don't loop.
"""
self.ensure_one()
if self.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — start it before clicking Finish."
) % (self.name, self.state))
# Prompt-first behaviour: show the Record Inputs dialog when the
# recipe step has authored prompts and nothing has been captured
# in this run. Bypass when context flag is set (i.e. we're being
# called BACK from the wizard's commit), or when the operator
# already saved values via the Record Inputs button earlier.
if (not self.env.context.get('fp_after_inputs')
and self._fp_has_uncaptured_step_inputs()):
return self._fp_open_input_wizard(advance_after=True)
self.button_finish()
next_step = self._fp_next_runnable_step()
if next_step:
next_step.with_context(
fp_skip_predecessor_check=True,
).button_start()
self.job_id.message_post(body=_(
'Step "%(prev)s" finished — auto-started next step "%(next)s".'
) % {'prev': self.name, 'next': next_step.name})
return True
def _fp_next_runnable_step(self):
"""The lowest-sequence step on this job that isn't terminal yet
and isn't this one. Used by action_finish_and_advance."""
self.ensure_one()
candidates = self.job_id.step_ids.filtered(
lambda s: s.id != self.id
and s.state in ('pending', 'ready', 'paused')
).sorted('sequence')
return candidates[:1] or self.env['fp.job.step']
def _fp_has_uncaptured_step_inputs(self):
"""True when the recipe step defines step_input prompts AND
the user hasn't already saved values for this step's current
run via the Record Inputs wizard.
"""
self.ensure_one()
node = self.recipe_node_id
if not node:
return False
prompts = node.input_ids
if 'kind' in prompts._fields:
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
if not prompts:
return False
# Has the operator already recorded values during this run?
# Heuristic: any in-place fp.job.step.move (transfer_type='step')
# for this step since date_started.
Move = self.env['fp.job.step.move']
already = Move.search_count([
('from_step_id', '=', self.id),
('transfer_type', '=', 'step'),
('move_datetime', '>=', self.date_started or fields.Datetime.now()),
])
return already == 0
def _fp_open_input_wizard(self, advance_after=False):
"""Open the simplified Record Inputs dialog. When advance_after
is True, the wizard's Save button finishes the step and starts
the next one as a single atomic flow."""
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id(
'fusion_plating_jobs.action_fp_job_step_input_wizard'
)
action['context'] = {
**dict(self.env.context),
'default_step_id': self.id,
'active_id': self.id,
'fp_advance_after_save': advance_after,
}
return action
# NB: action_open_input_wizard is defined further down (line ~829)
# — that one stays as the per-row "Record" button entry-point.
# _fp_open_input_wizard above adds the advance_after pathway used
# only by action_finish_and_advance.
def button_finish(self):
"""Override to:
1) Auto-spawn a bake.window when a wet plating step finishes

View File

@@ -18,11 +18,16 @@
<field name="name">FP Traveller — A4 landscape narrow margins</field>
<field name="format">A4</field>
<field name="orientation">Landscape</field>
<field name="margin_top">10</field>
<!-- margin_top + header_spacing both reserve room above the body
so the H1 / Item Information table doesn't ride into the
external_layout's company logo band. The screenshot showed
"Work Order / Bon de Travail" overlapping the ENTECH logo
with the prior 10 / 5 values; 28 / 22 buys ~1cm clear gap. -->
<field name="margin_top">28</field>
<field name="margin_bottom">10</field>
<field name="margin_left">8</field>
<field name="margin_right">8</field>
<field name="header_spacing">5</field>
<field name="header_spacing">22</field>
<field name="dpi">90</field>
</record>

View File

@@ -21,11 +21,17 @@
<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>
<!-- margin_top + header_spacing both reserve room above the body
content. The external_layout puts the company logo + address
in that band; without enough space the header overlaps the
body's first line (the H1 on page 1, the Certified By table
on page 2). 35 / 28 puts a clean ~1cm clear gap below the
logo block. -->
<field name="margin_top">35</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="header_spacing">28</field>
<field name="dpi">90</field>
</record>
@@ -46,14 +52,42 @@
<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')"/>
<t t-set="so" t-value="job.sale_order_id"/>
<!-- All datetimes in Postgres are naive UTC. QWeb's
eval scope exposes neither pytz nor format_datetime,
so timestamp formatting happens via job.fp_format_local()
on the record itself — record methods are always
available in templates. The helper resolves user.tz
→ company.x_fc_default_tz → UTC. -->
<!-- First SO line linked to this job — source of truth
for the customer-facing description, serial(s),
and part metadata. -->
<t t-set="primary_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="po_number"
t-value="(so and (so.client_order_ref or (
'x_fc_po_number' in so._fields and so.x_fc_po_number) or ''))
or ''"/>
<t t-set="customer_desc"
t-value="primary_line and primary_line.fp_customer_description() or ''"/>
<t t-set="serial_names"
t-value="primary_line and 'x_fc_serial_ids' in primary_line._fields
and ', '.join(primary_line.x_fc_serial_ids.mapped('name'))
or ''"/>
<!-- Walk EVERY step in sequence, not just moves. The
old report only rendered moves so steps without
recorded measurements (just Finish & Next) never
appeared on the cert. -->
<t t-set="all_steps" t-value="job.step_ids.filtered(
lambda s: s.state not in ('cancelled',)
).sorted('sequence')"/>
<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 h1 { text-align: center; font-size: 18pt; margin: 0 0 14px 0; font-weight: bold; color: #1a4d80; }
.fp-wo-detail h3 { font-size: 11pt; margin: 12px 0 4px 0; font-weight: bold; }
.fp-wo-detail .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 6px; }
.fp-wo-detail table.bordered,
.fp-wo-detail table.bordered th,
.fp-wo-detail table.bordered td { border: 1px solid #000; border-collapse: collapse; }
@@ -61,15 +95,16 @@
.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; }
.fp-wo-detail hr.heavy { border: 0; border-top: 2px solid #000; margin: 12px 0; }
.fp-wo-detail .fp-spec { font-size: 10pt; font-weight: bold; margin: 10px 0 6px 0; }
.fp-wo-detail .fp-step-block { page-break-inside: avoid; margin-bottom: 14px; }
.fp-wo-detail .fp-prepared { margin-bottom: 14px; }
</style>
<h1>Work Order Detail</h1>
<!-- ===== HEADER — Prepared For + summary table ===== -->
<div style="margin-bottom: 8px;">
<div class="fp-prepared">
<strong>Prepared For:</strong>
<span style="font-size: 11pt;"
t-esc="(job.partner_id and job.partner_id.name) or '—'"/>
@@ -77,35 +112,41 @@
<table class="bordered">
<tr>
<th style="width: 20%;">Part Number</th>
<th style="width: 18%;">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: 7%;">Quantity</th>
<th style="width: 11%;">Work Order</th>
<th style="width: 12%;">PO Number</th>
<th style="width: 12%;">Serial 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-if="'revision' in job.part_catalog_id._fields and job.part_catalog_id.revision">
<br/>
<span style="font-size: 7.5pt;">Rev <span t-esc="job.part_catalog_id.revision"/></span>
</t>
</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 style="vertical-align: top;">
<!-- Customer-facing description. The
pre-line wrapper lives on an
INNER div, not the <td>: keeping
pre-line on the cell rendered
the indentation between <td>
and <t t-if> as literal blank
lines, pushing the description
halfway down the cell. The div
only sees the t-esc'd text, so
pre-line preserves the operator's
intentional \n\n paragraph
breaks but nothing else. -->
<div style="white-space: pre-line;"><t t-if="customer_desc"><span t-esc="customer_desc.strip()"/></t><t t-elif="'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></div>
</td>
<td class="text-center">
<span t-esc="job.qty"/>
@@ -114,11 +155,15 @@
<span t-esc="job.name"/>
</td>
<td>
<span t-esc="(job.sale_order_id and job.sale_order_id.client_order_ref) or '—'"/>
<span t-esc="po_number 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 ''"/>
<span t-esc="serial_names or '—'"/>
</td>
<td>
<t t-set="_hdr_dt"
t-value="job.date_finished or job.date_started or job.create_date"/>
<span t-esc="job.fp_format_local(_hdr_dt, '%Y-%m-%d')"/>
</td>
</tr>
</table>
@@ -130,15 +175,39 @@
<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 ''"/>
<!-- ===== STEPS WALK ===== -->
<t t-foreach="all_steps" t-as="step">
<!-- Aggregate captured input values from any
move that touches this step (incoming or
outgoing — the Record Inputs wizard
creates a self-loop move with from=to=step). -->
<t t-set="step_moves"
t-value="job.move_ids.filtered(
lambda m: m.from_step_id == step or m.to_step_id == step
).sorted('move_datetime')"/>
<t t-set="step_values"
t-value="step_moves.mapped('transition_input_value_ids')"/>
<!-- Pick a representative "Moved By" / Time:
prefer the step's own date_finished, fall
back to first move on the step, fall back
to date_started. Same for the user. -->
<t t-set="display_dt"
t-value="step.date_finished or (step_moves and step_moves[-1].move_datetime) or step.date_started or False"/>
<t t-set="display_user"
t-value="(step.finished_by_user_id and step.finished_by_user_id.name)
or (step_moves and step_moves[-1].moved_by_user_id and step_moves[-1].moved_by_user_id.name)
or (step.started_by_user_id and step.started_by_user_id.name)
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>
<span t-esc="step.name or '—'"/>
<t t-if="step.tank_id and step.tank_id.code">
(<span t-esc="step.tank_id.code"/>)
</t>
<t t-if="step.state == 'skipped'">
<span style="font-size: 9pt; color: #888; font-weight: normal;">— SKIPPED</span>
</t>
</h3>
<div class="fp-meta">
<strong>Part Number:</strong>
@@ -151,69 +220,72 @@
<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 ''"/>
<t t-if="display_user or display_dt">
<br/>
<strong>Moved By:</strong>
<span t-esc="display_user or '—'"/>
<span> </span>
<strong>Time:</strong>
<span t-esc="job.fp_format_local(display_dt, '%b %d, %Y %I:%M:%S %p') or '—'"/>
</t>
</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">
<!-- Captured inputs table — only rendered
when this step has at least one
value recorded across all its moves. -->
<t t-if="step_values">
<table class="bordered">
<thead>
<tr>
<th style="width: 24%;">Name</th>
<th style="width: 30%;">Description</th>
<th style="width: 32%;">Description</th>
<th style="width: 18%;">Value</th>
<th style="width: 28%;">Recorded By</th>
<th style="width: 26%;">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-foreach="step_values" t-as="cv">
<t t-set="inp" t-value="cv.node_input_id"/>
<t t-set="prompt_name"
t-value="(inp and inp.name) or (cv.value_text and cv.value_text.split(':')[0]) or 'Measurement'"/>
<t t-set="prompt_hint"
t-value="(inp and 'hint' in inp._fields and inp.hint) or ''"/>
<t t-set="actual_str" t-value="''"/>
<t t-if="cv.value_text">
<t t-set="actual_str" t-value="cv.value_text"/>
<!-- Strip the leading "Prompt:" prefix that
ad-hoc rows store so the Value cell
shows just the value, not the prompt
twice. -->
<t t-if="inp and inp.name and actual_str.startswith(inp.name + ':')">
<t t-set="actual_str" t-value="actual_str[len(inp.name)+1:].strip()"/>
</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 t-elif="cv.value_number">
<t t-set="_unit" t-value="(inp and 'target_unit' in inp._fields and inp.target_unit) or ''"/>
<t t-set="actual_str" t-value="('%s %s' % (cv.value_number, _unit)).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="job.fp_format_local(cv.value_date, '%Y-%m-%d %H:%M')"/>
</t>
<tr>
<td><span t-esc="prompt_name"/></td>
<td>
<t t-if="prompt_hint">
<span t-esc="prompt_hint"/>
</t>
</td>
<td>
<strong t-esc="actual_str"/>
</td>
<td>
<span t-esc="(cv.move_id.moved_by_user_id and cv.move_id.moved_by_user_id.name) or ''"/>
</td>
</tr>
</t>
</tbody>
</table>
@@ -221,16 +293,22 @@
</div>
</t>
<t t-if="not moves">
<t t-if="not all_steps">
<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.
No steps on this job yet — operators progress the
job via Start / Finish &amp; Next on the form, or
via the tablet.
</p>
</t>
<!-- ===== CERTIFIED BY + CERT STATEMENT ===== -->
<p style="page-break-before: always;"/>
<!-- page-break-before is honoured by wkhtmltopdf
but the new page starts flush against the
header_spacing band; the spacer div below
gives the cert table breathing room so it
doesn't sit under the company logo. -->
<div style="page-break-before: always;"/>
<div style="height: 8mm;"/>
<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">

View File

@@ -25,8 +25,14 @@
class="btn-secondary"
icon="fa-sitemap"
invisible="state == 'draft'"/>
<button name="action_open_move_wizard" type="object"
string="Move to Next Step"
<!-- Steelhead-style "Finish & Next": one click finishes
whatever's running and auto-starts the next pending
step. Falls back to starting the first step if
nothing is running yet. The classic Move wizard is
still available via the per-row Move button (used
for cross-station moves and rework / scrap). -->
<button name="action_finish_current_step" type="object"
string="Finish &amp; Next"
class="btn-primary"
icon="fa-arrow-right"
invisible="state not in ('confirmed', 'in_progress')"/>
@@ -42,6 +48,28 @@
invisible="state in ('draft', 'cancelled')"/>
</xpath>
<!-- Surface part / coating / recipe on the header so the
floor knows WHAT they're plating without diving into
Source. The "Reference Product" line in core is just
the FP-SERVICE stub from the SO — relabel it so it
doesn't compete with the real part identification. -->
<xpath expr="//field[@name='product_id']" position="attributes">
<attribute name="string">Service Product</attribute>
<attribute name="invisible">part_catalog_id</attribute>
</xpath>
<xpath expr="//field[@name='product_id']" position="after">
<field name="part_catalog_id" string="Part"/>
<field name="coating_config_id" string="Coating"/>
<field name="recipe_id" string="Process Recipe"/>
</xpath>
<!-- Show qty completed alongside total so the partial-qty
picture is visible at a glance without opening Move Log. -->
<xpath expr="//field[@name='qty']" position="after">
<field name="qty_done" string="Qty Done"/>
<field name="qty_scrapped" string="Qty Scrapped"
invisible="not qty_scrapped"/>
</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
@@ -67,6 +95,14 @@
<field name="duration_expected" optional="show"/>
<field name="duration_running_minutes" string="Running Min" optional="show"/>
<field name="duration_actual" optional="show"/>
<!-- Live qty currently parked at this step. Hits
zero once everything has moved on; >0 means
the floor still has parts to process here. -->
<field name="qty_at_step" string="Qty Here" optional="show"/>
<!-- Primary action: state-aware. Pending/ready → Start,
in_progress → Finish & Next (auto-advance like
Steelhead), paused → Resume. Done / skipped /
cancelled rows show no primary. -->
<button name="button_start" type="object"
string="Start" icon="fa-play"
class="btn-link text-success"
@@ -75,26 +111,32 @@
string="Resume" icon="fa-play-circle"
class="btn-link text-success"
invisible="state != 'paused'"/>
<button name="action_finish_and_advance" type="object"
string="Finish &amp; Next" icon="fa-check-circle"
class="btn-link text-primary"
invisible="state != 'in_progress'"/>
<!-- Secondary actions — small icons only. Pause is
only relevant on a running step; Record Inputs
stays available so operators can capture
measurements without finishing the step;
Skip + Move (cross-station) tucked together. -->
<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"
string="Record" 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')"/>
<button name="action_open_move_wizard" type="object"
string="Move…" icon="fa-exchange"
class="btn-link text-muted"
invisible="state in ('done', 'cancelled', 'skipped', 'pending')"/>
</list>
</field>
</xpath>

View File

@@ -154,6 +154,14 @@ class FpJobStepInputWizard(models.TransientModel):
self.step_id.message_post(body=_(
'%(n)s step input(s) recorded by %(user)s'
) % {'n': captured, 'user': self.env.user.name})
# When the wizard was opened from "Finish & Next" we re-enter
# the step's finish-and-advance flow with a context flag so it
# skips the prompt-for-inputs branch and finishes directly.
if self.env.context.get('fp_advance_after_save'):
return self.step_id.with_context(
fp_after_inputs=True,
).action_finish_and_advance()
return {'type': 'ir.actions.act_window_close'}
@@ -207,6 +215,36 @@ class FpJobStepInputWizardLine(models.TransientModel):
for rec in self:
rec.is_authored = bool(rec.node_input_id)
# ---- Single-column value editor -----------------------------------------
# The previous wizard exposed FOUR value columns (text / number /
# yes-no / date) — operators saw 9 columns wide and got lost. We
# collapse them into one "Value" column whose widget routes to the
# right typed field based on input_type. Booleans and dates get
# their own dedicated field (still per-row) so the widget behaves
# naturally; everything else types into a single value box.
is_boolean_type = fields.Boolean(
compute='_compute_type_flags',
)
is_date_type = fields.Boolean(
compute='_compute_type_flags',
)
is_numeric_type = fields.Boolean(
compute='_compute_type_flags',
)
@api.depends('input_type')
def _compute_type_flags(self):
numeric_types = {
'number', 'temperature', 'thickness',
'time_seconds',
}
for rec in self:
it = rec.input_type or 'text'
rec.is_boolean_type = it in ('boolean', 'pass_fail')
rec.is_date_type = it == 'date'
rec.is_numeric_type = it in numeric_types
def _has_value(self):
self.ensure_one()
return any([

View File

@@ -11,38 +11,58 @@
<field name="step_id" readonly="1"/>
<field name="job_id" readonly="1"/>
</group>
<separator string="Step Inputs"/>
<separator string="Measurements"/>
<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.
Click <strong>Add a line</strong> to record one or
more measurements for this step.
</p>
<field name="line_ids">
<list editable="bottom">
<field name="is_authored" column_invisible="1"/>
<field name="is_boolean_type" column_invisible="1"/>
<field name="is_date_type" column_invisible="1"/>
<field name="is_numeric_type" column_invisible="1"/>
<field name="name"
string="Measurement"
readonly="is_authored"
placeholder="e.g. Oven Temp, Operator Initials, Bath Reading"/>
placeholder="e.g. Oven Temp, Bath Reading, Operator Initials"/>
<field name="input_type"
string="Type"
readonly="is_authored"/>
<field name="target_unit"
string="Unit"
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"/>
<!-- Distinct column labels so the operator
reads which input matches the row's
type. List-view columns are static in
Odoo — labelling each by its purpose
removes the "four identical Value
columns" guesswork from the previous
layout. Only the cell matching the
row's type stays editable; others sit
blank. -->
<field name="value_number"
string="Number"
invisible="not is_numeric_type"/>
<field name="value_boolean"
string="Yes / No"
widget="boolean_toggle"
invisible="not is_boolean_type"/>
<field name="value_date"
string="Date / Time"
invisible="not is_date_type"/>
<field name="value_text"
string="Text"
invisible="is_numeric_type or is_boolean_type or is_date_type"/>
<field name="target_min" optional="hide"/>
<field name="target_max" optional="hide"/>
</list>
</field>
</sheet>
<footer>
<button name="action_commit" type="object"
string="Record" class="btn-primary"/>
string="Save" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>

View File

@@ -115,7 +115,14 @@ class FpJobStepMoveWizard(models.TransientModel):
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)
# Default to "qty currently here", not "job total". A job
# already mid-flight may have parts split across steps;
# pre-filling with the full job qty would silently let
# the operator move more than is actually parked here.
# Fall back to job qty when qty_at_step is 0 (e.g.
# opened on a fresh step before any movement).
qty_here = int(from_step.qty_at_step or 0)
defaults['qty_moved'] = qty_here or 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),
@@ -222,6 +229,29 @@ class FpJobStepMoveWizard(models.TransientModel):
if not self.from_step_id or not self.to_step_id:
raise UserError(_('Pick both From and To steps before moving.'))
# Partial-qty guards. The operator can't move more than is
# parked at the from-step, and zero/negative is meaningless.
# Self-loop moves (input recording) bypass the upper bound
# because they don't move qty.
if self.qty_moved <= 0:
raise UserError(_(
'Qty Moved must be at least 1. Use Skip on the step row '
'instead if no parts are being processed.'
))
is_self_loop = (self.from_step_id == self.to_step_id)
if not is_self_loop:
qty_here = int(self.from_step_id.qty_at_step or 0)
if qty_here > 0 and self.qty_moved > qty_here:
raise UserError(_(
'Cannot move %(req)s parts — only %(here)s currently '
'parked at "%(step)s". Adjust Qty Moved or split '
'across multiple moves.'
) % {
'req': self.qty_moved,
'here': qty_here,
'step': self.from_step_id.name,
})
Move = self.env['fp.job.step.move']
move = Move.create({
'job_id': self.job_id.id,