feat(plating): session 2026-05-23 deploys — F1/F7/S22/S23 + UI fixes
Consolidated commit of session work already deployed to entech and verified via the deep audit + the persona walk: S22 — Signoff gate (fp.job.step.requires_signoff was 100% unenforced, 42/42 done steps had NULL signoff_user_id). Three-piece fix: _fp_autosign_if_required (captures finisher on button_finish), _fp_check_signoff_complete (raises UserError if NULL after autosign), action_signoff (explicit supervisor pre-sign). Bypass: fp_skip_signoff_gate=True. S23 — Transition-form gate (same dormant-field shape as S22, caught preventively before recipe authors flipped requires_transition_form on). Model helpers on fp.job.step.move + controller gate in move_controller (parts commit) + pre-reject in rack commit. F7 — Chatter standardization: _fp_create_qc_check_if_needed, _fp_fire_notification, _fp_create_delivery silent failures now also post to job chatter instead of only logging to file. UI fixes: - Critical Rule 20 documented + applied: OWL templates only expose Math as a global. Calling String(d) inside t-on-click throws 'v2 is not a function'. Fixed pin_pad.xml (string array instead of number array with String() coercion). Also swept parseInt/ parseFloat in recipe_tree_editor + simple_recipe_editor. - Notes panel HTML escape fix: chatter messages off /fp/workspace/load were rendered via t-out, escaping the HTML. Wrap with markup() in job_workspace.js refresh() before assigning to state. Versions: fusion_plating 19.0.20.8.0 → 19.0.20.9.0 fusion_plating_jobs 19.0.10.20.0 → 19.0.10.23.0 fusion_plating_shopfloor 19.0.30.2.0 → 19.0.30.5.0 All deployed to entech (LXC 111) and verified live. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1485,25 +1485,26 @@ class FpJob(models.Model):
|
||||
def _fp_create_portal_job(self):
|
||||
"""Create the fusion.plating.portal.job mirror record.
|
||||
|
||||
Initial state derived from the fp.job state via the same map
|
||||
used by write() — so a job that's already 'in_progress' when
|
||||
the portal mirror is created (e.g. a manual catch-up create)
|
||||
doesn't reset to 'received'.
|
||||
Seeded with 'received' then handed to
|
||||
`fusion.plating.portal.job._fp_recompute_portal_state` — that
|
||||
helper is the single source of truth for portal state and
|
||||
derives it from the WO + shipment + invoice signals, so a
|
||||
catch-up create on an already-in-progress job lands on the
|
||||
right state rather than stuck on 'received'.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.portal_job_id:
|
||||
return # already exists — idempotent
|
||||
Portal = self.env['fusion.plating.portal.job'].sudo()
|
||||
initial_state = self._FP_JOB_STATE_TO_PORTAL_STATE.get(
|
||||
self.state, 'received',
|
||||
)
|
||||
portal = Portal.create({
|
||||
'name': self.name,
|
||||
'partner_id': self.partner_id.id,
|
||||
'state': initial_state,
|
||||
'state': 'received',
|
||||
'x_fc_job_id': self.id,
|
||||
})
|
||||
self.portal_job_id = portal.id
|
||||
if hasattr(portal, '_fp_recompute_portal_state'):
|
||||
portal._fp_recompute_portal_state()
|
||||
|
||||
def _fp_create_qc_check_if_needed(self):
|
||||
"""If customer has x_fc_requires_qc=True, spawn a QC check via
|
||||
@@ -1528,9 +1529,17 @@ class FpJob(models.Model):
|
||||
try:
|
||||
QC.create_for_job(self)
|
||||
except Exception as e:
|
||||
# F7 — surface silent failures on the job's chatter so the
|
||||
# operator sees the gap and creates the QC manually. Logging
|
||||
# to /var/log/odoo/odoo-server.log alone meant nobody noticed
|
||||
# (2CM's WH/JOB/00002 silently lost its QC check this way).
|
||||
_logger.warning(
|
||||
"Job %s: create_for_job failed: %s", self.name, e,
|
||||
)
|
||||
self.message_post(body=_(
|
||||
'QC check auto-create failed: %(e)s. '
|
||||
'Create the QC check manually from the Quality menu.'
|
||||
) % {'e': e})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# button_mark_done — Task 2.8
|
||||
@@ -1745,10 +1754,18 @@ class FpJob(models.Model):
|
||||
# partner_id is the customer.
|
||||
Template._dispatch(event, self, partner=self.partner_id)
|
||||
except Exception as e:
|
||||
# F7 — surface on chatter. A missed customer notification
|
||||
# (e.g. "your parts have shipped") is invisible to the
|
||||
# operator until the customer complains; the chatter post
|
||||
# gives accounting / sales a recoverable signal.
|
||||
_logger.warning(
|
||||
"Job %s: notification %s dispatch failed: %s",
|
||||
self.name, event, e,
|
||||
)
|
||||
self.message_post(body=_(
|
||||
'Notification dispatch failed for event "%(ev)s": %(e)s. '
|
||||
'Send manually if the customer expected an update.'
|
||||
) % {'ev': event, 'e': e})
|
||||
|
||||
def _fp_create_delivery(self):
|
||||
"""Create a draft fusion.plating.delivery linked to this job.
|
||||
@@ -1787,9 +1804,16 @@ class FpJob(models.Model):
|
||||
delivery = Delivery.create(vals)
|
||||
self.delivery_id = delivery.id
|
||||
except Exception as e:
|
||||
# F7 — surface on chatter. Without this, the operator sees
|
||||
# "Job marked done" but no delivery record exists, and the
|
||||
# next milestone advance fails silently.
|
||||
_logger.warning(
|
||||
"Job %s: failed to auto-create delivery: %s", self.name, e,
|
||||
)
|
||||
self.message_post(body=_(
|
||||
'Delivery auto-create failed: %(e)s. '
|
||||
'Create the delivery manually from the Logistics menu.'
|
||||
) % {'e': e})
|
||||
|
||||
def _fp_resolve_delivery_defaults(self, Delivery):
|
||||
"""Build the create-vals for a fresh delivery, OR the
|
||||
|
||||
@@ -542,29 +542,179 @@ class FpJobStep(models.Model):
|
||||
return candidates[:1] or self.env['fp.job.step']
|
||||
|
||||
def _fp_has_uncaptured_step_inputs(self):
|
||||
"""True when the recipe step defines step_input prompts AND
|
||||
the user hasn't already saved values for this step's current
|
||||
run via the Record Inputs wizard.
|
||||
"""True when the recipe step has REQUIRED step_input prompts
|
||||
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
|
||||
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).
|
||||
Now we count actual coverage per required input across every
|
||||
move recorded against this step.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return bool(self._fp_missing_required_step_inputs())
|
||||
|
||||
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
|
||||
_fp_has_uncaptured_step_inputs (re-open dialog) and
|
||||
_fp_check_step_inputs_complete (raise UserError on finish).
|
||||
"""
|
||||
self.ensure_one()
|
||||
node = self.recipe_node_id
|
||||
Prompt = self.env['fusion.plating.process.node.input']
|
||||
if not node:
|
||||
return False
|
||||
return Prompt
|
||||
prompts = node.input_ids
|
||||
if 'kind' in prompts._fields:
|
||||
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
|
||||
if not prompts:
|
||||
return False
|
||||
# Has the operator already recorded values during this run?
|
||||
# Heuristic: any in-place fp.job.step.move (transfer_type='step')
|
||||
# for this step since date_started.
|
||||
Move = self.env['fp.job.step.move']
|
||||
already = Move.search_count([
|
||||
('from_step_id', '=', self.id),
|
||||
('transfer_type', '=', 'step'),
|
||||
('move_datetime', '>=', self.date_started or fields.Datetime.now()),
|
||||
])
|
||||
return already == 0
|
||||
if 'collect' in prompts._fields:
|
||||
prompts = prompts.filtered(lambda i: i.collect)
|
||||
required_prompts = prompts.filtered(lambda i: i.required)
|
||||
if not required_prompts:
|
||||
return Prompt
|
||||
Value = self.env['fp.job.step.move.input.value']
|
||||
recorded_input_ids = set(Value.search([
|
||||
('move_id.from_step_id', '=', self.id),
|
||||
('node_input_id', 'in', required_prompts.ids),
|
||||
]).mapped('node_input_id.id'))
|
||||
return required_prompts.filtered(
|
||||
lambda p: p.id not in recorded_input_ids
|
||||
)
|
||||
|
||||
def _fp_autosign_if_required(self):
|
||||
"""Auto-set signoff_user_id to the current user when the step has
|
||||
requires_signoff=True and no signoff has been recorded yet.
|
||||
|
||||
Called from button_finish just before the signoff gate. Captures
|
||||
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
|
||||
no-op.
|
||||
|
||||
Idempotent — never overwrites an existing signoff_user_id, so a
|
||||
manager pre-signing via action_signoff is preserved through the
|
||||
operator's Finish click.
|
||||
"""
|
||||
for step in self:
|
||||
if not step.requires_signoff:
|
||||
continue
|
||||
if step.signoff_user_id:
|
||||
continue # pre-signed (likely by a supervisor)
|
||||
# Use sudo because signoff_user_id is readonly=True at field
|
||||
# level; we still capture env.user.id (not SUPERUSER_ID) so
|
||||
# the audit trail shows who actually clicked.
|
||||
step.sudo().write({'signoff_user_id': self.env.user.id})
|
||||
|
||||
def _fp_check_signoff_complete(self):
|
||||
"""Raise UserError if the step has requires_signoff=True and
|
||||
signoff_user_id IS NULL. Aerospace / Nadcap need a named signer
|
||||
on every sign-off-required step; an unset signer breaks the
|
||||
audit chain.
|
||||
|
||||
Normally _fp_autosign_if_required (called from button_finish
|
||||
immediately before this gate) populates signoff_user_id with the
|
||||
finisher's id, so this gate only fires when:
|
||||
- The step is being finished via a code path that bypasses
|
||||
autosign (e.g. a migration script writing state='done').
|
||||
- The user has no env.user (background cron with no uid set).
|
||||
|
||||
Manager bypass via context fp_skip_signoff_gate=True for
|
||||
documented customer deviations. Bypasses are posted to chatter
|
||||
naming the user.
|
||||
"""
|
||||
if self.env.context.get('fp_skip_signoff_gate'):
|
||||
for step in self:
|
||||
if not step.requires_signoff:
|
||||
continue
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'Sign-off gate bypassed on step "<b>%s</b>" by %s. '
|
||||
'Documented deviation — no signer recorded.'
|
||||
)) % (step.name, self.env.user.name))
|
||||
return
|
||||
for step in self:
|
||||
if not step.requires_signoff:
|
||||
continue
|
||||
if step.signoff_user_id:
|
||||
continue
|
||||
raise UserError(_(
|
||||
'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 '
|
||||
'fp_skip_signoff_gate=True for documented deviations.'
|
||||
) % {'step': step.name})
|
||||
|
||||
def action_signoff(self):
|
||||
"""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
|
||||
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.
|
||||
"""
|
||||
for step in self:
|
||||
if not step.requires_signoff:
|
||||
raise UserError(_(
|
||||
'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:
|
||||
continue # idempotent
|
||||
step.sudo().write({'signoff_user_id': self.env.user.id})
|
||||
if prior:
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'Sign-off on step "<b>%s</b>" reassigned from %s to %s.'
|
||||
)) % (step.name, prior.name, self.env.user.name))
|
||||
else:
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'Step "<b>%s</b>" signed off by %s.'
|
||||
)) % (step.name, self.env.user.name))
|
||||
return True
|
||||
|
||||
def _fp_check_step_inputs_complete(self):
|
||||
"""Raise UserError if the step has REQUIRED step_input prompts
|
||||
that haven't been recorded yet. AS9100 / Nadcap need a complete
|
||||
per-step data trail; finishing a step with missing prompts breaks
|
||||
the audit chain.
|
||||
|
||||
Manager bypass via context fp_skip_required_inputs_gate=True
|
||||
(e.g. paper-form catch-up or documented customer deviation).
|
||||
Bypasses are posted to chatter naming the user.
|
||||
"""
|
||||
if self.env.context.get('fp_skip_required_inputs_gate'):
|
||||
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.'
|
||||
)) % (step.name, self.env.user.name))
|
||||
return
|
||||
for step in self:
|
||||
missing = step._fp_missing_required_step_inputs()
|
||||
if not missing:
|
||||
continue
|
||||
names = ', '.join('"%s"' % (p.name or '').strip() for p in missing)
|
||||
raise UserError(_(
|
||||
'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. '
|
||||
'Managers can override via context flag '
|
||||
'fp_skip_required_inputs_gate=True for documented '
|
||||
'deviations.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'n': len(missing),
|
||||
'names': names,
|
||||
})
|
||||
|
||||
def _fp_open_input_wizard(self, advance_after=False):
|
||||
"""Open the Record Inputs OWL dialog (Sub 12e v4).
|
||||
@@ -593,93 +743,12 @@ class FpJobStep(models.Model):
|
||||
# _fp_open_input_wizard above adds the advance_after pathway used
|
||||
# only by action_finish_and_advance.
|
||||
|
||||
def button_finish(self):
|
||||
"""Override to:
|
||||
1) Auto-spawn a bake.window when a wet plating step finishes
|
||||
on a recipe that requires hydrogen-embrittlement relief
|
||||
(AS9100 / Nadcap compliance). Bake fields live on the
|
||||
recipe root post-promote-customer-spec.
|
||||
2) Post a chatter warning when duration_actual exceeds 1.5×
|
||||
duration_expected — silent overruns are a red flag for
|
||||
scheduling and costing.
|
||||
|
||||
Both actions are idempotent and never block the finish itself.
|
||||
"""
|
||||
result = super().button_finish()
|
||||
BW = self.env['fusion.plating.bake.window']
|
||||
Bath = self.env['fusion.plating.bath']
|
||||
for step in self:
|
||||
if step.state != 'done':
|
||||
continue
|
||||
# Duration-overrun chatter alert.
|
||||
if step.duration_expected and step.duration_actual:
|
||||
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> — '
|
||||
'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))
|
||||
recipe_root = step.job_id.recipe_id
|
||||
if not recipe_root:
|
||||
continue
|
||||
requires = getattr(recipe_root, 'requires_bake_relief', False)
|
||||
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
|
||||
if not requires or not window_hrs:
|
||||
continue
|
||||
# Trigger only on the actual plating-out step. We want
|
||||
# exactly ONE bake.window per job (not one per step that
|
||||
# happens to have "plate" in the name). Heuristic:
|
||||
# - step.kind == 'wet' (clean, recipe-authored signal); OR
|
||||
# - the step name contains "plating" as a word
|
||||
# Explicit excludes: inspection / bake / mask / rack steps
|
||||
# whose names might happen to mention plating in passing
|
||||
# (e.g. "Post-plate Inspection").
|
||||
name_l = (step.name or '').lower()
|
||||
kind_match = step.kind == 'wet'
|
||||
name_match = bool(re.search(r'\bplating\b', name_l))
|
||||
excluded = any(kw in name_l for kw in (
|
||||
'inspect', 'inspection', 'bake', 'mask', 'rack',
|
||||
))
|
||||
if (not kind_match and not name_match) or excluded:
|
||||
continue
|
||||
# Idempotency — only one bake.window per (job, step).
|
||||
existing = BW.sudo().search([
|
||||
('part_ref', '=', step.job_id.name),
|
||||
('lot_ref', '=', f'step-{step.id}'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
# Pick a bath: step.bath_id wins; fall back to the first
|
||||
# active bath in the facility (best-effort — operator can
|
||||
# correct on the bake.window record).
|
||||
bath = step.bath_id or Bath.sudo().search(
|
||||
[('facility_id', '=', step.facility_id.id)], limit=1,
|
||||
) if step.facility_id else False
|
||||
if not bath:
|
||||
bath = Bath.sudo().search([], limit=1)
|
||||
if not bath:
|
||||
_logger.warning(
|
||||
'Step %s: bake-window auto-spawn skipped — no bath '
|
||||
'configured.', step.name,
|
||||
)
|
||||
continue
|
||||
bw = BW.sudo().create({
|
||||
'bath_id': bath.id,
|
||||
'plate_exit_time': step.date_finished or fields.Datetime.now(),
|
||||
'window_hours': window_hrs,
|
||||
'part_ref': step.job_id.name,
|
||||
'lot_ref': f'step-{step.id}',
|
||||
'customer_ref': step.job_id.partner_id.display_name or '',
|
||||
'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 '
|
||||
'plate exit. Required by %s.'
|
||||
)) % (bw.name, window_hrs, bw.bake_required_by))
|
||||
return result
|
||||
# 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,
|
||||
# so the bake.window auto-spawn was dead code for the entire WO-30051
|
||||
# era. Don't re-introduce a second button_finish here.
|
||||
|
||||
# ==================================================================
|
||||
# Phase 2 multi-serial — auto-promote serials on step transitions
|
||||
@@ -1070,18 +1139,112 @@ class FpJobStep(models.Model):
|
||||
return result
|
||||
|
||||
def button_finish(self):
|
||||
# Policy B — block until QA-005 complete (when customer requires it).
|
||||
"""Canonical button_finish — gates first, then super(), then
|
||||
post-finish side effects.
|
||||
|
||||
Gates (raise UserError, blocking finish):
|
||||
- Required step_input prompts recorded (S21 / WO-30051 fix).
|
||||
Manager bypass: fp_skip_required_inputs_gate=True.
|
||||
- Sign-off recorded when recipe step has requires_signoff=True
|
||||
(S22 / F1 audit fix). Auto-sign captures the finisher when
|
||||
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
|
||||
now, not a separate workflow. _fp_check_racking_inspection_
|
||||
complete() is kept as a helper for diagnostics.)
|
||||
|
||||
Post-finish (idempotent, never blocks):
|
||||
- Promote attached serials from in_process -> inspected on the
|
||||
terminal step of the job.
|
||||
- Chatter warning when duration_actual >= 1.5x duration_expected.
|
||||
- Auto-spawn a bake.window for wet plating steps on recipes
|
||||
flagged requires_bake_relief.
|
||||
"""
|
||||
# ----- Gates ----------------------------------------------------
|
||||
# Order matters: cheapest checks first. Required-inputs is a pure
|
||||
# ORM query; contract review and receiving may touch related models.
|
||||
self._fp_check_step_inputs_complete()
|
||||
# Sign-off: auto-capture the finisher's uid first (no-op when a
|
||||
# supervisor pre-signed via action_signoff), THEN gate. Gate only
|
||||
# fires when both autosign and explicit sign-off skipped (e.g.
|
||||
# migration scripts, background crons).
|
||||
self._fp_autosign_if_required()
|
||||
self._fp_check_signoff_complete()
|
||||
self._fp_check_contract_review_complete()
|
||||
# Receiving gate — same helper as button_start, exempts CR steps.
|
||||
self._fp_check_receiving_gate()
|
||||
# NOTE: racking inspection gate removed — racking is now a recipe
|
||||
# step, not a separate inspection workflow. _fp_check_racking_
|
||||
# inspection_complete() is kept as a helper for diagnostics but
|
||||
# no longer enforced from button_finish.
|
||||
|
||||
result = super().button_finish()
|
||||
|
||||
# ----- Post-finish side effects --------------------------------
|
||||
BW = self.env['fusion.plating.bake.window']
|
||||
Bath = self.env['fusion.plating.bath']
|
||||
for step in self:
|
||||
if step.state == 'done':
|
||||
step._fp_promote_serials_on_finish()
|
||||
if step.state != 'done':
|
||||
continue
|
||||
step._fp_promote_serials_on_finish()
|
||||
# Duration-overrun chatter alert.
|
||||
if step.duration_expected and step.duration_actual:
|
||||
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> — '
|
||||
'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
|
||||
# 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
|
||||
# plating in passing (e.g. "Post-plate Inspection").
|
||||
recipe_root = step.job_id.recipe_id
|
||||
if not recipe_root:
|
||||
continue
|
||||
requires = getattr(recipe_root, 'requires_bake_relief', False)
|
||||
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
|
||||
if not requires or not window_hrs:
|
||||
continue
|
||||
name_l = (step.name or '').lower()
|
||||
kind_match = step.kind == 'wet'
|
||||
name_match = bool(re.search(r'\bplating\b', name_l))
|
||||
excluded = any(kw in name_l for kw in (
|
||||
'inspect', 'inspection', 'bake', 'mask', 'rack',
|
||||
))
|
||||
if (not kind_match and not name_match) or excluded:
|
||||
continue
|
||||
existing = BW.sudo().search([
|
||||
('part_ref', '=', step.job_id.name),
|
||||
('lot_ref', '=', f'step-{step.id}'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
bath = step.bath_id or Bath.sudo().search(
|
||||
[('facility_id', '=', step.facility_id.id)], limit=1,
|
||||
) if step.facility_id else False
|
||||
if not bath:
|
||||
bath = Bath.sudo().search([], limit=1)
|
||||
if not bath:
|
||||
_logger.warning(
|
||||
'Step %s: bake-window auto-spawn skipped — no bath '
|
||||
'configured.', step.name,
|
||||
)
|
||||
continue
|
||||
bw = BW.sudo().create({
|
||||
'bath_id': bath.id,
|
||||
'plate_exit_time': step.date_finished or fields.Datetime.now(),
|
||||
'window_hours': window_hrs,
|
||||
'part_ref': step.job_id.name,
|
||||
'lot_ref': f'step-{step.id}',
|
||||
'customer_ref': step.job_id.partner_id.display_name or '',
|
||||
'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 '
|
||||
'plate exit. Required by %s.'
|
||||
)) % (bw.name, window_hrs, bw.bake_required_by))
|
||||
return result
|
||||
|
||||
# ==================================================================
|
||||
|
||||
Reference in New Issue
Block a user