chore(plating): de-dash shipped code + intake-neutral customer emails

Replace em-dashes and en-dashes with hyphens across 789 shipped source
files (py/xml/js/scss) so the delivered module reads as human-written;
em-dashes had become a recognizable AI-generated tell. Internal .md dev
notes are excluded. The WO-sticker mojibake strippers keep their dash
search targets (now written — / –). No logic changes: comments
and display strings only; validated with py_compile + lxml parse.

Rewrite the 7 customer notification emails to be intake-neutral
(ship-in / drop-off / pickup) and repair-aware, and fix the Shipped
email documents line (packing slip vs bill of lading; certificate only
when issued). Subjects use a hyphen separator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-05 00:16:19 -04:00
parent c9eb61ee0c
commit 8c76a16366
789 changed files with 4692 additions and 4692 deletions

View File

@@ -2,10 +2,10 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating Native Jobs',
'name': 'Fusion Plating - Native Jobs',
'version': '19.0.12.5.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model replaces mrp.production / mrp.workorder bridge.',
'summary': 'Native plating job model - replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
@@ -18,7 +18,7 @@ Native Plating Job Bridge
Bridges fp.job and fp.job.step (defined in fusion_plating core, Phase 1 of
the migration spec dated 2026-04-25) to the rest of the Fusion Plating
module family configurator, portal, logistics, quality, certificates.
module family - configurator, portal, logistics, quality, certificates.
Coexists with fusion_plating_bridge_mrp during the migration period.
Activate native jobs via the x_fc_use_native_jobs settings flag (default:
@@ -53,9 +53,9 @@ full design rationale and §6.2 of the implementation plan for task list.
'security/legacy_groups.xml',
'security/ir.model.access.csv',
'data/fp_cron_data.xml',
# Spec 2026-05-25 mail.activity.type for QM Issue-CoC nudge.
# Spec 2026-05-25 - mail.activity.type for QM Issue-CoC nudge.
'data/fp_activity_types_data.xml',
# Sub 14 workflow state catalog (must load before fp_job_form_inherit
# Sub 14 - workflow state catalog (must load before fp_job_form_inherit
# so the statusbar's m2o has its targets available at view-render time).
'data/fp_workflow_state_data.xml',
'views/fp_workflow_state_views.xml',
@@ -80,8 +80,8 @@ full design rationale and §6.2 of the implementation plan for task list.
'report/report_fp_job_margin.xml',
],
'assets': {
# Sub 12d Step Details quick-look modal styles
# Sub 12e v4 Record Inputs OWL Dialog (replaces v2/v3)
# Sub 12d - Step Details quick-look modal styles
# Sub 12e v4 - Record Inputs OWL Dialog (replaces v2/v3)
# Both registered in both bundles so light + dark mode each
# compile correctly ($o-webclient-color-scheme branches at
# compile time per CLAUDE.md note).

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Consolidated 2026-04-24: the parallel OWL/controller stack was
# removed. job_scan is the only controller retained it powers the
# removed. job_scan is the only controller retained - it powers the
# QR-sticker scan redirect for fp.job records.
from . import job_scan
from . import record_inputs

View File

@@ -2,12 +2,12 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# /fp/job/<id> scan-redirect endpoint for native fp.job stickers.
# /fp/job/<id> - scan-redirect endpoint for native fp.job stickers.
#
# The fp.job sticker (Phase 5) embeds a QR encoding this URL. When a
# warehouse user scans it, this controller redirects them to either
# the fp.job form (for managers) or the upcoming process-tree client
# action (for operators Phase 6 expansion).
# action (for operators - Phase 6 expansion).
from odoo import http
from odoo.http import request

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Record Inputs Dialog (OWL) JSONRPC backend.
"""Record Inputs Dialog (OWL) - JSONRPC backend.
Replaces the v3 form-based wizard with a custom OWL dialog. The dialog
loads step + prompt metadata via /fp/record_inputs/load, then commits
@@ -20,7 +20,7 @@ from odoo.http import request
class FpRecordInputsController(http.Controller):
# ------------------------------------------------------------------
# Load return the prompt definitions + an empty values payload
# Load - return the prompt definitions + an empty values payload
# ------------------------------------------------------------------
@http.route('/fp/record_inputs/load', type='jsonrpc', auth='user')
def load(self, step_id):
@@ -30,7 +30,7 @@ class FpRecordInputsController(http.Controller):
return {'ok': False, 'error': 'Step not found.'}
step.check_access('read')
# Mirror the wizard's default_get logic build prompts from
# Mirror the wizard's default_get logic - build prompts from
# the recipe node's input_ids filtered to step_input + collect.
prompts = []
node = step.recipe_node_id
@@ -57,7 +57,7 @@ class FpRecordInputsController(http.Controller):
'is_authored': True,
})
# Operator initials used by the JS dialog to pre-fill
# Operator initials - used by the JS dialog to pre-fill
# signature / "Reviewer Initials" prompts. The user can edit
# the value in the dialog and the new value is persisted back
# via /fp/record_inputs/commit so future jobs and other steps
@@ -68,7 +68,7 @@ class FpRecordInputsController(http.Controller):
except AttributeError:
user_initials = ''
# Instruction images the recipe author's reference photos /
# Instruction images - the recipe author's reference photos /
# screenshots that show the operator HOW to do this step
# (masking patterns, fixture orientation, annotated diagrams).
# Returned as URL pointers so the dialog renders thumbnails
@@ -82,13 +82,13 @@ class FpRecordInputsController(http.Controller):
'mimetype': att.mimetype or '',
'url': '/web/image/%s' % att.id,
})
# Operator instructions text shown above the prompts so the
# Operator instructions text - shown above the prompts so the
# author's written guidance is visible at runtime.
instructions_html = ''
if node and node.description:
instructions_html = node.description
# Recipe root id surfaced so the dialog's "Edit Recipe" shortcut
# Recipe root id - surfaced so the dialog's "Edit Recipe" shortcut
# opens the Simple Editor on the EXACT recipe variant this job is
# reading from. Avoids the trap where the operator edits a sibling
# variant (e.g. the template, while the job runs the part-specific
@@ -119,7 +119,7 @@ class FpRecordInputsController(http.Controller):
}
# ------------------------------------------------------------------
# Commit write values via the existing wizard (reuse semantics)
# Commit - write values via the existing wizard (reuse semantics)
# ------------------------------------------------------------------
@http.route('/fp/record_inputs/commit', type='jsonrpc', auth='user')
def commit(self, step_id, values, advance_after=False, user_initials=None):

View File

@@ -3,9 +3,9 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Daily cron nudge supervisor for steps stuck in `paused` state
Daily cron - nudge supervisor for steps stuck in `paused` state
longer than 24 hours. Schedules a mail.activity on the parent job
so the manager sees a TODO. Idempotent re-running the same day
so the manager sees a TODO. Idempotent - re-running the same day
won't double-schedule.
-->
<odoo noupdate="1">
@@ -32,7 +32,7 @@
<field name="active" eval="True"/>
</record>
<!-- Phase 2 tablet redesign actual auto-pause (not just nudge).
<!-- Phase 2 tablet redesign - actual auto-pause (not just nudge).
Flips in_progress steps idle > N hours to paused with chatter
audit. Threshold configurable via ir.config_parameter
`fp.shopfloor.autopause_threshold_hours` (default 8.0). Recipe

View File

@@ -2,7 +2,7 @@
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Sub 14 Default 7-state workflow milestone catalog.
Sub 14 - Default 7-state workflow milestone catalog.
Shops can edit, deactivate, or add their own states via
Settings → Fusion Plating → Workflow States. These records use
@@ -34,7 +34,7 @@
<field name="sequence">30</field>
<field name="color">cyan</field>
<field name="trigger_default_kinds">receiving</field>
<field name="description">Parts physically on the floor production CAN start. Triggered by completion of any step with default_kind='receiving'.</field>
<field name="description">Parts physically on the floor - production CAN start. Triggered by completion of any step with default_kind='receiving'.</field>
</record>
<record id="workflow_state_in_progress" model="fp.job.workflow.state">

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# 19.0.10.24.0 Plant-view Shop Floor kanban redesign.
# 19.0.10.24.0 - Plant-view Shop Floor kanban redesign.
# Backfill fp.job.step.last_activity_at from write_date so existing
# in-progress steps don't immediately trip the S16 idle-warning gate
# (8 hours since last activity) on first compute after deploy.

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""19.0.10.25.0 Template metadata backfill + recipe-node repointing.
"""19.0.10.25.0 - Template metadata backfill + recipe-node repointing.
Runs AFTER fusion_plating's pre-migrate 19.0.21.2.0 (which seeds
kind.area_kind and activates derack/demask/gating). At this point:
@@ -17,7 +17,7 @@ This migration:
3. Recomputes area_kind on all fp.job.step rows.
4. Recomputes active_step_id + card_state on in-flight jobs.
All phases idempotent re-running -u is safe.
All phases idempotent - re-running -u is safe.
See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
"""
@@ -82,7 +82,7 @@ NODE_REPOINTING = [
def migrate(cr, version):
env = Environment(cr, SUPERUSER_ID, {})
# Phase 1 Template metadata backfill. Idempotent: only fills
# Phase 1 - Template metadata backfill. Idempotent: only fills
# NULL/empty fields, doesn't overwrite admin edits.
Tpl = env['fp.step.template']
Kind = env['fp.step.kind']
@@ -110,7 +110,7 @@ def migrate(cr, version):
fixed_tpl,
)
# Phase 2 Recipe node repointing. Idempotent: AND k.code != %s
# Phase 2 - Recipe node repointing. Idempotent: AND k.code != %s
# ensures already-correct rows are skipped on re-run.
for filter_sql, cur_code, new_code, desc in NODE_REPOINTING:
params = [new_code]
@@ -132,7 +132,7 @@ def migrate(cr, version):
cr.rowcount, desc,
)
# Phase 3 Recompute area_kind on every fp.job.step row.
# Phase 3 - Recompute area_kind on every fp.job.step row.
steps = env['fp.job.step'].search([])
if steps:
steps._compute_area_kind()
@@ -141,7 +141,7 @@ def migrate(cr, version):
'[live-step-fix] recomputed area_kind on %s steps', len(steps),
)
# Phase 4 Recompute active_step_id + card_state on in-flight jobs.
# Phase 4 - Recompute active_step_id + card_state on in-flight jobs.
jobs = env['fp.job'].search([
('state', 'in', ('confirmed', 'in_progress')),
])

View File

@@ -162,7 +162,7 @@ def migrate(cr, version):
# without rolling back the others.
clone_recipes = Node.search([
('node_type', '=', 'recipe'),
('name', 'ilike', '%%'),
('name', 'ilike', '% - %'),
])
if not clone_recipes:
_logger.info(
@@ -188,7 +188,7 @@ def migrate(cr, version):
failed.append((cid, cname, type(e).__name__, str(e)[:120]))
_logger.warning(
'[recipe-cleanup] Phase 3: failed to delete '
'clone %s ("%s"): %s continuing',
'clone %s ("%s"): %s - continuing',
cid, cname, type(e).__name__,
)
_logger.info(

View File

@@ -8,7 +8,7 @@ Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-desig
Rules:
- in_progress + all steps terminal + draft cert exists → awaiting_cert
- in_progress + all steps terminal + no cert required → awaiting_ship
- done jobs LEFT ALONE historically completed (already shipped)
- done jobs LEFT ALONE - historically completed (already shipped)
Idempotent: re-running on a fresh upgrade is a no-op because no
in_progress job will match the all-terminal predicate after the first
@@ -23,7 +23,7 @@ _logger = logging.getLogger(__name__)
def migrate(cr, version):
"""Post-migrate entrypoint called by Odoo after the module's
"""Post-migrate entrypoint - called by Odoo after the module's
XML/Python loads on -u of fusion_plating_jobs."""
# ---- Pass 1: in_progress + all-terminal + draft cert → awaiting_cert

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
#
# Phase 1 (Sub 11) relocate ir.model.data XML IDs from
# Phase 1 (Sub 11) - relocate ir.model.data XML IDs from
# fusion_plating_bridge_mrp to fusion_plating_jobs for the four
# models that moved: fp.work.role, fp.operator.proficiency,
# fp.qc.checklist.template (+line), fp.job.consumption.
@@ -10,7 +10,7 @@
# Pre-migration so Odoo's normal load pass sees the records under the
# new module owner, not as orphans pending deletion.
#
# Idempotent `ON CONFLICT DO NOTHING` skips rows already migrated.
# Idempotent - `ON CONFLICT DO NOTHING` skips rows already migrated.
import logging
@@ -19,7 +19,7 @@ _logger = logging.getLogger(__name__)
def migrate(cr, version):
if not version:
return # Fresh install nothing to migrate
return # Fresh install - nothing to migrate
moves = [
# (xmlid pattern, list of model identifiers to move)
@@ -77,7 +77,7 @@ def migrate(cr, version):
# Phase 1 swap: fp.job.consumption columns. Drop the legacy
# MRP-pointing columns (production_id, workorder_id) from the
# already-existing table there are zero rows referencing MRP, and
# already-existing table - there are zero rows referencing MRP, and
# the new model declares job_id / step_id instead.
cr.execute(
"""

View File

@@ -5,7 +5,7 @@
# Phase 2 of the native plating job model migration. Models are added
# task-by-task in Tasks 2.2 onwards.
from . import fp_job_workflow_state # Sub 14 must load before fp_job (FK target)
from . import fp_job_workflow_state # Sub 14 - must load before fp_job (FK target)
from . import fp_job
from . import fp_job_sticker
from . import fp_job_step
@@ -17,7 +17,7 @@ from . import sale_order
from . import sale_order_line
from . import res_users
# Phase 3 parallel job/step links on dependent modules' models.
# Phase 3 - parallel job/step links on dependent modules' models.
from . import fp_batch
from . import fp_quality_hold
from . import fp_certificate
@@ -26,19 +26,19 @@ from . import fp_delivery
from . import fp_racking_inspection
from . import fp_receiving
# Phase 4 light refactors batch B (notifications, KPI source tag).
# Phase 4 - light refactors batch B (notifications, KPI source tag).
from . import fp_notification_trigger
from . import fusion_plating_kpi_value
# Phase 5 Job Margin report.
# Phase 5 - Job Margin report.
from . import report_fp_job_margin
# Phase 1 of MRP cut-out (Sub 11) relocated from fusion_plating_bridge_mrp.
# Phase 1 of MRP cut-out (Sub 11) - relocated from fusion_plating_bridge_mrp.
# (fp.qc.checklist.template lives in fusion_plating_quality; can't depend
# back on jobs without a cycle.)
from . import fp_job_consumption
# Multi-rack splitting at Racking (Phase 1) jobs-side extension of
# Multi-rack splitting at Racking (Phase 1) - jobs-side extension of
# fp.rack.load (core model in fusion_plating) + fp.job rollups.
from . import fp_job_rack
# fp.work.role, fp.operator.proficiency, fp_process_node inherit, and the

View File

@@ -5,7 +5,7 @@
1. Block direct creation of out_invoice / out_refund for ALL users
including administrators. The only legal entry points are:
* sale.order._create_invoices() sets context fp_from_so_invoice=True
* sale.order._create_invoices() - sets context fp_from_so_invoice=True
* manual create() with invoice_origin matching an existing sale.order.name
2. Once a customer move is created via a legitimate path, derive its
@@ -98,7 +98,7 @@ class AccountMove(models.Model):
# it doesn't survive the copy. Allow reversals through as long
# as the reversed entry is itself a customer-facing move (which
# means it already went through this validator at original
# creation time the audit trail is intact).
# creation time - the audit trail is intact).
reversed_id = vals.get('reversed_entry_id')
if reversed_id:
parent = self.env['account.move'].sudo().browse(reversed_id)
@@ -139,7 +139,7 @@ class AccountMove(models.Model):
portal = job.portal_job_id
if 'invoice_ref' in portal._fields:
portal.invoice_ref = self.name
# Recompute state via the central helper it'll only land on
# Recompute state via the central helper - it'll only land on
# 'complete' if the WO is actually done AND the shipment is
# delivered. Posting an invoice early no longer skips the floor.
if hasattr(portal, '_fp_recompute_portal_state'):

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 parallel job/step links on fusion.plating.batch.
# Phase 3 - parallel job/step links on fusion.plating.batch.
# The legacy workorder_id link to mrp.workorder stays in place.
from odoo import fields, models

View File

@@ -2,13 +2,13 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 parallel job link on fp.certificate.
# Phase 3 - parallel job link on fp.certificate.
# Coexists with bridge_mrp's production_id link.
#
# v19.0.6.20.0 surface the Fischerscope PDF on the cert form so
# v19.0.6.20.0 - surface the Fischerscope PDF on the cert form so
# operators can SEE that the thickness report will be (or has been)
# merged into the CoC. The merge logic itself lives in
# fusion_plating_certificates/models/fp_certificate.py this file
# fusion_plating_certificates/models/fp_certificate.py - this file
# only adds the human-readable indicators.
from odoo import api, fields, models
@@ -74,7 +74,7 @@ class FpCertificate(models.Model):
else:
status = 'pending'
elif QC is not None and rec.x_fc_job_id:
# Same lookup the merge method uses passed-first,
# Same lookup the merge method uses - passed-first,
# then any QC with a PDF.
qc = QC.sudo().search([
('job_id', '=', rec.x_fc_job_id.id),
@@ -97,7 +97,7 @@ class FpCertificate(models.Model):
rec.x_fc_thickness_status = status
def action_view_thickness_qc(self):
"""Smart-button target open the linked QC for inspection."""
"""Smart-button target - open the linked QC for inspection."""
self.ensure_one()
if not self.x_fc_thickness_qc_id:
return False
@@ -111,7 +111,7 @@ class FpCertificate(models.Model):
}
def action_open_job(self):
"""Smart-button target open the linked plating job."""
"""Smart-button target - open the linked plating job."""
self.ensure_one()
if not self.x_fc_job_id:
return False
@@ -127,7 +127,7 @@ class FpCertificate(models.Model):
# ---- Parse-on-upload for the cert-form Fischerscope field (2026-05-28)
# The Issue Certs wizard parses .doc/.docx/RTF Fischerscope exports into
# readings + metadata + microscope image. Dropping the same file straight
# onto the cert form's x_fc_local_thickness_pdf field did nothing it
# onto the cert form's x_fc_local_thickness_pdf field did nothing - it
# just stored the bytes. These hooks give the form the SAME behaviour as
# the wizard: on save, a non-PDF upload is parsed and relocated to the
# evidence field (a real PDF is left in place to merge as page 2).
@@ -157,7 +157,7 @@ class FpCertificate(models.Model):
then relocate the non-PDF source to x_fc_local_thickness_evidence_id
and clear the PDF field (so the page-2 merge doesn't choke on it).
A real PDF is left in place it merges as page 2 of the CoC on
A real PDF is left in place - it merges as page 2 of the CoC on
Issue and carries no parseable readings. Unknown non-PDF types are
left untouched.
"""
@@ -178,7 +178,7 @@ class FpCertificate(models.Model):
is_rtf = raw[:5] == b'{\\rtf'
is_docx = name.endswith('.docx')
if not (is_rtf or is_docx):
return # unknown non-PDF don't guess
return # unknown non-PDF - don't guess
from ..wizards.fp_cert_issue_wizard import (
_fp_parse_fischerscope_rtf, _fp_parse_fischerscope_docx,
@@ -219,7 +219,7 @@ class FpCertificate(models.Model):
except ValueError:
continue
# Readings replace any existing set with the freshly-parsed rows
# Readings - replace any existing set with the freshly-parsed rows
# (the uploaded report is authoritative for this cert).
readings = parsed.get('readings') or []
Reading = self.env.get('fp.thickness.reading')
@@ -249,7 +249,7 @@ class FpCertificate(models.Model):
vals['x_fc_local_thickness_pdf'] = False
vals['x_fc_local_thickness_pdf_filename'] = False
# Microscope image (RTF only .docx images need a different path).
# Microscope image (RTF only - .docx images need a different path).
if is_rtf and 'x_fc_thickness_image_id' in self._fields:
try:
pngs = _fp_extract_rtf_images(raw)

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 parallel job link on fusion.plating.delivery.
# Phase 3 - parallel job link on fusion.plating.delivery.
# Coexists with the legacy job_ref Char.
from odoo import fields, models

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Phase 1 (Sub 11) relocated from fusion_plating_bridge_mrp.
# Phase 1 (Sub 11) - relocated from fusion_plating_bridge_mrp.
# MRP-flavoured fields (production_id, workorder_id) replaced by their
# native fp.job / fp.job.step equivalents.
@@ -14,14 +14,14 @@ class FpJobConsumption(models.Model):
"""A single consumable drawdown charged to a plating job.
Sources include bath replenishment applied against a job, masking tape
rolls, PPE, nickel salts anything that has a cost and should roll
rolls, PPE, nickel salts - anything that has a cost and should roll
into job costing.
Kept deliberately lightweight: one row per event, cost derived from
`product.standard_price` at log time (snapshot, not reactive).
"""
_name = 'fp.job.consumption'
_description = 'Fusion Plating Job Consumption'
_description = 'Fusion Plating - Job Consumption'
_order = 'logged_date desc, id desc'
job_id = fields.Many2one(

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
"""Masking reference attachments captured at Express order entry, surfaced
"""Masking reference attachments - captured at Express order entry, surfaced
on the job's masking step (operator workstation) and rolled up to the job
form (office). Populated by sale.order.line._fp_apply_express_overrides_to_job.
"""

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job.node.override per-job opt-in/out decisions for opt_in/opt_out
# fp.job.node.override - per-job opt-in/out decisions for opt_in/opt_out
# recipe nodes. Mirrors fusion.plating.job.node.override from bridge_mrp,
# but bound to fp.job instead of mrp.production.
#

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Multi-rack splitting at Racking Phase 1 jobs-module extension.
# Multi-rack splitting at Racking - Phase 1 jobs-module extension.
# Core models live in fusion_plating/models/fp_rack_load.py. This file owns
# everything that touches jobs-module fields (fp.job.step.area_kind,
# fp.job.part_catalog_id) and the racking-step detection (_fp_is_racking_step).
@@ -30,7 +30,7 @@ class FpRackLoad(models.Model):
@api.model
def _fp_racking_step_for(self, job):
# Detect the racking step by area_kind == 'racking' (the corrected
# classification), NOT _fp_is_racking_step() the latter keys off the
# classification), NOT _fp_is_racking_step() - the latter keys off the
# step's kind, and de-racking steps are frequently mis-tagged
# kind='racking' in the data, which would wrongly match De-Racking.
return job.step_ids.filtered(lambda s: s.area_kind == 'racking')[:1]

View File

@@ -17,7 +17,7 @@ from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# 2026-05-24 Shop Floor live-step fix (19.0.10.24.0):
# 2026-05-24 - Shop Floor live-step fix (19.0.10.24.0):
# The legacy `_STEP_KIND_TO_AREA` dict that lived here was removed.
# fp.step.kind now self-declares its area_kind, so the kind taxonomy
# IS the source of truth for Shop Floor column routing.
@@ -27,7 +27,7 @@ _logger = logging.getLogger(__name__)
class FpJobStep(models.Model):
_inherit = 'fp.job.step'
# ===== Sub 13 sequential enforcement (recipe + per-step) =============
# ===== Sub 13 - sequential enforcement (recipe + per-step) =============
# Decision matrix for whether button_start must verify predecessors:
#
# recipe.enforce_sequential | step.parallel_start | step.req_pred (legacy) | block?
@@ -56,7 +56,7 @@ class FpJobStep(models.Model):
return True
# Partial-flow short-circuit (2026-06-02 partial-order handling).
# Once REAL parts have physically arrived at this step (a move
# parked them here), the predecessor lock is moot the parts are
# parked them here), the predecessor lock is moot - the parts are
# on the floor at this station, so the step is startable
# regardless of whether upstream steps are fully done. This is
# what lets a partial group "light up" the next stage while the
@@ -68,12 +68,12 @@ class FpJobStep(models.Model):
recipe_seq = self.job_id.enforce_sequential
if recipe_seq:
return not self.parallel_start
# Free-flow recipe only the legacy per-step flag still gates.
# Free-flow recipe - only the legacy per-step flag still gates.
return bool(self.requires_predecessor_done)
def _fp_has_real_incoming(self):
"""True when real parts have physically arrived at this step via
a move an incoming move from a DIFFERENT step with qty_moved > 0.
a move - an incoming move from a DIFFERENT step with qty_moved > 0.
Distinct from the qty_at_step first-step seed (a notional UI hint
with no backing move) and from self-loop measurement moves
@@ -131,7 +131,7 @@ class FpJobStep(models.Model):
)
step.can_start = not bool(blocking)
# ===== 2026-05-23 plant-view redesign area_kind + activity =========
# ===== 2026-05-23 plant-view redesign - area_kind + activity =========
area_kind = fields.Selection(
[
('receiving', 'Receiving'),
@@ -171,22 +171,22 @@ class FpJobStep(models.Model):
Priority chain (non-gating steps):
1. step-NAME override for unambiguous de-rack / de-mask / bake
steps (2026-06-03) their recipe kind and/or work-centre is
steps (2026-06-03) - their recipe kind and/or work-centre is
frequently wrong (tagged 'racking'/'mask', a shared station, or
left blank), scattering cards across the Racking / Masking /
Plating columns. The operator-facing NAME is unambiguous, so it
wins OUTRIGHT even over an explicit work-centre. Bake/oven
wins OUTRIGHT - even over an explicit work-centre. Bake/oven
steps that merely mention "de-rack" stay in Baking. See spec
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
2. work_centre.area_kind (explicit operator setup)
3. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
4. catch-all 'plating' (data integrity issue if we land here)
Gating/marker steps (kind `code == 'gating'` the "Ready for X"
Gating/marker steps (kind `code == 'gating'` - the "Ready for X"
steps) have NO physical location; the taxonomy maps them to
'receiving', which made a mid-recipe gate snap the job's card back
to the first column (Racking -> "Ready for processing" jumped to
Receiving, so the job looked like it vanished 2026-06-02). A
Receiving, so the job looked like it vanished - 2026-06-02). A
gating step FALLS FORWARD to the next non-gating step's column
(it's "ready for [that stage]"), keeping the card moving
left->right. If nothing real follows, it falls back to the last
@@ -196,7 +196,7 @@ class FpJobStep(models.Model):
step.area_kind = step._fp_resolve_area_kind()
def _fp_raw_area_kind(self):
"""Area from this step's OWN name / work_centre / kind only no
"""Area from this step's OWN name / work_centre / kind only - no
look-ahead and no dependence on the computed `area_kind` field (so
the gating fall-forward below can't recurse).
@@ -261,7 +261,7 @@ class FpJobStep(models.Model):
x = (name or '').strip().lower()
if not x:
return None
# bake / oven first a "post de-rack" oven bake IS a bake
# bake / oven first - a "post de-rack" oven bake IS a bake
if 'oven' in x or 'bake' in x:
if any(w in x for w in (
'processing', 'inspect', 'check', 'qc',
@@ -306,7 +306,7 @@ class FpJobStep(models.Model):
_logger.debug("last_activity_at stamp on message_post failed: %s", exc)
return res
# Gate visualizer drives the OWL GateViz component on the tablet.
# Gate visualizer - drives the OWL GateViz component on the tablet.
# Returns kind of blocker + human reason + optional (model, id) jump
# target. Reuses _fp_should_block_predecessors so this stays in sync
# with can_start as a single source of truth.
@@ -349,7 +349,7 @@ class FpJobStep(models.Model):
step.blocker_jump_target_id = 0
continue
# Predecessor gate same policy as _compute_can_start
# Predecessor gate - same policy as _compute_can_start
if step._fp_should_block_predecessors():
earlier_open = step.job_id.step_ids.filtered(lambda x: (
x.id != step.id
@@ -376,7 +376,7 @@ class FpJobStep(models.Model):
step.blocker_jump_target_id = 0
# ==================================================================
# Shop-Floor auto-pause cron (Phase 2 tablet redesign)
# Shop-Floor auto-pause cron (Phase 2 - tablet redesign)
# ==================================================================
@api.model
def _cron_autopause_stale_steps(self):
@@ -386,7 +386,7 @@ class FpJobStep(models.Model):
fp.shopfloor.autopause_threshold_hours (default 8.0)
Recipes can opt out per node via
fusion.plating.process.node.long_running (Phase 2 P2.1)
fusion.plating.process.node.long_running (Phase 2 - P2.1)
Fixes the 411-hour ghost timer that bit us on the original tablet
when an operator started a step and never tapped Finish. Posts an
@@ -422,7 +422,7 @@ class FpJobStep(models.Model):
paused += 1
except Exception:
_logger.exception(
"Auto-pause failed for step %s skipping", step.id,
"Auto-pause failed for step %s - skipping", step.id,
)
if paused:
_logger.info(
@@ -448,7 +448,7 @@ class FpJobStep(models.Model):
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' only in-progress steps can pause."
"Step '%s' is in state '%s' - only in-progress steps can pause."
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
@@ -465,7 +465,7 @@ class FpJobStep(models.Model):
for step in self:
if step.state not in ('pending', 'ready'):
raise UserError(_(
"Step '%s' is in state '%s' only pending/ready steps can be skipped."
"Step '%s' is in state '%s' - only pending/ready steps can be skipped."
) % (step.name, step.state))
step.state = 'skipped'
return True
@@ -477,7 +477,7 @@ class FpJobStep(models.Model):
for step in self:
if step.state == 'done':
raise UserError(_(
"Step '%s' is done cannot cancel."
"Step '%s' is done - cannot cancel."
) % step.name)
if step.state == 'cancelled':
raise UserError(_(
@@ -487,7 +487,7 @@ class FpJobStep(models.Model):
return True
def button_reset(self):
"""Reset a step back to 'ready' so it can be redone operator
"""Reset a step back to 'ready' so it can be redone - operator
self-serve for a mistake, an accidental skip, or a customer redo
request. Clears the finish + sign-off stamps and closes any open
timelog so the redo re-captures them; KEEPS the first-start audit
@@ -529,7 +529,7 @@ class FpJobStep(models.Model):
"""Post a chatter trail on the parent JOB whenever an active
step gets reassigned. The step itself already tracks
assigned_user_id (tracking=True) but supervisors don't open
each step's chatter they read the job. Without a job-level
each step's chatter - they read the job. Without a job-level
post the takeover is invisible.
Only fires for steps in active states (in_progress / paused)
@@ -593,7 +593,7 @@ class FpJobStep(models.Model):
@api.model
def _cron_nudge_stale_in_progress(self, threshold_hours=8):
"""Cron nudge for steps stuck in `in_progress` longer than
threshold. Default 8 hours operator started, walked away,
threshold. Default 8 hours - operator started, walked away,
timelog accumulating phantom hours.
"""
return self._cron_nudge_stale_steps(
@@ -610,7 +610,7 @@ class FpJobStep(models.Model):
Finds every fp.job.step in any of `states` with date_started
older than N hours. Schedules a 'todo' mail.activity on the
parent job for the job's manager_id (falls back to the user
who started the step). Idempotent won't double-schedule if
who started the step). Idempotent - won't double-schedule if
an open activity with the same summary already exists.
"""
from datetime import timedelta as _td
@@ -689,7 +689,7 @@ class FpJobStep(models.Model):
for step in self:
if step.state not in ('in_progress', 'paused'):
raise UserError(_(
"Step '%s' is in state '%s' only in_progress / "
"Step '%s' is in state '%s' - only in_progress / "
"paused steps can be aborted for retry."
) % (step.name, step.state))
old_tank = step.tank_id.display_name or '(no tank set)'
@@ -715,7 +715,7 @@ class FpJobStep(models.Model):
'Reason: <em>%s</em><br/>'
'Equipment: tank=%s, bath=%s%s<br/>'
'Partial work captured: %.2f min in %d timelog(s). '
'Step is back in <b>ready</b> state operator can '
'Step is back in <b>ready</b> state - operator can '
'restart when the issue is resolved.'
)) % (
step.name, self.env.user.name, reason,
@@ -725,7 +725,7 @@ class FpJobStep(models.Model):
return True
def action_recompute_duration_from_timelogs(self):
"""Manual button re-sum duration_actual + post to chatter
"""Manual button - re-sum duration_actual + post to chatter
for audit. Use case: supervisor adjusts a timelog row and
wants an explicit audit trail of the recompute. The
automatic version called from timelog hooks is
@@ -743,7 +743,7 @@ class FpJobStep(models.Model):
return True
def _fp_resum_duration_actual(self):
"""Quiet re-sum used by automatic triggers (timelog
"""Quiet re-sum - used by automatic triggers (timelog
create/write/unlink hooks). No chatter post. Skips no-op
updates so writes are minimised."""
for step in self:
@@ -753,7 +753,7 @@ class FpJobStep(models.Model):
return True
def action_finish_and_advance(self):
"""Steelhead-style "Finish & Next" finish this step then auto-
"""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.
@@ -765,10 +765,10 @@ class FpJobStep(models.Model):
self.ensure_one()
if self.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' start it before clicking Finish."
"Step '%s' is in state '%s' - start it before clicking Finish."
) % (self.name, self.state))
# Contract Review (QA-005) routing when the recipe step is
# Contract Review (QA-005) routing - when the recipe step is
# flagged as a contract-review step, the operator should land on
# the part's QA-005 form rather than the generic measurement
# wizard. Once the review is complete or dismissed we fall
@@ -798,7 +798,7 @@ class FpJobStep(models.Model):
fp_skip_predecessor_check=True,
).button_start()
self.job_id.message_post(body=_(
'Step "%(prev)s" finished auto-started next step "%(next)s".'
'Step "%(prev)s" finished - auto-started next step "%(next)s".'
) % {'prev': self.name, 'next': next_step.name})
return True
@@ -817,31 +817,31 @@ class FpJobStep(models.Model):
parts (2026-06-02 partial-order handling).
Called by the Move controller after a bulk move commits. When the
last parts leave an in_progress step it should close itself one
last parts leave an in_progress step it should close itself - one
fewer tap for the operator. But finishing runs the full gate chain
(required inputs, sign-off, contract review, receiving, and the
post-shop close gates on the last step). If any gate isn't
satisfied we must NOT fail the move that already succeeded so we
satisfied we must NOT fail the move that already succeeded - so we
swallow the UserError and leave the step in_progress for the
operator to finish manually (the board will show it "running, 0
here", which reads as "finish me").
Fires for any step that actually moved parts OUT and drained to
zero INCLUDING the first/seeded stage (its qty comes from the
zero - INCLUDING the first/seeded stage (its qty comes from the
qty_at_step seed, not a real incoming move). Returns True if the
step finished.
"""
self.ensure_one()
if self.state != 'in_progress':
return False
# qty_at_step is a non-stored compute off the move rows force a
# qty_at_step is a non-stored compute off the move rows - force a
# re-read so we see the just-committed outgoing move.
self.invalidate_recordset(['qty_at_step'])
if self.qty_at_step != 0:
return False
# Guard: only auto-finish a step that genuinely moved parts OUT (a
# real outgoing move, excluding self-loop measurement moves). The
# earlier guard checked _fp_has_real_incoming() the WRONG
# earlier guard checked _fp_has_real_incoming() - the WRONG
# direction: the first/seeded stage (e.g. Racking) is fed by the
# qty_at_step seed, not an incoming move, so it never auto-finished
# when all its parts were sent forward. Checking for a real
@@ -853,7 +853,7 @@ class FpJobStep(models.Model):
self.button_finish()
return True
except UserError:
# Gates still pending (missing prompts / sign-off / etc.)
# Gates still pending (missing prompts / sign-off / etc.) -
# leave the step in_progress for a manual finish. The move
# itself stands.
return False
@@ -863,7 +863,7 @@ class FpJobStep(models.Model):
whose values haven't been recorded yet.
Previously this checked "any move with input values exists since
date_started" too coarse. Operator clicked Save on the dialog
date_started" - too coarse. Operator clicked Save on the dialog
after filling ONE prompt and the helper went quiet, letting
action_finish_and_advance bypass the dialog re-open even when
4 of 5 required prompts were still empty (WO-30051 / Riya 2026-05-23).
@@ -876,7 +876,7 @@ class FpJobStep(models.Model):
def _fp_missing_required_step_inputs(self):
"""Return the recordset of REQUIRED step_input prompts on this
step's recipe node that have NO value recorded across any move
from this step. Centralised helper used by both
from this step. Centralised helper - used by both
_fp_has_uncaptured_step_inputs (re-open dialog) and
_fp_check_step_inputs_complete (raise UserError on finish).
"""
@@ -888,9 +888,9 @@ class FpJobStep(models.Model):
# Master switch (Sub 12d): when the recipe node opts OUT of
# measurement collection, the Record-Inputs wizard returns ZERO
# rows (fp_job_step_input_wizard.default_get). The finish gate MUST
# agree otherwise required prompts are demanded with no way to
# agree - otherwise required prompts are demanded with no way to
# enter them and the step is permanently stuck (bake nodes with
# collect_measurements=False but required prompts WO-30098 + 63
# collect_measurements=False but required prompts - WO-30098 + 63
# others on entech). Honour the switch here so gate <=> wizard.
if ('collect_measurements' in node._fields
and not node.collect_measurements):
@@ -920,10 +920,10 @@ class FpJobStep(models.Model):
WHO finished the step as the signer-of-record. For shops that
need separate operator+supervisor sign-off, call action_signoff()
explicitly from a supervisor session BEFORE the operator clicks
Finish that pre-sets signoff_user_id and this helper becomes a
Finish - that pre-sets signoff_user_id and this helper becomes a
no-op.
Idempotent never overwrites an existing signoff_user_id, so a
Idempotent - never overwrites an existing signoff_user_id, so a
manager pre-signing via action_signoff is preserved through the
operator's Finish click.
"""
@@ -960,7 +960,7 @@ class FpJobStep(models.Model):
continue
step.job_id.message_post(body=Markup(_(
'Sign-off gate bypassed on step "<b>%s</b>" by %s. '
'Documented deviation no signer recorded.'
'Documented deviation - no signer recorded.'
)) % (step.name, self.env.user.name))
return
for step in self:
@@ -969,7 +969,7 @@ class FpJobStep(models.Model):
if step.signoff_user_id:
continue
raise UserError(_(
'Step "%(step)s" cannot be finished sign-off required '
'Step "%(step)s" cannot be finished - sign-off required '
'but no signer recorded. Click "Sign Off" on the step '
'(or have your supervisor sign before you finish). '
'Managers can override via context flag '
@@ -977,13 +977,13 @@ class FpJobStep(models.Model):
) % {'step': step.name})
def action_signoff(self):
"""Explicit sign-off action sets signoff_user_id = env.user.id
"""Explicit sign-off action - sets signoff_user_id = env.user.id
for the calling user. Use case: a supervisor reviews an operator's
work and signs off BEFORE the operator clicks Finish. Once signed,
the operator's Finish click passes the signoff gate without auto-
assigning a different signer.
Idempotent re-clicking by the same user is a no-op. A DIFFERENT
Idempotent - re-clicking by the same user is a no-op. A DIFFERENT
user re-signing overwrites the prior signer (and chatters the change)
so a senior supervisor can override a junior's premature sign-off
without leaving the audit trail mute.
@@ -991,7 +991,7 @@ class FpJobStep(models.Model):
for step in self:
if not step.requires_signoff:
raise UserError(_(
'Step "%s" does not require sign-off nothing to sign.'
'Step "%s" does not require sign-off - nothing to sign.'
) % step.name)
prior = step.signoff_user_id
if prior and prior.id == self.env.user.id:
@@ -1013,7 +1013,7 @@ class FpJobStep(models.Model):
per-step data trail; finishing a step with missing prompts breaks
the audit chain.
2026-05-24: also blocks orphaned steps (recipe_node_id NULL
2026-05-24: also blocks orphaned steps (recipe_node_id NULL -
happens when the source recipe was deleted, e.g. a per-part clone
cleanup). Without a recipe link there's no way to verify required
prompts; defaulting to "let it through" was a silent compliance
@@ -1028,19 +1028,19 @@ class FpJobStep(models.Model):
for step in self:
step.job_id.message_post(body=Markup(_(
'Required-inputs gate bypassed on step "<b>%s</b>" by %s. '
'Documented deviation review the step\'s prompts.'
'Documented deviation - review the step\'s prompts.'
)) % (step.name, self.env.user.name))
return
for step in self:
# Orphan-step block NULL recipe_node means we can't list
# Orphan-step block - NULL recipe_node means we can't list
# required prompts, so we conservatively refuse to finish.
if not step.recipe_node_id:
raise UserError(_(
'Step "%(step)s" cannot be finished this step has '
'Step "%(step)s" cannot be finished - this step has '
'no recipe link (the source recipe was deleted or the '
'job was created before recipes were assigned). '
'Required-input verification is impossible without '
'the recipe. Escalate to a manager they can bypass '
'the recipe. Escalate to a manager - they can bypass '
'with an audit-chatter entry.'
) % {'step': step.name})
missing = step._fp_missing_required_step_inputs()
@@ -1048,7 +1048,7 @@ class FpJobStep(models.Model):
continue
names = ', '.join('"%s"' % (p.name or '').strip() for p in missing)
raise UserError(_(
'Step "%(step)s" cannot be finished %(n)s required '
'Step "%(step)s" cannot be finished - %(n)s required '
'input(s) not recorded yet: %(names)s. '
'Click "Record Inputs" on the step row to enter the '
'missing values, then finish. '
@@ -1066,7 +1066,7 @@ class FpJobStep(models.Model):
Replaces the form-view-based wizard with a custom OWL Dialog
component (fp_record_inputs_dialog.js). The dialog renders
each prompt as a proper card with semantic HTML no more
each prompt as a proper card with semantic HTML - no more
list-cell-as-card CSS hacks.
When advance_after is True, the dialog's Save button commits
@@ -1084,11 +1084,11 @@ class FpJobStep(models.Model):
}
# NB: action_open_input_wizard is defined further down (line ~829)
# that one stays as the per-row "Record" button entry-point.
# - 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.
# NOTE the earlier duplicate `button_finish` definition that held
# NOTE - the earlier duplicate `button_finish` definition that held
# the duration-overrun + bake.window auto-spawn logic has been merged
# into the canonical button_finish further down (line ~1130). Python
# was silently keeping only the LAST definition in this class body,
@@ -1096,16 +1096,16 @@ class FpJobStep(models.Model):
# era. Don't re-introduce a second button_finish here.
# ==================================================================
# Phase 2 multi-serial auto-promote serials on step transitions
# 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
into `in_process`. Idempotent - already-promoted serials are
skipped.
"""
for step in self:
# sudo() technicians lack sale.order ACL (Rule 13m).
# sudo() - technicians lack sale.order ACL (Rule 13m).
job = step.sudo().job_id
if not job.sale_order_line_ids:
continue
@@ -1124,9 +1124,9 @@ class FpJobStep(models.Model):
"""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 packing. Conservative - only promotes from `in_process`."""
for step in self:
# sudo() technicians lack sale.order ACL (Rule 13m).
# sudo() - technicians lack sale.order ACL (Rule 13m).
job = step.sudo().job_id
if not job.sale_order_line_ids:
continue
@@ -1150,7 +1150,7 @@ class FpJobStep(models.Model):
) % (step.name, self.env.user.name))
# ==================================================================
# Policy B (2026-04-28) Contract Review enforcement
# 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-
@@ -1158,7 +1158,7 @@ class FpJobStep(models.Model):
# 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
# 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):
@@ -1182,7 +1182,7 @@ class FpJobStep(models.Model):
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()
# sudo() technicians lack sale.order ACL (Rule 13m).
# sudo() - technicians lack sale.order ACL (Rule 13m).
for so_line in self.sudo().job_id.sale_order_line_ids:
if (so_line.x_fc_part_catalog_id
and 'fp.contract.review' in self.env):
@@ -1199,7 +1199,7 @@ class FpJobStep(models.Model):
return None
Review = self.env.get('fp.contract.review')
if Review is None:
return None # quality module not installed skip
return None # quality module not installed - skip
review = part.x_fc_contract_review_id
if not review:
review = Review.sudo().create({
@@ -1223,7 +1223,7 @@ class FpJobStep(models.Model):
'res_id': review.id,
'view_mode': 'form',
'target': 'current',
'name': _('Contract Review %s') % (
'name': _('Contract Review - %s') % (
part.display_name or part.part_number or ''
),
}
@@ -1247,7 +1247,7 @@ class FpJobStep(models.Model):
review.state if review else _('not started')
)
raise UserError(_(
'Contract Review for %(part)s is %(state)s must be '
'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: '
@@ -1272,7 +1272,7 @@ class FpJobStep(models.Model):
) % ', '.join(approvers.mapped('name')))
# ==================================================================
# Sub 8 follow-up (2026-04-28) Racking Inspection enforcement
# 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
@@ -1325,13 +1325,13 @@ class FpJobStep(models.Model):
'res_id': ri.id,
'view_mode': 'form',
'target': 'current',
'name': _('Racking Inspection %s') % self.job_id.name,
'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
"""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
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
@@ -1340,7 +1340,7 @@ class FpJobStep(models.Model):
continue
ri = step.job_id.racking_inspection_id
if not ri:
# No inspection at all still let it finish, but log a
# 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 '
@@ -1352,7 +1352,7 @@ class FpJobStep(models.Model):
state_label = dict(ri._fields['state'].selection).get(
ri.state, ri.state)
raise UserError(_(
'Racking inspection for %(job)s is "%(state)s" must '
'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. '
@@ -1365,7 +1365,7 @@ class FpJobStep(models.Model):
def _fp_check_receiving_gate(self):
"""Block step transitions until parts are physically received.
Applied to every step EXCEPT Contract Review (paperwork doesn't
Applied to every step EXCEPT Contract Review (paperwork - doesn't
need parts on the floor). Fires from both button_start and
button_finish so an operator can't begin OR complete physical
work before the receiving record is closed.
@@ -1384,13 +1384,13 @@ class FpJobStep(models.Model):
for step in self:
if step._fp_is_contract_review_step():
continue
# sudo() technicians don't have sale.order ACL but the
# sudo() - technicians don't have sale.order ACL but the
# gate's purpose is checking a denormalized state field.
# Rule 13m: cross-module reads in tablet/floor controllers
# must sudo() the source recordset.
so = step.sudo().job_id.sale_order_id
if not so:
# Internal rework / no SO gate doesn't apply.
# Internal rework / no SO - gate doesn't apply.
continue
if 'x_fc_receiving_status' not in so._fields:
# Defensive: configurator module not installed.
@@ -1403,7 +1403,7 @@ class FpJobStep(models.Model):
so.x_fc_receiving_status or 'unknown',
)
raise UserError(_(
'Step "%(step)s" cannot proceed parts not received '
'Step "%(step)s" cannot proceed - parts not received '
'yet (SO %(so)s receiving status: %(status)s).\n\n'
'Close the receiving record (Sales > %(so)s > '
'Receiving) before starting or finishing work on '
@@ -1491,7 +1491,7 @@ class FpJobStep(models.Model):
return result
def button_finish(self):
"""Canonical button_finish gates first, then super(), then
"""Canonical button_finish - gates first, then super(), then
post-finish side effects.
Gates (raise UserError, blocking finish):
@@ -1502,8 +1502,8 @@ class FpJobStep(models.Model):
no supervisor has pre-signed. Manager bypass:
fp_skip_signoff_gate=True.
- Contract Review (QA-005) complete when customer requires it.
- Receiving gate parts physically on site for this WO.
(Racking-inspection gate removed racking is a recipe step
- Receiving gate - parts physically on site for this WO.
(Racking-inspection gate removed - racking is a recipe step
now, not a separate workflow. _fp_check_racking_inspection_
complete() is kept as a helper for diagnostics.)
@@ -1530,7 +1530,7 @@ class FpJobStep(models.Model):
# ----- Post-shop gate (spec 2026-05-25 D12) ---------------------
# When finishing the LAST open step on an in_progress job, run
# the bake/qty/QC gates that used to live in button_mark_done.
# Failure raises UserError on THIS click operator fixes
# Failure raises UserError on THIS click - operator fixes
# (qty, bake, QC) and retries the finish. Without this the
# auto-advance helper would silently fail with no error path.
for step in self:
@@ -1545,7 +1545,7 @@ class FpJobStep(models.Model):
and s.state not in ('done', 'skipped', 'cancelled')
)
if siblings_open:
continue # not the last open step skip the gates
continue # not the last open step - skip the gates
job._fp_check_finish_gates()
result = super().button_finish()
@@ -1569,13 +1569,13 @@ class FpJobStep(models.Model):
ratio = step.duration_actual / step.duration_expected
if ratio >= 1.5:
step.job_id.message_post(body=Markup(_(
'⚠️ <b>Step "%s" ran %.1fx expected</b> '
'⚠️ <b>Step "%s" ran %.1fx expected</b> - '
'expected %.0f min, actual %.0f min. Investigate: '
'equipment issue, training gap, or recipe time '
'estimate too tight.'
)) % (step.name, ratio, step.duration_expected,
step.duration_actual))
# Bake-window auto-spawn wet plating step + recipe flagged
# Bake-window auto-spawn - wet plating step + recipe flagged
# requires_bake_relief. Heuristic identifies the actual
# plate-out step (kind=wet OR "plating" as a word in name),
# excluding inspection/bake/mask/rack steps that mention
@@ -1608,7 +1608,7 @@ class FpJobStep(models.Model):
bath = Bath.sudo().search([], limit=1)
if not bath:
_logger.warning(
'Step %s: bake-window auto-spawn skipped no bath '
'Step %s: bake-window auto-spawn skipped - no bath '
'configured.', step.name,
)
continue
@@ -1622,7 +1622,7 @@ class FpJobStep(models.Model):
'quantity': int(step.job_id.qty or 0),
})
step.job_id.message_post(body=Markup(_(
'Bake window <b>%s</b> auto-created %.1fh window from '
'Bake window <b>%s</b> auto-created - %.1fh window from '
'plate exit. Required by %s.'
)) % (bw.name, window_hrs, bw.bake_required_by))
return result
@@ -1675,11 +1675,11 @@ class FpJobStep(models.Model):
Only valid for kind='gating' steps in state in (ready, pending,
paused). NOOPs on already-terminal steps for idempotency. Raises
UserError if called on a non-gating step (defensive UI dispatcher
UserError if called on a non-gating step (defensive - UI dispatcher
only renders Mark Passed for gating kinds).
Bypasses the S21 required-inputs gate (gating steps have no
required inputs by design they're admin gates).
required inputs by design - they're admin gates).
Spec: 2026-05-24-workspace-step-actions-design.md Change 5.
"""
@@ -1735,7 +1735,7 @@ class FpJobStep(models.Model):
if not part:
_logger.warning(
"Contract-review step '%s' on job %s has no part_catalog_id "
" cannot redirect to QA-005 form, falling through to "
"- cannot redirect to QA-005 form, falling through to "
"standard wizard.",
self.name, self.job_id.name,
)
@@ -1746,7 +1746,7 @@ class FpJobStep(models.Model):
return part.action_start_contract_review()
# ------------------------------------------------------------------
# Live duration helper view binds to a non-stored compute that
# 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
@@ -1781,13 +1781,13 @@ class FpJobStep(models.Model):
step.duration_running_minutes = step.duration_actual or 0.0
# ------------------------------------------------------------------
# Sub 12d Step Details Quick-Look modal
# Sub 12d - Step Details Quick-Look modal
# ------------------------------------------------------------------
# Three computed/related fields that power the read-only manager
# quick-look modal. The modal is bound via context= on the parent
# job form's <field name="step_ids"/> no TransientModel needed.
# job form's <field name="step_ids"/> - no TransientModel needed.
# Job-level context for the quick-look modal restored after commit
# Job-level context for the quick-look modal - restored after commit
# b0070afc accidentally removed these while still referencing them in
# fp_job_step_quick_look_views.xml (entech caught the mismatch during
# the 2026-05-22 Phase 1-4 deploy).
@@ -1871,7 +1871,7 @@ class FpJobStep(models.Model):
def action_open_quick_look(self):
"""Open the read-only Step Details quick-look modal.
Bound to the row-button on the embedded step list explicit
Bound to the row-button on the embedded step list - explicit
trigger needed because editable="bottom" intercepts row clicks
for inline editing rather than opening the form view.
"""
@@ -1900,7 +1900,7 @@ class FpJobStep(models.Model):
step in one move. Paperwork / first steps don't physically
hold parts per-piece.
- real qty == 1 + downstream: record move(1).
- real qty > 1 + downstream: raise operator must use
- real qty > 1 + downstream: raise - operator must use
Complete 1 → Next (streaming) or Move… (batched).
- real qty > 1 + last step: allow (qty_done auto-tick Phase 2).
Called from action_finish_and_advance just before button_finish.
@@ -1921,7 +1921,7 @@ class FpJobStep(models.Model):
# - real incoming, qty == 1 (streaming flow last part):
# same as Complete 1 → Next's tail call
# - real incoming, qty > 1 (batched flow): one click moves
# everything forward operators with small parts don't
# everything forward - operators with small parts don't
# have to click Complete 1 → Next repeatedly
# Complete 1 → Next is still available for one-by-one flow.
self.env['fp.job.step.move'].create({

View File

@@ -16,13 +16,13 @@ def _clean(text):
if not text:
return ''
t = str(text)
for a, b in ((u'', '-'), (u'', '-'), (u'', "'"),
for a, b in ((u'\u2014', '-'), (u'\u2013', '-'), (u'', "'"),
(u'', "'"), (u'', '"'), (u'', '"'),
(u'', '...'),
# Degree symbols: the masculine-ordinal 'º' (U+00BA) operators
# type for "375ºF", the real degree '°' (U+00B0), and the ring
# '˚' ALL mojibake to "°"/"º" through this sticker's lightweight
# html_container path (no .article UTF-8 wrapper and adding one
# html_container path (no .article UTF-8 wrapper - and adding one
# blows up the dpi=96 mm layout). Strip to clean ASCII: "375F".
(u'º', ''), (u'°', ''), (u'˚', '')):
t = t.replace(a, b)
@@ -94,7 +94,7 @@ class FpJob(models.Model):
'qty': qty,
'due': due_s,
'thk': thk,
# Real thickness present (has a digit) drives the prominent
# Real thickness present (has a digit) - drives the prominent
# THICKNESS banner; skips empty / 'N/A' / '-' placeholders.
'has_thk': bool(thk and any(c.isdigit() for c in thk)),
'mask': bool(line and 'x_fc_masking_enabled' in line._fields and line.x_fc_masking_enabled),
@@ -105,7 +105,7 @@ class FpJob(models.Model):
def _fp_sticker_boxes(self):
"""The job's tracked boxes (External sticker prints one label each).
Empty recordset when none yet the template falls back to 1/1."""
Empty recordset when none yet - the template falls back to 1/1."""
self.ensure_one()
if self.sale_order_id and 'fp.box' in self.env:
return self.env['fp.box'].sudo().search(

View File

@@ -2,11 +2,11 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Sub 14 Configurable workflow status bar.
"""Sub 14 - Configurable workflow status bar.
Each job carries a workflow_state_id that auto-advances along a
shop-configurable sequence of milestones (Draft → Confirmed → Received
→ In Progress → Inspected → Shipped → Done by default).
→ In Progress → Inspected → Shipped → Done - by default).
Recipe authors tag specific recipe steps as "this step's completion
triggers workflow state X" via process_node.triggers_workflow_state_id.
@@ -27,7 +27,7 @@ from odoo import _, api, fields, models
class FpJobWorkflowState(models.Model):
_name = 'fp.job.workflow.state'
_description = 'Fusion Plating Job Workflow State (status bar milestone)'
_description = 'Fusion Plating - Job Workflow State (status bar milestone)'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'sequence, id'
_rec_name = 'name'
@@ -44,7 +44,7 @@ class FpJobWorkflowState(models.Model):
string='Code',
required=True,
tracking=True,
help='Stable identifier used by code/migrations to reference '
help='Stable identifier - used by code/migrations to reference '
'this state without depending on the (translatable) name. '
'Lowercase snake_case (e.g. "received", "inspected").',
)
@@ -98,8 +98,8 @@ class FpJobWorkflowState(models.Model):
# A state is "passed" when ALL recipe steps matching its trigger
# conditions are in done/skipped/cancelled. Two ways to define
# which steps trigger:
# 1. trigger_default_kinds match on recipe step's default_kind
# Selection. Easiest path covers standard recipes that use
# 1. trigger_default_kinds - match on recipe step's default_kind
# Selection. Easiest path - covers standard recipes that use
# the curated kind values (receiving, final_inspect, ship, etc.)
# 2. Per-recipe-node override via
# fusion.plating.process.node.triggers_workflow_state_id
@@ -117,7 +117,7 @@ class FpJobWorkflowState(models.Model):
trigger_first_step_started = fields.Boolean(
string='Trigger on First Step Started',
default=False,
help='Special trigger passes as soon as the first wet step '
help='Special trigger - passes as soon as the first wet step '
'(or any step with kind not in inspection/admin) starts. '
'Used for the "In Progress" milestone.',
)
@@ -125,18 +125,18 @@ class FpJobWorkflowState(models.Model):
trigger_all_steps_done = fields.Boolean(
string='Trigger on All Steps Done',
default=False,
help='Special trigger passes when every non-cancelled step '
help='Special trigger - passes when every non-cancelled step '
'is in done/skipped state. Used for the "Done" milestone.',
)
trigger_on_delivery_state = fields.Boolean(
string='Trigger on Delivery Delivered',
default=False,
help='Special trigger passes once the fusion.plating.delivery '
help='Special trigger - passes once the fusion.plating.delivery '
'linked to the job (job.delivery_id) reaches state="delivered". '
'Used for the Shipped milestone in lieu of recipe-side '
'default_kind="ship" tagging. Shipping is logistics, not '
'manufacturing keeping the trigger off the recipe lets us '
'manufacturing - keeping the trigger off the recipe lets us '
'route deliveries (split shipments, RMA reverse-flow, '
'customer pickup) independently from plating steps.',
)
@@ -160,12 +160,12 @@ class FpJobWorkflowState(models.Model):
trigger_on_parts_received = fields.Boolean(
string='Trigger on Parts Received',
default=False,
help='Special trigger passes once the job\'s sale order has '
help='Special trigger - passes once the job\'s sale order has '
'a receiving status of "partial" or "received" (set by '
'the fp.receiving inbound logistics flow). Used by the '
'Received milestone in lieu of recipe-side '
'default_kind="receiving" tagging. Receiving is a pre-'
'recipe activity (parts physically arrive on the dock) '
'recipe activity (parts physically arrive on the dock) - '
'keeping the trigger off recipe steps means shops without '
'a "Receiving" step in their recipe still see the bar '
'advance correctly.',
@@ -176,7 +176,7 @@ class FpJobWorkflowState(models.Model):
default=False,
help='If True, this state will NOT pass while there is an open '
'quality hold on the job. Used for the "Inspected" '
'milestone you can finish the inspection step but the '
'milestone - you can finish the inspection step but the '
'state stays at the previous milestone until the NCR clears.',
)
@@ -208,7 +208,7 @@ class FpJobWorkflowState(models.Model):
the given fp.job. Called from the job's compute method.
"""
self.ensure_one()
# Initial state always passed (every job starts here)
# Initial state - always passed (every job starts here)
if self.is_initial:
return True
@@ -265,7 +265,7 @@ class FpJobWorkflowState(models.Model):
for s in steps
)
else:
# Untagged recipe any started step counts as
# Untagged recipe - any started step counts as
# "production has started".
production_started = any(
s.state in ('in_progress', 'paused', 'done')
@@ -290,7 +290,7 @@ class FpJobWorkflowState(models.Model):
)
)
if not matching_steps:
# Nothing matches this state can't pass for this recipe.
# Nothing matches - this state can't pass for this recipe.
# Treat as not-passed so the bar stays at the previous state.
return False

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 parallel job/step links on fusion.plating.quality.hold.
# Phase 3 - parallel job/step links on fusion.plating.quality.hold.
# Coexists with bridge_mrp's existing production_id link.
from odoo import fields, models

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 / Phase 9 native-job link on fp.racking.inspection.
# Phase 3 / Phase 9 - native-job link on fp.racking.inspection.
# Coexists with the legacy production_id (mrp.production) link; either
# (or both) may be set.
@@ -20,7 +20,7 @@ class FpRackingInspection(models.Model):
def _compute_name(self):
for rec in self:
if rec.x_fc_job_id:
rec.name = _('Inspection %s') % rec.x_fc_job_id.name
rec.name = _('Inspection - %s') % rec.x_fc_job_id.name
else:
rec.name = _('Racking Inspection')

View File

@@ -84,7 +84,7 @@ class FpReceiving(models.Model):
jobs = self._fp_sticker_jobs()
if not jobs:
raise UserError(_(
'No work order exists for this receiving yet create the '
'No work order exists for this receiving yet - create the '
'Work Order before printing stickers.'))
return self.env.ref(xmlid).report_action(jobs)
@@ -94,6 +94,6 @@ class FpReceiving(models.Model):
'fusion_plating_jobs.action_report_fp_job_sticker')
def action_print_internal_sticker(self):
"""Shop (internal) box sticker(s) same layout, internal notes."""
"""Shop (internal) box sticker(s) - same layout, internal notes."""
return self._fp_print_sticker(
'fusion_plating_jobs.action_report_fp_job_sticker_internal')

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 parallel job/step links on fp.thickness.reading.
# Phase 3 - parallel job/step links on fp.thickness.reading.
from odoo import fields, models

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Native fp.job margin report replaces report_wo_margin which binds
# Native fp.job margin report - replaces report_wo_margin which binds
# to mrp.production. Uses fp.job.step.cost_total (already computed in
# Phase 1: duration_actual / 60 * cost_per_hour).

View File

@@ -13,7 +13,7 @@ class ResUsers(models.Model):
string='Plating Initials',
help='Operator / inspector initials used to pre-fill signature '
'and "Reviewer Initials" style prompts in the Record Inputs '
'dialog. Editable in the dialog itself when the user types '
'dialog. Editable in the dialog itself - when the user types '
'a different value and saves, it persists here for every '
'future job and step.',
)

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# sale.order.action_confirm hook creates fp.job records on confirm.
# sale.order.action_confirm hook - creates fp.job records on confirm.
# Sub 11 (2026-04-26) removed MRP entirely; fp.job is the only fulfilment
# path. The former x_fc_use_native_jobs migration toggle was dropped in
# 19.0.8.19.0 once the legacy bridge_mrp fallback became unreachable.
@@ -59,11 +59,11 @@ class SaleOrder(models.Model):
help='The quote-stage name (e.g. Q202605-200). Preserved when '
'the SO is renamed on confirm.',
)
# Per-model counters monotonic, never decrement. Source of truth
# Per-model counters - monotonic, never decrement. Source of truth
# for the next sibling's x_fc_doc_index. Updated via row-locked SQL
# in fp.parent.numbered.mixin so concurrent creates can't drift.
#
# Naming: `x_fc_pn_*_count` the `pn_` infix distinguishes our
# Naming: `x_fc_pn_*_count` - the `pn_` infix distinguishes our
# storage counters from pre-existing compute fields (e.g. the
# `x_fc_delivery_count` compute in bridge_mrp, `x_fc_ncr_count`
# in configurator, `x_fc_receiving_count` in fp_receiving) which
@@ -82,7 +82,7 @@ class SaleOrder(models.Model):
x_fc_pn_rma_count = fields.Integer(string='Parent: RMA Count', readonly=True, copy=False, default=0)
# ------------------------------------------------------------------
# Phase 4 (Sub 11) workflow-stage field + assigned-manager field
# Phase 4 (Sub 11) - workflow-stage field + assigned-manager field
# relocated from fusion_plating_bridge_mrp. Field re-declared with
# the same selection + compute pointer; jobs is now the source of
# truth so Phase 5 can delete bridge_mrp without losing the field.
@@ -236,7 +236,7 @@ class SaleOrder(models.Model):
return action
def action_view_fp_certificates(self):
"""Smart-button target open the certificate(s) linked to this
"""Smart-button target - open the certificate(s) linked to this
SO. One cert → form view; many → list view filtered to this SO."""
self.ensure_one()
certs = self.env['fp.certificate'].search([
@@ -258,7 +258,7 @@ class SaleOrder(models.Model):
return action
# ------------------------------------------------------------------
# Parent-number hierarchy quote naming on create
# Parent-number hierarchy - quote naming on create
# ------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
@@ -287,7 +287,7 @@ class SaleOrder(models.Model):
Parent number is drawn from fp.parent.number; the quote name
was already saved to x_fc_quote_ref on create() so it survives
the rename. Idempotent if x_fc_parent_number is already set,
the rename. Idempotent - if x_fc_parent_number is already set,
the rename is skipped (re-confirm scenarios)."""
Seq = self.env['ir.sequence']
for so in self:
@@ -344,7 +344,7 @@ class SaleOrder(models.Model):
))._create_invoices(grouped=grouped, final=final, date=date)
def unlink(self):
"""Spec §6.2 confirmed SOs are part of the compliance audit
"""Spec §6.2 - confirmed SOs are part of the compliance audit
trail and cannot be deleted. Cancellation must go through the
state machine instead. Draft SOs (no parent_number assigned
yet) remain freely deletable per Odoo standard. Applies to
@@ -352,7 +352,7 @@ class SaleOrder(models.Model):
for so in self:
if so.x_fc_parent_number:
raise UserError(_(
'Sale Order "%(name)s" cannot be deleted it has '
'Sale Order "%(name)s" cannot be deleted - it has '
'been confirmed (parent number %(parent)s issued) '
'and is part of the compliance audit trail. Cancel '
'it instead. This rule applies to all users '
@@ -364,14 +364,14 @@ class SaleOrder(models.Model):
"""Recipe resolution with Express-Orders SO header fallback.
Priority (most-specific first):
1. line.x_fc_process_variant_id explicit per-line variant
1. line.x_fc_process_variant_id - explicit per-line variant
(always wins; this is where G3 propagation lands a value).
2. self.x_fc_material_process Express Orders order-level
2. self.x_fc_material_process - Express Orders order-level
recipe. Catches the case where G3 propagation failed to
reach the line but the header has the recipe.
3. part.default_process_id part's flagged default
3. part.default_process_id - part's flagged default
variant. Customer-and-part-tuned recipe.
4. part.recipe_id legacy fallback.
4. part.recipe_id - legacy fallback.
Returns the recipe record or an empty recordset.
"""
Node = self.env['fusion.plating.process.node']
@@ -428,7 +428,7 @@ class SaleOrder(models.Model):
'x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id
):
_logger.info(
'SO %s: no line-level part but header carries one '
'SO %s: no line-level part but header carries one - '
'treating all lines as a single plating job.', self.name,
)
plating_lines = self.order_line
@@ -439,7 +439,7 @@ class SaleOrder(models.Model):
# Group by (recipe, part, spec, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Bundling lines with
# different specs / thicknesses / serials under one WO would
# carry the first line's values onto the cert + sticker
# carry the first line's values onto the cert + sticker -
# silent mis-attestation. No-recipe lines still get their own
# group each.
groups = {}
@@ -513,7 +513,7 @@ class SaleOrder(models.Model):
if recipe:
vals['recipe_id'] = recipe.id
# Customer references mirror onto the job so the shop floor
# Customer references - mirror onto the job so the shop floor
# has them without round-tripping to the SO.
if 'x_fc_customer_job_number' in self._fields \
and self.x_fc_customer_job_number:
@@ -523,7 +523,7 @@ class SaleOrder(models.Model):
if 'x_fc_rush_order' in self._fields:
vals['x_fc_rush_order'] = bool(self.x_fc_rush_order)
# Scheduling targets mirror the SO's customer-facing dates.
# Scheduling targets - mirror the SO's customer-facing dates.
if 'x_fc_internal_deadline' in self._fields \
and self.x_fc_internal_deadline:
vals['x_fc_internal_deadline'] = self.x_fc_internal_deadline
@@ -531,7 +531,7 @@ class SaleOrder(models.Model):
and self.x_fc_planned_start_date:
vals['x_fc_planned_start_date'] = self.x_fc_planned_start_date
# Operational notes mirror so the shop has them on the WO.
# Operational notes - mirror so the shop has them on the WO.
if 'x_fc_internal_note' in self._fields \
and self.x_fc_internal_note:
vals['x_fc_internal_note'] = self.x_fc_internal_note
@@ -539,7 +539,7 @@ class SaleOrder(models.Model):
and self.x_fc_external_note:
vals['x_fc_external_note'] = self.x_fc_external_note
# Customer spec / facility / manager copy from SO if present
# Customer spec / facility / manager - copy from SO if present
if 'x_fc_customer_spec_id' in self._fields and self.x_fc_customer_spec_id:
vals['customer_spec_id'] = self.x_fc_customer_spec_id.id
if 'x_fc_facility_id' in self._fields and self.x_fc_facility_id:
@@ -568,12 +568,12 @@ class SaleOrder(models.Model):
self.name, job.name, qty, (recipe.name if recipe else '-'),
)
# Express Orders (2026-05-26) apply per-line masking + bake
# Express Orders (2026-05-26) - apply per-line masking + bake
# overrides to the new job. This runs BEFORE step generation
# (which happens in fp.job.action_confirm) so the override rows
# are in place when _generate_steps_from_recipe reads override_map.
# Step.instructions writes are deferred to a second pass after
# step gen see fp.job.action_confirm override.
# step gen - see fp.job.action_confirm override.
if job.recipe_id and 'x_fc_masking_enabled' in self.env['sale.order.line']._fields:
for sol in lines:
if hasattr(sol, '_fp_apply_express_overrides_to_job'):
@@ -590,7 +590,7 @@ class SaleOrder(models.Model):
return True
# ------------------------------------------------------------------
# Phase 4 (Sub 11) workflow stage action buttons.
# Phase 4 (Sub 11) - workflow stage action buttons.
# Native versions of bridge_mrp's action_fp_* methods. Drop the
# mrp.production lookups; talk to fp.job and fp.receiving directly.
# ------------------------------------------------------------------
@@ -619,7 +619,7 @@ class SaleOrder(models.Model):
if Recv is None:
return False
for rec in Recv.search([('sale_order_id', '=', self.id)]):
# Push receiving to its terminal state 'closed' is the
# Push receiving to its terminal state - 'closed' is the
# post-Sub-8 terminal; 'accepted' kept as a legacy fallback
# only for old records still in pre-Sub-8 states.
if rec.state in ('draft', 'counted', 'staged'):
@@ -628,7 +628,7 @@ class SaleOrder(models.Model):
rec.state = 'accepted'
if 'x_fc_receiving_status' in self._fields:
self.x_fc_receiving_status = 'received'
self.message_post(body=_('Parts accepted ready to assign manager.'))
self.message_post(body=_('Parts accepted - ready to assign manager.'))
return True
def action_fp_assign_to_me(self):
@@ -687,6 +687,6 @@ class SaleOrder(models.Model):
return {
'type': 'ir.actions.client',
'tag': 'fp_plant_overview',
'name': _('Shop Floor %s') % self.name,
'name': _('Shop Floor - %s') % self.name,
'target': 'current',
}

View File

@@ -4,7 +4,7 @@
#
# Mid-job qty drift guard. When Sarah edits an SO line's qty after a
# fp.job has been spawned and started, the job's qty does NOT auto-
# update (intentionally Carlos may already be plating). But without
# update (intentionally - Carlos may already be plating). But without
# a warning the qty drift is silent and bills go out wrong. This
# write-override posts chatter on every active linked job so operators
# see the change immediately, AND offers a "Sync qty from SO" action
@@ -41,7 +41,7 @@ class SaleOrderLine(models.Model):
job.message_post(body=Markup(_(
'⚠️ <b>SO qty changed mid-job</b> by %(user)s. '
'SO line %(name)s went from %(old)g to %(new)g. '
'Job qty is still <b>%(jobqty)g</b> operator '
'Job qty is still <b>%(jobqty)g</b> - operator '
'must manually adjust scope (start more racks or '
'stop early) and the supervisor should hit '
'<b>Sync qty from SO</b> on the job header to '

View File

@@ -16,7 +16,7 @@
<t t-foreach="rows" t-as="row">
<t t-call="web.external_layout">
<div class="page">
<h2>Job Margin <span t-esc="row['job'].name"/></h2>
<h2>Job Margin - <span t-esc="row['job'].name"/></h2>
<table class="table table-sm" style="margin-top: 1em; max-width: 600px;">
<tr><th>Customer</th><td><span t-esc="row['job'].partner_id.name"/></td></tr>
<tr><th>Recipe</th><td><span t-esc="row['job'].recipe_id.name or '-'"/></td></tr>

View File

@@ -4,10 +4,10 @@
License OPL-1 (Odoo Proprietary License v1.0)
Redesigned job stickers (thermal label, 6x4 in / 152x102 mm):
* Internal Job Sticker Layout A (stacked, full-width notes),
* Internal Job Sticker - Layout A (stacked, full-width notes),
ONE label per job. Shop copy: x_fc_internal_description notes,
job QR (/fp/job/<id>).
* External Job Sticker Layout B (left rail + tall notes),
* External Job Sticker - Layout B (left rail + tall notes),
ONE label per fp.box. Customer copy: factory logo, BOX n/N,
per-box QR (/fp/box/<id>), customer-facing description notes.
@@ -100,7 +100,7 @@
<!-- QR quiet-zone crop: the barcode bakes a ~12% white border
around the pattern. Render the image oversized in an
overflow:hidden wrapper, offset so the wrapper clips ~10% off
each edge (under the quiet zone, so no modules are lost) the
each edge (under the quiet zone, so no modules are lost) - the
black pattern then fills the box. White label cell around the
wrapper still provides the scan margin. CLAUDE.md rule 14. -->
.qfwrap-qr { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
@@ -110,7 +110,7 @@
.qffull { line-height: 32mm; }
.qfwrap-full { display: inline-block; position: relative; overflow: hidden; width: 31mm; height: 31mm; vertical-align: middle; }
.qfwrap-full img { position: absolute; width: 38.75mm; height: 38.75mm; top: -3.875mm; left: -3.875mm; }
/* Internal (Layout A) header QR same ~10% quiet-zone crop. Trimmed
/* Internal (Layout A) header QR - same ~10% quiet-zone crop. Trimmed
30mm -> 27mm so the QR-driven black band is shorter and the freed
height flows to the NOTES block below. Still well above scan size. */
.qfwrap-int { display: inline-block; position: relative; overflow: hidden; width: 27mm; height: 27mm; vertical-align: middle; }
@@ -124,7 +124,7 @@
</style>
</template>
<!-- ===================== External body Layout B ===================== -->
<!-- ===================== External body - Layout B ===================== -->
<template id="fp_job_external_body">
<div class="label-page"><div class="label">
<div class="rail">
@@ -173,7 +173,7 @@
</tr></table></div>
</div>
<div class="main">
<!-- Plating thickness the team's most-watched spec. Relocated
<!-- Plating thickness - the team's most-watched spec. Relocated
out of the cramped rail to a prominent full-width banner at
the top of the main panel. -->
<t t-if="d['has_thk']">

View File

@@ -4,18 +4,18 @@
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Sub 12c v2 paper-style A4 landscape job traveller.
Sub 12c v2 - paper-style A4 landscape job traveller.
Mirrors the Amphenol Canada paper sheets (Steelhead screens 16-18):
barcode + WO header, item-info block, recipe sub-process header, then
the routing table with target ranges + actuals + sign-off cells per
step. Operators print one of these per job, pencil in actuals; the
tablet captures the same data digitally printed traveller is the
tablet captures the same data digitally - printed traveller is the
redundant audit copy.
-->
<odoo>
<record id="paperformat_fp_traveller_landscape" model="report.paperformat">
<field name="name">FP Traveller A4 landscape narrow margins</field>
<field name="name">FP Traveller - A4 landscape narrow margins</field>
<field name="format">A4</field>
<field name="orientation">Landscape</field>
<!-- margin_top + header_spacing both reserve room above the body
@@ -84,28 +84,28 @@
</td>
<td style="width: 18%;">
<strong>Date In:</strong>
<span t-esc="job.create_date and job.create_date.strftime('%d-%m-%Y') or ''"/><br/>
<span t-esc="job.create_date and job.create_date.strftime('%d-%m-%Y') or '-'"/><br/>
<strong>Due Date:</strong>
<span t-esc="job.date_deadline and job.date_deadline.strftime('%d-%m-%Y') or ''"/><br/>
<span t-esc="job.date_deadline and job.date_deadline.strftime('%d-%m-%Y') or '-'"/><br/>
<strong>Type:</strong>
<span t-esc="(job.recipe_id and job.recipe_id.name) or ''"/>
<span t-esc="(job.recipe_id and job.recipe_id.name) or '-'"/>
</td>
<td style="width: 18%;">
<strong>Order #:</strong>
<span t-esc="(job.sale_order_id and job.sale_order_id.name) or ''"/><br/>
<span t-esc="(job.sale_order_id and job.sale_order_id.name) or '-'"/><br/>
<strong>P.O. #:</strong>
<span t-esc="(job.sale_order_id and job.sale_order_id.client_order_ref) or ''"/><br/>
<span t-esc="(job.sale_order_id and job.sale_order_id.client_order_ref) or '-'"/><br/>
<strong>WO Generated By:</strong>
<span t-esc="(job.create_uid and job.create_uid.name) or ''"/>
<span t-esc="(job.create_uid and job.create_uid.name) or '-'"/>
</td>
<td style="width: 28%; vertical-align: top;">
<strong t-esc="(job.partner_id and job.partner_id.name) or ''"/><br/>
<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-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/>
<strong>Tel:</strong>
<span t-esc="(job.partner_id and job.partner_id.phone) or ''"/>
<span t-esc="(job.partner_id and job.partner_id.phone) or '-'"/>
</td>
</tr>
</table>
@@ -125,31 +125,31 @@
<td>
<strong>Part #:</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 ''"/>
<span t-esc="job.part_catalog_id.part_number or '-'"/>
</t>
<t t-else=""></t><br/>
<t t-else="">-</t><br/>
<strong>Rev:</strong>
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id and 'revision' in job.part_catalog_id._fields">
<span t-esc="job.part_catalog_id.revision or ''"/>
<span t-esc="job.part_catalog_id.revision or '-'"/>
</t>
<t t-else=""></t><br/>
<t t-else="">-</t><br/>
<strong>Mat:</strong>
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id and 'base_material' in job.part_catalog_id._fields">
<span t-esc="job.part_catalog_id.base_material or ''"/>
<span t-esc="job.part_catalog_id.base_material or '-'"/>
</t>
<t t-else=""></t><br/>
<t t-else="">-</t><br/>
<strong>Catg:</strong>
<span t-esc="(job.recipe_id and job.recipe_id.name) or ''"/><br/>
<span t-esc="(job.recipe_id and job.recipe_id.name) or '-'"/><br/>
<strong>S/N:</strong>
<t t-if="'serial_number' in job._fields"><span t-esc="job.serial_number or ''"/></t>
</td>
<td>
<strong>
<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 ''"/>
<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 ''"/>
<span t-esc="(job.product_id and job.product_id.name) or '-'"/>
</t>
</strong>
<div style="font-size: 7.5pt; margin-top: 2px;">
@@ -166,19 +166,19 @@
<t t-if="'qty_visual_inspection_rejects' in job._fields">
<span t-esc="job.qty_visual_inspection_rejects or 0"/>
</t>
<t t-else=""></t>
<t t-else="">-</t>
</td>
<td class="text-center">
<t t-if="'qty_rework' in job._fields">
<span t-esc="job.qty_rework or 0"/>
</t>
<t t-else=""></t>
<t t-else="">-</t>
</td>
<td style="font-size: 7pt; white-space: pre-wrap;">
<t t-if="'special_requirements' in job._fields">
<span t-esc="job.special_requirements or ''"/>
<span t-esc="job.special_requirements or '-'"/>
</t>
<t t-else=""></t>
<t t-else="">-</t>
</td>
<td class="fp-trav-stamp"/>
</tr>
@@ -192,12 +192,12 @@
<th style="width: 50%;">Spec / Info</th>
</tr>
<tr>
<td><span t-esc="(job.recipe_id and job.recipe_id.name) or ''"/></td>
<td><span t-esc="(job.recipe_id and job.recipe_id.name) or '-'"/></td>
<td>
<t t-if="job.recipe_id and job.recipe_id.process_type_id">
<span t-esc="job.recipe_id.process_type_id.name"/>
</t>
<t t-else=""></t>
<t t-else="">-</t>
</td>
<td>
<t t-if="'customer_spec_id' in job._fields and job.customer_spec_id">
@@ -208,12 +208,12 @@
</table>
<!-- ===== ROUTING TABLE =====
Continues on subsequent pages paperformat handles
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) -->
<!-- (placed after the routing table - see end-of-template) -->
<!-- ===== ROUTING TABLE ===== -->
<table class="bordered" style="margin-top: 4px;">
@@ -238,7 +238,7 @@
<tr>
<td class="text-center"><span t-esc="step_index + 1"/></td>
<td class="text-center">
<span t-esc="(step.tank_id and step.tank_id.code) or ''"/>
<span t-esc="(step.tank_id and step.tank_id.code) or '-'"/>
</td>
<td>
<strong t-esc="step.name"/>
@@ -261,7 +261,7 @@
<t t-if="rn and 'time_unit' in rn._fields and rn.time_unit">
<span t-esc="rn.time_unit"/>
</t>
<t t-else=""></t>
<t t-else="">-</t>
</td>
<td class="text-center fp-trav-target">
<t t-if="rn and 'material_callout' in rn._fields and rn.material_callout">
@@ -298,7 +298,7 @@
</tbody>
</table>
<!-- ===== FOOTER SHIP ORDER + NOTES ===== -->
<!-- ===== FOOTER - SHIP ORDER + NOTES ===== -->
<table class="bordered" style="margin-top: 6px;">
<tr>
<th style="width: 30%;">Ship Order To</th>
@@ -306,7 +306,7 @@
</tr>
<tr>
<td style="vertical-align: top;">
<strong t-esc="(job.partner_id and job.partner_id.name) or ''"/><br/>
<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"/>

View File

@@ -4,7 +4,7 @@
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
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
@@ -18,7 +18,7 @@
<odoo>
<record id="paperformat_fp_wo_detail" model="report.paperformat">
<field name="name">FP Work Order Detail A4 portrait</field>
<field name="name">FP Work Order Detail - A4 portrait</field>
<field name="format">A4</field>
<field name="orientation">Portrait</field>
<!-- margin_top + header_spacing both reserve room above the body
@@ -56,11 +56,11 @@
<!-- 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
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
<!-- 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]"/>
@@ -88,7 +88,7 @@
or (job.name or '').split('/')[-1]
)"/>
<!-- Photo evidence collect every captured-input value
<!-- Photo evidence - collect every captured-input value
that has an attachment, in step / time order. We
number them globally (Photo 1..N) and use those
numbers both in the per-step measurement tables
@@ -128,7 +128,7 @@
.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; }
/* Photo gallery bordered tile per attachment.
/* Photo gallery - bordered tile per attachment.
flex-wrap so wkhtmltopdf lays out two per row
on A4 portrait; page-break-inside on the tile
keeps captions glued to their image. */
@@ -166,7 +166,7 @@
white-space: nowrap;
}
/* Inline signature image inside the step
measurement Value cell rendered when a
measurement Value cell - rendered when a
`signature` prompt has a recorder with a
Plating Signature on file. Sized to fit the
table row without blowing it up. */
@@ -178,11 +178,11 @@
<h1>Work Order Detail</h1>
<!-- ===== HEADER Prepared For + summary table ===== -->
<!-- ===== HEADER - Prepared For + summary table ===== -->
<div class="fp-prepared">
<strong>Prepared For:</strong>
<span style="font-size: 11pt;"
t-esc="(job.partner_id and job.partner_id.name) or ''"/>
t-esc="(job.partner_id and job.partner_id.name) or '-'"/>
</div>
<table class="bordered">
@@ -198,14 +198,14 @@
<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 ''"/>
<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 ''"/>
<span t-esc="(job.product_id and job.product_id.default_code) or '-'"/>
</t>
</td>
<td style="vertical-align: top;">
@@ -221,7 +221,7 @@
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>
<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"/>
@@ -230,10 +230,10 @@
<span t-esc="short_wo"/>
</td>
<td>
<span t-esc="po_number or ''"/>
<span t-esc="po_number or '-'"/>
</td>
<td>
<span t-esc="serial_names or ''"/>
<span t-esc="serial_names or '-'"/>
</td>
<td>
<t t-set="_hdr_dt"
@@ -283,20 +283,20 @@
<span style="font-weight: bold; color: #2e2e2e;">QA-005 Approved</span>
</t>
<t t-else="">
<span style="color: #aa0000;">Pending <span t-esc="dict(review._fields['state'].selection).get(review.state, review.state)"/></span>
<span style="color: #aa0000;">Pending - <span t-esc="dict(review._fields['state'].selection).get(review.state, review.state)"/></span>
</t>
</td>
<td>
<span t-esc="(_signer and _signer.name) or ''"/>
<span t-esc="(_signer and _signer.name) or '-'"/>
</td>
<td>
<span style="font-weight: bold;" t-esc="_initials or ''"/>
<span style="font-weight: bold;" t-esc="_initials or '-'"/>
</td>
<td>
<t t-if="_signed_dt">
<span t-esc="job.fp_format_local(_signed_dt, '%Y-%m-%d')"/>
</t>
<t t-else=""></t>
<t t-else="">-</t>
</td>
</tr>
</table>
@@ -304,7 +304,7 @@
<div class="fp-spec">Specification(s):
<span style="font-weight: normal;"
t-esc="(job.recipe_id and job.recipe_id.name) or ''"/>
t-esc="(job.recipe_id and job.recipe_id.name) or '-'"/>
</div>
<hr class="heavy"/>
@@ -313,7 +313,7 @@
<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
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(
@@ -335,12 +335,12 @@
<div class="fp-step-block">
<h3>
<span t-esc="step.name or ''"/>
<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>
<span style="font-size: 9pt; color: #888; font-weight: normal;">- SKIPPED</span>
</t>
</h3>
<div class="fp-meta">
@@ -357,14 +357,14 @@
<t t-if="display_user or display_dt">
<br/>
<strong>Moved By:</strong>
<span t-esc="display_user or ''"/>
<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 ''"/>
<span t-esc="job.fp_format_local(display_dt, '%b %d, %Y %I:%M:%S %p') or '-'"/>
</t>
</div>
<!-- Captured inputs table only rendered
<!-- Captured inputs table - only rendered
when this step has at least one
value recorded across all its moves. -->
<t t-if="step_values">
@@ -463,7 +463,7 @@
<t t-if="not all_steps">
<p style="color: #888; font-style: italic;">
No steps on this job yet operators progress the
No steps on this job yet - operators progress the
job via Start / Finish &amp; Next on the form, or
via the tablet.
</p>
@@ -507,7 +507,7 @@
t-att-alt="att.name"/>
</div>
<div class="fp-photo-title">
Photo #<span t-esc="pidx"/> <span t-esc="ptitle"/>
Photo #<span t-esc="pidx"/> - <span t-esc="ptitle"/>
</div>
<div class="fp-photo-desc">
<t t-if="pstep">
@@ -515,7 +515,7 @@
</t>
<t t-if="puser or pdt">
<strong>Captured by:</strong>
<span t-esc="puser or ''"/>
<span t-esc="puser or '-'"/>
<t t-if="pdt">
on <span t-esc="job.fp_format_local(pdt, '%b %d, %Y %I:%M %p')"/>
</t>
@@ -548,7 +548,7 @@
Signature (`x_fc_signature_image`) from
Preferences → My Profile.
Resolution priority added 2026-05-17 per
ops request was auto-defaulting to the
ops request - was auto-defaulting to the
current user (whoever the job manager
happened to be) which signed every cert as
the wrong person. -->

View File

@@ -4,5 +4,5 @@
#
# This package holds standalone migration / audit scripts for the native
# job model rollout. Scripts under this directory are NOT imported at
# module load time they are invoked manually from `odoo shell` by the
# module load time - they are invoked manually from `odoo shell` by the
# cutover engineer. See README.md in this directory for usage.

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Post-migration audit. Verifies migration counts match expectations.
# Read-only does NOT modify data. Run from `odoo shell`.
# Read-only - does NOT modify data. Run from `odoo shell`.
import logging
@@ -47,7 +47,7 @@ def run(env):
if step_migrated < wo_total:
print('WARNING: %d WOs not migrated' % (wo_total - step_migrated))
# Cross-references for each dependent model, show counts of records
# Cross-references - for each dependent model, show counts of records
# with the LEGACY production_id set vs the NEW x_fc_job_id set. After
# migration, the second column should match the first (we don't clear
# production_id during shadow period).
@@ -163,7 +163,7 @@ def run(env):
try:
run(env) # noqa: F821 `env` is provided by odoo shell
run(env) # noqa: F821 - `env` is provided by odoo shell
except NameError:
print(
'This script expects to run inside `odoo shell` where `env` is defined.'

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Pre-migration audit. Reports row counts and data-quality concerns
# before running migrate_to_fp_jobs.py. Read-only does NOT modify data.
# before running migrate_to_fp_jobs.py. Read-only - does NOT modify data.
#
# Run from `odoo shell` where `env` is in scope. See ./README.md.
@@ -58,7 +58,7 @@ def run(env):
no_wc = cr.fetchone()[0]
print('WOs without workcenter_id:', no_wc)
# Dependent records check by model registry (truthful even when
# Dependent records - check by model registry (truthful even when
# the schema names differ from defaults).
if 'fp.quality.hold' in env:
cr.execute(
@@ -111,6 +111,6 @@ def run(env):
# Run when the script is exec'd from odoo shell (env is in scope).
try:
run(env) # noqa: F821 `env` is provided by odoo shell
run(env) # noqa: F821 - `env` is provided by odoo shell
except NameError:
print('This script expects to run inside `odoo shell` where `env` is defined.')

View File

@@ -27,7 +27,7 @@ def run(env):
print(' Deleted %d fp.job.node.override rows' % n)
# 3. Deliveries linked to jobs OR with job_ref set OR linked to a SO that
# we will delete. Delete ALL deliveries they're test data.
# we will delete. Delete ALL deliveries - they're test data.
if 'fusion.plating.delivery' in env:
deliveries = env['fusion.plating.delivery'].sudo().search([])
n = len(deliveries)
@@ -62,7 +62,7 @@ def run(env):
portals.unlink()
print(' Deleted %d fusion.plating.portal.job rows' % n)
# 8. Racking inspections required FK to mrp.production, so delete
# 8. Racking inspections - required FK to mrp.production, so delete
# BEFORE we kill the productions.
if 'fp.racking.inspection' in env:
insps = env['fp.racking.inspection'].sudo().search([])
@@ -70,7 +70,7 @@ def run(env):
insps.unlink()
print(' Deleted %d fp.racking.inspection rows' % n)
# 9. Receiving records (required FK to sale.order delete before SOs)
# 9. Receiving records (required FK to sale.order - delete before SOs)
if 'fp.receiving' in env:
recs = env['fp.receiving'].sudo().search([])
n = len(recs)
@@ -92,7 +92,7 @@ def run(env):
env['mrp.workorder'].sudo().search([]).unlink()
print(' Deleted %d mrp.workorder rows' % n)
# 13. mrp.production (legacy) force state via SQL so unlink() bypasses
# 13. mrp.production (legacy) - force state via SQL so unlink() bypasses
# Odoo's _unlink_except_done guard (which forbids deleting done MOs)
# and the action_cancel guard (which forbids cancelling done MOs).
# Demo data only.
@@ -111,7 +111,7 @@ def run(env):
env['mrp.production'].sudo().search([]).unlink()
print(' Deleted %d mrp.production rows' % n)
# 14. Account payments (must come before invoices payment is reconciled
# 14. Account payments (must come before invoices - payment is reconciled
# against move lines)
Payment = env['account.payment'].sudo()
payments = Payment.search([])
@@ -230,7 +230,7 @@ def run(env):
)
print(' Deleted %d stock.move rows' % n)
# 17. Sale orders (cancel any non-cancel state first). Delete ALL
# 17. Sale orders (cancel any non-cancel state first). Delete ALL -
# demo data only.
sos = env['sale.order'].sudo().search([])
n = len(sos)

View File

@@ -15,15 +15,15 @@
# fp.job.step with same name, work centre (mapped via legacy
# code), sequence, durations, state.
# 4. Time logs: copy mrp.workorder.time_ids if available.
# 5. Rebind cross-references on dependent models (defensive only
# 5. Rebind cross-references on dependent models (defensive - only
# writes a value when the field exists on both sides AND the
# target field is currently empty).
# 6. Write audit log to /tmp/fp_jobs_migration.log.
#
# This is NOT an Odoo upgrade hook it is an explicit cutover step.
# This is NOT an Odoo upgrade hook - it is an explicit cutover step.
# Run from `odoo shell -d <db>` so the surrounding transaction can be
# rolled back manually if the operator spots a problem (`env.cr.rollback()`).
# At the end of run() we env.cr.commit() the operator can comment that
# At the end of run() we env.cr.commit() - the operator can comment that
# out if they want to inspect changes before persisting.
#
# See ./README.md for usage.
@@ -60,7 +60,7 @@ def map_work_centre(env, mrp_wc):
"""Find the fp.work.centre that corresponds to a mrp.workcenter.
Strategy: match by code. If no match, return False (the step will
have no work centre operator can fix manually post-cutover).
have no work centre - operator can fix manually post-cutover).
"""
if not mrp_wc:
return False
@@ -120,7 +120,7 @@ def migrate_mo(env, mo, audit):
'state': JOB_STATE_MAP.get(mo.state, 'draft'),
'legacy_mrp_production_id': mo.id,
}
# Optional fields only set when the source has them
# Optional fields - only set when the source has them
if 'x_fc_facility_id' in mo._fields and mo.x_fc_facility_id:
if 'facility_id' in Job._fields:
vals['facility_id'] = mo.x_fc_facility_id.id
@@ -140,7 +140,7 @@ def migrate_mo(env, mo, audit):
if 'coating_config_id' in Job._fields:
vals['coating_config_id'] = mo.x_fc_coating_config_id.id
# Bypass any auto-create lifecycle hooks while migrating the source
# Bypass any auto-create lifecycle hooks while migrating - the source
# MO already had its hooks run when it was originally created. We
# don't want a second portal job / racking inspection / etc.
job = Job.with_context(
@@ -196,7 +196,7 @@ def migrate_wo(env, wo, job, audit):
).create(vals)
audit['wo_migrated'] += 1
# Migrate time logs only if both sides have a time-log model
# Migrate time logs - only if both sides have a time-log model
if 'time_ids' in wo._fields and wo.time_ids \
and 'fp.job.step.timelog' in env:
TimeLog = env['fp.job.step.timelog']
@@ -382,7 +382,7 @@ def run(env):
# every run.
if 'legacy_mrp_production_id' not in env['fp.job']._fields:
msg = (
'fp.job.legacy_mrp_production_id field missing upgrade '
'fp.job.legacy_mrp_production_id field missing - upgrade '
'fusion_plating_jobs to v19.0.2.0.0+ before running this '
'script.'
)
@@ -391,7 +391,7 @@ def run(env):
return None
if 'legacy_mrp_workorder_id' not in env['fp.job.step']._fields:
msg = (
'fp.job.step.legacy_mrp_workorder_id field missing upgrade '
'fp.job.step.legacy_mrp_workorder_id field missing - upgrade '
'fusion_plating_jobs to v19.0.2.0.0+ before running this '
'script.'
)
@@ -404,7 +404,7 @@ def run(env):
# fp.job.button_mark_done to skip lifecycle side-effects (creating
# portal jobs, QC checks, racking inspections, deliveries, certs,
# notifications). The migration script rebinds existing records via
# x_fc_job_id directly so the side-effects would create duplicates.
# x_fc_job_id directly - so the side-effects would create duplicates.
env = env(context=dict(env.context, fp_jobs_migration=True))
MO = env['mrp.production']
all_mos = MO.search([])
@@ -480,7 +480,7 @@ def run(env):
# Run when exec'd from odoo shell
try:
result = run(env) # noqa: F821 `env` is provided by odoo shell
result = run(env) # noqa: F821 - `env` is provided by odoo shell
except NameError:
print(
'This script expects to run inside `odoo shell` where `env` is defined.'

View File

@@ -133,4 +133,4 @@ print('=' * 60)
print(f'PASS: every doc tied to parent {parent}')
print('=' * 60)
env.cr.rollback()
print('(rolled back DB unchanged)')
print('(rolled back - DB unchanged)')

View File

@@ -60,7 +60,7 @@ def _build_combos(env):
def _create_so(env, partner, part, coating, qty, deadline_offset_days):
"""Create + confirm a SO with one plating line. Returns (so, job)."""
# fp.part.catalog has no product_id field use a generic product
# fp.part.catalog has no product_id field - use a generic product
# for the SO line. Plating-specific fields (x_fc_part_catalog_id,
# x_fc_coating_config_id) carry the real linkage.
fallback_product = env['product.product'].search(
@@ -106,7 +106,7 @@ def _create_job_direct(env, partner, part, recipe, qty, deadline_offset_days):
}
if part:
vals['part_catalog_id'] = part.id
# fp.part.catalog has no product_id field leave fp.job.product_id
# fp.part.catalog has no product_id field - leave fp.job.product_id
# null. It's an optional field used as a "Reference Product".
return Job.create(vals)

View File

@@ -18,7 +18,7 @@ def run(env):
coatings_with_recipe = Coating.search([('recipe_id', '!=', False)])
if not coatings_with_recipe:
print(' No coatings with recipes available abort')
print(' No coatings with recipes available - abort')
return
print(f' Coatings with recipes: {len(coatings_with_recipe)}')

View File

@@ -1,7 +1,7 @@
/** @odoo-module **/
/**
* Fusion Plating Issue Certs wizard: auto-edit the first incomplete row
* Fusion Plating - Issue Certs wizard: auto-edit the first incomplete row
* on wizard mount.
*
* Background: Odoo's editable o2m list keeps non-selected rows in display
@@ -12,7 +12,7 @@
* Fix without fighting CSS: when the wizard's list renders, simulate a
* click on the first row that still needs thickness data. The native
* binary widget then renders in edit mode and the upload link is
* immediately visible no theme override needed.
* immediately visible - no theme override needed.
*
* Scoped to `.o_fp_cert_issue_wizard_form` (the wizard form's
* css_class) so this DOM-poke doesn't fire on other editable o2m lists.
@@ -64,7 +64,7 @@ export class FpCertIssueWizardFormController extends FormController {
if (!target) {
target = dataRows[0];
}
// Find the fischer_file cell specifically clicking THAT cell
// Find the fischer_file cell specifically - clicking THAT cell
// (not just any cell) puts the row in edit mode AND focuses the
// upload widget, so the native "Upload your file" link is the
// very first thing the operator sees.

View File

@@ -4,7 +4,7 @@
*
* Replaces the form-view + list-as-cards CSS hack with a proper OWL
* Dialog that owns its own DOM. No more fighting Odoo's editable list
* renderer semantic HTML, full visual control, dark-mode aware.
* renderer - semantic HTML, full visual control, dark-mode aware.
*
* Backend dispatch:
* fp_job_step.action_open_input_wizard / action_finish_and_advance
@@ -25,11 +25,11 @@ import { registry } from "@web/core/registry";
import { _t } from "@web/core/l10n/translation";
// Type categories drives which input widget renders per row.
// Type categories - drives which input widget renders per row.
const NUMERIC_TYPES = new Set([
"number", "temperature", "thickness", "time_seconds", "ph",
]);
// Generic boolean only pass_fail gets its own dedicated PASS/FAIL widget
// Generic boolean only - pass_fail gets its own dedicated PASS/FAIL widget
// because a bare Yes/No toggle gives the operator no context about which
// state is the good outcome.
const BOOLEAN_TYPES = new Set(["boolean"]);
@@ -71,7 +71,7 @@ export class FpRecordInputsDialog extends Component {
jobName: "",
recipeRootId: false,
rows: [],
// Operator's persisted initials pre-filled into signature
// Operator's persisted initials - pre-filled into signature
// / "Reviewer Initials" prompts on load. When the operator
// edits and saves a different value, the controller persists
// it back to res.users.x_fc_initials so it sticks for every
@@ -107,7 +107,7 @@ export class FpRecordInputsDialog extends Component {
this.state.recipeRootId = data.recipe_root_id || false;
this.state.userInitials = data.user_initials || "";
// `t-out` only renders unescaped HTML when the value is a
// `markup()`-tagged string otherwise it shows literal tags
// `markup()`-tagged string - otherwise it shows literal tags
// (e.g. `<p>foo</p>`). See CLAUDE.md "OWL `t-out` escapes".
this.state.instructionsHtml = markup(data.instructions_html || "");
this.state.instructionImages = data.instruction_images || [];
@@ -115,7 +115,7 @@ export class FpRecordInputsDialog extends Component {
this.state.rows = data.prompts.map((p) => {
const row = {
...p,
// value fields initialized blank, populated as operator types
// value fields - initialized blank, populated as operator types
value_text: "",
value_number: 0,
value_boolean: false,
@@ -126,9 +126,9 @@ export class FpRecordInputsDialog extends Component {
point_4: 0, point_5: 0,
panel_ph: 0, panel_concentration: 0,
panel_temperature: 0, panel_bath_id: "",
// Pass/Fail explicit choice tracking see onPass/onFail.
// Pass/Fail explicit choice tracking - see onPass/onFail.
_passfail_chosen: "",
// Min / max range entry see hasRangeEntry().
// Min / max range entry - see hasRangeEntry().
value_min: 0,
value_max: 0,
};
@@ -140,7 +140,7 @@ export class FpRecordInputsDialog extends Component {
// Pass / Fail defaults:
// - Simple pass_fail (no target range) → default PASS so the
// common "everything good" path is one less click.
// - Range-based pass_fail (Bore A 0.0050.007 etc.) → DO NOT
// - Range-based pass_fail (Bore A 0.005-0.007 etc.) → DO NOT
// pre-select. The verdict must reflect the readings the
// operator enters; pre-selecting PASS would silently
// record PASS even when readings are out of spec.
@@ -208,15 +208,15 @@ export class FpRecordInputsDialog extends Component {
&& !this.isSignature(row) && !this.isTimeHms(row);
}
// Friendly label for the type pill defaults to the raw key when no
// Friendly label for the type pill - defaults to the raw key when no
// mapping exists so a future input_type still renders something.
inputTypeLabel(row) {
return TYPE_LABELS[row.input_type] || row.input_type || "Text";
}
// Step granularity for <input type="number"> drives the up/down
// Step granularity for <input type="number"> - drives the up/down
// arrow increment AND the typed-decimal validity. Defaults of step=1
// make tablet entry painful when the spec is 0.03 0.05 mil because
// make tablet entry painful when the spec is 0.03 - 0.05 mil because
// every arrow press jumps a full unit. Derive from the recipe-author's
// target_min / target_max precision so operator arrow-taps move in the
// same decimal magnitude the spec was written in. Falls back to
@@ -236,7 +236,7 @@ export class FpRecordInputsDialog extends Component {
return "any";
}
// Stepper helpers give the operator a tap-to-increment / -decrement
// Stepper helpers - give the operator a tap-to-increment / -decrement
// pair next to each numeric input so they don't have to open the
// keyboard for small adjustments. Field is one of: value_number,
// value_min, value_max. Increment uses stepFor() so taps move in the
@@ -273,7 +273,7 @@ export class FpRecordInputsDialog extends Component {
}
inputModeFor(row) {
// Tablet keyboard hint show numeric keypad instead of full
// Tablet keyboard hint - show numeric keypad instead of full
// keyboard when the operator does tap the input. 'decimal' is
// safer than 'numeric' because it includes the decimal point
// (needed for pH, thickness, temperature).
@@ -296,7 +296,7 @@ export class FpRecordInputsDialog extends Component {
// EXACT recipe variant this job step is bound to. Closes the dialog
// (operator returns by re-opening Record Inputs after editing). The
// intent is to remove the "I edited the recipe but nothing changed"
// confusion they were editing a sibling variant.
// confusion - they were editing a sibling variant.
async openSimpleEditor() {
if (!this.state.recipeRootId) {
this.notification.add(
@@ -315,12 +315,12 @@ export class FpRecordInputsDialog extends Component {
}
// True when the recipe author defined BOTH target_min and target_max
// on the prompt the signal that the operator is expected to capture
// on the prompt - the signal that the operator is expected to capture
// a range (multiple readings → record their min and max observation).
//
// Fires for numeric AND pass_fail types: a Bore inspection is a
// canonical example where the prompt is "PASS/FAIL" but the recipe
// sets a target range (e.g. 0.0050.007 in) operator records the
// sets a target range (e.g. 0.005-0.007 in) - operator records the
// observed min and max bore reading AND marks pass/fail.
hasRangeEntry(row) {
if (!row.target_min || !row.target_max) return false;
@@ -328,7 +328,7 @@ export class FpRecordInputsDialog extends Component {
return this.isNumeric(row) || this.isPassFail(row);
}
// Range hint for the dual-entry case both bounds must be within
// Range hint for the dual-entry case - both bounds must be within
// spec for a green "in range" verdict; otherwise call out which one
// is the offender.
dualRangeHint(row) {
@@ -336,7 +336,7 @@ export class FpRecordInputsDialog extends Component {
const hi = parseFloat(row.value_max);
if (!lo && !hi) return null;
if (hi && lo && hi < lo) {
return { kind: "low", text: _t("max < min check entry") };
return { kind: "low", text: _t("max < min - check entry") };
}
if (lo && row.target_min && lo < row.target_min) {
return { kind: "low", text: _t("min below target") };
@@ -350,7 +350,7 @@ export class FpRecordInputsDialog extends Component {
return null;
}
// Pass/Fail handlers set value_boolean explicitly per button.
// Pass/Fail handlers - set value_boolean explicitly per button.
// Three states: undecided (false + nothing chosen yet), passed, failed.
// We track the operator's CHOICE separately from the underlying boolean
// so the buttons can show "FAIL" as the active state (which would
@@ -384,7 +384,7 @@ export class FpRecordInputsDialog extends Component {
return (minOk && maxOk && sane) ? "pass" : "fail";
}
// ---- Selection options recipe author may store as comma-sep ------
// ---- Selection options - recipe author may store as comma-sep ------
selectionOptions(row) {
const raw = row.selection_options || "";
return raw.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
@@ -398,7 +398,7 @@ export class FpRecordInputsDialog extends Component {
return (pts.reduce((a, b) => a + b, 0) / pts.length).toFixed(3);
}
// ---- In-range hint for numeric "in range" / "low" / "high" -------
// ---- In-range hint for numeric - "in range" / "low" / "high" -------
rangeHint(row) {
if (!this.isNumeric(row)) return null;
if (!row.target_min && !row.target_max) return null;
@@ -418,14 +418,14 @@ export class FpRecordInputsDialog extends Component {
if (s.endsWith("Z")) {
s = s.slice(0, -1);
}
// datetime-local without step gives "HH:MM" pad to "HH:MM:SS".
// datetime-local without step gives "HH:MM" - pad to "HH:MM:SS".
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(s)) {
s += ":00";
}
return s;
}
// ---- Photo upload file -> base64 ----------------------------------
// ---- Photo upload - file -> base64 ----------------------------------
async onPhotoChange(row, ev) {
const file = ev.target.files[0];
if (!file) return;
@@ -504,7 +504,7 @@ export class FpRecordInputsDialog extends Component {
|| row.value_min || row.value_max);
}
// The "current" initials value across all rows a row counts as a
// The "current" initials value across all rows - a row counts as a
// signature/initials field when ``_fpIsInitialsField`` is true.
// Returns the most-recently-set value (last write wins) or empty.
// The commit endpoint persists this back to res.users.x_fc_initials
@@ -534,7 +534,7 @@ export class FpRecordInputsDialog extends Component {
// Validate range-based pass_fail rows: when readings are entered
// (or the prompt is required), the operator must explicitly pick
// PASS or FAIL. Otherwise readings would be recorded with no
// verdict silent ambiguity that breaks the audit trail.
// verdict - silent ambiguity that breaks the audit trail.
for (const row of this.state.rows) {
if (!this.isPassFail(row) || !this.hasRangeEntry(row)) continue;
const hasReadings = row.value_min || row.value_max;
@@ -560,7 +560,7 @@ export class FpRecordInputsDialog extends Component {
.map((r) => r.name || _t("(unnamed)"));
if (missing.length) {
this.notification.add(
_t("Cannot finish step %n required prompt(s) missing: %list")
_t("Cannot finish step - %n required prompt(s) missing: %list")
.replace("%n", missing.length)
.replace("%list", missing.map((n) => `"${n}"`).join(", ")),
{ type: "danger", sticky: true },
@@ -585,7 +585,7 @@ export class FpRecordInputsDialog extends Component {
const unit = r.target_unit ? ` ${r.target_unit}` : "";
let txt = `Min: ${lo}, Max: ${hi}${unit}`;
if (this.isPassFail(r) && r._passfail_chosen) {
txt += ` ${r._passfail_chosen.toUpperCase()}`;
txt += ` - ${r._passfail_chosen.toUpperCase()}`;
}
valueText = txt;
valueNumber = hi || lo;
@@ -634,8 +634,8 @@ export class FpRecordInputsDialog extends Component {
);
this.props.close();
// Dispatch a meaningful next action when the backend returns one
// (e.g. opening another form). Otherwise and for the no-op
// ir.actions.act_window_close case soft-reload so the job form
// (e.g. opening another form). Otherwise - and for the no-op
// ir.actions.act_window_close case - soft-reload so the job form
// behind the dialog re-fetches and the operator sees the step
// state flip from In Progress -> Done without manually refreshing.
const next = result.next_action;
@@ -666,7 +666,7 @@ function fpRecordInputsDialogActionHandler(env, action) {
stepId: action.params.step_id,
advanceAfter: action.params.advance_after || false,
});
// Action chain ends dialog is self-managed.
// Action chain ends - dialog is self-managed.
return { type: "ir.actions.act_window_close" };
}

View File

@@ -1,6 +1,6 @@
// Pulsing vivid-green Finish & Next icon for the embedded step list.
// Compiled into both web.assets_backend (bright) and web.assets_web_dark
// (dark) see fusion-plating/CLAUDE.md for the SCSS branch convention.
// (dark) - see fusion-plating/CLAUDE.md for the SCSS branch convention.
$o-webclient-color-scheme: bright !default;

View File

@@ -1,15 +1,15 @@
// =============================================================================
// Record Inputs Wizard v3 card layout (light + dark mode)
// Record Inputs Wizard - v3 card layout (light + dark mode)
// Copyright 2026 Nexa Systems Inc.
//
// Replaces the long-row table layout (v2) with a stacked card layout
// Replaces the long-row table layout (v2) with a stacked card layout -
// one card per measurement prompt, the right input widget rendered per
// type, target range + required indicator visible inline.
//
// Pattern (per fusion-plating/CLAUDE.md):
// * SCSS branches at COMPILE TIME on $o-webclient-color-scheme
// * File is registered in BOTH web.assets_backend AND web.assets_web_dark
// * No reliance on runtime DOM classes (.o_dark_mode etc) Odoo 19
// * No reliance on runtime DOM classes (.o_dark_mode etc) - Odoo 19
// does not flip dark mode via runtime; it serves a separate bundle.
// * Tokens fall through to CSS custom properties so deployments can
// override via :root { --fp-card-bg: ... } without touching SCSS.
@@ -17,7 +17,7 @@
$o-webclient-color-scheme: bright !default;
// ---------- Surface tokens branched at compile time ------------------------
// ---------- Surface tokens - branched at compile time ------------------------
$_fp-iw-card-hex : #ffffff;
$_fp-iw-card-hover-hex: #f8f9fa;
@@ -63,7 +63,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
// =============================================================================
// Wizard layout header + section title + card grid + empty state
// Wizard layout - header + section title + card grid + empty state
// =============================================================================
.o_fp_input_wizard_v3 {
@@ -122,7 +122,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
//
// We keep Odoo's <list editable="bottom"> for inline editing semantics
// (operators tab through cells, Enter saves the row) and re-render it
// as a stack of cards via CSS only. No JS, no OWL component the
// as a stack of cards via CSS only. No JS, no OWL component - the
// existing wizard model is unchanged.
//
// Strategy: turn each <table>/<tr>/<td> into block-level / grid
@@ -131,7 +131,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
// =============================================================================
.o_fp_input_card_list {
// Override the default list chrome no border, no horizontal scroll
// Override the default list chrome - no border, no horizontal scroll
.o_list_renderer {
background: transparent;
border: none;
@@ -145,7 +145,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
border-collapse: separate;
background: transparent;
// No column headers each card carries its own labels
// No column headers - each card carries its own labels
> thead { display: none; }
> tbody {
@@ -181,7 +181,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
color-mix(in srgb, #{$fp-iw-border-focus} 18%, transparent);
}
// Per-cell rest strip table styling; we'll re-position via
// Per-cell rest - strip table styling; we'll re-position via
// grid-area on the cells we actually want visible.
> td {
display: block;
@@ -206,7 +206,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
display: none;
}
// ---------- Card header prompt name ----------
// ---------- Card header - prompt name ----------
// Char field renders as bare <span> (read) or <input> (edit)
// directly in the td. Style typography on the td and make
// the inner element inherit + transparent.
@@ -240,14 +240,14 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
}
}
// ---------- Meta pills type + unit each in its OWN column ----
// ---------- Meta pills - type + unit each in its OWN column ----
// Both fields carry the .o_fp_iw_meta class for shared
// pill styling, plus a distinct class (_type / _unit) so
// CSS Grid can put each in its own area.
td.o_fp_iw_meta_type { grid-area: type; }
td.o_fp_iw_meta_unit { grid-area: unit; }
// Meta pills input_type (Selection) and target_unit
// Meta pills - input_type (Selection) and target_unit
// (Selection) render as bare <span> (read mode) or <select>
// (edit mode) directly inside the td. Style the td as a
// pill, make the inner element transparent.
@@ -284,7 +284,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
}
}
// ---------- Value the live widget for this row's type --------
// ---------- Value - the live widget for this row's type --------
//
// VERIFIED FROM ODOO 19 SOURCE
// (web/static/src/views/fields/float/float_field.xml +
@@ -300,11 +300,11 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
// Cells with widget="boolean_toggle"/"image" don't get our
// o_fp_iw_value class at all (Odoo's canUseFormatter strips
// custom classes when column.widget is set), so they render
// natively handled in their own rules below.
// natively - handled in their own rules below.
// ---------------------------------------------------------------
td.o_fp_iw_value {
grid-area: value;
// Override the global "td { display: block; }" rule above
// Override the global "td { display: block; }" rule above -
// we need flex layout so the inner span/input centers.
display: flex !important;
align-items: center !important;
@@ -339,7 +339,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
#{$fp-iw-border-focus} 25%, transparent) !important;
}
// Inner span (read mode) fills the cell, left-aligned,
// Inner span (read mode) - fills the cell, left-aligned,
// inherits typography from the td.
> span {
display: block;
@@ -349,7 +349,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
font: inherit;
}
// Inner input (edit mode) same treatment as the span,
// Inner input (edit mode) - same treatment as the span,
// fully transparent so the td chrome shows through.
> input,
> input.o_input,
@@ -378,11 +378,11 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
}
}
// ---------- Boolean toggle cells render bare ---------------
// ---------- Boolean toggle cells - render bare ---------------
// value_boolean has widget="boolean_toggle" so canUseFormatter
// returns false → our o_fp_iw_value class is NOT added.
// We target the cell via the type flag column's td position
// (4th visible td when is_boolean_type) easier: target the
// (4th visible td when is_boolean_type) - easier: target the
// toggle directly anywhere it appears.
.o_boolean_toggle,
.form-switch {
@@ -391,8 +391,8 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
margin: 12px 0 12px 16px;
}
// ---------- Image / photo cells constrain ------------------
// Same canUseFormatter issue widget="image" strips our class.
// ---------- Image / photo cells - constrain ------------------
// Same canUseFormatter issue - widget="image" strips our class.
// Target the o_field_image natively wherever it lands.
.o_field_image {
max-width: 240px;
@@ -408,7 +408,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
}
}
// ---------- Extras composite types (multi-point, panel) ----
// ---------- Extras - composite types (multi-point, panel) ----
// Same approach as value cells: chrome on the td itself,
// because Float fields render as bare span/input.
td.o_fp_iw_extra {
@@ -433,7 +433,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
}
// Per-cell label (R1/R2/.../pH/Conc/Temp/Bath) is the
// field's `string=` attribute Odoo renders it inside
// field's `string=` attribute - Odoo renders it inside
// the cell when in form mode but skips it in list mode.
// We surface it via the data-label attr if present, or
// fall back to no-label (composite cells stack inline
@@ -459,7 +459,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
}
}
// Trash button hidden by default to declutter the card.
// Trash button - hidden by default to declutter the card.
// Operators rarely need to delete authored prompts; ad-hoc
// rows can still be removed via the row's context menu.
// Show on row hover for power users.
@@ -491,7 +491,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
}
}
// "Add a line" footer make it a tasteful CTA card
// "Add a line" footer - make it a tasteful CTA card
tfoot, .o_field_x2many_list_row_add {
display: block;
margin-top: 4px;
@@ -522,7 +522,7 @@ $fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
// =============================================================================
// Tablet polish operators on shop-floor tablets need bigger touch targets
// Tablet polish - operators on shop-floor tablets need bigger touch targets
// =============================================================================
@media (max-width: 900px) {

View File

@@ -1,5 +1,5 @@
// =============================================================================
// Record Inputs Dialog Sub 12e v4 (proper OWL component)
// Record Inputs Dialog - Sub 12e v4 (proper OWL component)
// Copyright 2026 Nexa Systems Inc.
//
// Pure semantic HTML inside a Dialog. No list view to fight, no
@@ -66,7 +66,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
// =============================================================================
// Dialog frame generous body, scrollable card stack
// Dialog frame - generous body, scrollable card stack
// =============================================================================
.o_fp_ri_dialog_content {
@@ -139,7 +139,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
}
// ---------- Card header prompt + meta + remove ----------------------------
// ---------- Card header - prompt + meta + remove ----------------------------
.o_fp_ri_card_head {
display: flex;
@@ -223,7 +223,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
// ---------- Target / hint helpers ------------------------------------------
// Target pill surfaces the recipe-author's target_min / target_max
// Target pill - surfaces the recipe-author's target_min / target_max
// (the "spec") so the operator knows what they're aiming for BEFORE
// they enter readings. Reads as a small inline badge with bullseye
// icon, separated visually from the body / hint copy.
@@ -269,7 +269,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
// =============================================================================
// Instructions block recipe author's narrative text + image gallery,
// Instructions block - recipe author's narrative text + image gallery,
// rendered above the prompt cards so the operator reads context BEFORE
// entering values. Hidden by the t-if when neither piece is authored.
// =============================================================================
@@ -332,7 +332,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
// =============================================================================
// Card body inputs per type
// Card body - inputs per type
// =============================================================================
.o_fp_ri_card_body {
@@ -381,7 +381,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
}
// ---------- Numeric input + range hint -----------------------------------
// ---------- Numeric - input + range hint -----------------------------------
.o_fp_ri_numeric {
display: flex;
@@ -406,7 +406,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
}
// ---------- Boolean toggle (custom bigger + clearer than Bootstrap) ------
// ---------- Boolean toggle (custom - bigger + clearer than Bootstrap) ------
.o_fp_ri_toggle {
display: inline-flex;
@@ -453,7 +453,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
}
// ---------- Photo upload modest size, semantic ---------------------------
// ---------- Photo upload - modest size, semantic ---------------------------
.o_fp_ri_photo {
display: inline-block;
@@ -529,7 +529,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
}
// ---------- Bath chemistry panel same shape as multi ---------------------
// ---------- Bath chemistry panel - same shape as multi ---------------------
.o_fp_ri_panel {
display: grid;
@@ -584,7 +584,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
// =============================================================================
// Tablet polish bigger inputs on narrow screens
// Tablet polish - bigger inputs on narrow screens
// =============================================================================
@media (max-width: 768px) {
@@ -610,7 +610,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
// =============================================================================
// Pass / Fail distinct two-button widget
// Pass / Fail - distinct two-button widget
//
// A bare boolean toggle hid the question's intent ("PASS or FAIL?" → "Yes
// or No?"). Two clearly-coloured buttons mirror the language the operator
@@ -673,7 +673,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
// =============================================================================
// Signature clearly-affordance'd input so operators know it's an
// Signature - clearly-affordance'd input so operators know it's an
// initial / signature, not free text.
// =============================================================================
@@ -717,7 +717,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
// =============================================================================
// Selection empty-state hint when recipe author didn't authoring options
// Selection - empty-state hint when recipe author didn't authoring options
// =============================================================================
.o_fp_ri_select_empty {
@@ -739,7 +739,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
// =============================================================================
// Dual-entry numeric Min Reading + Max Reading side-by-side
// Dual-entry numeric - Min Reading + Max Reading side-by-side
//
// Fires when the recipe author authored both target_min AND target_max on
// a numeric prompt (signal: this measurement is a range, not a point).
@@ -776,7 +776,7 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
// =============================================================================
// PASS/FAIL suggestion banner fires when a pass_fail prompt has both a
// PASS/FAIL suggestion banner - fires when a pass_fail prompt has both a
// target range and the operator has entered Min/Max readings. Shows the
// suggested verdict so the operator knows what the system thinks before
// they tap PASS or FAIL.

View File

@@ -1,7 +1,7 @@
// Step Details Quick-Look modal dark-mode aware tokens.
// Step Details Quick-Look modal - dark-mode aware tokens.
// Pattern documented in fusion-plating/CLAUDE.md: branch hex values
// at SCSS compile-time via $o-webclient-color-scheme. Don't rely on
// var(--bs-body-bg) Odoo 19 doesn't flip it consistently across
// var(--bs-body-bg) - Odoo 19 doesn't flip it consistently across
// addons.
$o-webclient-color-scheme: bright !default;

View File

@@ -26,7 +26,7 @@
<span class="ms-2">Loading prompts...</span>
</div>
<!-- Instructions block recipe-author HTML + image gallery shown
<!-- Instructions block - recipe-author HTML + image gallery shown
above the prompt cards so the operator reads context BEFORE
entering values. Hidden when neither is authored. -->
<div t-if="!state.loading and (state.instructionsHtml or state.instructionImages.length)"
@@ -48,7 +48,7 @@
</div>
<!-- Empty state. Independent t-if (not t-elif) so the
instructions block above doesn't break the chain the
instructions block above doesn't break the chain - the
cards / empty branch must only depend on loading + rows. -->
<div t-if="!state.loading and !state.rows.length" class="o_fp_ri_empty">
<p>No measurement prompts on this step.</p>
@@ -57,13 +57,13 @@
</button>
</div>
<!-- Cards. Same fix independent t-if. -->
<!-- Cards. Same fix - independent t-if. -->
<div t-if="!state.loading and state.rows.length" class="o_fp_ri_cards">
<t t-foreach="state.rows" t-as="row" t-key="row_index">
<div class="o_fp_ri_card"
t-att-class="{ 'o_fp_ri_card_required': row.required }">
<!-- Card header prompt name + meta pills -->
<!-- Card header - prompt name + meta pills -->
<div class="o_fp_ri_card_head">
<div class="o_fp_ri_prompt">
<!-- Authored prompt: read-only label -->
@@ -97,8 +97,8 @@
</div>
<!-- Target range hint (any prompt with a target_min /
target_max numeric, pass_fail, etc.). Renders
as a small "Target: 0.005 0.007 in" pill so the
target_max - numeric, pass_fail, etc.). Renders
as a small "Target: 0.005 - 0.007 in" pill so the
operator can see the spec before they enter
readings. -->
<div t-if="row.target_min or row.target_max"
@@ -106,7 +106,7 @@
<i class="fa fa-bullseye me-1"/>
<span class="o_fp_ri_target_label">Target</span>
<strong class="o_fp_ri_target_value">
<t t-if="row.target_min" t-esc="row.target_min"/><t t-if="row.target_min and row.target_max"> </t><t t-if="row.target_max" t-esc="row.target_max"/>
<t t-if="row.target_min" t-esc="row.target_min"/><t t-if="row.target_min and row.target_max"> - </t><t t-if="row.target_max" t-esc="row.target_max"/>
</strong>
<span t-if="row.target_unit" class="o_fp_ri_target_unit" t-esc="row.target_unit"/>
</div>
@@ -114,10 +114,10 @@
<!-- Hint text from recipe author -->
<div t-if="row.hint" class="o_fp_ri_hint" t-esc="row.hint"/>
<!-- Card body live input widget per type -->
<!-- Card body - live input widget per type -->
<div class="o_fp_ri_card_body">
<!-- Numeric single value (no range defined) -->
<!-- Numeric - single value (no range defined) -->
<div t-if="isNumeric(row) and !hasRangeEntry(row)"
class="o_fp_ri_numeric">
<div class="o_fp_ri_stepper">
@@ -147,7 +147,7 @@
t-esc="hint.text"/>
</div>
<!-- Numeric dual entry (recipe author defined a
<!-- Numeric - dual entry (recipe author defined a
min and max target → operator records both
observed extremes from their measurements).
Constrained to numeric so it doesn't duplicate
@@ -206,7 +206,7 @@
t-esc="dhint.text"/>
</div>
<!-- Pass / Fail with range operator records min
<!-- Pass / Fail with range - operator records min
+ max measurements first, system suggests the
verdict, then operator confirms with PASS/FAIL.
This branch fires when the recipe author
@@ -270,7 +270,7 @@
<div t-if="sugg" class="o_fp_ri_pf_suggest"
t-att-class="'o_fp_ri_pf_suggest_' + sugg">
<i t-att-class="sugg === 'pass' ? 'fa fa-check-circle me-1' : 'fa fa-exclamation-triangle me-1'"/>
Readings suggest <strong t-esc="sugg.toUpperCase()"/> confirm below.
Readings suggest <strong t-esc="sugg.toUpperCase()"/> - confirm below.
</div>
<div class="o_fp_ri_passfail">
<button type="button"
@@ -288,7 +288,7 @@
</div>
</t>
<!-- Pass / Fail without range distinct two-button
<!-- Pass / Fail without range - distinct two-button
widget so the operator sees the OUTCOME, not a
generic toggle. Active button fills with green
(PASS) or red (FAIL); the inactive one stays
@@ -331,14 +331,14 @@
<select t-if="opts.length"
class="o_fp_ri_input o_fp_ri_input_select"
t-model="row.value_text">
<option value=""> choose </option>
<option value="">- choose -</option>
<t t-foreach="opts" t-as="opt" t-key="opt">
<option t-att-value="opt" t-esc="opt"/>
</t>
</select>
<div t-else="" class="o_fp_ri_select_empty">
<i class="fa fa-info-circle me-1"/>
No options configured for this prompt type a value below.
No options configured for this prompt - type a value below.
<input type="text"
class="o_fp_ri_input o_fp_ri_input_text mt-2"
t-model="row.value_text"
@@ -346,7 +346,7 @@
</div>
</t>
<!-- Signature distinct affordance so the operator
<!-- Signature - distinct affordance so the operator
knows initials are required (not free text). -->
<div t-if="isSignature(row)" class="o_fp_ri_signature">
<i class="fa fa-pencil-square-o o_fp_ri_signature_icon"/>
@@ -378,7 +378,7 @@
</div>
</div>
<!-- Multi-point thickness 5 readings + live avg -->
<!-- Multi-point thickness - 5 readings + live avg -->
<div t-if="isMulti(row)" class="o_fp_ri_multi">
<div class="o_fp_ri_multi_grid">
<label>R1
@@ -403,7 +403,7 @@
</div>
</div>
<!-- Bath chemistry panel pH / conc / temp / bath -->
<!-- Bath chemistry panel - pH / conc / temp / bath -->
<div t-if="isPanel(row)" class="o_fp_ri_panel">
<label>pH
<input type="number" step="0.01" t-model.number="row.panel_ph"/>
@@ -419,7 +419,7 @@
</label>
</div>
<!-- Time (HH:MM:SS) native time picker with seconds.
<!-- Time (HH:MM:SS) - native time picker with seconds.
Mobile/tablet browsers surface the OS time wheel. -->
<input t-if="isTimeHms(row)"
type="time"

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. License OPL-1
"""Plan task P2.3 fp.job.active_step_id compute."""
# Copyright 2026 Nexa Systems Inc. - License OPL-1
"""Plan task P2.3 - fp.job.active_step_id compute."""
from odoo.tests.common import TransactionCase, tagged

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. License OPL-1
"""Plan task P2.4 _cron_autopause_stale_steps method."""
# Copyright 2026 Nexa Systems Inc. - License OPL-1
"""Plan task P2.4 - _cron_autopause_stale_steps method."""
from datetime import datetime, timedelta
from odoo.tests.common import TransactionCase, tagged

View File

@@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. License OPL-1
# Copyright 2026 Nexa Systems Inc. - License OPL-1
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestBlockerCompute(TransactionCase):
"""fp.job.step.blocker_kind / blocker_reason / blocker_jump_target_*
Gate visualizer source of truth for the OWL GateViz component.
- Gate visualizer source of truth for the OWL GateViz component.
"""
def setUp(self):

View File

@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. License OPL-1
# Copyright 2026 Nexa Systems Inc. - License OPL-1
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestDisplayWoName(TransactionCase):
"""fp.job.display_wo_name Tablet/dashboard formatter."""
"""fp.job.display_wo_name - Tablet/dashboard formatter."""
def setUp(self):
super().setUp()

View File

@@ -287,7 +287,7 @@ class TestSoConfirmHook(TransactionCase):
return so
def test_so_confirm_creates_job(self):
# Need a plating line add x_fc_part_catalog_id if available
# Need a plating line - add x_fc_part_catalog_id if available
if 'x_fc_part_catalog_id' in self.env['sale.order.line']._fields:
partner_for_part = self.env['res.partner'].create({'name': 'PartOwner'})
part = self.env['fp.part.catalog'].create({
@@ -325,11 +325,11 @@ class TestSoConfirmHook(TransactionCase):
def test_so_confirm_splits_by_thickness(self):
"""Two lines with same recipe+part+coating but DIFFERENT thicknesses
must produce TWO fp.jobs silent merge was a compliance bug (the
must produce TWO fp.jobs - silent merge was a compliance bug (the
second thickness's CoC would carry the first thickness).
The bug only manifests when lines hit the `if recipe:` branch in
_fp_auto_create_job without a resolved recipe, the no_recipe
_fp_auto_create_job - without a resolved recipe, the no_recipe
branch already splits per line. We seed a recipe via
part.default_process_id so both lines resolve to the same recipe
and reach the buggy grouping path.
@@ -351,7 +351,7 @@ class TestSoConfirmHook(TransactionCase):
self.skipTest('need >= 2 fp.coating.thickness records seeded')
thick_a, thick_b = thicknesses[0], thicknesses[1]
# Any existing top-level recipe works the test only needs both
# Any existing top-level recipe works - the test only needs both
# lines to resolve to the SAME recipe so they collide on the key.
recipe = Node.search([('parent_id', '=', False)], limit=1)
if not recipe:
@@ -451,7 +451,7 @@ class TestJobLifecycleHooks(TransactionCase):
class TestPhase3Refactors(TransactionCase):
"""Phase 3 verify parallel job/step links exist on the dependent
"""Phase 3 - verify parallel job/step links exist on the dependent
modules' models. Field-presence is enough; the migration logic is
Phase 9's concern."""
@@ -531,11 +531,11 @@ class TestPhase3Refactors(TransactionCase):
class TestPhase4Refactors(TransactionCase):
"""Phase 4 light refactors batch B (notifications, KPI source tag).
"""Phase 4 - light refactors batch B (notifications, KPI source tag).
Configurator integration is already covered by Task 2.5's SO confirm
hook (which reads x_fc_part_catalog_id / x_fc_coating_config_id from
sale.order.line see TestSoConfirmHook above).
sale.order.line - see TestSoConfirmHook above).
"""
def setUp(self):
@@ -560,7 +560,7 @@ class TestPhase4Refactors(TransactionCase):
self.assertIn('job_complete', triggers)
def test_action_confirm_calls_fire_notification(self):
# Smoke test creates a job, confirms it, verifies no exception
# Smoke test - creates a job, confirms it, verifies no exception
# thrown by the notification path even when no templates exist.
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
@@ -643,7 +643,7 @@ class TestPhase6Controllers(TransactionCase):
class TestFpJobSmartButtons(TransactionCase):
"""Feature A verify smart-button count fields and action methods
"""Feature A - verify smart-button count fields and action methods
are wired on fp.job. Runtime-detect tests confirm the methods exist
without requiring downstream models to be installed."""
@@ -690,7 +690,7 @@ class TestFpJobSmartButtons(TransactionCase):
class TestPhase7Migration(TransactionCase):
"""Phase 7 verify the migration script idempotency-key fields are
"""Phase 7 - verify the migration script idempotency-key fields are
in place and the script files are present + parse as valid Python.
We cannot run the migration end-to-end in a unit test (it would need
@@ -703,7 +703,7 @@ class TestPhase7Migration(TransactionCase):
'legacy_mrp_production_id',
self.env['fp.job']._fields,
)
# Should be Integer (we store the raw db id, not a Many2one the
# Should be Integer (we store the raw db id, not a Many2one - the
# source MO may be archived later without breaking the link).
self.assertEqual(
self.env['fp.job']._fields['legacy_mrp_production_id'].type,
@@ -894,7 +894,7 @@ class TestContractReviewStepRouting(TransactionCase):
)
def test_button_start_routes_cr_step_to_qa005(self):
"""Sub 12e v4 UX clicking Start on a contract_review step
"""Sub 12e v4 UX - clicking Start on a contract_review step
should set state=in_progress AND immediately return the QA-005
action so the operator lands on the form without needing to
click Finish & Next."""
@@ -916,7 +916,7 @@ class TestContractReviewStepRouting(TransactionCase):
def test_button_start_does_not_route_when_review_complete(self):
"""If the QA-005 review is already complete, button_start
on the CR step should NOT redirect operator just starts
on the CR step should NOT redirect - operator just starts
the step normally and clicks Finish & Next when ready."""
review = self.env['fp.contract.review'].create({
'part_id': self.part.id,
@@ -936,7 +936,7 @@ class TestContractReviewStepRouting(TransactionCase):
class TestSequentialEnforcement(TransactionCase):
"""Sub 13 recipe-level + per-step sequential enforcement.
"""Sub 13 - recipe-level + per-step sequential enforcement.
Decision matrix being verified:
recipe.enforce_sequential | step.parallel_start | step.req_pred (legacy) | block?
@@ -999,7 +999,7 @@ class TestSequentialEnforcement(TransactionCase):
'state': 'ready',
}))
# job.enforce_sequential is a related from recipe.enforce_sequential
# invalidate to force re-read after the fact-of-life writes above.
# - invalidate to force re-read after the fact-of-life writes above.
job.invalidate_recordset(['enforce_sequential'])
return job, steps
@@ -1009,7 +1009,7 @@ class TestSequentialEnforcement(TransactionCase):
recipe, nodes = self._build_recipe(enforce_sequential=True)
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
# Start A first should succeed
# Start A first - should succeed
a.button_start()
self.assertEqual(a.state, 'in_progress')
# Now try to start C while A is still in_progress
@@ -1054,7 +1054,7 @@ class TestSequentialEnforcement(TransactionCase):
# B is still blocked (default behaviour)
with self.assertRaises(self._UserError):
b.button_start()
# C is parallel should start fine while A is in_progress
# C is parallel - should start fine while A is in_progress
c.button_start()
self.assertEqual(c.state, 'in_progress')
@@ -1064,7 +1064,7 @@ class TestSequentialEnforcement(TransactionCase):
recipe, nodes = self._build_recipe(enforce_sequential=False)
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
# All three startable in any order no enforcement
# All three startable in any order - no enforcement
c.button_start()
a.button_start()
b.button_start()
@@ -1099,9 +1099,9 @@ class TestSequentialEnforcement(TransactionCase):
recipe, nodes = self._build_recipe(enforce_sequential=True)
job, steps = self._build_job(recipe, nodes)
a, b, c = steps
# All ready only first step can start
# All ready - only first step can start
steps.invalidate_recordset(['can_start'])
self.assertTrue(a.can_start, 'First step has no predecessor should be startable')
self.assertTrue(a.can_start, 'First step has no predecessor - should be startable')
self.assertFalse(b.can_start, 'Step B blocked by Step A (ready, not done)')
self.assertFalse(c.can_start, 'Step C blocked by Step A')
# After A finishes, B becomes startable (C still blocked by B)

View File

@@ -291,7 +291,7 @@ class TestMilestoneCascade(TransactionCase):
def test_mark_delivered_bypass_skips_cert_gate(self):
"""With fp_skip_cert_gate=True the gate doesn't raise. Downstream
super() chain (notifications, invoicing) may still raise for
their own reasons out of scope for this test."""
their own reasons - out of scope for this test."""
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
job.state = 'done'
@@ -311,7 +311,7 @@ class TestMilestoneCascade(TransactionCase):
def test_mark_delivered_passes_when_cert_issued(self):
"""Issuing the cert clears the gate. Downstream chain errors
are accepted (delivery PDF render etc. see test above)."""
are accepted (delivery PDF render etc. - see test above)."""
part = self._make_part(certificate_requirement='coc')
job = self._make_job(part_catalog_id=part.id)
job.state = 'done'
@@ -404,7 +404,7 @@ class TestQtyGate(TransactionCase):
self.assertEqual(step1.state, 'done')
def test_button_finish_allows_last_step_with_qty(self):
"""Last runnable step is exempt parts complete in place."""
"""Last runnable step is exempt - parts complete in place."""
from odoo import fields
job = self._make_job(qty=5)
last = self._make_step(
@@ -592,7 +592,7 @@ class TestQtyGate(TransactionCase):
class TestCertCreationAndGates(TransactionCase):
"""2026-05-18 cert creation bug fix + gate hardening.
"""2026-05-18 - cert creation bug fix + gate hardening.
Covers the fixes for the WO-30040 incident where
_fp_create_certificates raised NameError on `coating` and the cert
@@ -767,7 +767,7 @@ class TestCertCreationAndGates(TransactionCase):
class TestReceivingGate(TransactionCase):
"""2026-05-18 Hard gate on button_start / button_finish blocking
"""2026-05-18 - Hard gate on button_start / button_finish blocking
step transitions until SO receiving status = 'received'. Contract
Review steps are exempt; manager bypass via context flag
`fp_skip_receiving_gate=True`. See
@@ -829,7 +829,7 @@ class TestReceivingGate(TransactionCase):
job, step = self._make_job_with_step(
recv_status='not_received', is_cr=True,
)
# button_start may return an action (CR auto-open) must not raise.
# button_start may return an action (CR auto-open) - must not raise.
try:
step.button_start()
except Exception as e:
@@ -837,7 +837,7 @@ class TestReceivingGate(TransactionCase):
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
self.fail('CR step should be exempt from receiving gate')
# Other failures (e.g. CR auto-open quirks in test env) are
# not the gate accept them.
# not the gate - accept them.
def test_start_bypass_via_context(self):
job, step = self._make_job_with_step(recv_status='not_received')
@@ -883,7 +883,7 @@ class TestReceivingGate(TransactionCase):
class TestCreateDeliveryShippingMirror(TransactionCase):
"""Phase A _fp_create_delivery mirrors shipping fields from the
"""Phase A - _fp_create_delivery mirrors shipping fields from the
linked receiving onto the auto-created fp.delivery."""
@classmethod

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. License OPL-1
"""Plan task P2.2 fp.job.late_risk_ratio compute."""
# Copyright 2026 Nexa Systems Inc. - License OPL-1
"""Plan task P2.2 - fp.job.late_risk_ratio compute."""
from datetime import datetime, timedelta
from odoo.tests.common import TransactionCase, tagged

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Order-level ship-readiness gate (spec D4 ship together).
"""Order-level ship-readiness gate (spec D4 - ship together).
Spec: docs/superpowers/specs/2026-05-29-technician-receiving-shipping-tablet-design.md
"""

View File

@@ -23,7 +23,7 @@ class TestPostShopAdvance(TransactionCase):
vals.update(kw)
return self.env['fp.job'].create(vals)
# ===== Task 2 _fp_check_advance_post_shop helper ==================
# ===== Task 2 - _fp_check_advance_post_shop helper ==================
def test_advance_helper_exists(self):
job = self._make_job()
@@ -36,13 +36,13 @@ class TestPostShopAdvance(TransactionCase):
self.assertEqual(job.state, 'confirmed')
def test_advance_noop_when_no_steps(self):
# job with zero steps stays put nothing to evaluate
# job with zero steps stays put - nothing to evaluate
job = self._make_job(state='in_progress')
self.assertFalse(job.step_ids)
job._fp_check_advance_post_shop()
self.assertEqual(job.state, 'in_progress')
# ===== Task 3 cert-issue + cert-void helpers =====================
# ===== Task 3 - cert-issue + cert-void helpers =====================
def test_advance_after_cert_issue_helper_exists(self):
job = self._make_job()
@@ -58,7 +58,7 @@ class TestPostShopAdvance(TransactionCase):
job._fp_check_advance_after_cert_issue()
self.assertEqual(job.state, 'draft')
# ===== Task 4 button_finish gates + auto-advance =================
# ===== Task 4 - button_finish gates + auto-advance =================
def test_button_finish_on_last_step_triggers_advance(self):
"""Finishing the only step of an in_progress job flips state
@@ -75,7 +75,7 @@ class TestPostShopAdvance(TransactionCase):
step.button_finish()
self.assertEqual(job.state, 'awaiting_ship')
# ===== Task 5 button_mark_shipped ================================
# ===== Task 5 - button_mark_shipped ================================
def test_button_mark_shipped_requires_awaiting_ship(self):
from odoo.exceptions import UserError
@@ -89,7 +89,7 @@ class TestPostShopAdvance(TransactionCase):
self.assertEqual(job.state, 'done')
self.assertTrue(job.date_finished)
# ===== Task 20 activity helpers ==================================
# ===== Task 20 - activity helpers ==================================
def test_schedule_cert_activity_helper_exists(self):
job = self._make_job()

View File

@@ -61,7 +61,7 @@ class TestQtyReceivedPropagation(TransactionCase):
# after the 2026-05-20 `staged` retirement).
recv.action_mark_counted()
recv.action_close()
# Reload the hook fires inside _update_so_receiving_status.
# Reload - the hook fires inside _update_so_receiving_status.
job.invalidate_recordset(['qty_received'])
self.assertEqual(job.qty_received, 5)
@@ -75,7 +75,7 @@ class TestQtyReceivedPropagation(TransactionCase):
def test_no_job_match_is_silent(self):
"""If the receiving line's part doesn't match any job, skip
without raising common for receivings without spawned jobs."""
without raising - common for receivings without spawned jobs."""
# Build a receiving with a part that no job uses.
other_part = self.env['fp.part.catalog'].create({
'name': 'Orphan',
@@ -143,7 +143,7 @@ class TestQtyReceivedPropagation(TransactionCase):
self.assertEqual(job_b.qty_received, 7)
def test_idempotent_under_repeated_writes(self):
"""Hook is safe to call multiple times value just settles."""
"""Hook is safe to call multiple times - value just settles."""
so, job = self._make_so_with_job()
recv = self._make_receiving(so, received_qty=5)
recv.action_mark_counted()

View File

@@ -145,7 +145,7 @@ class TestRecipeCertSuppression(TransactionCase):
# ---- Test 7: passivation recipe also suppresses the ISSUE-TIME gate ----
def test_passivation_recipe_suppresses_thickness_issue_gate(self):
"""A passivation recipe (requires_thickness_report=False) must drop
the thickness-data requirement at issue time too even for a
the thickness-data requirement at issue time too - even for a
strict-thickness customer. Regression: the recipe-suppression
feature updated _resolve_required_cert_types but NOT the
action_issue / wizard thickness gate, so passivation CoCs could

View File

@@ -2,7 +2,7 @@
<odoo>
<!-- ============================================================ -->
<!-- S19 Surface Fischerscope thickness PDF on the cert form -->
<!-- S19 - Surface Fischerscope thickness PDF on the cert form -->
<!-- ============================================================ -->
<!-- Without this extension the operator has no way to know, -->
<!-- before clicking Issue, whether the QC's Fischerscope PDF -->
@@ -44,7 +44,7 @@
</button>
</xpath>
<!-- 2. Banner row above the title explicit, can't miss. -->
<!-- 2. Banner row above the title - explicit, can't miss. -->
<!-- Three states with distinct alert classes. -->
<xpath expr="//sheet/div[@class='oe_title']" position="before">
<div class="alert alert-info" role="alert"
@@ -61,7 +61,7 @@
aria-label="Merged"/>
<strong> Fischerscope thickness report merged.</strong>
The issued CoC PDF includes the Fischerscope report
as page&#160;2 open the Certificate&#160;PDF tab to verify.
as page&#160;2 - open the Certificate&#160;PDF tab to verify.
</div>
<div class="alert alert-warning" role="alert"
invisible="state != 'draft' or x_fc_thickness_status != 'none' or not partner_id"
@@ -76,14 +76,14 @@
</div>
</xpath>
<!-- 3. Thickness Report tab single place to see/edit
<!-- 3. Thickness Report tab - single place to see/edit
every Fischerscope-related field on the cert.
Reorganized 2026-05-21:
* Status + linked QC at the top (read-only context)
* XDAL 600 metadata (operator/product/etc.) editable
so manager can correct OCR mistakes
* Microscope image preview (auto-extracted from RTF
or manually uploaded either way editable here)
or manually uploaded - either way editable here)
* Source files (PDF / non-PDF evidence / source name)
* Upload wizard button + help text -->
<xpath expr="//notebook/page[@name='pdf']" position="after">

View File

@@ -20,7 +20,7 @@
<field name="inherit_id" ref="fusion_plating.view_fp_job_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<!-- Phase 1 Tablet redesign. Opens the JobWorkspace OWL
<!-- Phase 1 - Tablet redesign. Opens the JobWorkspace OWL
client action focused on this WO. Primary entry point
for techs before the Landing kanban (Phase 3) ships;
stays as a back-office shortcut after. -->
@@ -79,7 +79,7 @@
help="Print Sticker"/>
</xpath>
<!-- Sub 14 Replace the generic Draft/Confirmed/In Progress/Done
<!-- Sub 14 - Replace the generic Draft/Confirmed/In Progress/Done
statusbar with the configurable workflow_state_id bar.
Operators see meaningful plating milestones (Received,
Inspected, Shipped, etc.) instead of generic Odoo states. -->
@@ -92,7 +92,7 @@
<!-- 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
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>
@@ -162,7 +162,7 @@
title="Finish &amp; Next" icon="fa-check-circle"
class="btn-link o_fp_finish_btn"
invisible="state != 'in_progress'"/>
<!-- Reset / redo back to Ready so the step can be
<!-- Reset / redo - back to Ready so the step can be
run again (mistake, accidental skip, customer
redo). Clears finish + sign-off stamps; keeps the
start audit + moves. Hidden on ready/pending. -->
@@ -171,7 +171,7 @@
class="btn-link text-warning"
invisible="state in ('ready', 'pending')"/>
<!-- Secondary actions small icons only. Pause is
<!-- 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;
@@ -205,7 +205,7 @@
</xpath>
<!-- New tabs in the notebook: Move Log + Time Logs.
Both read-only operators write via the wizards;
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">

View File

@@ -3,7 +3,7 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Sub 12 Phase D quality smart-button row on fp.job. The quality
Sub 12 Phase D - quality smart-button row on fp.job. The quality
fields (fp_qc_hold_count, fp_qc_check_count, fp_qc_ncr_count,
fp_qc_capa_count, fp_qc_rma_count) are defined in fusion_plating_quality
via _inherit on fp.job. The view lives here because the button_box

View File

@@ -6,7 +6,7 @@
Read-only "Step Details" quick-look modal. Triggered by clicking a
step's name in the embedded step list inside fp.job's form. Bound
via context="{'form_view_ref': '...'}" on the parent see
via context="{'form_view_ref': '...'}" on the parent - see
fp_job_form_inherit.xml. The standalone editable form view stays
registered for the Job Steps menu / direct navigation.
-->
@@ -45,7 +45,7 @@
</div>
</div>
<!-- Job context what job is this step part of, who's
<!-- Job context - what job is this step part of, who's
the customer, what part, how many. The single most
useful thing to surface up top so the operator
orients themselves before drilling in. -->
@@ -65,10 +65,10 @@
</group>
</group>
<!-- Equipment / Schedule only render when there's
<!-- Equipment / Schedule - only render when there's
actually something to show. An Inspection step with
no tank / bath / time-budget shouldn't display
four empty rows of "—" — that's misleading. -->
four empty rows of "-" - that's misleading. -->
<group invisible="not work_centre_id and not tank_id and not bath_id and not rack_id and not duration_expected and not duration_actual and not assigned_user_id">
<group string="Equipment"
invisible="not work_centre_id and not tank_id and not bath_id and not rack_id">
@@ -98,10 +98,10 @@
role="alert"
invisible="quick_look_collect_master or not recipe_node_id">
<i class="fa fa-exclamation-triangle"/>
<strong> Master switch off</strong> no values will be collected at runtime for this step.
<strong> Master switch off</strong> - no values will be collected at runtime for this step.
</div>
<!-- Operator Instructions hide the whole section when
<!-- Operator Instructions - hide the whole section when
the recipe author didn't write any. -->
<separator string="Operator Instructions"
invisible="not quick_look_instructions"/>
@@ -110,7 +110,7 @@
<field name="quick_look_instructions" nolabel="1" readonly="1"/>
</div>
<!-- Instruction images visual reference photos /
<!-- Instruction images - visual reference photos /
screenshots the recipe author attached to the
node. Hidden when none. -->
<separator string="Reference Images"

View File

@@ -16,7 +16,7 @@
<field name="inherit_id" ref="fusion_plating_receiving.view_fp_receiving_form"/>
<field name="arch" type="xml">
<!-- Work Order header button only after receiving is
<!-- Work Order header button - only after receiving is
closed and while at least one job is still open. -->
<xpath expr="//header" position="inside">
<field name="x_fc_show_work_order_btn" invisible="1"/>
@@ -25,17 +25,17 @@
class="btn-primary" icon="fa-cogs"
invisible="not x_fc_show_work_order_btn"
help="Open the Work Order(s) for this receiving. Hidden automatically once every linked WO is marked Done."/>
<!-- Print box stickers for this receiving's work order one
<!-- Print box stickers for this receiving's work order - one
label per tracked box (external = customer copy, internal
= shop copy with internal notes). Shown once a WO exists. -->
<button name="action_print_external_sticker"
string="External Sticker" type="object" icon="fa-print"
invisible="x_fc_fp_job_count == 0"
help="Print the customer (external) box sticker(s) one per box."/>
help="Print the customer (external) box sticker(s) - one per box."/>
<button name="action_print_internal_sticker"
string="Internal Sticker" type="object" icon="fa-print"
invisible="x_fc_fp_job_count == 0"
help="Print the shop (internal) box sticker(s) same layout, internal notes."/>
help="Print the shop (internal) box sticker(s) - same layout, internal notes."/>
</xpath>
<!-- Work Order smart button on the button_box (mirrors the

View File

@@ -3,7 +3,7 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Phase 3 (Sub 11) native Production Priorities kanban / list on
Phase 3 (Sub 11) - native Production Priorities kanban / list on
fp.job.step. Replaces the bridge_mrp version that bound to
mrp.workorder. Same UX (drag-drop ordering across work centres,
list with handle, badges by state).
@@ -40,7 +40,7 @@
<field name="assigned_user_id"/>
</t>
<t t-if="record.duration_actual.raw_value">
<field name="duration_actual" widget="float_time"/> elapsed
- <field name="duration_actual" widget="float_time"/> elapsed
</t>
</div>
</div>

View File

@@ -2,7 +2,7 @@
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Sub 14 Workflow state catalog UI (admin / Settings).
Sub 14 - Workflow state catalog UI (admin / Settings).
-->
<odoo>
@@ -66,7 +66,7 @@
<field name="trigger_all_steps_done"/>
</group>
<!-- Help block full sheet width, alert-info card so
<!-- Help block - full sheet width, alert-info card so
the explanation is readable instead of squeezed
into a 2-column form layout. -->
<div class="alert alert-info mt-3" role="alert">
@@ -105,7 +105,7 @@
<field name="description" nolabel="1"
placeholder="What this milestone represents and when it should fire..."/>
</sheet>
<!-- Chatter adds activity log + message thread + followers
<!-- Chatter - adds activity log + message thread + followers
so admins can audit who changed which trigger and when. -->
<chatter/>
</form>

View File

@@ -8,7 +8,7 @@
Sequences fit between Tablet Station (10) and Bake Windows (20)
in shopfloor's existing fp_menu.xml.
Renamed 2026-05-25 "All Jobs" → "Work Orders" for consistency
Renamed 2026-05-25 - "All Jobs" → "Work Orders" for consistency
with the rest of the UI (SO smart button is "WO", tablet cards
show "WO # 00001", KPI tile says "Work Orders"). xmlid kept
as menu_fp_jobs_all_jobs so bookmarks and inherits don't break.

View File

@@ -10,7 +10,7 @@
user to the group. -->
<!-- Reset group_ids on the shopfloor menus that used to be
hidden they are now the canonical UIs and should be visible
hidden - they are now the canonical UIs and should be visible
to all users (subject to the original groups= attribute on
each menuitem in fusion_plating_shopfloor/views/fp_menu.xml). -->
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_manager" model="ir.ui.menu">
@@ -20,7 +20,7 @@
<field name="group_ids" eval="[(6, 0, [])]"/>
</record>
<!-- Phase 3 tablet redesign (2026-05-22) the standalone Plant
<!-- Phase 3 tablet redesign (2026-05-22) - the standalone Plant
Overview menu was superseded by the single Workstation entry.
The original <delete> directive lived here; it was retired
2026-05-25 because the menu row had been gone for weeks and the

View File

@@ -37,7 +37,7 @@
</button>
</xpath>
<!-- Work Order header action appears once any linked
<!-- Work Order header action - appears once any linked
receiving is closed AND at least one WO is still open.
Reuses the existing action_view_fp_jobs smart-button
target so single-job SOs land on the form directly. -->
@@ -53,9 +53,9 @@
<!-- Quote ref: small grey "Originally quoted as Q202605-200"
line under the SO name (the big SO-30000 heading). Only
renders once the SO has been confirmed (quote_ref is set
on create, parent_number is set on confirm both
on create, parent_number is set on confirm - both
needed for the line to make sense).
NB: Odoo 19 forbids t-if in standard form views using
NB: Odoo 19 forbids t-if in standard form views - using
`invisible` attribute on the wrapper div instead. -->
<xpath expr="//div[hasclass('oe_title')]" position="inside">
<field name="x_fc_parent_number" invisible="1"/>

View File

@@ -15,7 +15,7 @@ readings before confirming. On confirm:
and the parsed readings are written as fp.thickness.reading rows.
- cert.action_issue() is called for each cert.
The wizard is a convenience layer it does NOT replace the per-cert
The wizard is a convenience layer - it does NOT replace the per-cert
Issue button on the cert form, which stays as the fallback path.
"""
import base64
@@ -53,7 +53,7 @@ _FISCHER_READING_RE = re.compile(
)
# Capture every {\pict ... \wmetafile8 ...hex...} group in an RTF, in
# document order. The hex blob can be interspersed with whitespace
# (RTF wraps to 80 cols) the consumer strips it.
# (RTF wraps to 80 cols) - the consumer strips it.
_RTF_PICT_WMF_RE = re.compile(
r'\{\\pict'
r'(?:\\[a-zA-Z]+-?\d*\s?)*?'
@@ -71,7 +71,7 @@ def _fp_extract_rtf_images(raw_bytes):
XDAL 600 RTF exports embed each picture as a WMF metafile wrapping
the actual raster. ImageMagick on Debian Bookworm doesn't carry a
WMF delegate, so we shell out to `wmf2svg` (from libwmf-bin) it
WMF delegate, so we shell out to `wmf2svg` (from libwmf-bin) - it
writes a thin SVG and a side-file `*-N.png` per raster block. We
keep the PNGs, drop the SVG/WMF temp files.
@@ -108,7 +108,7 @@ def _fp_extract_rtf_images(raw_bytes):
)
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
_logger.warning(
'wmf2svg unavailable or timed out (%s) skipping '
'wmf2svg unavailable or timed out (%s) - skipping '
'RTF image extraction.', e,
)
return []
@@ -125,7 +125,7 @@ def _fp_extract_rtf_images(raw_bytes):
def _fp_pick_microscope_image(png_bytes_list):
"""Pick the largest-area PNG (by pixel count, not file size) from
the list that's almost always the microscope photo. Header
the list - that's almost always the microscope photo. Header
banners are wide-but-thin so their pixel area falls below the
threshold. Returns (png_bytes, width, height) or (None, 0, 0)
when no PNG meets the threshold.
@@ -153,7 +153,7 @@ _FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+?)(?:\s{2,}|$)',
_FISCHER_OPERATOR_RE = re.compile(r'Operator:\s*(\S+)', re.IGNORECASE)
_FISCHER_DATE_RE = re.compile(r'Date:\s*([\d/]+)', re.IGNORECASE)
_FISCHER_TIME_RE = re.compile(r'Time:\s*([\d:]+\s*[APMapm]*)')
# XDAL 600 header lines only present on full RTF reports (not on
# XDAL 600 header lines - only present on full RTF reports (not on
# the .docx body the upstream parser already handled).
_FISCHER_PRODUCT_RE = re.compile(r'Product:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
_FISCHER_DIRECTORY_RE = re.compile(r'Directory:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
@@ -168,14 +168,14 @@ def _fp_strip_rtf(raw_bytes):
all of those plus the hex-encoded image data so the Fischerscope
reading regex hits clean text.
Not a full parser meant for the narrow case of XRF/XDAL reports
Not a full parser - meant for the narrow case of XRF/XDAL reports
that have a simple body wrapped around an embedded WMF image.
"""
if not raw_bytes:
return ''
# RTF is ASCII-safe; latin-1 round-trips every byte.
text = raw_bytes.decode('latin-1', errors='replace')
# Drop destination groups entirely these are the image data,
# Drop destination groups entirely - these are the image data,
# font tables, color tables, etc. The pattern `{\* ...}` and other
# nested destinations carry binary-ish hex strings we never want.
text = re.sub(r'\{\\\*[^{}]*\}', ' ', text)
@@ -185,7 +185,7 @@ def _fp_strip_rtf(raw_bytes):
# part between `\pict...goal\d+` and the closing brace of the group.
# Easier: nuke anything matching the picture marker through the
# next closing brace at the same depth (single-level approximation
# works for FedEx/XRF docs that have one image per pict block).
# - works for FedEx/XRF docs that have one image per pict block).
text = re.sub(r'\{\\pict[^{}]*\}', ' ', text)
# Remove control words like \rtf1, \ansicpg1252, \par, \tab,
# \tx2840, etc. (`\` + letters + optional digits + optional space)
@@ -204,7 +204,7 @@ def _fp_strip_rtf(raw_bytes):
def _fp_parse_fischerscope_rtf(raw_bytes):
"""Fischerscope XDAL 600 RTF export → same dict shape as the
.docx parser. RTF detection is by magic bytes (`{\\rtf`) the
.docx parser. RTF detection is by magic bytes (`{\\rtf`) - the
XRF software names the file `.doc` for legacy reasons, but the
contents are RTF.
"""
@@ -267,7 +267,7 @@ def _fp_parse_fischerscope_docx(raw_bytes):
}
Soft-fails to an empty dict-like result when python-docx isn't
installed or the bytes don't parse the wizard still works, the
installed or the bytes don't parse - the wizard still works, the
operator just has to type readings manually.
"""
empty = {
@@ -280,7 +280,7 @@ def _fp_parse_fischerscope_docx(raw_bytes):
import docx # python-docx
except ImportError:
_logger.info(
'python-docx not installed Fischerscope auto-parse '
'python-docx not installed - Fischerscope auto-parse '
'skipped. Operator will enter readings manually.'
)
return empty
@@ -335,7 +335,7 @@ def _fp_parse_fischerscope_docx(raw_bytes):
class FpCertIssueWizard(models.TransientModel):
_name = 'fp.cert.issue.wizard'
_description = 'Fusion Plating Issue Certs Wizard'
_description = 'Fusion Plating - Issue Certs Wizard'
job_id = fields.Many2one(
'fp.job', string='Job', required=True, readonly=True,
@@ -357,7 +357,7 @@ class FpCertIssueWizard(models.TransientModel):
@api.model
def open_for_job(self, job):
"""Factory create a wizard pre-populated with one line per
"""Factory - create a wizard pre-populated with one line per
draft cert on the job. Returns an action dict that opens the
wizard form."""
Cert = self.env['fp.certificate'].sudo()
@@ -375,7 +375,7 @@ class FpCertIssueWizard(models.TransientModel):
})
return {
'type': 'ir.actions.act_window',
'name': _('Issue Certs %s') % job.name,
'name': _('Issue Certs - %s') % job.name,
'res_model': self._name,
'res_id': wiz.id,
'view_mode': 'form',
@@ -416,7 +416,7 @@ class FpCertIssueWizard(models.TransientModel):
class FpCertIssueWizardLine(models.TransientModel):
_name = 'fp.cert.issue.wizard.line'
_description = 'Fusion Plating Issue Certs Wizard Line'
_description = 'Fusion Plating - Issue Certs Wizard Line'
wizard_id = fields.Many2one(
'fp.cert.issue.wizard', required=True, ondelete='cascade',
@@ -438,7 +438,7 @@ class FpCertIssueWizardLine(models.TransientModel):
fischer_filename = fields.Char(string='Filename')
# Optional: microscope/coupon image exported separately from the
# XDAL 600. The RTF carries an embedded WMF that the entech host
# can't rasterize (no imagemagick/libwmf see CLAUDE.md "entech
# can't rasterize (no imagemagick/libwmf - see CLAUDE.md "entech
# apt is in a broken-deps state"), so the operator exports a PNG
# from the XDAL software and uploads it here. Rendered inline on
# the CoC's thickness section when present.
@@ -464,8 +464,8 @@ class FpCertIssueWizardLine(models.TransientModel):
'cert_id.partner_id.x_fc_strict_thickness_required',
'cert_id.x_fc_job_id.recipe_id.requires_thickness_report')
def _compute_needs_thickness(self):
# Delegate to fp.certificate._fp_needs_thickness_data the single
# source of truth shared with the action_issue hard gate so the
# Delegate to fp.certificate._fp_needs_thickness_data - the single
# source of truth shared with the action_issue hard gate - so the
# wizard's readiness hint and the gate can never drift. Honours
# recipe-level thickness suppression (passivation = no thickness
# even if the customer asked).
@@ -493,7 +493,7 @@ class FpCertIssueWizardLine(models.TransientModel):
@api.onchange('fischer_file', 'fischer_filename')
def _onchange_fischer_file(self):
"""Parse .docx OR RTF on upload (XDAL 600 names RTF files
`.doc` detected by magic bytes; see CLAUDE.md "Fischerscope
`.doc` - detected by magic bytes; see CLAUDE.md "Fischerscope
XDAL 600 `.doc` files are actually RTF"). Prefill the readings
+ summary so the operator can verify before issuing."""
if not self.fischer_file:
@@ -511,7 +511,7 @@ class FpCertIssueWizardLine(models.TransientModel):
parsed = _fp_parse_fischerscope_docx(raw)
else:
self.parsed_summary = _(
'Non-parseable upload (%s) file will be attached as '
'Non-parseable upload (%s) - file will be attached as '
'evidence. Type readings manually below if needed.'
) % (self.fischer_filename or 'unnamed')
return
@@ -531,9 +531,9 @@ class FpCertIssueWizardLine(models.TransientModel):
'Operator: %(o)s · Date: %(d)s %(t)s'
) % {
'n': len(readings),
'c': parsed.get('calibration') or '',
'o': parsed.get('operator') or '',
'd': parsed.get('date_str') or '',
'c': parsed.get('calibration') or '-',
'o': parsed.get('operator') or '-',
'd': parsed.get('date_str') or '-',
't': parsed.get('time_str') or '',
}
@@ -559,7 +559,7 @@ class FpCertIssueWizardLine(models.TransientModel):
for fname, fval in field_map:
if fname in cert._fields and fval:
vals[fname] = fval
# Combine the gauge's date+time and parse to Datetime try a
# Combine the gauge's date+time and parse to Datetime - try a
# few formats since XDAL exports vary (12h vs 24h, with/without
# seconds). Best-effort: leave the field blank if no format
# matches rather than crashing the cert issue.
@@ -589,7 +589,7 @@ class FpCertIssueWizardLine(models.TransientModel):
Order matters: operator-uploaded PNG must run LAST so it wins
over any image the RTF auto-extraction picked. Reverse order
(PNG first, then RTF) lets the WMF blow away the explicit
operator choice exactly the bug we just hit.
operator choice - exactly the bug we just hit.
"""
self.ensure_one()
cert = self.cert_id.sudo()
@@ -602,13 +602,13 @@ class FpCertIssueWizardLine(models.TransientModel):
name = (self.fischer_filename or 'fischerscope').lower()
calibration = '' # backfilled below if the parser hits
if name.endswith('.pdf'):
# Drop the PDF into the cert-local field merges into page 2.
# Drop the PDF into the cert-local field - merges into page 2.
cert.write({
'x_fc_local_thickness_pdf': self.fischer_file,
'x_fc_local_thickness_pdf_filename': self.fischer_filename,
})
else:
# .doc / .docx / anything else attach as evidence AND
# .doc / .docx / anything else - attach as evidence AND
# link the attachment to the cert's evidence slot so the
# thickness-required gate recognises it. Without the link,
# the gate would still raise (it checks specific fields,
@@ -675,7 +675,7 @@ class FpCertIssueWizardLine(models.TransientModel):
'Fischerscope file <b>%s</b> attached via Issue wizard.'
)) % (self.fischer_filename or 'unnamed'))
self._push_readings_to_cert(calibration=calibration)
# Operator's PNG upload wins over auto-extracted WMF runs
# Operator's PNG upload wins over auto-extracted WMF - runs
# last so it overwrites x_fc_thickness_image_id if both paths
# supplied an image.
self._apply_image_to_cert(cert)
@@ -704,7 +704,7 @@ class FpCertIssueWizardLine(models.TransientModel):
def _push_readings_to_cert(self, calibration=''):
"""Create fp.thickness.reading rows on the cert from wizard rows.
Skips when no rows. Does not deduplicate against existing
readings the manager has just told us this is the new data.
readings - the manager has just told us this is the new data.
Per-reading calibration_std_ref is stamped from the optional
`calibration` arg so the printed CoC's calibration line stays
accurate even when readings are re-pushed from a fresh upload.
@@ -729,7 +729,7 @@ class FpCertIssueWizardLine(models.TransientModel):
class FpCertIssueWizardReading(models.TransientModel):
_name = 'fp.cert.issue.wizard.reading'
_description = 'Fusion Plating Issue Certs Wizard Reading Row'
_description = 'Fusion Plating - Issue Certs Wizard Reading Row'
_order = 'sequence, id'
line_id = fields.Many2one(

View File

@@ -15,7 +15,7 @@
<sheet>
<div class="oe_title">
<h2>
Issue Certs
Issue Certs -
<field name="job_id" readonly="1" nolabel="1"/>
</h2>
</div>
@@ -30,7 +30,7 @@
</div>
<!-- 2026-05-20: surface the file upload INLINE in the
list instead of behind a row-click into a sub-form.
Operators kept missing the upload affordance the
Operators kept missing the upload affordance - the
list looked like a status display, not an action
surface. Adding the binary field as a column lets
them drop the Fischerscope file right where they

View File

@@ -18,7 +18,7 @@ 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
format as the tablet-captured values - single source of truth for
report rendering.
"""
@@ -51,7 +51,7 @@ _FP_INPUT_TYPE_SELECTION = [
class FpJobStepInputWizard(models.TransientModel):
_name = 'fp.job.step.input.wizard'
_description = 'Fusion Plating Step Input Recording (Backend)'
_description = 'Fusion Plating - Step Input Recording (Backend)'
step_id = fields.Many2one(
'fp.job.step', string='Step', required=True, readonly=True,
@@ -76,11 +76,11 @@ class FpJobStepInputWizard(models.TransientModel):
return defaults
defaults['step_id'] = step.id
node = step.recipe_node_id
# Sub 12d master switch when off, return no input rows.
# Sub 12d - master switch - when off, return no input rows.
if hasattr(node, 'collect_measurements') and not node.collect_measurements:
defaults['line_ids'] = []
return defaults
# Filter to step_input prompts only transition inputs go on the
# Filter to step_input prompts only - transition inputs go on the
# Move wizard, not here. Also filter to collect=True (per-recipe
# opt-out, default True).
inputs = node.input_ids
@@ -106,7 +106,7 @@ class FpJobStepInputWizard(models.TransientModel):
'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
# 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()
@@ -143,7 +143,7 @@ class FpJobStepInputWizard(models.TransientModel):
'value_boolean': line.value_boolean,
'value_date': line.value_date or False,
}
# Sub 12d composite + photo input types serialise differently.
# Sub 12d - composite + photo input types serialise differently.
if line.is_photo_type and line.photo_value:
att = Attachment.create({
'name': line.photo_filename or 'photo.jpg',
@@ -206,12 +206,12 @@ class FpJobStepInputWizard(models.TransientModel):
class FpJobStepInputWizardLine(models.TransientModel):
_name = 'fp.job.step.input.wizard.line'
_description = 'Fusion Plating 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
# 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
@@ -221,7 +221,7 @@ class FpJobStepInputWizardLine(models.TransientModel):
'fusion.plating.process.node.input', ondelete='set null',
)
name = fields.Char(string='Prompt')
# 2026-04-28 convert input_type + target_unit from Char → Selection
# 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(
@@ -233,7 +233,7 @@ class FpJobStepInputWizardLine(models.TransientModel):
target_unit = fields.Selection(
FP_UOM_SELECTION,
string='Unit',
help='Pick from the curated list keeps every step\'s readings '
help='Pick from the curated list - keeps every step\'s readings '
'in the same vocabulary across the shop.',
)
@@ -242,7 +242,7 @@ class FpJobStepInputWizardLine(models.TransientModel):
value_boolean = fields.Boolean(string='Yes/No')
value_date = fields.Datetime(string='Date / Time')
# Sub 12d composite + photo input types
# Sub 12d - composite + photo input types
photo_value = fields.Binary(string='Photo', attachment=True)
photo_filename = fields.Char(string='Photo Filename')
point_1 = fields.Float(string='R1')
@@ -274,7 +274,7 @@ class FpJobStepInputWizardLine(models.TransientModel):
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, '
'Drives field readonly state - authored prompts are locked, '
'ad-hoc rows are fully editable.',
)
@@ -285,7 +285,7 @@ class FpJobStepInputWizardLine(models.TransientModel):
# ---- 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
# 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

View File

@@ -94,7 +94,7 @@
</record>
<!-- ================================================================== -->
<!-- v3 Card-based stacked layout (light + dark mode aware) -->
<!-- v3 - Card-based stacked layout (light + dark mode aware) -->
<!-- -->
<!-- Replaces v2's wide editable table. Each measurement renders as a -->
<!-- card with: prompt name (header), type/unit pills (meta), and ONLY -->
@@ -133,7 +133,7 @@
<field name="line_ids" class="o_fp_input_card_list" nolabel="1">
<list editable="bottom" create="true" delete="true">
<!-- Hidden flag fields drive value-cell visibility -->
<!-- Hidden flag fields - drive value-cell visibility -->
<field name="is_authored" column_invisible="1"/>
<field name="is_boolean_type" column_invisible="1"/>
<field name="is_date_type" column_invisible="1"/>
@@ -144,14 +144,14 @@
<field name="point_avg" column_invisible="1"/>
<field name="photo_filename" column_invisible="1"/>
<!-- Card header prompt name (large, bold via SCSS) -->
<!-- Card header - prompt name (large, bold via SCSS) -->
<field name="name"
string="Measurement"
readonly="is_authored"
placeholder="e.g. Oven Temp, Bath Reading, Operator Initials"
class="o_fp_iw_prompt"/>
<!-- Meta type + unit rendered as pills (top-right).
<!-- Meta - type + unit rendered as pills (top-right).
Distinct classes so each pill lands in its own
grid column (otherwise they stack on top of
each other and the labels overlap). -->
@@ -165,12 +165,12 @@
class="o_fp_iw_meta o_fp_iw_meta_unit"
optional="show"/>
<!-- Hidden by default operator can opt in via the cog menu
<!-- Hidden by default - operator can opt in via the cog menu
if they want to see/edit target ranges per row -->
<field name="target_min" optional="hide"/>
<field name="target_max" optional="hide"/>
<!-- Mutually exclusive value widgets only the one
<!-- Mutually exclusive value widgets - only the one
matching the row's input_type renders -->
<field name="value_number"
string="Value"
@@ -196,7 +196,7 @@
invisible="not is_photo_type"
class="o_fp_iw_value"/>
<!-- Composite type 1: Multi-Point Thickness 5 readings -->
<!-- Composite type 1: Multi-Point Thickness - 5 readings -->
<field name="point_1" string="R1"
invisible="not is_multi_point_type"
class="o_fp_iw_extra" optional="show"/>

View File

@@ -8,7 +8,7 @@
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`
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.
@@ -40,7 +40,7 @@ _FP_INPUT_TYPE_SELECTION = [
class FpJobStepMoveWizard(models.TransientModel):
_name = 'fp.job.step.move.wizard'
_description = 'Fusion Plating Move Step Wizard (Backend)'
_description = 'Fusion Plating - Move Step Wizard (Backend)'
job_id = fields.Many2one('fp.job', string='Job', required=True, readonly=True)
from_step_id = fields.Many2one(
@@ -97,7 +97,7 @@ class FpJobStepMoveWizard(models.TransientModel):
'wizard_id',
string='Compliance Prompts',
help='Authored transition inputs from the to-step\'s recipe node. '
'Capture the operator\'s answers they snapshot to '
'Capture the operator\'s answers - they snapshot to '
'fp.job.step.move.input.value when the wizard commits.',
)
@@ -108,7 +108,7 @@ class FpJobStepMoveWizard(models.TransientModel):
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
# 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)
@@ -133,7 +133,7 @@ class FpJobStepMoveWizard(models.TransientModel):
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
# (script tests, RPC clients) get them too -
# @api.onchange only fires in interactive UI.
seen = set()
rows = []
@@ -173,14 +173,14 @@ class FpJobStepMoveWizard(models.TransientModel):
def _onchange_to_step_seed_inputs(self):
"""Seed prompt rows from BOTH
* the to-step's recipe node `transition_input` prompts
* 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
* 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,
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.
@@ -189,7 +189,7 @@ class FpJobStepMoveWizard(models.TransientModel):
wiz.input_value_ids = [(5, 0, 0)]
seen = set()
rows = []
# 1. From-step's step_input prompts measurements captured
# 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
@@ -205,7 +205,7 @@ class FpJobStepMoveWizard(models.TransientModel):
'name': '%s (Step Input)' % inp.name,
'input_type': inp.input_type,
}))
# 2. To-step's transition_input prompts compliance gates
# 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
@@ -243,7 +243,7 @@ class FpJobStepMoveWizard(models.TransientModel):
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 '
'Cannot move %(req)s parts - only %(here)s currently '
'parked at "%(step)s". Adjust Qty Moved or split '
'across multiple moves.'
) % {
@@ -277,7 +277,7 @@ class FpJobStepMoveWizard(models.TransientModel):
'value_boolean': line.value_boolean,
'value_date': line.value_date or False,
}
# Ad-hoc rows (no node_input_id) preserve the operator's typed
# 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:
@@ -325,13 +325,13 @@ class FpJobStepMoveWizardInput(models.TransientModel):
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
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'
_description = 'Fusion Plating - Move Wizard Input Row'
wizard_id = fields.Many2one(
'fp.job.step.move.wizard',
@@ -355,7 +355,7 @@ class FpJobStepMoveWizardInput(models.TransientModel):
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, '
'Drives field readonly state - authored prompts are locked, '
'ad-hoc rows are fully editable.',
)

View File

@@ -48,7 +48,7 @@
</field>
<separator string="Notes"/>
<field name="notes" nolabel="1"
placeholder="Optional context why this move, what to watch for next..."/>
placeholder="Optional context - why this move, what to watch for next..."/>
</sheet>
<footer>
<button name="action_commit" type="object"