feat(plating): merge Fischerscope PDF into CoC as page 2+

When a QC uploaded the XDAL 600 report, the CoC PDF render pipeline
now appends the Fischerscope PDF directly after the cert pages. This
matches what aerospace / Nadcap auditors expect (and how Steelhead
ships certs today) — a single PDF file carrying both the certificate
declaration and the raw equipment report.

Flow:
* _fp_generate_cert_pdf renders the CoC via QWeb as before
* _fp_merge_thickness_into_cert resolves the QC for the MO (preferring
  the passed one) and extracts its thickness_report_pdf_id bytes
* PyPDF2.PdfMerger concatenates CoC then Fischerscope into a single PDF
* Merged bytes replace pdf_content before the ir.attachment is written
* Falls back to CoC-only (and logs a warning) if PyPDF2 is missing or
  either PDF fails to parse — never blocks MO completion

Smoke test: synthetic Fischerscope + real QWeb CoC → 2-page merged PDF
with page 1 CoC text and page 2 Fischerscope text, verified via
PyPDF2 extract_text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-21 08:37:09 -04:00
parent e86d897bce
commit bdbfda7ce9

View File

@@ -1233,6 +1233,15 @@ class MrpProduction(models.Model):
force_report_rendering=True,
)._render_qweb_pdf(report.report_name, [cert.id])
# Append the Fischerscope / XDAL 600 PDF as page 2+ of the CoC
# when a QC uploaded one. Aerospace / Nadcap customers need the
# raw equipment report in the same PDF as the cert — this is
# how Steelhead does it and what auditors expect.
if cert.certificate_type == 'coc':
merged = self._fp_merge_thickness_into_cert(cert, pdf_content)
if merged:
pdf_content = merged
# Filename: CoC-<CustomerSlug>-<CertName>.pdf so the email
# attachment doesn't just say CERT-00123.pdf to the customer.
cust_name = cert.partner_id.name if cert.partner_id else ''
@@ -1258,3 +1267,86 @@ class MrpProduction(models.Model):
job.coc_attachment_id = att.id
if delivery and not delivery.coc_attachment_id:
delivery.coc_attachment_id = att.id
# ------------------------------------------------------------------
# CoC + Fischerscope PDF merge
# ------------------------------------------------------------------
def _fp_merge_thickness_into_cert(self, cert, coc_pdf_bytes):
"""Return a merged PDF: CoC first, Fischerscope report appended.
Returns None (so caller falls back to CoC-only) when:
- no QC on the MO has a thickness_report_pdf_id, or
- the Fischerscope attachment is missing / empty, or
- PyPDF2 is not installed, or
- either PDF fails to parse (corrupt / encrypted upload).
The uploaded PDF is treated as opaque — we don't try to normalise
page size or re-render. WinFTM exports are US-letter portrait,
which matches our CoC template. Mismatches will just show two
sizes in a PDF reader, which is fine.
"""
import io
import base64 as _b64
if not cert or cert.certificate_type != 'coc':
return None
mo = cert.production_id
if not mo:
return None
QC = self.env.get('fusion.plating.quality.check')
if QC is None:
return None
# Prefer the passed QC for this MO; if there isn't one yet (e.g.
# cert issuance before QC is walked) fall through to any QC
# that has a PDF uploaded.
qc = QC.search([
('production_id', '=', mo.id),
('state', '=', 'passed'),
('thickness_report_pdf_id', '!=', False),
], order='completed_at desc', limit=1)
if not qc:
qc = QC.search([
('production_id', '=', mo.id),
('thickness_report_pdf_id', '!=', False),
], order='create_date desc', limit=1)
if not qc or not qc.thickness_report_pdf_id:
return None
fischer_bytes = _b64.b64decode(qc.thickness_report_pdf_id.datas or b'')
if not fischer_bytes:
return None
try:
from PyPDF2 import PdfMerger
except ImportError:
try:
from pypdf import PdfMerger # newer name
except ImportError:
_logger.warning(
'Neither PyPDF2 nor pypdf installed — cannot merge '
'Fischerscope PDF into CoC %s. Attaching CoC only.',
cert.name,
)
return None
try:
merger = PdfMerger()
merger.append(io.BytesIO(coc_pdf_bytes))
merger.append(io.BytesIO(fischer_bytes))
out = io.BytesIO()
merger.write(out)
merger.close()
merged = out.getvalue()
except Exception:
_logger.exception(
'PDF merge failed for CoC %s — falling back to CoC-only. '
'The Fischerscope PDF may be corrupt / encrypted / '
'malformed.',
cert.name,
)
return None
cert.message_post(body=_(
'Fischerscope report from QC %s appended to CoC PDF.'
) % qc.name)
return merged