refactor(coc): use web.external_layout for header/footer + 3-column bordered accreditations

Per feedback, dropped the custom company-contact header and paperformat
in favour of Odoo's standard web.external_layout. This gives the CoC:
  - Company-branded header (logo, name, address, phone, email, tax id)
    matching whichever layout variant the company picked in
    Settings → General → Document Layout (Standard / Boxed / Clean /
    Striped). Automatically themed with company.primary_color.
  - Consistent page X/Y footer + "Printed on" timestamp.
  - Correct header_spacing so the letterhead band lines up with the
    default paperformat.

Our body now owns:
  - Centred "Certificate of Conformance" / "Certificat de Conformité"
  - 3-column bordered accreditation table — one logo per cell (Nadcap,
    AS9100D/ISO 9001, CGP) with equal 33.33% widths and #000 borders,
    2.8cm cell height so logos centre vertically
  - Optional customer logo (res.partner.image_1920) right-aligned
    below the accreditation row
  - Customer info block (name, address, contact, email, phone)
  - Certification info table (date, generated-by, WO#)
  - Quantities table (part, process, PO, shipped, NC qty, job no)
  - Signature image + bordered cert statement
  - "Fusion Plating by Nexa Systems" brand note

Template plumbing:
- Explicit `<t t-set="company" t-value="doc.sale_order_id.company_id
  or doc.production_id.company_id or env.company"/>` in the EN/FR
  wrappers because QWeb's t-call scoping doesn't expose variables set
  inside external_layout to the body we pass through. Without this,
  coc_body's `company.x_fc_owner_user_id` raises KeyError.
- Removed paperformat_fp_coc from the report actions (now uses the
  default paperformat, which is designed for external_layout's
  reserved header_spacing).

Verified: 332KB PDF, 1 page, all 5 images embedded, Amphenol logo on
right side of accreditation row, signature renders, company header
band at top, page footer at bottom.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-17 01:59:54 -04:00
parent 66f7f6c644
commit 44a980c468
2 changed files with 117 additions and 142 deletions

View File

@@ -70,6 +70,8 @@
<!-- ============================================================= --> <!-- ============================================================= -->
<!-- Formal Certificate of Conformance — English --> <!-- Formal Certificate of Conformance — English -->
<!-- Uses Odoo's default paperformat so web.external_layout's -->
<!-- header/footer band gets its reserved space correctly. -->
<!-- ============================================================= --> <!-- ============================================================= -->
<record id="action_report_coc_en" model="ir.actions.report"> <record id="action_report_coc_en" model="ir.actions.report">
<field name="name">Certificate of Conformance (English)</field> <field name="name">Certificate of Conformance (English)</field>
@@ -80,7 +82,6 @@
<field name="print_report_name">'CoC EN - %s' % object.name</field> <field name="print_report_name">'CoC EN - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_certificates.model_fp_certificate"/> <field name="binding_model_id" ref="fusion_plating_certificates.model_fp_certificate"/>
<field name="binding_type">report</field> <field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_coc"/>
</record> </record>
<!-- ============================================================= --> <!-- ============================================================= -->
@@ -95,7 +96,6 @@
<field name="print_report_name">'CoC FR - %s' % object.name</field> <field name="print_report_name">'CoC FR - %s' % object.name</field>
<field name="binding_model_id" ref="fusion_plating_certificates.model_fp_certificate"/> <field name="binding_model_id" ref="fusion_plating_certificates.model_fp_certificate"/>
<field name="binding_type">report</field> <field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_coc"/>
</record> </record>
<!-- ============================================================= --> <!-- ============================================================= -->

View File

@@ -4,21 +4,25 @@
License OPL-1 (Odoo Proprietary License v1.0) License OPL-1 (Odoo Proprietary License v1.0)
Fusion Plating — Certificate of Conformance Fusion Plating — Certificate of Conformance
Four variants: Design note:
- report_coc_en English, portrait, ENTECH-style formal cert (primary) The EN + FR CoCs wrap their body in web.external_layout. That gives
- report_coc_fr French, portrait, mirror of EN us Odoo's standard company-branded header (logo, name, address,
- report_coc_portrait Legacy portrait (kept for existing bindings) phone, email, tax id) and footer (page X/Y, printed-on) for free,
- report_coc Legacy landscape (kept for existing bindings) using whichever layout variant the company has chosen in
Settings → General → Document Layout. Our body just renders the
CoC-specific content: title, accreditation badges, customer block,
details table, signature, and certification statement.
Settings sourced from res.company (Settings → Fusion Plating): Variants:
- x_fc_owner_user_id.employee_ids[:1].signature Default signer image - report_coc_en English formal cert (primary)
- x_fc_coc_signature_override Optional override image - report_coc_fr French mirror
- x_fc_{nadcap,as9100,cgp}_logo + _active Accreditation badges - report_coc, report_coc_portrait
Legacy portal-job-bound variants (unchanged)
--> -->
<odoo> <odoo>
<!-- ================================================================== --> <!-- ================================================================== -->
<!-- Shared CoC body macro (English / French switched via LANG) --> <!-- Shared CoC body — rendered inside web.external_layout -->
<!-- ================================================================== --> <!-- ================================================================== -->
<template id="coc_body"> <template id="coc_body">
<t t-set="is_fr" t-value="LANG == 'fr'"/> <t t-set="is_fr" t-value="LANG == 'fr'"/>
@@ -33,95 +37,91 @@
<t t-set="signer_name" t-value="doc.certified_by_id.name or (company.x_fc_owner_user_id.name if company.x_fc_owner_user_id else '')"/> <t t-set="signer_name" t-value="doc.certified_by_id.name or (company.x_fc_owner_user_id.name if company.x_fc_owner_user_id else '')"/>
<style> <style>
/* Kill all Odoo-inherited body/main/page spacing */ .fp-coc { font-family: Arial, sans-serif; font-size: 9pt; color: #000; }
html, body { margin: 0 !important; padding: 0 !important; } .fp-coc h1 { text-align: center; font-size: 20pt; margin: 0 0 10px 0; font-weight: bold; }
main, .article, .page, .o_report_layout_boxed { .fp-coc hr.heavy { border: 0; border-top: 2px solid #000; margin: 6px 0; }
margin: 0 !important; padding: 0 !important; .fp-coc table { width: 100%; border-collapse: collapse; margin-bottom: 6px; }
page-break-after: auto !important;
min-height: 0 !important;
}
header.o_company_header, footer.o_company_footer { display: none !important; }
.fp-coc { font-family: Arial, sans-serif; font-size: 9pt; color: #000;
margin: 0; padding: 0; }
.fp-coc h1 { text-align: center; font-size: 20pt; margin: 0 0 6px 0;
font-weight: bold; page-break-before: avoid; }
.fp-coc hr.heavy { border: 0; border-top: 2px solid #000; margin: 4px 0; }
.fp-coc table { width: 100%; border-collapse: collapse; }
.fp-coc table.bordered, .fp-coc table.bordered,
.fp-coc table.bordered th, .fp-coc table.bordered th,
.fp-coc table.bordered td { border: 1px solid #000; } .fp-coc table.bordered td { border: 1px solid #000; }
.fp-coc th { background-color: #ededed; font-weight: bold; .fp-coc th { background-color: #ededed; font-weight: bold;
padding: 4px 6px; font-size: 8.5pt; text-align: center; } padding: 5px 8px; font-size: 8.5pt; text-align: center; }
.fp-coc td { padding: 4px 6px; vertical-align: top; font-size: 8.5pt; } .fp-coc td { padding: 5px 8px; vertical-align: top; font-size: 8.5pt; }
.fp-coc .text-center { text-align: center; } .fp-coc .text-center { text-align: center; }
.fp-coc .text-end { text-align: right; } .fp-coc .text-end { text-align: right; }
.fp-coc .hdr-company { font-size: 8pt; line-height: 1.35; padding: 4px; } .fp-coc .accreditation-table { margin: 8px 0; }
.fp-coc .hdr-company strong { font-size: 10pt; } .fp-coc .accreditation-table td.accreditation-cell {
.fp-coc .cert-statement-box { border: 1px solid #000; padding: 8px; font-size: 8pt; } width: 33.33%;
.fp-coc .cert-statement-box h4 { margin: 0 0 4px 0; font-size: 9pt; font-weight: bold; } text-align: center;
.fp-coc .signature-img { max-height: 1.8cm; max-width: 6.5cm; } vertical-align: middle;
.fp-coc .accreditations { text-align: center; vertical-align: middle; padding: 4px; } padding: 10px;
.fp-coc .accreditations img { max-height: 1.8cm; margin: 0 3px; vertical-align: middle; } border: 1px solid #000;
.fp-coc .logo-box { text-align: right; vertical-align: middle; padding: 4px; } height: 2.8cm;
.fp-coc .logo-box img { max-height: 2cm; max-width: 3.5cm; } }
.fp-coc .customer-logo-box img { max-height: 1.6cm; max-width: 3cm; } .fp-coc .accreditation-table img {
.fp-coc .fp-footer-brand { font-size: 7.5pt; color: #666; text-align: center; max-height: 2cm;
margin-top: 8px; } max-width: 95%;
vertical-align: middle;
}
.fp-coc .customer-logo { max-height: 1.8cm; max-width: 3.5cm; }
.fp-coc .cert-statement-box { border: 1px solid #000; padding: 10px; font-size: 8.5pt; }
.fp-coc .cert-statement-box h4 { margin: 0 0 6px 0; font-size: 9.5pt; font-weight: bold; }
.fp-coc .signature-img { max-height: 2.2cm; max-width: 7cm; }
.fp-coc .small-label { font-size: 7.5pt; opacity: 0.7; } .fp-coc .small-label { font-size: 7.5pt; opacity: 0.7; }
.fp-coc .brand-note { font-size: 7.5pt; color: #888; text-align: center;
margin-top: 10px; font-style: italic; }
</style> </style>
<div class="fp-coc"> <div class="fp-coc">
<!-- ============================= --> <!-- Title -->
<!-- HEADER -->
<!-- ============================= -->
<h1 t-if="not is_fr">Certificate of Conformance</h1> <h1 t-if="not is_fr">Certificate of Conformance</h1>
<h1 t-if="is_fr">Certificat de Conformité</h1> <h1 t-if="is_fr">Certificat de Conformité</h1>
<table> <!-- Accreditations — 3 bordered columns, one logo per column -->
<tr> <t t-set="nadcap_on" t-value="company.x_fc_nadcap_active and company.x_fc_nadcap_logo"/>
<td style="width: 33%;" class="hdr-company"> <t t-set="as9100_on" t-value="company.x_fc_as9100_active and company.x_fc_as9100_logo"/>
<strong t-field="company.name"/><br/> <t t-set="cgp_on" t-value="company.x_fc_cgp_active and company.x_fc_cgp_logo"/>
<t t-if="company.email"><t t-esc="company.email"/><br/></t> <t t-if="nadcap_on or as9100_on or cgp_on">
<t t-if="company.phone"><t t-esc="company.phone"/><br/></t> <table class="bordered accreditation-table">
<t t-if="company.partner_id.street"><t t-esc="company.partner_id.street"/><br/></t> <tr>
<t t-if="company.partner_id.street2"><t t-esc="company.partner_id.street2"/><br/></t> <td class="accreditation-cell">
<span t-if="company.partner_id.city"><t t-esc="company.partner_id.city"/></span> <t t-if="nadcap_on">
<span t-if="company.partner_id.state_id">, <t t-esc="company.partner_id.state_id.name"/></span> <img t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
<span t-if="company.partner_id.zip"> <t t-esc="company.partner_id.zip"/></span><br/> alt="Nadcap Accredited"/>
<t t-if="company.partner_id.country_id"><t t-esc="company.partner_id.country_id.name.upper()"/></t> </t>
</td> </td>
<td style="width: 34%;" class="accreditations"> <td class="accreditation-cell">
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo"> <t t-if="as9100_on">
<img t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()" <img t-att-src="'data:image/png;base64,%s' % company.x_fc_as9100_logo.decode()"
alt="Nadcap Accredited"/> alt="AS9100 / ISO 9001"/>
</t> </t>
<t t-if="company.x_fc_as9100_active and company.x_fc_as9100_logo"> </td>
<img t-att-src="'data:image/png;base64,%s' % company.x_fc_as9100_logo.decode()" <td class="accreditation-cell">
alt="AS9100 / ISO 9001"/> <t t-if="cgp_on">
</t> <img t-att-src="'data:image/png;base64,%s' % company.x_fc_cgp_logo.decode()"
<t t-if="company.x_fc_cgp_active and company.x_fc_cgp_logo"> alt="Controlled Goods Program"/>
<img t-att-src="'data:image/png;base64,%s' % company.x_fc_cgp_logo.decode()" </t>
alt="Controlled Goods Program"/> </td>
</t> </tr>
</td> </table>
<td style="width: 33%;" class="logo-box"> </t>
<img t-if="company.logo"
t-att-src="'data:image/png;base64,%s' % company.logo.decode()" <!-- Customer logo (separate, right-aligned) -->
alt=""/> <t t-if="doc.partner_id.image_1920">
</td> <div style="text-align: right; margin-top: 6px;">
</tr> <img class="customer-logo"
</table> t-att-src="'data:image/png;base64,%s' % doc.partner_id.image_1920.decode()"
alt=""/>
</div>
</t>
<hr class="heavy"/> <hr class="heavy"/>
<!-- ============================= --> <!-- Customer block -->
<!-- CUSTOMER BLOCK --> <table>
<!-- ============================= -->
<table style="margin-top: 6px;">
<tr> <tr>
<td style="width: 40%; vertical-align: top;"> <td style="width: 50%; vertical-align: top;">
<div> <div>
<strong t-if="not is_fr">Customer Name: </strong> <strong t-if="not is_fr">Customer Name: </strong>
<strong t-if="is_fr">Nom du client : </strong> <strong t-if="is_fr">Nom du client : </strong>
@@ -138,7 +138,7 @@
<span t-if="doc.partner_id.zip"> <t t-esc="doc.partner_id.zip"/></span> <span t-if="doc.partner_id.zip"> <t t-esc="doc.partner_id.zip"/></span>
</div> </div>
</td> </td>
<td style="width: 40%; vertical-align: top;"> <td style="width: 50%; vertical-align: top;">
<t t-set="contact" t-value="doc.contact_partner_id or doc.partner_id"/> <t t-set="contact" t-value="doc.contact_partner_id or doc.partner_id"/>
<div> <div>
<strong t-if="not is_fr">Contact Name: </strong> <strong t-if="not is_fr">Contact Name: </strong>
@@ -156,20 +156,13 @@
<t t-esc="contact.phone or '-'"/> <t t-esc="contact.phone or '-'"/>
</div> </div>
</td> </td>
<td style="width: 20%; vertical-align: top;" class="logo-box">
<img t-if="doc.partner_id.image_1920"
t-att-src="'data:image/png;base64,%s' % doc.partner_id.image_1920.decode()"
alt=""/>
</td>
</tr> </tr>
</table> </table>
<hr class="heavy"/> <hr class="heavy"/>
<!-- ============================= --> <!-- Certification info table -->
<!-- CERTIFICATION INFO --> <table class="bordered" style="margin-top: 8px;">
<!-- ============================= -->
<table class="bordered" style="margin-top: 10px;">
<thead> <thead>
<tr> <tr>
<th style="width: 33%;"> <th style="width: 33%;">
@@ -201,10 +194,8 @@
</tbody> </tbody>
</table> </table>
<!-- ============================= --> <!-- Quantities / line item table -->
<!-- LINE ITEM / QUANTITIES TABLE --> <div class="text-end small-label" style="margin-top: 8px;">
<!-- ============================= -->
<div class="text-end small-label" style="margin-top: 12px;">
<strong t-if="not is_fr">Quantities</strong> <strong t-if="not is_fr">Quantities</strong>
<strong t-if="is_fr">Quantités</strong> <strong t-if="is_fr">Quantités</strong>
</div> </div>
@@ -239,54 +230,42 @@
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td class="text-center"> <td class="text-center"><t t-esc="doc.part_number or '-'"/></td>
<t t-esc="doc.part_number or '-'"/>
</td>
<td> <td>
<t t-esc="doc.process_description or ''"/> <t t-esc="doc.process_description or ''"/>
<t t-if="doc.spec_reference"> <t t-if="doc.spec_reference">
<br/><em t-esc="doc.spec_reference"/> <br/><em t-esc="doc.spec_reference"/>
</t> </t>
</td> </td>
<td class="text-center"> <td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
<t t-esc="doc.po_number or '-'"/> <td class="text-center"><t t-esc="doc.quantity_shipped or 0"/></td>
</td> <td class="text-center"><t t-esc="doc.nc_quantity or 0"/></td>
<td class="text-center"> <td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
<t t-esc="doc.quantity_shipped or 0"/>
</td>
<td class="text-center">
<t t-esc="doc.nc_quantity or 0"/>
</td>
<td class="text-center">
<t t-esc="doc.customer_job_no or '-'"/>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- ============================= --> <!-- Signature + certification statement -->
<!-- SIGNATURE + STATEMENT --> <table style="margin-top: 18px;">
<!-- ============================= -->
<table style="margin-top: 28px;">
<tr> <tr>
<td style="width: 50%; vertical-align: top;"> <td style="width: 50%; vertical-align: top;">
<div> <div>
<strong t-if="not is_fr">Certified By:</strong> <strong t-if="not is_fr">Certified By:</strong>
<strong t-if="is_fr">Certifié par :</strong> <strong t-if="is_fr">Certifié par :</strong>
</div> </div>
<div style="min-height: 3cm; margin-top: 8px;"> <div style="min-height: 2.5cm; margin-top: 6px;">
<img t-if="signature_img" <img t-if="signature_img"
class="signature-img" class="signature-img"
t-att-src="'data:image/png;base64,%s' % signature_img.decode()" t-att-src="'data:image/png;base64,%s' % signature_img.decode()"
alt=""/> alt=""/>
</div> </div>
<div style="margin-top: 6px; border-top: 1px solid #000; padding-top: 4px;"> <div style="margin-top: 4px; border-top: 1px solid #000; padding-top: 4px;">
<strong t-if="not is_fr">Name: </strong> <strong t-if="not is_fr">Name: </strong>
<strong t-if="is_fr">Nom : </strong> <strong t-if="is_fr">Nom : </strong>
<t t-esc="signer_name or ''"/> <t t-esc="signer_name or ''"/>
</div> </div>
</td> </td>
<td style="width: 50%; vertical-align: top; padding-left: 16px;"> <td style="width: 50%; vertical-align: top; padding-left: 14px;">
<div class="cert-statement-box"> <div class="cert-statement-box">
<h4 t-if="not is_fr">Certification Statement</h4> <h4 t-if="not is_fr">Certification Statement</h4>
<h4 t-if="is_fr">Énoncé de conformité</h4> <h4 t-if="is_fr">Énoncé de conformité</h4>
@@ -311,36 +290,28 @@
</tr> </tr>
</table> </table>
<!-- ============================= --> <!-- Brand note (small, above Odoo's page footer) -->
<!-- FOOTER --> <div class="brand-note">
<!-- ============================= --> <t t-if="not is_fr">Fusion Plating by Nexa Systems</t>
<div class="fp-footer-brand"> <t t-if="is_fr">Fusion Plating par Nexa Systems</t>
<t t-if="not is_fr">
Cert Created At:
<span t-esc="context_timestamp(doc.create_date).strftime('%Y-%m-%d %H:%M') if doc.create_date else ''"/>
· Fusion Plating by Nexa Systems
</t>
<t t-if="is_fr">
Certificat créé le :
<span t-esc="context_timestamp(doc.create_date).strftime('%Y-%m-%d %H:%M') if doc.create_date else ''"/>
· Fusion Plating par Nexa Systems
</t>
</div> </div>
</div> </div>
</template> </template>
<!-- ================================================================== --> <!-- ================================================================== -->
<!-- English CoC — renders directly in html_container with NO wrapper --> <!-- English CoC — wrapped in web.external_layout -->
<!-- templates, so Odoo's `.article` and `.page` styles (which force -->
<!-- page-break-after + reserved header padding) don't apply. -->
<!-- ================================================================== --> <!-- ================================================================== -->
<template id="report_coc_en"> <template id="report_coc_en">
<t t-call="web.html_container"> <t t-call="web.html_container">
<t t-foreach="docs" t-as="doc"> <t t-foreach="docs" t-as="doc">
<t t-set="company" t-value="(doc.portal_job_id.company_id if doc.portal_job_id else False) or (doc.sale_order_id.company_id if doc.sale_order_id else False) or env.company"/> <t t-set="company" t-value="(doc.sale_order_id.company_id if doc.sale_order_id else False) or (doc.production_id.company_id if doc.production_id else False) or env.company"/>
<t t-set="LANG" t-value="'en'"/> <t t-call="web.external_layout">
<t t-call="fusion_plating_reports.coc_body"/> <t t-set="LANG" t-value="'en'"/>
<div class="page">
<t t-call="fusion_plating_reports.coc_body"/>
</div>
</t>
</t> </t>
</t> </t>
</template> </template>
@@ -351,9 +322,13 @@
<template id="report_coc_fr"> <template id="report_coc_fr">
<t t-call="web.html_container"> <t t-call="web.html_container">
<t t-foreach="docs" t-as="doc"> <t t-foreach="docs" t-as="doc">
<t t-set="company" t-value="(doc.portal_job_id.company_id if doc.portal_job_id else False) or (doc.sale_order_id.company_id if doc.sale_order_id else False) or env.company"/> <t t-set="company" t-value="(doc.sale_order_id.company_id if doc.sale_order_id else False) or (doc.production_id.company_id if doc.production_id else False) or env.company"/>
<t t-set="LANG" t-value="'fr'"/> <t t-call="web.external_layout">
<t t-call="fusion_plating_reports.coc_body"/> <t t-set="LANG" t-value="'fr'"/>
<div class="page">
<t t-call="fusion_plating_reports.coc_body"/>
</div>
</t>
</t> </t>
</t> </t>
</template> </template>