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:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')),
|
||||
])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']">
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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 & 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. -->
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)}')
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.005–0.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.005–0.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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 2 — open the Certificate PDF tab to verify.
|
||||
as page 2 - open the Certificate 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">
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user