changes
This commit is contained in:
@@ -413,3 +413,370 @@ class FpJobStep(models.Model):
|
||||
'plate exit. Required by %s.'
|
||||
)) % (bw.name, window_hrs, bw.bake_required_by))
|
||||
return result
|
||||
|
||||
# ==================================================================
|
||||
# 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
|
||||
skipped.
|
||||
"""
|
||||
for step in self:
|
||||
job = step.job_id
|
||||
if not job.sale_order_line_ids:
|
||||
continue
|
||||
serials = job.sale_order_line_ids.mapped('x_fc_serial_ids')
|
||||
to_promote = serials.filtered(
|
||||
lambda s: s.state in ('received', 'racked')
|
||||
)
|
||||
if to_promote:
|
||||
# Use sudo on the helper so operator-tier users can promote
|
||||
# serial state without needing direct write on fp.serial.
|
||||
to_promote.sudo()._set_state('in_process', message=_(
|
||||
'Promoted to In Process on step "%s" start by %s.'
|
||||
) % (step.name, self.env.user.name))
|
||||
|
||||
def _fp_promote_serials_on_finish(self):
|
||||
"""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 step in self:
|
||||
job = step.job_id
|
||||
if not job.sale_order_line_ids:
|
||||
continue
|
||||
# Is this the highest-sequence non-cancelled step on the job?
|
||||
siblings = job.step_ids.filtered(
|
||||
lambda s: s.state not in ('cancelled', 'skipped')
|
||||
)
|
||||
if not siblings:
|
||||
continue
|
||||
last_seq = max(siblings.mapped('sequence'))
|
||||
is_terminal = (step.sequence == last_seq) or (
|
||||
step.kind == 'inspect' or 'final' in (step.name or '').lower()
|
||||
)
|
||||
if not is_terminal:
|
||||
continue
|
||||
serials = job.sale_order_line_ids.mapped('x_fc_serial_ids')
|
||||
to_promote = serials.filtered(lambda s: s.state == 'in_process')
|
||||
if to_promote:
|
||||
to_promote.sudo()._set_state('inspected', message=_(
|
||||
'Promoted to Inspected on step "%s" finish by %s.'
|
||||
) % (step.name, self.env.user.name))
|
||||
|
||||
# ==================================================================
|
||||
# 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-
|
||||
# creates one if missing) and button_finish blocks completion until
|
||||
# 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
|
||||
# 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):
|
||||
self.ensure_one()
|
||||
if (self.name or '').strip().lower() in ('contract review', 'qa-005'):
|
||||
return True
|
||||
node = self.recipe_node_id
|
||||
if not node:
|
||||
return False
|
||||
# Source template kind (when authored via simple editor library)
|
||||
if 'source_template_id' in node._fields and node.source_template_id:
|
||||
if node.source_template_id.default_kind == 'contract_review':
|
||||
return True
|
||||
if 'default_kind' in node._fields and node.default_kind == 'contract_review':
|
||||
return True
|
||||
return False
|
||||
|
||||
def _fp_resolve_contract_review_part(self):
|
||||
"""Find the fp.part.catalog this step's job is for. Used by the
|
||||
Contract Review hooks to auto-create / look up the QA-005 form.
|
||||
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()
|
||||
for so_line in self.job_id.sale_order_line_ids:
|
||||
if (so_line.x_fc_part_catalog_id
|
||||
and 'fp.contract.review' in self.env):
|
||||
return so_line.x_fc_part_catalog_id
|
||||
return None
|
||||
|
||||
def _fp_open_contract_review(self):
|
||||
"""Auto-create the QA-005 form for this step's part if missing,
|
||||
return the act_window pointing at it. Called from button_start
|
||||
on Contract Review steps."""
|
||||
self.ensure_one()
|
||||
part = self._fp_resolve_contract_review_part()
|
||||
if not part:
|
||||
return None
|
||||
Review = self.env.get('fp.contract.review')
|
||||
if Review is None:
|
||||
return None # quality module not installed — skip
|
||||
review = part.x_fc_contract_review_id
|
||||
if not review:
|
||||
review = Review.sudo().create({
|
||||
'part_id': part.id,
|
||||
'state': 'assistant_review',
|
||||
})
|
||||
part.sudo().write({
|
||||
'x_fc_contract_review_id': review.id,
|
||||
'x_fc_contract_review_dismissed': False,
|
||||
})
|
||||
self.job_id.message_post(body=_(
|
||||
'Contract Review (QA-005) auto-created for %(part)s on '
|
||||
'Contract Review step start by %(user)s.'
|
||||
) % {
|
||||
'part': part.display_name or part.part_number or '',
|
||||
'user': self.env.user.name,
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.contract.review',
|
||||
'res_id': review.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'name': _('Contract Review — %s') % (
|
||||
part.display_name or part.part_number or ''
|
||||
),
|
||||
}
|
||||
|
||||
def _fp_check_contract_review_complete(self):
|
||||
"""Block button_finish on a Contract Review step until QA-005 is
|
||||
signed off. Only enforced when the customer has
|
||||
partner.x_fc_contract_review_required=True. Manager bypass via
|
||||
context fp_skip_contract_review_gate=True."""
|
||||
if self.env.context.get('fp_skip_contract_review_gate'):
|
||||
return
|
||||
for step in self:
|
||||
if not step._fp_is_contract_review_step():
|
||||
continue
|
||||
part = step._fp_resolve_contract_review_part()
|
||||
if not part or not part.partner_id.x_fc_contract_review_required:
|
||||
continue
|
||||
review = part.x_fc_contract_review_id
|
||||
if not review or review.state != 'complete':
|
||||
state_label = (
|
||||
review.state if review else _('not started')
|
||||
)
|
||||
raise UserError(_(
|
||||
'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: '
|
||||
'fp_skip_contract_review_gate=True in context.'
|
||||
) % {
|
||||
'part': part.display_name or part.part_number or '',
|
||||
'state': state_label,
|
||||
})
|
||||
# Approver-list gate (restored from pre-Sub-11). When the
|
||||
# recipe author named approvers on the recipe root, only those
|
||||
# users can finish the Contract Review step.
|
||||
recipe = step.recipe_node_id and step.recipe_node_id.recipe_root_id
|
||||
approvers = (recipe.contract_review_user_ids
|
||||
if (recipe and 'contract_review_user_ids' in recipe._fields)
|
||||
else False)
|
||||
if approvers and self.env.user not in approvers:
|
||||
raise UserError(_(
|
||||
'Only authorised Contract Review approvers can finish '
|
||||
'this step. Approvers: %s.\n\nContact your Plating '
|
||||
'Manager to add yourself if this is wrong, or hand '
|
||||
'the step to one of the approvers.'
|
||||
) % ', '.join(approvers.mapped('name')))
|
||||
|
||||
# ==================================================================
|
||||
# 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
|
||||
# straight into the inspection form. When the same step finishes,
|
||||
# block unless the inspection is in `done` or `discrepancy_flagged`
|
||||
# (operator cleared every line). Manager bypass via context
|
||||
# `fp_skip_racking_inspection_gate=True`.
|
||||
def _fp_is_racking_step(self):
|
||||
self.ensure_one()
|
||||
if (self.name or '').strip().lower() in ('racking', 'rack'):
|
||||
return True
|
||||
node = self.recipe_node_id
|
||||
if not node:
|
||||
return False
|
||||
if 'source_template_id' in node._fields and node.source_template_id:
|
||||
if node.source_template_id.default_kind == 'racking':
|
||||
return True
|
||||
if 'default_kind' in node._fields and node.default_kind == 'racking':
|
||||
return True
|
||||
if self.kind == 'rack':
|
||||
return True
|
||||
return False
|
||||
|
||||
def _fp_open_racking_inspection(self):
|
||||
"""Auto-promote draft → inspecting + return act_window for the
|
||||
linked racking inspection. Auto-creates one if missing."""
|
||||
self.ensure_one()
|
||||
if 'fp.racking.inspection' not in self.env:
|
||||
return None
|
||||
# Reach the job's existing inspection (auto-created on action_confirm)
|
||||
# or trigger a fresh create if none exists.
|
||||
ri = self.job_id.racking_inspection_id
|
||||
if not ri:
|
||||
self.job_id._fp_create_racking_inspection()
|
||||
self.job_id.invalidate_recordset(['racking_inspection_ids'])
|
||||
ri = self.job_id.racking_inspection_id
|
||||
if not ri:
|
||||
return None
|
||||
# Promote draft → inspecting. action_start raises if state isn't
|
||||
# draft, so guard.
|
||||
if ri.state == 'draft':
|
||||
ri.sudo().action_start()
|
||||
self.job_id.message_post(body=_(
|
||||
'Racking inspection auto-promoted to "Inspecting" on '
|
||||
'%(step)s start by %(user)s.'
|
||||
) % {'step': self.name, 'user': self.env.user.name})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.racking.inspection',
|
||||
'res_id': ri.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'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
|
||||
linked inspection is in a terminal state. discrepancy_flagged
|
||||
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
|
||||
for step in self:
|
||||
if not step._fp_is_racking_step():
|
||||
continue
|
||||
ri = step.job_id.racking_inspection_id
|
||||
if not ri:
|
||||
# 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 '
|
||||
'inspection on file. Sub 8 expected one to be '
|
||||
'auto-created on job confirm.'
|
||||
) % step.name)
|
||||
continue
|
||||
if ri.state not in ('done', 'discrepancy_flagged'):
|
||||
state_label = dict(ri._fields['state'].selection).get(
|
||||
ri.state, ri.state)
|
||||
raise UserError(_(
|
||||
'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. '
|
||||
'Manager bypass: fp_skip_racking_inspection_gate=True.'
|
||||
) % {
|
||||
'job': step.job_id.name,
|
||||
'state': state_label,
|
||||
})
|
||||
|
||||
def button_start(self):
|
||||
# Policy B — Contract Review takes priority (auto-opens QA-005).
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
action = step._fp_open_contract_review()
|
||||
if action:
|
||||
super(FpJobStep, step).button_start()
|
||||
if step.state == 'in_progress':
|
||||
step._fp_promote_serials_on_start()
|
||||
return action
|
||||
# Sub 8 — Racking step auto-opens the inspection form.
|
||||
for step in self:
|
||||
if step._fp_is_racking_step():
|
||||
action = step._fp_open_racking_inspection()
|
||||
if action:
|
||||
super(FpJobStep, step).button_start()
|
||||
if step.state == 'in_progress':
|
||||
step._fp_promote_serials_on_start()
|
||||
return action
|
||||
result = super().button_start()
|
||||
for step in self:
|
||||
if step.state == 'in_progress':
|
||||
step._fp_promote_serials_on_start()
|
||||
return result
|
||||
|
||||
def button_finish(self):
|
||||
# Policy B — block until QA-005 complete (when customer requires it).
|
||||
self._fp_check_contract_review_complete()
|
||||
# Sub 8 — block until racking inspection is Done / Flagged.
|
||||
self._fp_check_racking_inspection_complete()
|
||||
result = super().button_finish()
|
||||
for step in self:
|
||||
if step.state == 'done':
|
||||
step._fp_promote_serials_on_finish()
|
||||
return result
|
||||
|
||||
# ==================================================================
|
||||
# Per-row shortcut actions used by the job form's inline action column
|
||||
# ==================================================================
|
||||
def action_open_move_wizard(self):
|
||||
"""Open the Move wizard with this step pre-filled as the from-step."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.job.step.move.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'name': _('Move from %s') % self.name,
|
||||
'context': {
|
||||
'default_from_step_id': self.id,
|
||||
'default_job_id': self.job_id.id,
|
||||
},
|
||||
}
|
||||
|
||||
def action_open_input_wizard(self):
|
||||
"""Open the Input Recording wizard for this step."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.job.step.input.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'name': _('Record Inputs — %s') % self.name,
|
||||
'context': {
|
||||
'default_step_id': self.id,
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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
|
||||
# backend manager's view.
|
||||
# ------------------------------------------------------------------
|
||||
duration_running_minutes = fields.Float(
|
||||
string='Running Min',
|
||||
compute='_compute_duration_running',
|
||||
help='Minutes since the step\'s current open timelog started. '
|
||||
'Re-reads on every form refresh; equals duration_actual once '
|
||||
'the step is finished.',
|
||||
)
|
||||
|
||||
@api.depends('state', 'date_started', 'time_log_ids',
|
||||
'time_log_ids.date_started', 'time_log_ids.date_finished',
|
||||
'duration_actual')
|
||||
def _compute_duration_running(self):
|
||||
now = fields.Datetime.now()
|
||||
for step in self:
|
||||
if step.state == 'in_progress':
|
||||
# Sum closed intervals + (now - open interval start)
|
||||
closed = sum(step.time_log_ids.mapped('duration_minutes'))
|
||||
open_log = step.time_log_ids.filtered(
|
||||
lambda l: not l.date_finished
|
||||
)[:1]
|
||||
running = 0.0
|
||||
if open_log and open_log.date_started:
|
||||
delta = (now - open_log.date_started).total_seconds() / 60.0
|
||||
running = max(0.0, delta)
|
||||
step.duration_running_minutes = closed + running
|
||||
else:
|
||||
step.duration_running_minutes = step.duration_actual or 0.0
|
||||
|
||||
Reference in New Issue
Block a user