# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job extension — cross-module fields that couldn't live in core
# because their target models are in dependent modules. Per spec §5.1
# this module is the umbrella that re-bundles the cross-module
# extensions for the native job flow.
#
# qc_check_id is deferred to Task 2.7 (the underlying QC model still
# lives in fusion_plating_bridge_mrp; we'll address its sourcing then).
import logging
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpJob(models.Model):
_inherit = 'fp.job'
# ---- Tier 3 mirrors from sale.order -----------------------------
# Related (not stored) — pure display mirrors. Values may change on
# the SO after job confirm (e.g. customer changes carrier preference)
# and the WO should reflect the latest; related auto-follows.
x_fc_delivery_method = fields.Selection(
related='sale_order_id.x_fc_delivery_method',
string='Delivery Method',
readonly=True,
)
x_fc_ship_via = fields.Char(
related='sale_order_id.x_fc_ship_via',
string='Ship Via',
readonly=True,
)
x_fc_invoice_strategy = fields.Selection(
related='sale_order_id.x_fc_invoice_strategy',
string='Invoice Strategy',
readonly=True,
)
part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part',
ondelete='restrict',
)
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Specification',
ondelete='set null',
help='Customer / industry spec the job ships under. Auto-filled '
'from the SO line at job creation.',
)
portal_job_id = fields.Many2one(
'fusion.plating.portal.job',
string='Portal Job',
ondelete='set null',
)
delivery_id = fields.Many2one(
'fusion.plating.delivery',
string='Delivery',
ondelete='set null',
)
override_ids = fields.One2many(
'fp.job.node.override',
'job_id',
string='Recipe Overrides',
)
# Sub 13 — sequential enforcement. Mirrored from the recipe root so
# button_start on each step can read the policy without walking the
# node tree. Stored so a recipe author flipping the recipe's flag
# AFTER job generation does NOT change behaviour mid-run (jobs
# snapshot the policy at creation, not on the fly).
enforce_sequential = fields.Boolean(
related='recipe_id.enforce_sequential',
string='Enforce Sequential Order',
store=True,
readonly=True,
help='Snapshotted from the recipe at job creation. When True, '
'every step waits for its predecessors before it can start '
'(unless the step itself is flagged Parallel Start, or a '
'manager bypasses via context).',
)
# Phase 7 — migration idempotency key. Populated by
# scripts/migrate_to_fp_jobs.py to mark a fp.job as the mirror of a
# specific mrp.production. Used to skip already-migrated MOs on
# subsequent runs. Cleared after the 2-week shadow period.
legacy_mrp_production_id = fields.Integer(
string='Legacy MRP Production ID',
index=True,
help='Database id of the source mrp.production record this job '
'was migrated from. Used by the migration script for '
'idempotency. Cleared post-cutover.',
)
# ------------------------------------------------------------------
# Sub 14 — Configurable workflow state (status bar milestone)
# ------------------------------------------------------------------
# workflow_state_id auto-advances along the highest passed milestone
# in fp.job.workflow.state's sequence order. Replaces the hardcoded
# state Selection on the form's statusbar.
workflow_state_id = fields.Many2one(
'fp.job.workflow.state',
string='Workflow Stage',
compute='_compute_workflow_state_id',
store=True,
readonly=True,
help='Highest workflow milestone this job has passed, computed '
'from step states + per-state trigger conditions. Updates '
'automatically — the operator never sets it.',
)
@api.depends(
'state',
'step_ids',
'step_ids.state',
'step_ids.kind',
'step_ids.recipe_node_id',
'step_ids.recipe_node_id.default_kind',
'step_ids.recipe_node_id.triggers_workflow_state_id',
'quality_hold_count',
'delivery_id',
'delivery_id.state',
'sale_order_id',
'sale_order_id.x_fc_receiving_status',
)
def _compute_workflow_state_id(self):
WS = self.env['fp.job.workflow.state']
all_states = WS.search([], order='sequence, id')
for job in self:
passed = WS.browse()
for ws in all_states:
# Highest-passed semantics: untagged / not-applicable
# states don't block the cascade. The bar reflects
# the furthest milestone the job has actually reached.
if ws._fp_is_passed_for_job(job):
passed = ws
job.workflow_state_id = passed
# ------------------------------------------------------------------
# Smart-button counts (Feature A — operator workflow)
#
# Compute counts for each downstream model so the form view can
# render an oe_stat_button row similar to sale.order. Cross-module
# models are runtime-detected so this still works when one of the
# bridge modules is uninstalled.
# ------------------------------------------------------------------
sale_order_count = fields.Integer(compute='_compute_smart_counts')
delivery_count = fields.Integer(compute='_compute_smart_counts')
invoice_count = fields.Integer(compute='_compute_smart_counts')
payment_count = fields.Integer(compute='_compute_smart_counts')
quality_hold_count = fields.Integer(compute='_compute_smart_counts')
certificate_count = fields.Integer(compute='_compute_smart_counts')
timelog_count = fields.Integer(compute='_compute_smart_counts')
portal_job_count = fields.Integer(compute='_compute_smart_counts')
# ------------------------------------------------------------------
# Milestone cascade (Phase 1) — drives the header-button replacement
# that fires when every recipe step reaches a terminal state. See
# docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md.
# ------------------------------------------------------------------
all_steps_terminal = fields.Boolean(
compute='_compute_all_steps_terminal',
store=True,
help='True ⇔ at least one step exists AND every step is in '
'done/skipped/cancelled. Used to swap the per-step '
'Finish & Next button for a milestone-advance button.',
)
@api.depends('step_ids', 'step_ids.state')
def _compute_all_steps_terminal(self):
for job in self:
if not job.step_ids:
job.all_steps_terminal = False
else:
job.all_steps_terminal = all(
s.state in ('done', 'skipped', 'cancelled')
for s in job.step_ids
)
def _resolve_required_cert_types(self):
"""Set of cert types this job must produce.
Priority: part.certificate_requirement wins; 'inherit' falls
back to partner-level send_coc / send_thickness_report flags.
'none' returns empty (commercial customer, no paperwork).
Unknown requirement codes default to {'coc'} as a safety net.
Bundling rule (2026-05-18 — Entech workflow): when a CoC is
wanted AND thickness is wanted, the thickness data is delivered
as page 2 of the CoC PDF (see _fp_merge_thickness_into_pdf),
so we return ONE cert ({'coc'}) instead of two. A standalone
thickness_report cert is only produced when thickness is wanted
WITHOUT a CoC — a rare edge case kept for completeness.
Action_issue's thickness-data gate enforces actual readings or
a Fischerscope PDF on the merged CoC.
"""
self.ensure_one()
req = (
self.part_catalog_id
and self.part_catalog_id.certificate_requirement
) or 'inherit'
if req == 'inherit':
want_coc = bool(self.partner_id.x_fc_send_coc)
want_thickness = bool(self.partner_id.x_fc_send_thickness_report)
if want_coc:
return {'coc'} # thickness gets merged in
if want_thickness:
return {'thickness_report'}
return set()
return {
'none': set(),
'coc': {'coc'},
'coc_thickness': {'coc'}, # bundled — thickness on page 2
}.get(req, {'coc'})
next_milestone_action = fields.Selection(
[
('mark_done', 'Mark Job Done'),
('issue_certs', 'Issue Certs'),
('schedule_delivery', 'Schedule Delivery'),
('mark_shipped', 'Mark Shipped'),
('closed', 'Closed'),
],
compute='_compute_next_milestone_action',
help='What the manager should click next once steps complete. '
'Drives the milestone-advance buttons on the form header. '
'False/empty while steps are still running.',
)
next_milestone_label = fields.Char(
compute='_compute_next_milestone_action',
help='Human label for the next-action button.',
)
@api.depends(
'all_steps_terminal',
'state',
'delivery_id',
'delivery_id.state',
)
def _compute_next_milestone_action(self):
"""Resolve next action in priority order:
1. NOT all_steps_terminal → False (Finish & Next stays)
2. state != 'done' → mark_done
3. ANY required draft cert → issue_certs
4. NO delivery or draft → schedule_delivery
5. delivery scheduled/transit → mark_shipped
6. otherwise (delivered) → closed
"""
labels = dict(self._fields['next_milestone_action'].selection)
for job in self:
if not job.all_steps_terminal:
job.next_milestone_action = False
job.next_milestone_label = ''
continue
if job.state != 'done':
job.next_milestone_action = 'mark_done'
elif job._fp_has_draft_required_certs():
job.next_milestone_action = 'issue_certs'
elif (not job.delivery_id
or job.delivery_id.state == 'draft'):
job.next_milestone_action = 'schedule_delivery'
elif job.delivery_id.state in ('scheduled', 'in_transit'):
job.next_milestone_action = 'mark_shipped'
else:
job.next_milestone_action = 'closed'
job.next_milestone_label = labels.get(
job.next_milestone_action, ''
)
def _fp_has_draft_required_certs(self):
"""True if at least one cert of a required type is still 'draft'.
Returns False when no certs are required (commercial customers).
"""
self.ensure_one()
if 'fp.certificate' not in self.env:
return False
required = self._resolve_required_cert_types()
if not required:
return False
Cert = self.env['fp.certificate']
dom = [
('certificate_type', 'in', list(required)),
('state', '=', 'draft'),
]
if 'x_fc_job_id' in Cert._fields:
dom.append(('x_fc_job_id', '=', self.id))
elif self.sale_order_id and 'sale_order_id' in Cert._fields:
dom.append(('sale_order_id', '=', self.sale_order_id.id))
else:
return False # can't link safely → don't block the cascade
return bool(Cert.search_count(dom))
def action_advance_next_milestone(self):
"""Single entry point bound to all four milestone header buttons.
Branches on next_milestone_action and delegates to the existing
business-logic method. Never invents new logic — just routes."""
self.ensure_one()
action_map = {
'mark_done': self.button_mark_done,
'issue_certs': self._action_open_draft_certs,
'schedule_delivery': self._action_open_draft_delivery,
'mark_shipped': self._action_mark_active_delivery_delivered,
}
fn = action_map.get(self.next_milestone_action)
if not fn:
raise UserError(_(
'No milestone action available for job %(j)s '
'(next=%(a)s).'
) % {
'j': self.name,
'a': self.next_milestone_action or 'none',
})
return fn()
def _action_open_draft_certs(self):
"""Open the Issue Certs wizard for this job's draft certs.
The wizard prompts for a Fischerscope upload + readings per cert
that needs thickness data (bundled CoC or standalone thickness
report). Pure CoC certs (no thickness needed) appear in the
wizard too and just need a Confirm click. Cleaner than the old
"list view → open each cert → click Issue" flow.
Falls back to the cert list view if the wizard model isn't
installed (defensive — should always exist when this module is).
"""
self.ensure_one()
Wizard = self.env.get('fp.cert.issue.wizard')
if Wizard is not None:
try:
return Wizard.open_for_job(self)
except UserError:
raise
except Exception as e:
_logger.warning(
"Job %s: cert issue wizard failed (%s) — "
"falling back to cert list.", self.name, e,
)
return {
'type': 'ir.actions.act_window',
'name': _('Draft Certificates — %s') % self.name,
'res_model': 'fp.certificate',
'view_mode': 'list,form',
'domain': [
('x_fc_job_id', '=', self.id),
('state', '=', 'draft'),
],
'target': 'current',
}
def _action_open_draft_delivery(self):
"""Open the linked delivery if it's still in draft state.
Falls back to the delivery list filtered to this job's
delivery if the state isn't draft (defensive)."""
self.ensure_one()
if self.delivery_id and self.delivery_id.state == 'draft':
return {
'type': 'ir.actions.act_window',
'name': _('Schedule Delivery — %s') % self.name,
'res_model': 'fusion.plating.delivery',
'res_id': self.delivery_id.id,
'view_mode': 'form',
'target': 'current',
}
return {
'type': 'ir.actions.act_window',
'name': _('Deliveries — %s') % self.name,
'res_model': 'fusion.plating.delivery',
'view_mode': 'list,form',
'domain': [('job_ref', '=', self.name)],
'target': 'current',
}
def _action_mark_active_delivery_delivered(self):
"""Call action_mark_delivered on the linked delivery if it's
in scheduled / in_transit. Posts to job chatter on success."""
self.ensure_one()
if (not self.delivery_id
or self.delivery_id.state not in ('scheduled', 'in_transit')):
raise UserError(_(
'No scheduled or in-transit delivery to mark shipped '
'for %s.'
) % self.name)
self.delivery_id.action_mark_delivered()
self.message_post(body=_(
'Delivery %s marked shipped via milestone cascade.'
) % self.delivery_id.name)
return True
@api.depends(
'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids',
'step_ids.time_log_ids', 'origin', 'partner_id',
)
def _compute_smart_counts(self):
AccountMove = self.env.get('account.move')
AccountPayment = self.env.get('account.payment')
QualityHold = self.env.get('fusion.plating.quality.hold')
Certificate = self.env.get('fp.certificate')
for job in self:
job.sale_order_count = 1 if job.sale_order_id else 0
job.delivery_count = 1 if job.delivery_id else 0
job.portal_job_count = 1 if job.portal_job_id else 0
# Invoices via origin (the SO name)
if AccountMove is not None and job.origin:
job.invoice_count = AccountMove.search_count([
('invoice_origin', '=', job.origin),
('move_type', 'in', ('out_invoice', 'out_refund')),
])
else:
job.invoice_count = 0
# Payments — find invoices for this SO, then payments
# reconciled against them.
if (AccountMove is not None and AccountPayment is not None
and job.origin):
inv_ids = AccountMove.search([
('invoice_origin', '=', job.origin),
('move_type', 'in', ('out_invoice', 'out_refund')),
]).ids
if inv_ids:
job.payment_count = AccountPayment.search_count([
('reconciled_invoice_ids', 'in', inv_ids),
])
else:
job.payment_count = 0
else:
job.payment_count = 0
if QualityHold is not None:
job.quality_hold_count = QualityHold.search_count([
('x_fc_job_id', '=', job.id),
])
else:
job.quality_hold_count = 0
if Certificate is not None:
job.certificate_count = Certificate.search_count([
('x_fc_job_id', '=', job.id),
])
else:
job.certificate_count = 0
job.timelog_count = sum(
len(s.time_log_ids) for s in job.step_ids
)
# ------------------------------------------------------------------
# Smart-button actions
# ------------------------------------------------------------------
def action_view_sale_order(self):
self.ensure_one()
if not self.sale_order_id:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
'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, or seed
lines from the SO if it exists but was created before line auto-
seeding shipped (the helper handles both cases idempotently)."""
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.'
))
# Always call the helper — it short-circuits for already-populated
# draft inspections and creates fresh ones when missing. This is
# also the entry point that backfills lines on inspections that
# pre-date the line-seeding feature.
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 {
'type': 'ir.actions.act_window',
'res_model': 'fp.job.step',
'view_mode': 'list,form',
'domain': [('job_id', '=', self.id)],
'name': 'Steps — %s' % self.name,
'context': {'default_job_id': self.id},
}
def action_finish_current_step(self):
"""Steelhead-style header button: finish whatever's currently
in_progress and auto-start the next pending/ready step. If
nothing is running yet, start the lowest-sequence pending step
instead — operator's first click on a fresh job just begins
the line.
Sub 12e v4 — when button_start returns an action (e.g. the
QA-005 redirect for contract_review steps), propagate it so
the operator lands on the right page in ONE click instead of
two.
"""
self.ensure_one()
running = self.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
if running:
return running.action_finish_and_advance()
# No running step — kick off the first pending/ready one.
first = self.step_ids.filtered(
lambda s: s.state in ('pending', 'ready', 'paused')
).sorted('sequence')[:1]
if not first:
raise UserError(_(
'No runnable step found on this job — either every step '
'is done or the job is still in draft.'
))
result = first.with_context(
fp_skip_predecessor_check=True,
).button_start()
self.message_post(body=_(
'Started first step "%s".'
) % first.name)
# Propagate any action returned by button_start (e.g. the
# QA-005 redirect on a contract_review step). If it's just
# True/False (the normal case), fall back to True.
if isinstance(result, dict):
return result
return True
def action_open_move_wizard(self):
"""Original Move wizard — kept available for cross-station moves
and rework / scrap transfers. The simple "finish current → start
next" flow is now action_finish_current_step (header button).
Opens the wizard pre-filled with the currently in-progress (or
most recently in-progress) step as the from-step.
"""
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_sticker(self):
"""Print the 6x4" job-box identification sticker (logo + WO# + QR
+ part / customer / thickness / notes). Used at receiving and at
every move so the box is always identifiable on the floor."""
self.ensure_one()
return self.env.ref(
'fusion_plating_jobs.action_report_fp_job_sticker'
).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:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.delivery',
'res_id': self.delivery_id.id,
'view_mode': 'form',
'name': self.delivery_id.name,
}
def action_view_invoices(self):
self.ensure_one()
if not self.origin:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [
('invoice_origin', '=', self.origin),
('move_type', 'in', ('out_invoice', 'out_refund')),
],
'name': 'Invoices — %s' % self.name,
}
def action_view_payments(self):
self.ensure_one()
if not self.origin:
return {'type': 'ir.actions.act_window_close'}
AccountMove = self.env.get('account.move')
if AccountMove is None:
return {'type': 'ir.actions.act_window_close'}
inv_ids = AccountMove.search([
('invoice_origin', '=', self.origin),
('move_type', 'in', ('out_invoice', 'out_refund')),
]).ids
return {
'type': 'ir.actions.act_window',
'res_model': 'account.payment',
'view_mode': 'list,form',
'domain': (
[('reconciled_invoice_ids', 'in', inv_ids)]
if inv_ids else [('id', '=', 0)]
),
'name': 'Payments — %s' % self.name,
}
def action_view_quality_holds(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.hold',
'view_mode': 'list,form',
'domain': [('x_fc_job_id', '=', self.id)],
'name': 'Quality Holds — %s' % self.name,
'context': {'default_x_fc_job_id': self.id},
}
def action_view_certificates(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.certificate',
'view_mode': 'list,form',
'domain': [('x_fc_job_id', '=', self.id)],
'name': 'Certificates — %s' % self.name,
'context': {'default_x_fc_job_id': self.id},
}
def action_view_timelogs(self):
self.ensure_one()
step_ids = self.step_ids.ids
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.job.step.timelog',
'view_mode': 'list,form',
'domain': (
[('step_id', 'in', step_ids)]
if step_ids else [('id', '=', 0)]
),
'name': 'Time Logs — %s' % self.name,
}
def action_view_portal_job(self):
self.ensure_one()
if not self.portal_job_id:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.portal.job',
'res_id': self.portal_job_id.id,
'view_mode': 'form',
'name': self.portal_job_id.name,
}
# fp.job.state -> fusion.plating.portal.job.state mapping. Kept tight so
# the customer doesn't see internal states. Anything not in this map
# leaves the portal_job state alone (e.g. 'on_hold' stays in_progress).
_FP_JOB_STATE_TO_PORTAL_STATE = {
'confirmed': 'received',
'in_progress': 'in_progress',
'done': 'ready_to_ship',
# 'on_hold' and 'cancelled' intentionally omitted — managers choose
# what to surface to the customer.
}
def write(self, vals):
"""Write hook: (a) when qty_scrapped INCREASES, auto-spawn a
fusion.plating.quality.hold for the scrapped delta — AS9100 /
Nadcap need a disposition record per scrap event. (b) when state
transitions, mirror to the linked fusion.plating.portal.job so
the customer-facing portal stays in sync with the shop floor.
Idempotent per write: one hold per increase event. Operator
fills hold_reason + description on the spawned record.
"""
from markupsafe import Markup as _Markup
scrap_deltas = {}
if 'qty_scrapped' in vals:
new = vals['qty_scrapped'] or 0
for job in self:
old = job.qty_scrapped or 0
if new > old:
scrap_deltas[job.id] = (old, new)
# Capture state changes before super().write() so we know which
# records actually transitioned (vs no-op writes).
state_changed_ids = set()
if 'state' in vals:
new_state = vals['state']
for job in self:
if job.state != new_state:
state_changed_ids.add(job.id)
result = super().write(vals)
# Mirror state to portal_job for records that actually changed.
if state_changed_ids:
target = self._FP_JOB_STATE_TO_PORTAL_STATE.get(vals.get('state'))
if target:
for job in self.filtered(lambda j: j.id in state_changed_ids):
if job.portal_job_id and job.portal_job_id.state != target:
job.portal_job_id.sudo().write({'state': target})
if not scrap_deltas:
return result
Hold = (self.env['fusion.plating.quality.hold']
if 'fusion.plating.quality.hold' in self.env else None)
if Hold is None:
return result
Facility = self.env['fusion.plating.facility']
for job in self:
if job.id not in scrap_deltas:
continue
old, new = scrap_deltas[job.id]
delta = new - old
facility = job.facility_id or Facility.search([
('company_id', '=', job.company_id.id),
], limit=1) or Facility.search([], limit=1)
part_ref = (
job.part_catalog_id.part_number if job.part_catalog_id
else job.product_id.default_code or job.name
)
# When the scrap was bumped from the tablet, the operator
# was prompted for a reason and we passed it via context as
# `fp_scrap_reason` (see /fp/shopfloor/bump_qty_scrapped).
# Prepend that reason to the description so the audit row
# captures what the operator actually typed instead of the
# generic "OPERATOR: replace this text..." placeholder.
scrap_reason = self.env.context.get('fp_scrap_reason')
if scrap_reason:
description = _(
'Operator reason: %s\n\n'
'Auto-spawned from job %s scrap update by %s: '
'qty_scrapped went from %g to %g (delta %g).'
) % (scrap_reason, job.name, self.env.user.name, old, new, delta)
else:
description = _(
'Auto-spawned from job %s scrap update by %s: '
'qty_scrapped went from %g to %g (delta %g). '
'OPERATOR: replace this text with the actual '
'reason (drop / contamination / out-of-spec / etc).'
) % (job.name, self.env.user.name, old, new, delta)
try:
hold = Hold.create({
'job_id': job.id,
'part_ref': (part_ref or job.name)[:64],
'qty_on_hold': int(delta),
'qty_original': int(job.qty or 0),
'mark_for_scrap': True,
'hold_reason': 'other',
'description': description,
'facility_id': facility.id if facility else False,
})
job.message_post(body=_Markup(_(
'⚠️ Scrap auto-Hold spawned: %s for %g part(s). '
'Operator must update description with the cause.'
)) % (hold.name, delta))
except Exception as e:
_logger.warning(
'Job %s: failed to auto-spawn scrap hold: %s',
job.name, e,
)
return result
def action_sync_qty_from_so(self):
"""Pull the SO qty into the job's qty field after a mid-job
SO line edit. Posts chatter so the audit trail captures who
synced + what the previous value was.
Manual action because qty changes mid-job have physical-world
consequences (rack more parts, stop early, scrap excess) — the
supervisor must explicitly acknowledge by clicking the button.
"""
from markupsafe import Markup
for job in self:
if not job.sale_order_id:
continue
so_qty = sum(job.sale_order_id.order_line.mapped('product_uom_qty'))
old = job.qty
if abs(old - so_qty) < 0.0001:
continue
job.qty = so_qty
job.message_post(body=Markup(_(
'Job qty synced from SO by %s: %g → %g (Δ %+g). '
'Operator: confirm physical scope matches.'
)) % (self.env.user.name, old, so_qty, so_qty - old))
return True
# ------------------------------------------------------------------
# Recipe → fp.job.step generation (Task 2.4)
#
# Native port of fusion_plating_bridge_mrp's
# _generate_workorders_from_recipe. Walks the recipe tree, creates
# one fp.job.step per 'operation' node, formats child 'step' nodes
# as step instructions on chatter, respects opt-in/out overrides
# from fp.job.node.override.
#
# Adaptations from the original:
# - Creates fp.job.step (not mrp.workorder)
# - Maps fusion.plating.work.center → fp.work.centre via code
# fallback (no forward link exists yet)
# - Uses native field names (job_id, work_centre_id, etc.)
# - Drops work_role_id (not on fp.job.step yet — Task 2.6+)
# - Drops _fp_autofill_default_equipment (not yet on step)
# ------------------------------------------------------------------
def _generate_steps_from_recipe(self):
"""Generate fp.job.step records from the assigned recipe.
Walks the recipe tree, creates one step per 'operation' node,
and formats child 'step' nodes as step instructions on the
chatter. Respects opt-in/out overrides from override_ids.
"""
Step = self.env['fp.job.step']
Node = self.env['fusion.plating.process.node']
for job in self:
if not job.recipe_id:
continue # No recipe assigned
if job.step_ids:
continue # Steps already exist — don't duplicate
# Build lookup of overrides keyed by node ID
override_map = {ov.node_id.id: ov.included for ov in job.override_ids}
# Start-at-node: if set, the allowed set is the union of:
# 1. start_node and all its descendants
# 2. each ancestor of start_node
# 3. at each ancestor level, any LATER-sequence sibling and
# all of its descendants
start_node = job.start_at_node_id
allowed_ids = None # None = include everything
if start_node:
descendants = Node.search([('id', 'child_of', start_node.id)])
allowed_ids = set(descendants.ids)
cur = start_node
while cur.parent_id:
parent = cur.parent_id
allowed_ids.add(parent.id)
later_sibs = parent.child_ids.filtered(
lambda n: n.sequence > cur.sequence
)
for sib in later_sibs:
sib_descendants = Node.search([
('id', 'child_of', sib.id),
])
allowed_ids |= set(sib_descendants.ids)
cur = parent
step_vals_list = []
wo_steps = {} # {sequence: instruction text}
seq_counter = [10]
def _is_node_included(node):
"""Determine if a node should be included based on
opt-in/out logic, per-job overrides, and start-at-node
filter.
"""
nid = node.id
if allowed_ids is not None and nid not in allowed_ids:
return False
opt = node.opt_in_out or 'disabled'
if opt == 'disabled':
return True
if nid in override_map:
return override_map[nid]
if opt == 'opt_in':
return False # Default excluded
return True # opt_out → default included
def _resolve_work_centre(legacy_wc):
"""Map fusion.plating.work.center → fp.work.centre.
The legacy work-centre model does not (yet) have a forward
link to the new fp.work.centre. Try a forward link
(x_fc_fp_work_centre_id) if some bridge module added one;
otherwise fall back to a code lookup.
"""
if not legacy_wc:
return self.env['fp.work.centre']
# Forward link, if any
if (
'x_fc_fp_work_centre_id' in legacy_wc._fields
and legacy_wc.x_fc_fp_work_centre_id
):
return legacy_wc.x_fc_fp_work_centre_id
# Code fallback (legacy code is unique-per-facility,
# native code is globally unique — first match wins)
if legacy_wc.code:
found = self.env['fp.work.centre'].search(
[('code', '=', legacy_wc.code)], limit=1,
)
if found:
return found
return self.env['fp.work.centre']
def walk_node(node):
if not _is_node_included(node):
return
if node.node_type == 'operation':
work_centre = _resolve_work_centre(node.work_center_id)
if not work_centre:
_logger.warning(
'Job %s: operation "%s" has no mapped fp.work.centre — '
'creating step without work centre.',
job.name, node.name,
)
# Collect step instructions from child 'step' nodes
instructions = []
step_num = 1
for child in node.child_ids.sorted('sequence'):
if child.node_type == 'step' and _is_node_included(child):
line = '%d. %s' % (step_num, child.name)
if child.estimated_duration:
line += ' (%.0f min)' % child.estimated_duration
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,
'work_centre_id': work_centre.id if work_centre else False,
'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
# Pull thickness target from the recipe root when this
# is a plating step (matched by node name keyword).
# Recipe-root carries thickness fields post-promote-spec.
recipe_root = job.recipe_id
name_l = (node.name or '').lower()
is_plating_node = (
'plat' in name_l or 'nickel' in name_l
or 'chrome' in name_l or 'anodiz' in name_l
)
if recipe_root and is_plating_node:
if (
'thickness_max' in recipe_root._fields
and recipe_root.thickness_max
):
vals['thickness_target'] = recipe_root.thickness_max
if (
'thickness_uom' in recipe_root._fields
and recipe_root.thickness_uom
):
# Recipe uses long-form uom names (mils /
# microns / inches); fp.job.step uses short
# codes (mil / um / inch). Map between them.
_UOM_MAP = {
'mils': 'mil',
'mil': 'mil',
'microns': 'um',
'micron': 'um',
'um': 'um',
'inches': 'inch',
'inch': 'inch',
'in': 'inch',
}
mapped = _UOM_MAP.get(recipe_root.thickness_uom)
if mapped:
vals['thickness_uom'] = mapped
step_vals_list.append(vals)
if instructions:
wo_steps[seq_counter[0]] = '\n'.join(instructions)
seq_counter[0] += 10
elif node.node_type in ('recipe', 'sub_process'):
for child in node.child_ids.sorted('sequence'):
walk_node(child)
# 'step' nodes at top level are handled by their parent operation
# Walk from recipe root
walk_node(job.recipe_id)
# Bulk create
if step_vals_list:
created = Step.create(step_vals_list)
for step in created:
instr_text = wo_steps.get(step.sequence)
if instr_text:
step.message_post(
body=Markup(
'Recipe steps:
%s' ) % instr_text, subtype_xmlid='mail.mt_note', ) job.message_post( body=('%d steps generated from recipe "%s".') % ( len(step_vals_list), job.recipe_id.name, ), ) # Rule 4 — repeat-order contract-review auto-complete. # Runs after step creation so the contract-review step shows # as already done on the operator's first view of the job. job._fp_autocomplete_repeat_order_contract_review() return True def _fp_autocomplete_repeat_order_contract_review(self): """Rule 4 of the contract-review flow — when a job's part already carries a complete fp.contract.review (i.e. the part has been through QA-005 on a prior order), mark every contract-review step in this job's recipe as 'done' immediately on job creation. Copies the reviewer identity + timestamp from the review's Section 3.0 sign-off (falling back to Section 2.0) so the Print WO Detail report shows the original audit trail — Reviewer initials, date reviewed, "QA-005 Approved" — not the operator who would have hit Finish. Skips: * jobs whose part has no contract review or it isn't complete (rule 5 still applies — the WO step gate will block finish) * steps not detected as contract-review steps via fp.job.step._fp_is_contract_review_step * steps already in a terminal state (defensive idempotency) """ for job in self: part = ( ('part_catalog_id' in job._fields and job.part_catalog_id) or False ) if not part: continue review = ( ('x_fc_contract_review_id' in part._fields and part.x_fc_contract_review_id) or False ) if not review or review.state != 'complete': continue signer = review.s30_signed_by or review.s20_signed_by signed_at = review.s30_signed_date or review.s20_signed_date if not signer or not signed_at: continue steps_to_complete = job.step_ids.filtered( lambda s: s.state not in ('done', 'skipped', 'cancelled') and s._fp_is_contract_review_step() ) if not steps_to_complete: continue steps_to_complete.write({ 'state': 'done', 'started_by_user_id': signer.id, 'finished_by_user_id': signer.id, 'date_started': signed_at, 'date_finished': signed_at, }) for step in steps_to_complete: step.message_post(body=_( 'Contract Review step auto-completed from existing ' 'QA-005 for %(part)s. Reviewer: %(user)s on %(date)s.' ) % { 'part': part.display_name or part.part_number or '', 'user': signer.name, 'date': fields.Datetime.to_string(signed_at), }) return True # ------------------------------------------------------------------ # UI — Process Tree client action (Phase 6) # ------------------------------------------------------------------ def action_open_process_tree(self): """Open the OWL process-tree visualization for this job. Launches the fp_process_tree client action (defined in fusion_plating_shopfloor) with job_id in context. The component fetches /fp/shopfloor/process_tree and renders the recipe -> sub_process -> operation hierarchy as cards with per-step state badges. Consolidated 2026-04-24: this points at the canonical shopfloor client action; the parallel fp_job_process_tree was removed. """ self.ensure_one() return { 'type': 'ir.actions.client', 'tag': 'fp_process_tree', 'context': { 'job_id': self.id, 'back_job_id': self.id, }, 'name': 'Process Tree — %s' % (self.name or ''), 'target': 'current', } # ------------------------------------------------------------------ # Lifecycle hooks (Tasks 2.6, 2.7, 2.8) # # On confirm: create the portal-job mirror record and (when the # customer requires QC) a fusion.plating.quality.check. # On done: create a draft fusion.plating.delivery and best-effort # trigger fp.certificate auto-generation. # # The QC and certificate models live in modules this module does NOT # depend on by design (bridge_mrp). We runtime-detect those models so # the hooks degrade gracefully when those modules are absent. # ------------------------------------------------------------------ def action_confirm(self): result = super().action_confirm() # During migration, lifecycle side-effects are skipped — the # migration script directly rebinds existing portal/QC/inspection # records via x_fc_job_id. See scripts/migrate_to_fp_jobs.py. if self.env.context.get('fp_jobs_migration'): return result for job in self: # Auto-generate steps from the recipe — was previously only # called by seed scripts, which meant real-life confirmed # jobs sat with zero operations. Idempotent: the generator # short-circuits when steps already exist. if job.recipe_id and not job.step_ids: job._generate_steps_from_recipe() # Promote freshly-generated 'pending' steps to 'ready' so the # operator has a Start button when they open the job. Without # this the floor stalls — every step is parked in pending with # no UI affordance to move it forward. pending_steps = job.step_ids.filtered( lambda s: s.state == 'pending' ) 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. Phase 9 — production_id is now optional on fp.racking.inspection, so we always create one bound by `x_fc_job_id`. When the job is also linked to an MO (legacy bridge_mrp coexistence), populate production_id too so legacy reports keep working. Idempotent — if an inspection already exists for this job, skip. Either way the inspection's lines are seeded from the SO's plating order lines so the racker walks into a pre-populated checklist instead of an empty form. """ self.ensure_one() if 'fp.racking.inspection' not in self.env: return Inspection = self.env['fp.racking.inspection'].sudo() if 'x_fc_job_id' not in Inspection._fields: # Schema not yet upgraded — skip. return existing = Inspection.search([ ('x_fc_job_id', '=', self.id), ], limit=1) if existing: # Self-heal: pre-existing inspections from before line seeding # was added show up empty. Top them up now if still empty + # the inspection isn't already finalised (don't rewrite history). if not existing.line_ids and existing.state == 'draft': self._fp_seed_racking_lines(existing) return # Phase 6 (Sub 11) — production_id retired; bind by x_fc_job_id only. vals = {'x_fc_job_id': self.id} try: insp = Inspection.create(vals) self._fp_seed_racking_lines(insp) except Exception as e: _logger.warning( "Job %s: failed to auto-create racking inspection: %s", self.name, e, ) def _fp_seed_racking_lines(self, inspection): """Populate the inspection with one line per SO plating order line. Walks sale_order_line_ids (the M2M of SO lines tied to this job), falling back to the linked SO's order_line. Each line carries the part_catalog and the quoted qty as the expected count — the racker confirms or amends on the floor. """ self.ensure_one() if not inspection or inspection.line_ids: return Line = self.env['fp.racking.inspection.line'].sudo() # Source preference: explicit M2M of plating lines bound to this # job (fast-order multi-part jobs), falling back to the SO header. so_lines = self.sale_order_line_ids if not so_lines and self.sale_order_id: so_lines = self.sale_order_id.order_line plating_lines = so_lines.filtered( lambda l: l.x_fc_part_catalog_id and not l.display_type ) if not plating_lines: return seq = 10 for sol in plating_lines: try: Line.create({ 'inspection_id': inspection.id, 'sequence': seq, 'part_catalog_id': sol.x_fc_part_catalog_id.id, 'qty_expected': int(sol.product_uom_qty or 0), 'condition': 'ok', }) except Exception as e: _logger.warning( "Job %s: failed to seed racking line for SO line %s: %s", self.name, sol.id, e, ) seq += 10 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'. """ 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, 'x_fc_job_id': self.id, }) self.portal_job_id = portal.id def _fp_create_qc_check_if_needed(self): """If customer has x_fc_requires_qc=True, spawn a QC check via the canonical fp.quality.check.create_for_job() entry point. Sub 11 — model relocated from bridge_mrp to fusion_plating_quality. create_for_job resolves the template (customer-specific or default), clones every template line, returns an existing record if one is already open, and posts a chatter trail. """ self.ensure_one() partner = self.partner_id wants_qc = ( 'x_fc_requires_qc' in partner._fields and partner.x_fc_requires_qc ) if not wants_qc: return if 'fusion.plating.quality.check' not in self.env: return QC = self.env['fusion.plating.quality.check'] try: QC.create_for_job(self) except Exception as e: _logger.warning( "Job %s: create_for_job failed: %s", self.name, e, ) # ------------------------------------------------------------------ # button_mark_done — Task 2.8 # ------------------------------------------------------------------ def button_mark_done(self): """Transition the job to 'done' and trigger downstream side effects. - Blocks if any step is not done/skipped (manager bypass via context key `fp_skip_step_gate=True`). Compliance: AS9100 / Nadcap require evidence that every recipe step ran. Without this guard an operator could close a job with zero work. - Blocks if customer requires QC and the QC check isn't passed (manager bypass via context key `fp_skip_qc_gate=True`) - Sets state='done', date_finished=now - Auto-creates a draft fusion.plating.delivery - Triggers certificate auto-generation (best-effort) """ # During migration, side-effects are skipped — see action_confirm. skip_side_effects = self.env.context.get('fp_jobs_migration') skip_qc_gate = self.env.context.get('fp_skip_qc_gate') skip_step_gate = self.env.context.get('fp_skip_step_gate') QC = self.env['fusion.plating.quality.check'] \ if 'fusion.plating.quality.check' in self.env else None for job in self: if job.state == 'done': continue if job.state == 'cancelled': raise UserError( "Job %s is cancelled — cannot mark done." % job.name ) # Step-completion gate: every step must be done (or explicitly # skipped, once button_skip is implemented). Without this # guard operators can close a recipe-driven job with zero # actual work logged. Manager bypass via context. if not skip_step_gate and job.step_ids: # `skipped` and `cancelled` count as terminal — operator # explicitly opted those out (skipped) or killed them # (cancelled). Only steps still in pending/ready/in_progress/ # paused block job close. undone = job.step_ids.filtered( lambda s: s.state not in ('done', 'skipped', 'cancelled') ) if undone: raise UserError(_( "Job %s cannot be marked Done — %d/%d step(s) " "are not finished:\n %s\n\nWalk each step on " "the tablet (or skip / cancel opt-in steps)." ) % ( job.name, len(undone), len(job.step_ids), '\n '.join( f'#{s.sequence} {s.name} ({s.state})' for s in undone[:5] ), )) # Bake-window gate (compliance — AS9100 / Nadcap): if any # auto-spawned bake.window is still awaiting_bake OR # bake_in_progress, the bake hasn't been documented and # parts cannot ship. Without this guard a careless # operator closes the job, parts ship, three weeks later # a field failure surfaces and the auditor asks for the # bake record that doesn't exist. Manager bypass via # fp_skip_bake_gate=True for documented customer deviation. skip_bake_gate = self.env.context.get('fp_skip_bake_gate') BW = (self.env['fusion.plating.bake.window'] if 'fusion.plating.bake.window' in self.env else None) if not skip_bake_gate and BW is not None: pending_bw = BW.sudo().search([ ('part_ref', '=', job.name), ('state', 'in', ('awaiting_bake', 'bake_in_progress')), ]) if pending_bw: raise UserError(_( "Job %s cannot be marked Done — bake window " "still pending:\n %s\n\nBake hydrogen " "embrittlement relief on the parts (start + " "end the bake on the bake.window record), then " "close the job. Manager override available for " "documented customer deviation." ) % ( job.name, '\n '.join( f'{bw.name} (state={bw.state}, ' f'required_by={bw.bake_required_by})' for bw in pending_bw[:5] ), )) # Qty reconciliation gate: qty_done + qty_scrapped must # equal qty when the job closes. Without this an operator # can ship "5 of 5" while only 4 are actually plated + # 1 contaminated, with no record of the missing piece. # Manager bypass via fp_skip_qty_reconcile=True (e.g. when # qty tracking truly doesn't apply). skip_qty_gate = self.env.context.get('fp_skip_qty_reconcile') if not skip_qty_gate and job.qty: accounted = (job.qty_done or 0) + (job.qty_scrapped or 0) if abs(accounted - job.qty) > 0.0001: raise UserError(_( "Job %s qty mismatch — ordered %g, but qty_done " "(%g) + qty_scrapped (%g) = %g. Update Quantity " "Completed and Quantity Scrapped on the job " "header so they sum to %g before closing." ) % ( job.name, job.qty, job.qty_done or 0, job.qty_scrapped or 0, accounted, job.qty, )) # Receiving reconciliation: parts must be physically # received before the job can close, and the count must # match what came out (done + scrapped + visual rejects). # Without this guard a job ships with the wrong cert qty, # or worse, with no closed receiving for the auditor to # trace back to. Same bypass flag covers both checks. if not job.qty_received: raise UserError(_( "Job %s cannot be marked Done — Quantity Received " "is blank. Close the receiving record for SO %s " "before completing this job." ) % ( job.name, job.sale_order_id.name if job.sale_order_id else '?', )) rejects = job.qty_visual_inspection_rejects or 0 accounted_out = ( (job.qty_done or 0) + (job.qty_scrapped or 0) + rejects ) if abs(job.qty_received - accounted_out) > 0.0001: raise UserError(_( "Job %s qty mismatch — received %g, but qty_done " "(%g) + qty_scrapped (%g) + visual rejects (%g) " "= %g. Reconcile before closing." ) % ( job.name, job.qty_received, job.qty_done or 0, job.qty_scrapped or 0, rejects, accounted_out, )) # QC gate: customers flagged x_fc_requires_qc must have a # passed QC before the job closes. AS9100 / Nadcap compliance. if QC and not skip_qc_gate \ and 'x_fc_requires_qc' in job.partner_id._fields \ and job.partner_id.x_fc_requires_qc: blocking_qc = QC.search([ ('job_id', '=', job.id), ('state', 'not in', ('passed',)), ], order='create_date desc', limit=1) if blocking_qc: raise UserError(_( "Job %s cannot be marked Done — QC check %s is in " "state '%s'. Pass the QC checklist first, or have " "a manager override via the bypass button." ) % (job.name, blocking_qc.name, blocking_qc.state)) # No QC at all? Spawn one now (idempotent) and require # the operator to walk it before retrying. no_qc = not QC.search_count([('job_id', '=', job.id)]) if no_qc: QC.create_for_job(job) raise UserError(_( "Job %s requires QC. A new check has been created — " "complete it before marking the job Done." ) % job.name) job.state = 'done' job.date_finished = fields.Datetime.now() if not skip_side_effects: job._fp_create_delivery() job._fp_create_certificates() job._fp_fire_notification('job_complete') return True # ------------------------------------------------------------------ # Notifications dispatch (Phase 4) # # Fires fp.notification.template records whose trigger_event matches # the given event name. Best-effort: silently skips if the # fusion_plating_notifications module is not installed (model not # registered) and logs (without raising) on any send failure so the # job lifecycle is never blocked by an email problem. # ------------------------------------------------------------------ def _fp_fire_notification(self, event): """Best-effort notification dispatch for fp.job lifecycle events. Looks up fp.notification.template records with the matching trigger_event and dispatches via the central _dispatch helper provided by fusion_plating_notifications. Silently no-ops when that module isn't installed. """ self.ensure_one() if 'fp.notification.template' not in self.env: return Template = self.env['fp.notification.template'].sudo() try: # The notifications module exposes a model-level _dispatch # helper that handles template lookup, recipient resolution # (Sub 6 contact routing), attachment rendering, and audit # logging in one go. Pass partner explicitly since fp.job's # partner_id is the customer. Template._dispatch(event, self, partner=self.partner_id) except Exception as e: _logger.warning( "Job %s: notification %s dispatch failed: %s", self.name, event, e, ) def _fp_create_delivery(self): """Create a draft fusion.plating.delivery linked to this job. Sets BOTH x_fc_job_id (Many2one — strong link) AND job_ref (Char — soft reference). Downstream code is split: smart-button navigation reads x_fc_job_id, but the box-parity check, RMA refund auto-link, and the legacy notification dispatch all look up by job_ref. Setting both ends keeps every consumer happy. Phase A — mirrors x_fc_carrier_id and x_fc_outbound_shipment_id from the linked receiving so the delivery carries the shipping choices made at receipt time. Shipping crew can override later. """ self.ensure_one() if self.delivery_id: return Delivery = self.env['fusion.plating.delivery'].sudo() vals = {'partner_id': self.partner_id.id} if 'x_fc_job_id' in Delivery._fields: vals['x_fc_job_id'] = self.id if 'job_ref' in Delivery._fields: vals['job_ref'] = self.name if 'x_fc_job_id' not in Delivery._fields \ and 'job_ref' not in Delivery._fields: _logger.warning( "Job %s: fusion.plating.delivery has no job link field; " "delivery created without job back-reference.", self.name, ) # Mirror outbound carrier + shipment from the SO's first # receiving record. If there are multiple receivings (split # shipments), the shipping crew can change either field on the # delivery form. Defensive: skip when fields aren't present # (older instance) or no receiving exists. if (self.sale_order_id and 'x_fc_receiving_ids' in self.sale_order_id._fields and self.sale_order_id.x_fc_receiving_ids): recv = self.sale_order_id.x_fc_receiving_ids[:1] if 'x_fc_carrier_id' in Delivery._fields \ and 'x_fc_carrier_id' in recv._fields \ and recv.x_fc_carrier_id: vals['x_fc_carrier_id'] = recv.x_fc_carrier_id.id if 'x_fc_outbound_shipment_id' in Delivery._fields \ and 'x_fc_outbound_shipment_id' in recv._fields \ and recv.x_fc_outbound_shipment_id: vals['x_fc_outbound_shipment_id'] = ( recv.x_fc_outbound_shipment_id.id ) try: delivery = Delivery.create(vals) self.delivery_id = delivery.id except Exception as e: _logger.warning( "Job %s: failed to auto-create delivery: %s", self.name, e, ) def _fp_create_certificates(self): """Auto-create one draft fp.certificate per type returned by _resolve_required_cert_types. Idempotent per type — re-running on a job that already has a CoC won't create another one. Each cert is pre-populated with everything action_issue needs (partner, spec_reference, process_description, certified_by, contact_partner, part_number, quantity_shipped, NC qty, PO, SO link, job link) so the manager just reviews and clicks Issue. Resolution sources for the new prefill fields: - process_description ← recipe.name (the job's process root) - certified_by_id ← customer_spec.signer_user_id, falling back to company.x_fc_owner_user_id - contact_partner_id ← partner.x_fc_default_coc_contact_id - nc_quantity ← qty_scrapped + qty_visual_insp_rejects Honours part.certificate_requirement (coc / coc_thickness / none / inherit) and partner-level send_coc / send_thickness_report flags. Closes spec gap C-G1. """ self.ensure_one() if 'fp.certificate' not in self.env: return Cert = self.env['fp.certificate'].sudo() required = self._resolve_required_cert_types() if not required: return has_job_link = 'x_fc_job_id' in Cert._fields # Spec drives the cert spec_reference. The customer.spec was # auto-filled onto the job at confirm time (sale_order.py). spec = self.customer_spec_id # Recipe drives the process description on the cert. Was previously # sourced from sale_order.x_fc_coating_config_id (since retired); # recipe.name is the human-readable replacement. recipe = self.recipe_id # Signer resolution: per-spec override wins, company default fills. signer = False if spec and 'signer_user_id' in spec._fields: signer = spec.signer_user_id if not signer and 'x_fc_owner_user_id' in self.company_id._fields: signer = self.company_id.x_fc_owner_user_id # Contact: per-customer default; blank means manager picks at issue. contact = False if 'x_fc_default_coc_contact_id' in self.partner_id._fields: contact = self.partner_id.x_fc_default_coc_contact_id # NC qty: scrapped + visual rejects. Both NULL-safe. nc_qty = int( (self.qty_scrapped or 0) + (self.qty_visual_inspection_rejects or 0) ) for cert_type in sorted(required): # Idempotency per type. existing_dom = [('certificate_type', '=', cert_type)] if has_job_link: existing_dom.append(('x_fc_job_id', '=', self.id)) elif self.sale_order_id and 'sale_order_id' in Cert._fields: existing_dom.append( ('sale_order_id', '=', self.sale_order_id.id), ) else: continue # can't safely identify — skip if Cert.search_count(existing_dom): continue try: vals = { 'partner_id': self.partner_id.id, 'certificate_type': cert_type, } if 'state' in Cert._fields: vals['state'] = 'draft' if has_job_link: vals['x_fc_job_id'] = self.id elif 'job_id' in Cert._fields: vals['job_id'] = self.id if 'sale_order_id' in Cert._fields and self.sale_order_id: vals['sale_order_id'] = self.sale_order_id.id # spec_reference is what action_issue blocks on. # Format spec.code + revision for the cert text. if spec and 'spec_reference' in Cert._fields: ref = spec.code or '' if spec.revision: ref = (f'{ref} Rev {spec.revision}' if ref else f'Rev {spec.revision}') if ref: vals['spec_reference'] = ref if 'customer_spec_id' in Cert._fields: vals['customer_spec_id'] = spec.id if 'part_number' in Cert._fields and self.part_catalog_id: vals['part_number'] = ( self.part_catalog_id.part_number or '' ) if 'quantity_shipped' in Cert._fields: vals['quantity_shipped'] = int( (self.qty_done or self.qty or 0) - (self.qty_scrapped or 0) ) if 'nc_quantity' in Cert._fields: vals['nc_quantity'] = nc_qty if 'po_number' in Cert._fields and self.sale_order_id \ and 'x_fc_po_number' in self.sale_order_id._fields: vals['po_number'] = ( self.sale_order_id.x_fc_po_number or '' ) if 'customer_job_no' in Cert._fields \ and self.sale_order_id \ and 'x_fc_customer_job_number' \ in self.sale_order_id._fields: vals['customer_job_no'] = ( self.sale_order_id.x_fc_customer_job_number or '' ) if 'process_description' in Cert._fields and recipe: vals['process_description'] = recipe.name or '' if 'certified_by_id' in Cert._fields and signer: vals['certified_by_id'] = signer.id if 'contact_partner_id' in Cert._fields and contact: vals['contact_partner_id'] = contact.id if 'entech_wo_number' in Cert._fields: vals['entech_wo_number'] = self.name or '' cert = Cert.create(vals) self.message_post(body=Markup(_( '%(t)s %(n)s auto-created (draft). Issuer ' 'should hit Issue when ready to ship.' )) % { 't': dict( Cert._fields['certificate_type'].selection ).get(cert_type, cert_type), 'n': cert.name, }) except Exception as e: _logger.warning( "Job %s: failed to auto-create cert (%s): %s", self.name, cert_type, e, ) self.message_post(body=_( 'Cert auto-create (%(t)s) failed: %(e)s. ' 'Create manually.' ) % {'t': cert_type, 'e': e}) # ------------------------------------------------------------------ # Backfill — closed jobs missing certs, plus cleanup of legacy # duplicate thickness_report certs created before the bundling rule. # ------------------------------------------------------------------ # One-shot management action for jobs that closed BEFORE the # _fp_create_certificates bug fix (e.g. WO-30040). Two passes: # 1. CREATE any missing draft cert per the (updated) resolver # 2. VOID legacy duplicate thickness_report certs that have a # paired CoC on the same job — the bundling rule says the # CoC carries the thickness data on page 2 # Both passes are idempotent — safe to re-run. @api.model def action_backfill_missing_certs(self): Cert = self.env.get('fp.certificate') if Cert is None: raise UserError(_( 'fp.certificate model is not installed. Install ' 'fusion_plating_certificates before running this action.' )) candidate_jobs = self.search([('state', '=', 'done')]) scanned = 0 backfilled_jobs = self.env['fp.job'] created_count = 0 voided_count = 0 has_job_link = 'x_fc_job_id' in Cert._fields for job in candidate_jobs: required = job._resolve_required_cert_types() if not required: continue scanned += 1 existing_certs = ( Cert.sudo().search([('x_fc_job_id', '=', job.id)]) if has_job_link else (Cert.sudo().search([ ('sale_order_id', '=', job.sale_order_id.id), ]) if job.sale_order_id else Cert.browse()) ) existing_types = set(existing_certs.mapped('certificate_type')) # ---- Pass 1: create missing certs -------------------------- missing = required - existing_types if missing: before = len(existing_certs) job._fp_create_certificates() # Re-read to get the freshly-created ones for pass 2. existing_certs = ( Cert.sudo().search([('x_fc_job_id', '=', job.id)]) if has_job_link else existing_certs ) delta = max(len(existing_certs) - before, 0) if delta: backfilled_jobs |= job created_count += delta # ---- Pass 2: void duplicate thickness_report certs --------- # Bundling rule (CLAUDE.md): when CoC + thickness are both # wanted, the CoC absorbs the thickness data. A leftover # draft thickness_report cert on the same job is now noise # and should not be issued. Void it with a clear reason so # the audit trail tells the story. if 'coc' in required and 'coc' in existing_types: dup_thickness = existing_certs.filtered( lambda c: (c.certificate_type == 'thickness_report' and c.state == 'draft') ) for cert in dup_thickness: cert.sudo().write({ 'state': 'voided', 'void_reason': ( 'Auto-voided: bundling rule — thickness ' 'data is delivered as page 2 of the paired ' 'CoC, not as a separate cert.' ), }) cert.message_post(body=_( 'Auto-voided by cleanup: bundling rule routes ' 'thickness data to the CoC.' )) voided_count += 1 backfilled_jobs |= job return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('Cert backfill + cleanup complete'), 'message': _( 'Scanned %(s)d closed jobs. Created %(c)d draft ' 'cert(s); voided %(v)d duplicate thickness_report ' 'cert(s) across %(j)d job(s).' ) % { 's': scanned, 'c': created_count, 'v': voided_count, 'j': len(backfilled_jobs), }, 'sticky': True, 'type': 'success' if (created_count or voided_count) else 'warning', }, } class FpJobStep(models.Model): """Phase 7 — adds the migration idempotency key on fp.job.step. Populated by scripts/migrate_to_fp_jobs.py to mark a step as the mirror of a specific mrp.workorder. Used to skip already-migrated WOs on subsequent runs. """ _inherit = 'fp.job.step' legacy_mrp_workorder_id = fields.Integer( string='Legacy MRP Work Order ID', index=True, help='Database id of the source mrp.workorder this step was ' 'migrated from. Used by the migration script for ' 'idempotency. Cleared post-cutover.', ) # ========================================================================== # Sub 14 — Recipe-side trigger field # ========================================================================== # Adds an optional Many2one on every recipe operation node so the recipe # author can explicitly map "completion of this step triggers workflow # state X". Wins over the default-kind matching defined on the workflow # state itself. Lives here (not core) because the target model # (fp.job.workflow.state) is defined in this module. class FusionPlatingProcessNodeWorkflow(models.Model): _inherit = 'fusion.plating.process.node' triggers_workflow_state_id = fields.Many2one( 'fp.job.workflow.state', string='Triggers Workflow State', ondelete='set null', help='When a job step generated from this recipe node finishes ' '(or is skipped/cancelled), the job advances to this ' 'workflow state. Leave blank to fall back to default-kind ' 'matching defined on the workflow state catalog.', ) class FpStepTemplateWorkflow(models.Model): """Sub 14 — workflow milestone trigger on the library step template. Declared here (jobs module) instead of fusion_plating core because the target model (fp.job.workflow.state) lives in this module — core can't reference it without a cyclic dependency. When the template lands in a recipe via simple_recipe_controller drag-drop, the value is snapshot-copied to the new process_node via _SNAPSHOT_FIELDS. """ _inherit = 'fp.step.template' triggers_workflow_state_id = fields.Many2one( 'fp.job.workflow.state', string='Triggers Workflow State', ondelete='set null', help='Sub 14. When a recipe step generated from this template ' 'finishes (or is skipped/cancelled), the parent job ' 'advances to this workflow state on its status bar. Leave ' 'blank to fall back to default-kind matching defined on ' 'the workflow state catalog.', )