changes
This commit is contained in:
@@ -12,6 +12,7 @@ from . import fp_portal_job
|
||||
from . import account_move
|
||||
from . import res_config_settings
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
|
||||
# Phase 3 — parallel job/step links on dependent modules' models.
|
||||
from . import fp_batch
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
#
|
||||
# Phase 3 — parallel job link on fp.certificate.
|
||||
# Coexists with bridge_mrp's production_id link.
|
||||
#
|
||||
# v19.0.6.20.0 — surface the Fischerscope PDF on the cert form so
|
||||
# operators can SEE that the thickness report will be (or has been)
|
||||
# merged into the CoC. The merge logic itself lives in
|
||||
# fusion_plating_certificates/models/fp_certificate.py — this file
|
||||
# only adds the human-readable indicators.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpCertificate(models.Model):
|
||||
@@ -17,3 +23,95 @@ class FpCertificate(models.Model):
|
||||
index=True,
|
||||
help="Native fp.job link. Coexists with bridge_mrp's production_id.",
|
||||
)
|
||||
|
||||
# ---- Fischerscope thickness-PDF visibility (S19) ---------------------
|
||||
# These three fields are computed from the linked job's QC checks so
|
||||
# the cert form can show the operator BEFORE issuing whether a
|
||||
# Fischerscope report is on file and will be appended as page 2.
|
||||
x_fc_thickness_qc_id = fields.Many2one(
|
||||
'fusion.plating.quality.check',
|
||||
string='Linked QC (Thickness)',
|
||||
compute='_compute_fischer_visibility',
|
||||
help='Quality check on the linked plating job that has a '
|
||||
'Fischerscope / XDAL 600 thickness PDF uploaded. Used to '
|
||||
'merge that PDF into the CoC on Issue.',
|
||||
)
|
||||
x_fc_thickness_pdf_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Fischerscope PDF',
|
||||
compute='_compute_fischer_visibility',
|
||||
help='Thickness report PDF that will be appended as page 2 of '
|
||||
'the CoC when the certificate is issued.',
|
||||
)
|
||||
x_fc_thickness_status = fields.Selection(
|
||||
[
|
||||
('none', 'No PDF Uploaded'),
|
||||
('pending', 'Will Append on Issue'),
|
||||
('merged', 'Merged into CoC'),
|
||||
],
|
||||
string='Thickness Report',
|
||||
compute='_compute_fischer_visibility',
|
||||
help='none = QC has no Fischerscope upload · '
|
||||
'pending = will be appended when Issue is clicked · '
|
||||
'merged = already in the issued CoC PDF',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id')
|
||||
def _compute_fischer_visibility(self):
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
empty_qc = self.env['fusion.plating.quality.check'] if QC is not None else None
|
||||
empty_att = self.env['ir.attachment']
|
||||
for rec in self:
|
||||
qc = empty_qc
|
||||
pdf = empty_att
|
||||
status = 'none'
|
||||
if QC is not None and rec.x_fc_job_id:
|
||||
# Same lookup the merge method uses — passed-first,
|
||||
# then any QC with a PDF.
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', rec.x_fc_job_id.id),
|
||||
('state', '=', 'passed'),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='completed_at desc', limit=1)
|
||||
if not qc:
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', rec.x_fc_job_id.id),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='create_date desc', limit=1)
|
||||
if qc and qc.thickness_report_pdf_id:
|
||||
pdf = qc.thickness_report_pdf_id
|
||||
if rec.state == 'issued' and rec.attachment_id:
|
||||
status = 'merged'
|
||||
else:
|
||||
status = 'pending'
|
||||
rec.x_fc_thickness_qc_id = qc or empty_qc
|
||||
rec.x_fc_thickness_pdf_id = pdf or empty_att
|
||||
rec.x_fc_thickness_status = status
|
||||
|
||||
def action_view_thickness_qc(self):
|
||||
"""Smart-button target — open the linked QC for inspection."""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_thickness_qc_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': self.x_fc_thickness_qc_id.name,
|
||||
'res_model': 'fusion.plating.quality.check',
|
||||
'res_id': self.x_fc_thickness_qc_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_open_job(self):
|
||||
"""Smart-button target — open the linked plating job."""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_job_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': self.x_fc_job_id.name,
|
||||
'res_model': 'fp.job',
|
||||
'res_id': self.x_fc_job_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -263,6 +263,95 @@ class FpJob(models.Model):
|
||||
'name': self.portal_job_id.name,
|
||||
}
|
||||
|
||||
def write(self, vals):
|
||||
"""Write hook: when qty_scrapped INCREASES, auto-spawn a
|
||||
fusion.plating.quality.hold for the scrapped delta. AS9100 /
|
||||
Nadcap need a disposition record per scrap event — without
|
||||
this the operator silently bumps qty_scrapped, no paper trail,
|
||||
auditor can't reconstruct what happened.
|
||||
|
||||
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)
|
||||
result = super().write(vals)
|
||||
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
|
||||
)
|
||||
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': _(
|
||||
'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),
|
||||
'facility_id': facility.id if facility else False,
|
||||
})
|
||||
job.message_post(body=_Markup(_(
|
||||
'⚠️ Scrap auto-Hold spawned: <b>%s</b> 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 <b>%s</b>: %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)
|
||||
#
|
||||
@@ -523,6 +612,15 @@ class FpJob(models.Model):
|
||||
# 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'})
|
||||
job._fp_create_portal_job()
|
||||
job._fp_create_qc_check_if_needed()
|
||||
job._fp_create_racking_inspection()
|
||||
@@ -576,13 +674,13 @@ class FpJob(models.Model):
|
||||
self.portal_job_id = portal.id
|
||||
|
||||
def _fp_create_qc_check_if_needed(self):
|
||||
"""If customer has x_fc_requires_qc=True, create a QC check.
|
||||
"""If customer has x_fc_requires_qc=True, spawn a QC check via
|
||||
the canonical fp.quality.check.create_for_job() entry point.
|
||||
|
||||
The fusion.plating.quality.check model lives in
|
||||
fusion_plating_bridge_mrp; we runtime-detect it to avoid a
|
||||
depends-on-bridge_mrp cycle. If the model isn't registered, log
|
||||
a warning and skip — bridge_mrp can be installed later without
|
||||
breaking this flow.
|
||||
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
|
||||
@@ -593,31 +691,13 @@ class FpJob(models.Model):
|
||||
if not wants_qc:
|
||||
return
|
||||
if 'fusion.plating.quality.check' not in self.env:
|
||||
_logger.warning(
|
||||
"Job %s: customer wants QC but fusion.plating.quality.check "
|
||||
"model not registered (bridge_mrp deferral).", self.name,
|
||||
)
|
||||
return
|
||||
QC = self.env['fusion.plating.quality.check'].sudo()
|
||||
# Try to create with the most likely required fields. If the
|
||||
# model has a different schema than expected, this may need
|
||||
# adjustment when bridge_mrp's QC model lands here.
|
||||
QC = self.env['fusion.plating.quality.check']
|
||||
try:
|
||||
qc_vals = {
|
||||
'partner_id': partner.id,
|
||||
'state': 'pending',
|
||||
}
|
||||
# Try the new field name first; fallback to mrp-bound.
|
||||
if 'job_id' in QC._fields:
|
||||
qc_vals['job_id'] = self.id
|
||||
elif 'production_id' in QC._fields:
|
||||
# bridge_mrp's QC binds to production. We can't fill that
|
||||
# from here — leave it null and let a manual link happen.
|
||||
pass
|
||||
QC.create(qc_vals)
|
||||
QC.create_for_job(self)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Job %s: failed to create QC check: %s", self.name, e,
|
||||
"Job %s: create_for_job failed: %s", self.name, e,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -626,12 +706,22 @@ class FpJob(models.Model):
|
||||
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
|
||||
@@ -639,6 +729,105 @@ class FpJob(models.Model):
|
||||
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,
|
||||
))
|
||||
# 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:
|
||||
@@ -682,33 +871,31 @@ class FpJob(models.Model):
|
||||
)
|
||||
|
||||
def _fp_create_delivery(self):
|
||||
"""Create a draft fusion.plating.delivery linked to this job."""
|
||||
"""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.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.delivery_id:
|
||||
return
|
||||
Delivery = self.env['fusion.plating.delivery'].sudo()
|
||||
# Verify the model has a job link field. The current delivery
|
||||
# model uses `job_ref` (Char) as a soft reference; some forks
|
||||
# may add `x_fc_job_id` (Many2one).
|
||||
vals = {'partner_id': self.partner_id.id}
|
||||
if 'x_fc_job_id' in Delivery._fields:
|
||||
ref_field = 'x_fc_job_id'
|
||||
ref_value = self.id
|
||||
elif 'job_ref' in Delivery._fields:
|
||||
ref_field = 'job_ref'
|
||||
ref_value = self.name
|
||||
else:
|
||||
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,
|
||||
)
|
||||
ref_field = None
|
||||
ref_value = None
|
||||
try:
|
||||
vals = {
|
||||
'partner_id': self.partner_id.id,
|
||||
}
|
||||
if ref_field:
|
||||
vals[ref_field] = ref_value
|
||||
delivery = Delivery.create(vals)
|
||||
self.delivery_id = delivery.id
|
||||
except Exception as e:
|
||||
@@ -719,29 +906,87 @@ class FpJob(models.Model):
|
||||
def _fp_create_certificates(self):
|
||||
"""Trigger cert auto-create on job done.
|
||||
|
||||
Best-effort: if fp.certificate has the right fields, create a
|
||||
draft CoC. Otherwise log + skip.
|
||||
Pre-populates ALL the fields a CoC issuer needs so Tom can hit
|
||||
Issue without filling 6 fields first:
|
||||
- partner_id from job
|
||||
- spec_reference from coating (required by action_issue)
|
||||
- part_number from part_catalog
|
||||
- quantity_shipped from job qty (minus scrap)
|
||||
- po_number from sale_order
|
||||
- sale_order_id link
|
||||
- x_fc_job_id link if the field exists
|
||||
|
||||
Idempotent — if a cert already exists for this job, skip
|
||||
(prevents dupes when button_mark_done is re-run after a
|
||||
manager bypass).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if 'fp.certificate' not in self.env:
|
||||
return
|
||||
Cert = self.env['fp.certificate'].sudo()
|
||||
# Idempotency: don't double-create on retry.
|
||||
existing_dom = []
|
||||
if 'x_fc_job_id' in Cert._fields:
|
||||
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))
|
||||
if existing_dom:
|
||||
existing = Cert.search(existing_dom, limit=1)
|
||||
if existing:
|
||||
_logger.info(
|
||||
'Job %s: cert %s already exists, skipping auto-create',
|
||||
self.name, existing.name,
|
||||
)
|
||||
return
|
||||
try:
|
||||
vals = {
|
||||
'partner_id': self.partner_id.id,
|
||||
}
|
||||
vals = {'partner_id': self.partner_id.id}
|
||||
if 'certificate_type' in Cert._fields:
|
||||
vals['certificate_type'] = 'coc'
|
||||
if 'state' in Cert._fields:
|
||||
vals['state'] = 'draft'
|
||||
# Add job link if Cert has the field
|
||||
# Job + SO links.
|
||||
if 'x_fc_job_id' in Cert._fields:
|
||||
vals['x_fc_job_id'] = self.id
|
||||
elif 'job_id' in Cert._fields:
|
||||
vals['job_id'] = self.id
|
||||
elif 'sale_order_id' in Cert._fields and self.sale_order_id:
|
||||
if 'sale_order_id' in Cert._fields and self.sale_order_id:
|
||||
vals['sale_order_id'] = self.sale_order_id.id
|
||||
Cert.create(vals)
|
||||
# Pre-fill from coating: the spec_reference is what action_issue
|
||||
# blocks on — without this every cert needs a manual edit.
|
||||
coating = self.coating_config_id
|
||||
if coating and 'spec_reference' in Cert._fields \
|
||||
and getattr(coating, 'spec_reference', False):
|
||||
vals['spec_reference'] = coating.spec_reference
|
||||
# Pre-fill part_number from the part catalog if we have one.
|
||||
if 'part_number' in Cert._fields and self.part_catalog_id:
|
||||
vals['part_number'] = self.part_catalog_id.part_number or ''
|
||||
# Quantity shipped = job qty minus scrap. AS9100 wants the
|
||||
# actual count that left the shop, not the order count.
|
||||
if 'quantity_shipped' in Cert._fields:
|
||||
vals['quantity_shipped'] = int(
|
||||
(self.qty_done or self.qty or 0) - (self.qty_scrapped or 0)
|
||||
)
|
||||
# PO number from the source SO.
|
||||
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 ''
|
||||
# Customer job# → cert label (helps customer search).
|
||||
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 ''
|
||||
)
|
||||
# Process description from coating name.
|
||||
if 'process_description' in Cert._fields and coating:
|
||||
vals['process_description'] = coating.name or ''
|
||||
# Job # for shop-side reference.
|
||||
if 'entech_wo_number' in Cert._fields:
|
||||
vals['entech_wo_number'] = self.name or ''
|
||||
cert = Cert.create(vals)
|
||||
self.message_post(body=Markup(_(
|
||||
'CoC <b>%s</b> auto-created (draft). Issuer should hit '
|
||||
'the Issue button on the certificate when ready to ship.'
|
||||
)) % cert.name)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Job %s: failed to auto-create cert: %s", self.name, e,
|
||||
|
||||
@@ -6,20 +6,55 @@
|
||||
# fusion_plating core's fp.job.step shipped as NotImplementedError
|
||||
# placeholders. Per spec §5.2 state machine.
|
||||
|
||||
from odoo import _, fields, models
|
||||
import logging
|
||||
import re
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
_inherit = 'fp.job.step'
|
||||
|
||||
def button_start(self):
|
||||
"""Override — soft gate when parts haven't been received yet.
|
||||
"""Override — soft gate when parts haven't been received yet,
|
||||
plus hard predecessor gate for steps flagged
|
||||
requires_predecessor_done by the recipe author.
|
||||
|
||||
Doesn't block (parts could be in-transit late, manager wants
|
||||
the shop to start prep regardless), but posts a chatter warning
|
||||
on the job so the audit trail captures premature starts.
|
||||
Receiving check is soft (logs to chatter) — manager wants the
|
||||
shop to start prep regardless when parts are in-transit late.
|
||||
|
||||
Predecessor check IS hard-blocking — if the recipe author
|
||||
marked this step as serial-required, every earlier-sequence
|
||||
step must be terminal (done / skipped / cancelled) before
|
||||
Start fires. Manager bypass via fp_skip_predecessor_check=True.
|
||||
"""
|
||||
skip_pred = self.env.context.get('fp_skip_predecessor_check')
|
||||
for step in self:
|
||||
if not step.requires_predecessor_done or skip_pred:
|
||||
continue
|
||||
blocking = step.job_id.step_ids.filtered(
|
||||
lambda s: s.sequence < step.sequence and s.state not in (
|
||||
'done', 'skipped', 'cancelled',
|
||||
)
|
||||
)
|
||||
if blocking:
|
||||
raise UserError(_(
|
||||
"Step '%s' requires predecessors done first. "
|
||||
"Blocking earlier step(s):\n %s\n\nFinish or skip "
|
||||
"those before starting this one (manager can "
|
||||
"override via context fp_skip_predecessor_check=True)."
|
||||
) % (
|
||||
step.name,
|
||||
'\n '.join(
|
||||
f'#{s.sequence} {s.name} ({s.state})'
|
||||
for s in blocking[:5]
|
||||
),
|
||||
))
|
||||
result = super().button_start()
|
||||
for step in self:
|
||||
so = step.job_id.sale_order_id
|
||||
@@ -88,3 +123,293 @@ class FpJobStep(models.Model):
|
||||
) % step.name)
|
||||
step.state = 'cancelled'
|
||||
return True
|
||||
|
||||
def write(self, vals):
|
||||
"""Post a chatter trail on the parent JOB whenever an active
|
||||
step gets reassigned. The step itself already tracks
|
||||
assigned_user_id (tracking=True) but supervisors don't open
|
||||
each step's chatter — they read the job. Without a job-level
|
||||
post the takeover is invisible.
|
||||
|
||||
Only fires for steps in active states (in_progress / paused)
|
||||
so creating a draft job + assigning a step to someone doesn't
|
||||
spam the job chatter. Comparing to the OLD assignment so we
|
||||
don't post on the initial set-from-False either.
|
||||
"""
|
||||
post_for = []
|
||||
if 'assigned_user_id' in vals:
|
||||
new_uid = vals['assigned_user_id']
|
||||
for step in self:
|
||||
if step.state not in ('in_progress', 'paused'):
|
||||
continue
|
||||
old_uid = step.assigned_user_id.id
|
||||
if not old_uid:
|
||||
continue
|
||||
if new_uid == old_uid:
|
||||
continue
|
||||
post_for.append((step, old_uid, new_uid))
|
||||
result = super().write(vals)
|
||||
Users = self.env['res.users']
|
||||
for step, old_uid, new_uid in post_for:
|
||||
old_name = Users.browse(old_uid).name if old_uid else '(unassigned)'
|
||||
new_name = Users.browse(new_uid).name if new_uid else '(unassigned)'
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'Step <b>%s</b> reassigned from <b>%s</b> to <b>%s</b> '
|
||||
'(state=%s) by %s.'
|
||||
)) % (step.name, old_name, new_name, step.state,
|
||||
self.env.user.name))
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _cron_nudge_stale_paused(self, threshold_hours=24):
|
||||
"""Daily nudge for steps stuck in `paused` longer than threshold."""
|
||||
return self._cron_nudge_stale_steps(
|
||||
states=('paused',),
|
||||
threshold_hours=threshold_hours,
|
||||
label='paused',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_nudge_stale_in_progress(self, threshold_hours=8):
|
||||
"""Cron nudge for steps stuck in `in_progress` longer than
|
||||
threshold. Default 8 hours — operator started, walked away,
|
||||
timelog accumulating phantom hours.
|
||||
"""
|
||||
return self._cron_nudge_stale_steps(
|
||||
states=('in_progress',),
|
||||
threshold_hours=threshold_hours,
|
||||
label='in-progress',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_nudge_stale_steps(self, states=('paused',),
|
||||
threshold_hours=24, label='stale'):
|
||||
"""Generic stale-step nudger.
|
||||
|
||||
Finds every fp.job.step in any of `states` with date_started
|
||||
older than N hours. Schedules a 'todo' mail.activity on the
|
||||
parent job for the job's manager_id (falls back to the user
|
||||
who started the step). Idempotent — won't double-schedule if
|
||||
an open activity with the same summary already exists.
|
||||
"""
|
||||
from datetime import timedelta as _td
|
||||
cutoff = fields.Datetime.now() - _td(hours=threshold_hours)
|
||||
stale = self.search([
|
||||
('state', 'in', list(states)),
|
||||
('date_started', '<', cutoff),
|
||||
('date_started', '!=', False),
|
||||
])
|
||||
Activity = self.env['mail.activity']
|
||||
ActivityType = self.env.ref(
|
||||
'mail.mail_activity_data_todo', raise_if_not_found=False,
|
||||
)
|
||||
nudged_count = 0
|
||||
for step in stale:
|
||||
job = step.job_id
|
||||
assignee = (job.manager_id or step.assigned_user_id
|
||||
or step.started_by_user_id or self.env.user)
|
||||
summary = _('Stale %s step: %s') % (label, step.name)
|
||||
existing = Activity.search([
|
||||
('res_model', '=', job._name),
|
||||
('res_id', '=', job.id),
|
||||
('summary', '=', summary),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
age_h = (fields.Datetime.now() - step.date_started).total_seconds() / 3600.0
|
||||
note = _(
|
||||
'Step "%(step)s" on job %(job)s has been in %(label)s state for '
|
||||
'%(hours).1f hours (since %(start)s). Investigate: operator '
|
||||
'reassignment, equipment failure, or finish + close out.'
|
||||
) % {
|
||||
'step': step.name, 'job': job.name, 'label': label,
|
||||
'hours': age_h, 'start': step.date_started,
|
||||
}
|
||||
vals = {
|
||||
'res_model_id': self.env['ir.model']._get(job._name).id,
|
||||
'res_id': job.id,
|
||||
'summary': summary,
|
||||
'note': note,
|
||||
'user_id': assignee.id,
|
||||
'date_deadline': fields.Date.context_today(self),
|
||||
}
|
||||
if ActivityType:
|
||||
vals['activity_type_id'] = ActivityType.id
|
||||
Activity.create(vals)
|
||||
nudged_count += 1
|
||||
job.message_post(body=Markup(_(
|
||||
'Stale %s step: <b>%s</b> has been idle %.1f hours. '
|
||||
'Activity created for %s.'
|
||||
)) % (label, step.name, age_h, assignee.name))
|
||||
if nudged_count:
|
||||
_logger.info(
|
||||
'fp.job.step stale-%s cron: nudged %d step(s)',
|
||||
label, nudged_count,
|
||||
)
|
||||
return nudged_count
|
||||
|
||||
def action_abort_for_retry(self, reason=None, new_tank_id=None,
|
||||
new_bath_id=None):
|
||||
"""Abort an in_progress / paused step so the operator can restart
|
||||
it (typically after an equipment failure mid-step).
|
||||
|
||||
Closes the open timelog (preserves the partial-work record on
|
||||
the audit trail), posts a clear chatter event on the JOB
|
||||
explaining why + which tank, optionally moves the step to a
|
||||
different tank/bath, and resets the step to `ready` so the
|
||||
operator can hit Start again.
|
||||
|
||||
Without this method the operator's only options are
|
||||
button_cancel (kills the step entirely) or
|
||||
pause → write tank → start (no failure audit).
|
||||
"""
|
||||
if not reason:
|
||||
reason = _('Equipment failure / abort for retry')
|
||||
for step in self:
|
||||
if step.state not in ('in_progress', 'paused'):
|
||||
raise UserError(_(
|
||||
"Step '%s' is in state '%s' — only in_progress / "
|
||||
"paused steps can be aborted for retry."
|
||||
) % (step.name, step.state))
|
||||
old_tank = step.tank_id.display_name or '(no tank set)'
|
||||
old_bath = step.bath_id.display_name or '(no bath set)'
|
||||
now = fields.Datetime.now()
|
||||
open_logs = step.time_log_ids.filtered(
|
||||
lambda l: not l.date_finished
|
||||
)
|
||||
if open_logs:
|
||||
open_logs.write({'date_finished': now})
|
||||
partial_min = sum(step.time_log_ids.mapped('duration_minutes'))
|
||||
change_msg = ''
|
||||
if new_tank_id:
|
||||
step.tank_id = new_tank_id
|
||||
change_msg += ' -> tank %s' % step.tank_id.display_name
|
||||
if new_bath_id:
|
||||
step.bath_id = new_bath_id
|
||||
change_msg += ' -> bath %s' % step.bath_id.display_name
|
||||
step.state = 'ready'
|
||||
step.duration_actual = partial_min
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'⚠️ Step <b>%s</b> aborted for retry by %s.<br/>'
|
||||
'Reason: <em>%s</em><br/>'
|
||||
'Equipment: tank=%s, bath=%s%s<br/>'
|
||||
'Partial work captured: %.2f min in %d timelog(s). '
|
||||
'Step is back in <b>ready</b> state — operator can '
|
||||
'restart when the issue is resolved.'
|
||||
)) % (
|
||||
step.name, self.env.user.name, reason,
|
||||
old_tank, old_bath, change_msg, partial_min,
|
||||
len(step.time_log_ids),
|
||||
))
|
||||
return True
|
||||
|
||||
def action_recompute_duration_from_timelogs(self):
|
||||
"""Re-sum duration_actual from the step's timelog rows.
|
||||
|
||||
Use case: supervisor adjusts a timelog row (back-date a forgotten
|
||||
click, fix wrong operator, delete a stale entry that was left
|
||||
open over a shift change) and needs the step's duration_actual
|
||||
to reflect the corrected reality. Without this, edits to time_log_ids
|
||||
rows don't propagate into duration_actual (which is set once
|
||||
by button_finish).
|
||||
|
||||
Posts the before/after to chatter for audit.
|
||||
"""
|
||||
for step in self:
|
||||
old = step.duration_actual or 0.0
|
||||
new = sum(step.time_log_ids.mapped('duration_minutes'))
|
||||
step.duration_actual = new
|
||||
if abs(old - new) > 0.001:
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'Step <b>%s</b> duration recomputed from timelog rows: '
|
||||
'%.2f min → %.2f min (Δ %+.2f). Recomputed by %s.'
|
||||
)) % (step.name, old, new, new - old, self.env.user.name))
|
||||
return True
|
||||
|
||||
def button_finish(self):
|
||||
"""Override to:
|
||||
1) Auto-spawn a bake.window when a wet plating step finishes
|
||||
on a coating that requires hydrogen-embrittlement relief
|
||||
(AS9100 / Nadcap compliance);
|
||||
2) Post a chatter warning when duration_actual exceeds 1.5×
|
||||
duration_expected — silent overruns are a red flag for
|
||||
scheduling and costing.
|
||||
|
||||
Both actions are idempotent and never block the finish itself.
|
||||
"""
|
||||
result = super().button_finish()
|
||||
BW = self.env['fusion.plating.bake.window']
|
||||
Bath = self.env['fusion.plating.bath']
|
||||
for step in self:
|
||||
if step.state != 'done':
|
||||
continue
|
||||
# Duration-overrun chatter alert.
|
||||
if step.duration_expected and step.duration_actual:
|
||||
ratio = step.duration_actual / step.duration_expected
|
||||
if ratio >= 1.5:
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'⚠️ <b>Step "%s" ran %.1fx expected</b> — '
|
||||
'expected %.0f min, actual %.0f min. Investigate: '
|
||||
'equipment issue, training gap, or recipe time '
|
||||
'estimate too tight.'
|
||||
)) % (step.name, ratio, step.duration_expected,
|
||||
step.duration_actual))
|
||||
coating = step.job_id.coating_config_id \
|
||||
if 'coating_config_id' in step.job_id._fields else False
|
||||
if not coating:
|
||||
continue
|
||||
requires = getattr(coating, 'requires_bake_relief', False)
|
||||
window_hrs = getattr(coating, 'bake_window_hours', 0.0)
|
||||
if not requires or not window_hrs:
|
||||
continue
|
||||
# Trigger only on the actual plating-out step. We want
|
||||
# exactly ONE bake.window per job (not one per step that
|
||||
# happens to have "plate" in the name). Heuristic:
|
||||
# - step.kind == 'wet' (clean, recipe-authored signal); OR
|
||||
# - the step name contains "plating" as a word
|
||||
# Explicit excludes: inspection / bake / mask / rack steps
|
||||
# whose names might happen to mention plating in passing
|
||||
# (e.g. "Post-plate Inspection").
|
||||
name_l = (step.name or '').lower()
|
||||
kind_match = step.kind == 'wet'
|
||||
name_match = bool(re.search(r'\bplating\b', name_l))
|
||||
excluded = any(kw in name_l for kw in (
|
||||
'inspect', 'inspection', 'bake', 'mask', 'rack',
|
||||
))
|
||||
if (not kind_match and not name_match) or excluded:
|
||||
continue
|
||||
# Idempotency — only one bake.window per (job, step).
|
||||
existing = BW.sudo().search([
|
||||
('part_ref', '=', step.job_id.name),
|
||||
('lot_ref', '=', f'step-{step.id}'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
# Pick a bath: step.bath_id wins; fall back to the first
|
||||
# active bath in the facility (best-effort — operator can
|
||||
# correct on the bake.window record).
|
||||
bath = step.bath_id or Bath.sudo().search(
|
||||
[('facility_id', '=', step.facility_id.id)], limit=1,
|
||||
) if step.facility_id else False
|
||||
if not bath:
|
||||
bath = Bath.sudo().search([], limit=1)
|
||||
if not bath:
|
||||
_logger.warning(
|
||||
'Step %s: bake-window auto-spawn skipped — no bath '
|
||||
'configured.', step.name,
|
||||
)
|
||||
continue
|
||||
bw = BW.sudo().create({
|
||||
'bath_id': bath.id,
|
||||
'plate_exit_time': step.date_finished or fields.Datetime.now(),
|
||||
'window_hours': window_hrs,
|
||||
'part_ref': step.job_id.name,
|
||||
'lot_ref': f'step-{step.id}',
|
||||
'customer_ref': step.job_id.partner_id.display_name or '',
|
||||
'quantity': int(step.job_id.qty or 0),
|
||||
})
|
||||
step.job_id.message_post(body=Markup(_(
|
||||
'Bake window <b>%s</b> auto-created — %.1fh window from '
|
||||
'plate exit. Required by %s.'
|
||||
)) % (bw.name, window_hrs, bw.bake_required_by))
|
||||
return result
|
||||
|
||||
@@ -25,6 +25,14 @@ class SaleOrder(models.Model):
|
||||
string='Plating Jobs',
|
||||
compute='_compute_fp_job_count',
|
||||
)
|
||||
x_fc_fp_certificate_count = fields.Integer(
|
||||
string='Certificates',
|
||||
compute='_compute_fp_certificate_count',
|
||||
help='Number of fp.certificate records issued (or draft) against '
|
||||
'this sale order. Surfaced as a smart button so Sarah/Tom '
|
||||
'can jump straight from the SO to the cert without having '
|
||||
'to drill through the linked Plating Job first.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Phase 4 (Sub 11) — workflow-stage field + assigned-manager field
|
||||
@@ -66,6 +74,13 @@ class SaleOrder(models.Model):
|
||||
[('sale_order_id', '=', so.id)]
|
||||
)
|
||||
|
||||
def _compute_fp_certificate_count(self):
|
||||
Cert = self.env['fp.certificate'].sudo()
|
||||
for so in self:
|
||||
so.x_fc_fp_certificate_count = Cert.search_count(
|
||||
[('sale_order_id', '=', so.id)]
|
||||
)
|
||||
|
||||
def _compute_workflow_stage(self):
|
||||
"""Native-jobs override — walks fp.job state instead of mrp.production.
|
||||
|
||||
@@ -162,6 +177,28 @@ class SaleOrder(models.Model):
|
||||
})
|
||||
return action
|
||||
|
||||
def action_view_fp_certificates(self):
|
||||
"""Smart-button target — open the certificate(s) linked to this
|
||||
SO. One cert → form view; many → list view filtered to this SO."""
|
||||
self.ensure_one()
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('sale_order_id', '=', self.id),
|
||||
])
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Certificates'),
|
||||
'res_model': 'fp.certificate',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sale_order_id', '=', self.id)],
|
||||
'context': {
|
||||
'default_sale_order_id': self.id,
|
||||
'default_partner_id': self.partner_id.id,
|
||||
},
|
||||
}
|
||||
if len(certs) == 1:
|
||||
action.update({'view_mode': 'form', 'res_id': certs.id})
|
||||
return action
|
||||
|
||||
def action_confirm(self):
|
||||
result = super().action_confirm()
|
||||
# Only run when the native flag is on
|
||||
@@ -209,6 +246,18 @@ class SaleOrder(models.Model):
|
||||
or ('x_fc_coating_config_id' in l._fields and l.x_fc_coating_config_id)
|
||||
)
|
||||
)
|
||||
# Fallback: legacy/configurator SOs that carry part+coating on the
|
||||
# header but not on the line. Treat the entire order as one
|
||||
# plating line so the planner gets an fp.job to work against.
|
||||
if not plating_lines and self.order_line and (
|
||||
('x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id)
|
||||
or ('x_fc_coating_config_id' in self._fields and self.x_fc_coating_config_id)
|
||||
):
|
||||
_logger.info(
|
||||
'SO %s: no line-level part/coating but header carries one — '
|
||||
'treating all lines as a single plating job.', self.name,
|
||||
)
|
||||
plating_lines = self.order_line
|
||||
if not plating_lines:
|
||||
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
|
||||
return
|
||||
@@ -239,13 +288,38 @@ class SaleOrder(models.Model):
|
||||
and first_line.x_fc_coating_config_id
|
||||
or False
|
||||
)
|
||||
# Recipe lookup: from coating, fallback to part
|
||||
# Header fallback for legacy/configurator SOs that put part +
|
||||
# coating on the SO header instead of the line.
|
||||
if not part and 'x_fc_part_catalog_id' in self._fields:
|
||||
part = self.x_fc_part_catalog_id or False
|
||||
if not coating and 'x_fc_coating_config_id' in self._fields:
|
||||
coating = self.x_fc_coating_config_id or False
|
||||
# Recipe lookup priority:
|
||||
# 1. line.x_fc_process_variant_id — Sarah explicitly picked
|
||||
# a part-scoped variant on this order line. Always wins.
|
||||
# 2. coating.recipe_id — coating-config recipe.
|
||||
# 3. part.default_process_id — part's flagged default.
|
||||
# 4. part.recipe_id — legacy fallback.
|
||||
#
|
||||
# If multiple lines in the same WO group have different
|
||||
# variants we use the FIRST line's variant (consistent with
|
||||
# everything else in this loop using `first_line`).
|
||||
recipe = False
|
||||
if coating and 'recipe_id' in coating._fields and coating.recipe_id:
|
||||
picked_variant = (
|
||||
'x_fc_process_variant_id' in first_line._fields
|
||||
and first_line.x_fc_process_variant_id
|
||||
or False
|
||||
)
|
||||
if picked_variant:
|
||||
recipe = picked_variant
|
||||
if not recipe and coating and 'recipe_id' in coating._fields \
|
||||
and coating.recipe_id:
|
||||
recipe = coating.recipe_id
|
||||
if not recipe and part and 'default_process_id' in part._fields and part.default_process_id:
|
||||
if not recipe and part and 'default_process_id' in part._fields \
|
||||
and part.default_process_id:
|
||||
recipe = part.default_process_id
|
||||
if not recipe and part and 'recipe_id' in part._fields and part.recipe_id:
|
||||
if not recipe and part and 'recipe_id' in part._fields \
|
||||
and part.recipe_id:
|
||||
recipe = part.recipe_id
|
||||
|
||||
vals = {
|
||||
|
||||
56
fusion_plating/fusion_plating_jobs/models/sale_order_line.py
Normal file
56
fusion_plating/fusion_plating_jobs/models/sale_order_line.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Mid-job qty drift guard. When Sarah edits an SO line's qty after a
|
||||
# fp.job has been spawned and started, the job's qty does NOT auto-
|
||||
# update (intentionally — Carlos may already be plating). But without
|
||||
# a warning the qty drift is silent and bills go out wrong. This
|
||||
# write-override posts chatter on every active linked job so operators
|
||||
# see the change immediately, AND offers a "Sync qty from SO" action
|
||||
# on the job for the supervisor to apply.
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, models
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
def write(self, vals):
|
||||
# Detect qty changes BEFORE the write so we can compare.
|
||||
old_qty_by_id = {}
|
||||
if 'product_uom_qty' in vals:
|
||||
for line in self:
|
||||
old_qty_by_id[line.id] = line.product_uom_qty
|
||||
result = super().write(vals)
|
||||
if 'product_uom_qty' not in vals:
|
||||
return result
|
||||
Job = self.env['fp.job']
|
||||
for line in self:
|
||||
new_qty = line.product_uom_qty
|
||||
old_qty = old_qty_by_id.get(line.id, new_qty)
|
||||
if old_qty == new_qty:
|
||||
continue
|
||||
jobs = Job.search([
|
||||
('sale_order_id', '=', line.order_id.id),
|
||||
('state', 'not in', ('draft', 'cancelled', 'done')),
|
||||
])
|
||||
for job in jobs:
|
||||
job.message_post(body=Markup(_(
|
||||
'⚠️ <b>SO qty changed mid-job</b> by %(user)s. '
|
||||
'SO line %(name)s went from %(old)g to %(new)g. '
|
||||
'Job qty is still <b>%(jobqty)g</b> — operator '
|
||||
'must manually adjust scope (start more racks or '
|
||||
'stop early) and the supervisor should hit '
|
||||
'<b>Sync qty from SO</b> on the job header to '
|
||||
'reconcile.'
|
||||
)) % {
|
||||
'user': self.env.user.name,
|
||||
'name': line.name[:60] if line.name else '(unnamed)',
|
||||
'old': old_qty,
|
||||
'new': new_qty,
|
||||
'jobqty': job.qty,
|
||||
})
|
||||
return result
|
||||
Reference in New Issue
Block a user