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:
@@ -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 '
|
||||
|
||||
Reference in New Issue
Block a user