fix(plating): CoC + invoice PDFs render full content

Three reported PDF bugs from the customer-facing email package:

1. Invoice body was empty — Odoo 19 sets display_type='product' on
   regular invoice/SO lines (was empty string in 18.0). Both
   report_fp_invoice.xml and report_fp_sale.xml only matched
   `not line.display_type`, so every product line was skipped.
   Fixed both portrait + landscape variants to also match
   display_type == 'product'.

2. CoC PDF was a bare 30 KB header — _fp_generate_cert_pdf was
   rendering action_report_coc, which is bound to portal_job and
   has minimal content. Rewrote to use the rich fp.certificate-bound
   report (action_report_coc_en / action_report_coc_fr based on
   cert.partner_id.lang) and slugged the filename to
   CoC-<Customer>-<CertName>.pdf so the email attachment reads
   nicely instead of CERT-00123.pdf.

3. Thickness cert was an exact duplicate of the CoC — the CoC
   template already embeds thickness readings. Skip thickness cert
   creation entirely when the customer also wants CoC; only create
   a standalone thickness cert when the customer opted out of CoC.

Also: dispatcher in fp_notification_template now prefers
portal_job.coc_attachment_id (the rich one we just generated) and
falls back to rendering action_report_coc_en against fp.certificate
by partner.lang — never the bare portal-job report.

Versions bumped: bridge_mrp 19.0.6.0.0, notifications 19.0.4.0.0,
reports 19.0.4.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-19 01:16:27 -04:00
parent 167c423bf5
commit 633427bcf8
7 changed files with 93 additions and 38 deletions

View File

