changes
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user