This commit is contained in:
gsinghpal
2026-05-18 22:33:23 -04:00
parent 25f568f225
commit 091f98e1f9
76 changed files with 4521 additions and 220 deletions

View File

@@ -56,7 +56,8 @@ class FpCertificate(models.Model):
'merged = already in the issued CoC PDF',
)
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id')
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id',
'x_fc_local_thickness_pdf')
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
@@ -65,7 +66,14 @@ class FpCertificate(models.Model):
qc = empty_qc
pdf = empty_att
status = 'none'
if QC is not None and rec.x_fc_job_id:
# Cert-local upload wins over QC-side PDF (matches the
# merge resolution order in fp_certificate.py).
if rec.x_fc_local_thickness_pdf:
if rec.state == 'issued' and rec.attachment_id:
status = 'merged'
else:
status = 'pending'
elif 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([

View File

@@ -189,6 +189,15 @@ class FpJob(models.Model):
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 = (
@@ -196,16 +205,17 @@ class FpJob(models.Model):
and self.part_catalog_id.certificate_requirement
) or 'inherit'
if req == 'inherit':
types = set()
if self.partner_id.x_fc_send_coc:
types.add('coc')
if self.partner_id.x_fc_send_thickness_report:
types.add('thickness_report')
return types
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', 'thickness_report'},
'coc_thickness': {'coc'}, # bundled — thickness on page 2
}.get(req, {'coc'})
next_milestone_action = fields.Selection(
@@ -308,9 +318,29 @@ class FpJob(models.Model):
return fn()
def _action_open_draft_certs(self):
"""Open the cert list filtered to draft certs for this job.
Manager reviews each in turn and clicks Issue per-cert."""
"""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,
@@ -1521,6 +1551,37 @@ class FpJob(models.Model):
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 \
@@ -1596,6 +1657,10 @@ class FpJob(models.Model):
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:
@@ -1612,6 +1677,25 @@ class FpJob(models.Model):
"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
@@ -1626,13 +1710,20 @@ class FpJob(models.Model):
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, part_number, quantity_shipped, po,
(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.
Replaces the single-CoC implementation: now honours
part.certificate_requirement (coc / coc_thickness / none /
inherit) and partner-level send_coc / send_thickness_report
flags. Closes spec gap C-G1.
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:
@@ -1645,6 +1736,25 @@ class FpJob(models.Model):
# 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)]
@@ -1691,6 +1801,8 @@ class FpJob(models.Model):
(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'] = (
@@ -1703,8 +1815,12 @@ class FpJob(models.Model):
vals['customer_job_no'] = (
self.sale_order_id.x_fc_customer_job_number or ''
)
if 'process_description' in Cert._fields and coating:
vals['process_description'] = coating.name 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)
@@ -1728,6 +1844,107 @@ class FpJob(models.Model):
) % {'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.

View File

@@ -823,16 +823,67 @@ class FpJobStep(models.Model):
'state': state_label,
})
def _fp_check_receiving_gate(self):
"""Block step transitions until parts are physically received.
Applied to every step EXCEPT Contract Review (paperwork — doesn't
need parts on the floor). Fires from both button_start and
button_finish so an operator can't begin OR complete physical
work before the receiving record is closed.
Manager bypass: ``fp_skip_receiving_gate=True`` in context. Same
pattern as the qty / QC / bake gates. Audit trail is preserved
via the state-transition tracking on chatter.
Threshold: SO ``x_fc_receiving_status == 'received'``. Post-Sub-8
that's the terminal state (inspection moved into the recipe's
racking step; ``'inspected'`` was dropped in the 2026-05-18
cleanup).
"""
if self.env.context.get('fp_skip_receiving_gate'):
return
for step in self:
if step._fp_is_contract_review_step():
continue
so = step.job_id.sale_order_id
if not so:
# Internal rework / no SO — gate doesn't apply.
continue
if 'x_fc_receiving_status' not in so._fields:
# Defensive: configurator module not installed.
continue
if so.x_fc_receiving_status != 'received':
label = dict(
so._fields['x_fc_receiving_status'].selection
).get(
so.x_fc_receiving_status,
so.x_fc_receiving_status or 'unknown',
)
raise UserError(_(
'Step "%(step)s" cannot proceed — parts not received '
'yet (SO %(so)s receiving status: %(status)s).\n\n'
'Close the receiving record (Sales > %(so)s > '
'Receiving) before starting or finishing work on '
'this step. A manager can bypass this gate for '
'documented exceptions.'
) % {
'step': step.name,
'so': so.name or '?',
'status': label,
})
def button_start(self):
"""Single source of truth for step start:
1. Sub 13 predecessor gate (raise UserError if blocking)
2. Policy B Contract Review auto-open (route to QA-005)
3. Sub 8 Racking auto-open (route to racking inspection)
4. super().button_start() + receiving soft check + serial
promotion for the standard path
2. Receiving gate (raise UserError if parts not received)
3. Policy B Contract Review auto-open (route to QA-005)
4. Sub 8 Racking auto-open (route to racking inspection)
5. super().button_start() + serial promotion for the standard
path
Manager bypasses available via context:
fp_skip_predecessor_check=True skips the Sub 13 gate
fp_skip_receiving_gate=True skips the receiving gate
"""
# ---- 1. Sub 13 predecessor gate ----------------------------------
skip_pred = self.env.context.get('fp_skip_predecessor_check')
@@ -863,7 +914,13 @@ class FpJobStep(models.Model):
),
))
# ---- 2. Policy B Contract Review auto-open -----------------------
# ---- 2. Receiving gate -------------------------------------------
# Hard block (replaces the prior soft chatter warning). The
# helper exempts Contract Review steps internally, so contract
# review can still auto-open below regardless of receiving state.
self._fp_check_receiving_gate()
# ---- 3. Policy B Contract Review auto-open -----------------------
for step in self:
if step._fp_is_contract_review_step():
action = step._fp_open_contract_review()
@@ -873,7 +930,7 @@ class FpJobStep(models.Model):
step._fp_promote_serials_on_start()
return action
# ---- 3. Sub 8 Racking auto-open ----------------------------------
# ---- 4. Sub 8 Racking auto-open ----------------------------------
for step in self:
if step._fp_is_racking_step():
action = step._fp_open_racking_inspection()
@@ -883,33 +940,18 @@ class FpJobStep(models.Model):
step._fp_promote_serials_on_start()
return action
# ---- 4. Standard path: start + receiving check + serial promote --
# ---- 5. Standard path: start + serial promote --------------------
result = super().button_start()
for step in self:
if step.state == 'in_progress':
step._fp_promote_serials_on_start()
so = step.job_id.sale_order_id
if not so:
continue
recv = so.x_fc_receiving_status if (
'x_fc_receiving_status' in so._fields
) else None
if recv in (False, None, 'not_received'):
step.job_id.message_post(body=_(
'Step "%(step)s" started before parts were received '
'(SO %(so)s — receiving status: %(status)s). '
'Confirm the parts are physically on the floor before '
'continuing.'
) % {
'step': step.name,
'so': so.name or '',
'status': recv or 'unknown',
})
return result
def button_finish(self):
# Policy B — block until QA-005 complete (when customer requires it).
self._fp_check_contract_review_complete()
# Receiving gate — same helper as button_start, exempts CR steps.
self._fp_check_receiving_gate()
# NOTE: racking inspection gate removed — racking is now a recipe
# step, not a separate inspection workflow. _fp_check_racking_
# inspection_complete() is kept as a helper for diagnostics but

View File

@@ -175,10 +175,13 @@ class SaleOrder(models.Model):
if recv_status == 'not_received':
so.x_fc_workflow_stage = 'awaiting_parts'
continue
if recv_status in ('partial', 'received'):
so.x_fc_workflow_stage = 'inspecting'
if recv_status == 'partial':
so.x_fc_workflow_stage = 'awaiting_parts'
continue
if recv_status == 'inspected':
if recv_status == 'received':
# Sub 8: 'received' is the terminal receiving state (no
# more separate 'inspected'). Parts are on the floor;
# inspection happens inside the recipe's racking step.
if not so.x_fc_assigned_manager_id and not jobs:
so.x_fc_workflow_stage = 'assign_work'
continue
@@ -562,16 +565,27 @@ class SaleOrder(models.Model):
return True
def action_fp_accept_parts(self):
"""Mark receiving accepted; flip SO receiving status to inspected."""
"""Mark receiving complete; flip SO receiving status to received.
Sub 8 (2026-04-22) moved inspection out of receiving and into the
recipe's racking step. Receiving's terminal state is now 'closed'
(or legacy 'accepted'), which maps to SO status 'received'. The
old 'inspected' SO status no longer exists.
"""
self.ensure_one()
Recv = self.env.get('fp.receiving')
if Recv is None:
return False
for rec in Recv.search([('sale_order_id', '=', self.id)]):
if rec.state in ('draft', 'inspecting'):
# Push receiving to its terminal state — 'closed' is the
# post-Sub-8 terminal; 'accepted' kept as a legacy fallback
# only for old records still in pre-Sub-8 states.
if rec.state in ('draft', 'counted', 'staged'):
rec.state = 'closed'
elif rec.state in ('inspecting',):
rec.state = 'accepted'
if 'x_fc_receiving_status' in self._fields:
self.x_fc_receiving_status = 'inspected'
self.x_fc_receiving_status = 'received'
self.message_post(body=_('Parts accepted — ready to assign manager.'))
return True