This commit is contained in:
gsinghpal
2026-04-28 19:39:37 -04:00
parent 2d42b33d68
commit 13e300d90e
103 changed files with 4959 additions and 331 deletions

View File

@@ -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