changes
This commit is contained in:
@@ -155,6 +155,79 @@ class FpJob(models.Model):
|
||||
'name': self.sale_order_id.name,
|
||||
}
|
||||
|
||||
# All time logs across every step on this job — backs the Time Logs
|
||||
# tab on the form so the manager sees the full labour audit without
|
||||
# clicking into each step.
|
||||
time_log_ids = fields.One2many(
|
||||
'fp.job.step.timelog',
|
||||
'job_id',
|
||||
string='All Time Logs',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# 2026-04-28 — link to the auto-created Sub 8 racking inspection so
|
||||
# the job form can show a smart button + the manager can route into
|
||||
# the inspection without leaving the job screen.
|
||||
racking_inspection_ids = fields.One2many(
|
||||
'fp.racking.inspection',
|
||||
'x_fc_job_id',
|
||||
string='Racking Inspections',
|
||||
)
|
||||
racking_inspection_id = fields.Many2one(
|
||||
'fp.racking.inspection',
|
||||
string='Racking Inspection',
|
||||
compute='_compute_racking_inspection',
|
||||
store=False,
|
||||
help='The single racking inspection scoped to this job (Sub 8 '
|
||||
'enforces uniqueness). Smart button on the form routes here.',
|
||||
)
|
||||
# Computed alongside racking_inspection_id so views can render the
|
||||
# state badge without needing a related-on-non-stored field (which
|
||||
# the ORM rejects). Selection mirrors fp.racking.inspection.state.
|
||||
racking_inspection_state = fields.Selection(
|
||||
[('draft', 'Draft'),
|
||||
('inspecting', 'Inspecting'),
|
||||
('done', 'Done'),
|
||||
('discrepancy_flagged', 'Discrepancy Flagged')],
|
||||
string='Racking Inspection Status',
|
||||
compute='_compute_racking_inspection',
|
||||
store=False,
|
||||
)
|
||||
|
||||
@api.depends('racking_inspection_ids', 'racking_inspection_ids.state')
|
||||
def _compute_racking_inspection(self):
|
||||
for job in self:
|
||||
ri = job.racking_inspection_ids[:1]
|
||||
job.racking_inspection_id = ri
|
||||
job.racking_inspection_state = ri.state if ri else False
|
||||
|
||||
def action_view_racking_inspection(self):
|
||||
"""Open the racking inspection. Auto-create if missing (e.g. job
|
||||
was created before Sub 8 shipped, or auto-create silently failed
|
||||
at action_confirm time)."""
|
||||
self.ensure_one()
|
||||
if 'fp.racking.inspection' not in self.env:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Sub 8 racking inspection module not installed. '
|
||||
'Install fusion_plating_receiving to enable.'
|
||||
))
|
||||
if not self.racking_inspection_id:
|
||||
self._fp_create_racking_inspection()
|
||||
self.invalidate_recordset(['racking_inspection_ids'])
|
||||
ri = self.racking_inspection_id or self.racking_inspection_ids[:1]
|
||||
if not ri:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_('Could not auto-create racking inspection.'))
|
||||
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.name,
|
||||
}
|
||||
|
||||
def action_view_steps(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
@@ -166,6 +239,53 @@ class FpJob(models.Model):
|
||||
'context': {'default_job_id': self.id},
|
||||
}
|
||||
|
||||
def action_open_move_wizard(self):
|
||||
"""Header button — opens the Move wizard pre-filled with the
|
||||
currently in-progress (or most recently in-progress) step as the
|
||||
from-step. Lets the manager move the job forward without first
|
||||
clicking into a specific step row.
|
||||
"""
|
||||
self.ensure_one()
|
||||
active_step = self.step_ids.filtered(
|
||||
lambda s: s.state == 'in_progress'
|
||||
)[:1]
|
||||
if not active_step:
|
||||
active_step = self.step_ids.filtered(
|
||||
lambda s: s.state in ('paused', 'ready')
|
||||
).sorted('sequence')[:1]
|
||||
if not active_step:
|
||||
raise UserError(_(
|
||||
'No in-progress, paused, or ready step found on this job. '
|
||||
'Either every step is done or the job is still in draft.'
|
||||
))
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.job.step.move.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'name': _('Move Step — %s') % active_step.name,
|
||||
'context': {
|
||||
'default_from_step_id': active_step.id,
|
||||
'default_job_id': self.id,
|
||||
},
|
||||
}
|
||||
|
||||
def action_print_traveller(self):
|
||||
self.ensure_one()
|
||||
return self.env.ref(
|
||||
'fusion_plating_jobs.action_report_fp_job_traveller'
|
||||
).report_action(self)
|
||||
|
||||
def action_print_wo_detail(self):
|
||||
"""Print the Steelhead-style Work Order Detail PDF — chronological
|
||||
chain-of-custody + per-step inputs + Certified By page. Use this
|
||||
as the AS9100/Nadcap shippable audit document.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.env.ref(
|
||||
'fusion_plating_jobs.action_report_fp_job_wo_detail'
|
||||
).report_action(self)
|
||||
|
||||
def action_view_deliveries(self):
|
||||
self.ensure_one()
|
||||
if not self.delivery_id:
|
||||
@@ -497,6 +617,38 @@ class FpJob(models.Model):
|
||||
instructions.append(line)
|
||||
step_num += 1
|
||||
|
||||
# Map recipe_node.default_kind → step.kind so the
|
||||
# downstream gates (Sub 8 racking soft-gate, Policy B
|
||||
# contract-review gate) work even when the step gets
|
||||
# renamed by the customer (e.g. "Hang on Bar" instead
|
||||
# of "Racking"). Without this, gate detection falls
|
||||
# back to fragile name matching.
|
||||
_NODE_KIND_TO_STEP_KIND = {
|
||||
'cleaning': 'wet',
|
||||
'etch': 'wet',
|
||||
'rinse': 'wet',
|
||||
'plate': 'wet',
|
||||
'dry': 'wet',
|
||||
'wbf_test': 'wet',
|
||||
'bake': 'bake',
|
||||
'mask': 'mask',
|
||||
'demask': 'mask',
|
||||
'racking': 'rack',
|
||||
'derack': 'rack',
|
||||
'inspect': 'inspect',
|
||||
'final_inspect': 'inspect',
|
||||
'contract_review': 'other',
|
||||
'gating': 'other',
|
||||
'ship': 'other',
|
||||
}
|
||||
step_kind = 'other'
|
||||
node_kind = (
|
||||
node.default_kind
|
||||
if 'default_kind' in node._fields else None
|
||||
)
|
||||
if node_kind and node_kind in _NODE_KIND_TO_STEP_KIND:
|
||||
step_kind = _NODE_KIND_TO_STEP_KIND[node_kind]
|
||||
|
||||
vals = {
|
||||
'job_id': job.id,
|
||||
'name': node.name,
|
||||
@@ -504,6 +656,7 @@ class FpJob(models.Model):
|
||||
'duration_expected': node.estimated_duration or 0.0,
|
||||
'sequence': seq_counter[0],
|
||||
'recipe_node_id': node.id,
|
||||
'kind': step_kind,
|
||||
}
|
||||
if node.estimated_duration:
|
||||
vals['dwell_time_minutes'] = node.estimated_duration
|
||||
@@ -636,12 +789,79 @@ class FpJob(models.Model):
|
||||
)
|
||||
if pending_steps:
|
||||
pending_steps.write({'state': 'ready'})
|
||||
# 2026-04-28 — auto-populate facility_id + manager_id so the
|
||||
# job header surfaces them on the form. Page-1 audit found
|
||||
# both empty on confirmed jobs.
|
||||
job._fp_autofill_facility_and_manager()
|
||||
job._fp_create_portal_job()
|
||||
job._fp_create_qc_check_if_needed()
|
||||
job._fp_create_racking_inspection()
|
||||
job._fp_fire_notification('job_confirmed')
|
||||
return result
|
||||
|
||||
def _fp_autofill_facility_and_manager(self):
|
||||
"""Populate facility_id + manager_id on confirm if empty.
|
||||
|
||||
Resolution order:
|
||||
facility_id —
|
||||
1. Already set → leave alone.
|
||||
2. First step with a work_centre that has a facility → use it.
|
||||
3. Recipe's process_type → facility (if process_type carries one).
|
||||
4. Single-facility company → use that one.
|
||||
|
||||
manager_id —
|
||||
1. Already set → leave alone.
|
||||
2. Confirming user IS in the Plating Manager group → use them.
|
||||
3. Sale order user_id (the salesperson who confirmed the SO).
|
||||
4. The customer's account manager (partner.user_id).
|
||||
5. Leave blank — no sensible default.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# ---- facility_id ----
|
||||
if not self.facility_id:
|
||||
facility = False
|
||||
for s in self.step_ids:
|
||||
if s.work_centre_id and 'facility_id' in s.work_centre_id._fields:
|
||||
facility = s.work_centre_id.facility_id
|
||||
if facility:
|
||||
break
|
||||
if not facility and self.recipe_id and 'process_type_id' in self.recipe_id._fields:
|
||||
pt = self.recipe_id.process_type_id
|
||||
if pt and 'facility_id' in pt._fields:
|
||||
facility = pt.facility_id
|
||||
if not facility:
|
||||
Facility = self.env.get('fusion.plating.facility')
|
||||
if Facility is not None:
|
||||
facilities = Facility.search([
|
||||
('company_id', '=', self.company_id.id),
|
||||
])
|
||||
if len(facilities) == 1:
|
||||
facility = facilities
|
||||
if facility:
|
||||
self.facility_id = facility.id
|
||||
self.message_post(body=_(
|
||||
'Facility auto-set on confirm: %s'
|
||||
) % facility.display_name)
|
||||
|
||||
# ---- manager_id ----
|
||||
if not self.manager_id:
|
||||
mgr = False
|
||||
ManagerGroup = self.env.ref(
|
||||
'fusion_plating.group_fusion_plating_manager',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if ManagerGroup and self.env.user in ManagerGroup.user_ids:
|
||||
mgr = self.env.user
|
||||
elif self.sale_order_id and self.sale_order_id.user_id:
|
||||
mgr = self.sale_order_id.user_id
|
||||
elif self.partner_id and self.partner_id.user_id:
|
||||
mgr = self.partner_id.user_id
|
||||
if mgr:
|
||||
self.manager_id = mgr.id
|
||||
self.message_post(body=_(
|
||||
'Plating Manager auto-set on confirm: %s'
|
||||
) % mgr.name)
|
||||
|
||||
def _fp_create_racking_inspection(self):
|
||||
"""Auto-create a draft racking inspection on job confirm.
|
||||
|
||||
|
||||
@@ -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