@@ -4,8 +4,8 @@
# Part of the Fusion Plating product family.
{
'name': 'Fusion Plating — MRP Bridge',
'version': '19.0.5.0.0',
"name": "Fusion Plating — MRP Bridge",
'version': '19.0.6.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """

View File

@@ -598,8 +598,13 @@ class MrpProduction(models.Model):
if not coc_cert:
coc_cert = Certificate.create({**base_vals, 'certificate_type': 'coc'})
# Skip thickness cert when CoC also wanted — the CoC
# template already embeds thickness readings, so creating
# a separate thickness cert just produces a duplicate PDF.
# Only create a standalone thickness cert when the customer
# has explicitly opted OUT of CoC and only wants thickness.
thickness_cert = False
if want_thickness:
if want_thickness and not want_coc:
thickness_cert = Certificate.search(
[('production_id', '=', mo.id),
('certificate_type', '=', 'thickness_report')], limit=1,
@@ -610,11 +615,21 @@ class MrpProduction(models.Model):
'certificate_type': 'thickness_report',
})
# Render PDFs and stash on the cert + portal job + delivery
# so the operator doesn't need to open each cert and click
# "Generate". Errors here never block MO completion.
# Issue + render PDFs and stash on the cert + portal job +
# delivery. The cert moves out of draft so chatter + DB
# state are honest. Errors never block MO completion.
for cert in (coc_cert, thickness_cert):
if cert and not cert.attachment_id:
if not cert:
continue
if cert.state == 'draft':
try:
cert.action_issue()
except Exception:
import logging
logging.getLogger(__name__).exception(
'Cert auto-issue failed for %s', cert.name,
)
if not cert.attachment_id:
try:
self._fp_generate_cert_pdf(cert, job, delivery)
except Exception:
@@ -676,30 +691,46 @@ class MrpProduction(models.Model):
"""Render a fp.certificate to PDF and attach it to the cert,
the portal job, and the delivery (so the customer-facing portal
and the shipping email both find it without an extra step).
Uses the rich fp.certificate-bound report (action_report_coc_en
or action_report_coc_fr based on partner lang). The older
action_report_coc is portal-job bound and produces a bare header
— don't use it here.
"""
report_xmlid = (
'fusion_plating_reports.action_report_coc'
if cert.certificate_type == 'coc'
else 'fusion_plating_reports.action_report_thickness'
# Pick the report variant by the customer's preferred language.
lang = (cert.partner_id.lang or '').lower() if cert.partner_id else ''
is_fr = lang.startswith('fr')
report = self.env.ref(
'fusion_plating_reports.action_report_coc_fr'
if is_fr
else 'fusion_plating_reports.action_report_coc_en',
raise_if_not_found=False,
)
report = self.env.ref(report_xmlid, raise_if_not_found=False)
if not report and cert.certificate_type == 'thickness_report':
# Fall back to the CoC layout for thickness if a dedicated
# thickness report isn't installed — better an attachment
# with thickness data baked in than nothing at all.
if not report:
# Last-resort fallback to the EN variant if FR is missing.
report = self.env.ref(
'fusion_plating_reports.action_report_coc',
'fusion_plating_reports.action_report_coc_en',
raise_if_not_found=False,
)
if not report:
return # reports module not available
import base64
import re
pdf_content, _ext = report.with_context(
force_report_rendering=True,
)._render_qweb_pdf(report.report_name, [cert.id])
# 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 ''
cust_slug = re.sub(r'[^A-Za-z0-9]+', '_', cust_name).strip('_') or 'Customer'
prefix = 'CoC' if cert.certificate_type == 'coc' else 'Thickness'
filename = f'{prefix}-{cust_slug}-{cert.name}.pdf'
att = self.env['ir.attachment'].create({
'name': f'{cert.name}.pdf',
'name': filename,
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'fp.certificate',

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Notifications',
'version': '19.0.3.0.0',
'version': '19.0.4.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
'author': 'Nexa Systems Inc.',

View File

@@ -218,25 +218,49 @@ class FpNotificationTemplate(models.Model):
)
if att:
ids.append(att)
# CoC — gated by customer preference (x_fc_send_coc, default True)
# CoC — gated by customer preference (x_fc_send_coc, default True).
# Prefer the rich PDF that mrp_production.button_mark_done already
# rendered against the fp.certificate (signatures, accreditation
# logos, thickness data). The legacy action_report_coc bound to
# fusion.plating.portal.job is only a header table; never use it
# when a real cert PDF exists.
if self.attach_coc and portal_job and _customer_wants('x_fc_send_coc'):
att = _render_report(
'fusion_plating_reports.action_report_coc', portal_job,
)
if att:
ids.append(att)
# Thickness report — gated by customer preference. Today the CoC
# template embeds thickness readings, so when a customer wants
# thickness-only we fall back to the CoC report attachment with
# a distinct filename. A standalone thickness-only template is
# TBD (not part of this chunk).
if portal_job.coc_attachment_id:
ids.append(portal_job.coc_attachment_id.id)
else:
# No pre-rendered cert (older job or cert-gen failed).
# Render the rich cert report against the most recent
# CoC fp.certificate, falling back to the bare portal_job
# template only if no cert exists at all.
Cert = self.env.get('fp.certificate')
cert = False
if Cert is not None and production:
cert = Cert.search([
('production_id', '=', production.id),
('certificate_type', '=', 'coc'),
], order='id desc', limit=1)
if cert:
lang = (cert.partner_id.lang or '').lower()
cert_xmlid = (
'fusion_plating_reports.action_report_coc_fr'
if lang.startswith('fr')
else 'fusion_plating_reports.action_report_coc_en'
)
att = _render_report(cert_xmlid, cert)
else:
att = _render_report(
'fusion_plating_reports.action_report_coc', portal_job,
)
if att:
ids.append(att)
# Thickness report — only attach when the customer opted OUT of
# CoC and ONLY wants thickness. The CoC PDF already embeds
# thickness data so attaching both would be a duplicate.
if (self.attach_thickness_report and portal_job
and _customer_wants('x_fc_send_thickness_report')
and not (self.attach_coc and _customer_wants('x_fc_send_coc'))):
# Avoid double-attaching the same PDF when both are wanted —
# the CoC already carries the thickness data.
att = _render_report(
'fusion_plating_reports.action_report_coc', portal_job,
'fusion_plating_reports.action_report_coc_en', portal_job,
)
if att:
ids.append(att)

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.3.0.0',
'version': '19.0.4.0.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -97,7 +97,7 @@
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr>
</t>
<t t-elif="not line.display_type">
<t t-elif="not line.display_type or line.display_type == 'product'">
<tr>
<td class="text-center"><span t-esc="line.product_id.default_code or ''"/></td>
<td>
@@ -290,7 +290,7 @@
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td t-att-colspan="col_count"><span t-field="line.name"/></td></tr>
</t>
<t t-elif="not line.display_type">
<t t-elif="not line.display_type or line.display_type == 'product'">
<tr>
<td class="text-center"><span t-esc="line.product_id.default_code or ''"/></td>
<td>

View File

@@ -118,7 +118,7 @@
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr>
</t>
<t t-elif="not line.display_type">
<t t-elif="not line.display_type or line.display_type == 'product'">
<tr>
<td class="text-center"><span t-esc="line.product_id.default_code or ''"/></td>
<td>
@@ -351,7 +351,7 @@
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td t-att-colspan="col_count"><span t-field="line.name"/></td></tr>
</t>
<t t-elif="not line.display_type">
<t t-elif="not line.display_type or line.display_type == 'product'">
<tr>
<td class="text-center"><span t-esc="line.product_id.default_code or ''"/></td>
<td>