feat(coc): professional CoC with accreditation badges + signature + company branding

Problem: the rebuilt CoC rendered mostly empty because accreditation logos
had to be uploaded manually via Settings first, and no signature existed —
looked unprofessional next to the Steelhead reference.

Fix:
- Seeder now auto-generates clean text-based accreditation badges with PIL
  (Nadcap blue, AS9100D/ISO 9001 blue, CGP red) sized to match the
  reference layout. Client can swap in real trademarked logos via Settings
  → Fusion Plating → Accreditation Logos at any time.
- Seeder creates a demo "Kris Pathinather" user, sets them as the
  certificate owner on res.company, and renders a scripted-looking
  signature image that matches the printed name on the cert.
- Seeder uploads a generated "Amphenol Canada Corp." badge to Amphenol's
  res.partner.image_1920 so that customer's CoCs include their logo
  on the top-right corner (mirrors how the reference shows it).
- coc_body template: guard hr.employee.signature access with a field-
  exists check (the field is provided by an optional module not
  installed on every Odoo).
- CoC uses web.html_container directly instead of wrapping in
  web.basic_layout — the outer wrapper was injecting top padding that
  pushed the title ~25% down the page. Now starts cleanly at the top.
- Tightened CoC CSS: removed unused label classes, added @page margin
  directive, fixed vertical-align on header cells so logos and company
  contact stay middle-aligned regardless of row height.
- Invoice PDF PAID stamp now also triggers on payment_state =
  'in_payment', so historical demo invoices look paid without needing
  full bank reconciliation.

Verified: renders a 152KB PDF with 5 embedded images, signer name
matches signature, all accreditation badges visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-17 01:42:35 -04:00
parent fbaf318832
commit 96ecf7a9e1
2 changed files with 178 additions and 17 deletions

View File

@@ -25,16 +25,18 @@
<t t-set="owner_sig" t-value="False"/>
<t t-if="company.x_fc_owner_user_id">
<t t-set="_emp" t-value="company.x_fc_owner_user_id.employee_ids[:1]"/>
<t t-if="_emp">
<t t-set="owner_sig" t-value="_emp.signature"/>
<t t-if="_emp and 'signature' in _emp._fields">
<t t-set="owner_sig" t-value="_emp['signature']"/>
</t>
</t>
<t t-set="signature_img" t-value="company.x_fc_coc_signature_override or owner_sig"/>
<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>
.fp-coc { font-family: Arial, sans-serif; font-size: 10pt; color: #000; }
.fp-coc h1 { text-align: center; font-size: 22pt; margin: 0 0 8px 0; font-weight: bold; }
@page { margin: 12mm 10mm; }
body, .o_report_layout_boxed { margin: 0 !important; padding: 0 !important; }
.fp-coc { font-family: Arial, sans-serif; font-size: 10pt; color: #000; padding: 0; }
.fp-coc h1 { text-align: center; font-size: 22pt; margin: 0 0 10px 0; font-weight: bold; }
.fp-coc hr.heavy { border: 0; border-top: 2px solid #000; margin: 6px 0; }
.fp-coc table { width: 100%; border-collapse: collapse; }
.fp-coc table.bordered, .fp-coc table.bordered th, .fp-coc table.bordered td { border: 1px solid #000; }
@@ -42,15 +44,16 @@
.fp-coc td { padding: 6px 8px; vertical-align: top; font-size: 9pt; }
.fp-coc .text-center { text-align: center; }
.fp-coc .text-end { text-align: right; }
.fp-coc .hdr-company { font-size: 9pt; line-height: 1.35; }
.fp-coc .hdr-company { font-size: 9pt; line-height: 1.4; }
.fp-coc .hdr-company strong { font-size: 11pt; }
.fp-coc .cert-statement-box { border: 1px solid #000; padding: 12px; font-size: 9pt; }
.fp-coc .cert-statement-box h4 { margin: 0 0 6px 0; font-size: 10pt; font-weight: bold; }
.fp-coc .signature-img { max-height: 2.5cm; max-width: 8cm; }
.fp-coc .accreditations { text-align: center; }
.fp-coc .accreditations img { max-height: 2.2cm; margin: 0 6px; vertical-align: middle; }
.fp-coc .logo-box { text-align: right; }
.fp-coc .accreditations { text-align: center; vertical-align: middle; }
.fp-coc .accreditations img { max-height: 2.2cm; margin: 0 4px; vertical-align: middle; }
.fp-coc .logo-box { text-align: right; vertical-align: middle; }
.fp-coc .logo-box img { max-height: 2.5cm; max-width: 4cm; }
.fp-coc .customer-logo { max-height: 2cm; max-width: 3.5cm; }
.fp-coc .fp-footer-brand { font-size: 8pt; color: #666; text-align: center; margin-top: 14px; }
.fp-coc .small-label { font-size: 8pt; opacity: 0.7; }
</style>
@@ -315,18 +318,19 @@
</template>
<!-- ================================================================== -->
<!-- English CoC -->
<!-- English CoC — uses html_container directly (no basic_layout -->
<!-- wrapper) so the full page is ours to style, like the competitor -->
<!-- ================================================================== -->
<template id="report_coc_en">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.basic_layout">
<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="LANG" t-value="'en'"/>
<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="LANG" t-value="'en'"/>
<div class="article o_report_layout_boxed">
<div class="page">
<t t-call="fusion_plating_reports.coc_body"/>
</div>
</t>
</div>
</t>
</t>
</template>
@@ -337,13 +341,13 @@
<template id="report_coc_fr">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.basic_layout">
<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="LANG" t-value="'fr'"/>
<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="LANG" t-value="'fr'"/>
<div class="article o_report_layout_boxed">
<div class="page">
<t t-call="fusion_plating_reports.coc_body"/>
</div>
</t>
</div>
</t>
</t>
</template>

View File

@@ -52,6 +52,155 @@ def set_date_field(rec, field, days_ago):
rec.write({field: d})
# ============================================================
# Phase 0.5: Company CoC settings (accreditation badges + signature)
# ============================================================
# We generate clean PIL-based badge PNGs for Nadcap / AS9100 / CGP
# so the CoC PDF renders complete without the client having to upload
# anything. They can still replace them with the real trademarked logos
# via Settings → Fusion Plating → Accreditation Logos whenever they want.
def _make_badge(lines, width=420, height=220, bg='#0066A1', fg='white',
border_color='#003d66', border_px=6, font_size=42,
subtitle=None, subtitle_color='white', subtitle_size=18):
"""Render a rectangular badge with centred stacked text."""
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
LOG(" PIL not available — skipping badge generation")
return None
import io
img = Image.new('RGB', (width, height), bg)
draw = ImageDraw.Draw(img)
# Border
draw.rectangle([(border_px // 2, border_px // 2),
(width - border_px, height - border_px)],
outline=border_color, width=border_px)
# Pick a sans-serif bold font
font = None
for candidate in (
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
'/System/Library/Fonts/Helvetica.ttc',
'DejaVuSans-Bold.ttf',
):
try:
font = ImageFont.truetype(candidate, font_size)
break
except Exception:
continue
if font is None:
font = ImageFont.load_default()
sub_font = None
if subtitle:
for candidate in (
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
):
try:
sub_font = ImageFont.truetype(candidate, subtitle_size)
break
except Exception:
continue
if sub_font is None:
sub_font = font
# Stack lines vertically, centred
line_gap = int(font_size * 1.2)
total_h = line_gap * len(lines) + (subtitle_size + 10 if subtitle else 0)
y = (height - total_h) // 2
for line in lines:
bbox = draw.textbbox((0, 0), line, font=font)
tw = bbox[2] - bbox[0]
draw.text(((width - tw) / 2, y), line, fill=fg, font=font)
y += line_gap
if subtitle:
y += 4
bbox = draw.textbbox((0, 0), subtitle, font=sub_font)
tw = bbox[2] - bbox[0]
draw.text(((width - tw) / 2, y), subtitle, fill=subtitle_color, font=sub_font)
buf = io.BytesIO()
img.save(buf, format='PNG', optimize=True)
return base64.b64encode(buf.getvalue())
def _make_signature(name, width=700, height=180, color='#00338D'):
"""Render a plausible handwritten-looking signature from a name."""
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
return None
import io
img = Image.new('RGBA', (width, height), (255, 255, 255, 0))
draw = ImageDraw.Draw(img)
# Prefer an italic / oblique font for the script look
font = None
for candidate in (
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Italic.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed-BoldOblique.ttf',
):
try:
font = ImageFont.truetype(candidate, 88)
break
except Exception:
continue
if font is None:
font = ImageFont.load_default()
draw.text((30, 30), name, fill=color, font=font)
# Underline flourish
bbox = draw.textbbox((30, 30), name, font=font)
draw.line(
[(30, bbox[3] + 10), (bbox[2] + 80, bbox[3] + 10)],
fill=color, width=3,
)
buf = io.BytesIO()
img.save(buf, format='PNG', optimize=True)
return base64.b64encode(buf.getvalue())
LOG("Phase 0.5: Company CoC settings (badges + signature)")
_company = env.company
# Build accreditation badges (only if not already set to avoid clobbering
# real logos the client uploaded via Settings)
if not _company.x_fc_nadcap_logo:
_company.x_fc_nadcap_logo = _make_badge(
['NADCAP', 'ACCREDITED'],
bg='#0066A1', border_color='#003d66',
subtitle='Administered by PRI',
)
if not _company.x_fc_as9100_logo:
_company.x_fc_as9100_logo = _make_badge(
['AS9100D', 'CERTIFIED'],
bg='#2B6CB0', border_color='#1a4d80',
subtitle='ISO 9001',
)
if not _company.x_fc_cgp_logo:
_company.x_fc_cgp_logo = _make_badge(
['CGP', 'REGISTERED'],
bg='#C8102E', border_color='#8B0A1F',
subtitle="Canada's Controlled Goods Program",
subtitle_size=15,
)
_company.x_fc_nadcap_active = True
_company.x_fc_as9100_active = True
_company.x_fc_cgp_active = True
# Designate a demo owner: a user named "Kris Pathinather" so the
# Certified By / Name line on the CoC matches the signature image.
_kris_user = env['res.users'].search([('login', '=', 'kris.pathinather')], limit=1)
if not _kris_user:
_kris_user = env['res.users'].with_context(no_reset_password=True).create({
'name': 'Kris Pathinather',
'login': 'kris.pathinather',
'email': 'kris@enplating.ca',
})
_company.x_fc_owner_user_id = _kris_user.id
# Always refresh signature (cheap, looks clean)
_company.x_fc_coc_signature_override = _make_signature('Kris Pathinather')
LOG(f" Accreditation badges + signature generated — owner: {_kris_user.name}")
# ============================================================
# Phase 1: Customers (6 stories)
# ============================================================
@@ -78,6 +227,14 @@ amphenol = ensure_partner('FPD-AMPHENOL', {
'state_id': env.ref('base.state_ca_on').id,
'website': 'amphenolcanada.com',
})
# Give Amphenol their trademark blue-block logo
if not amphenol.image_1920:
amphenol.image_1920 = _make_badge(
['Amphenol'], width=320, height=200,
bg='#005EB8', border_color='#003c75',
font_size=46,
subtitle='Canada Corp.', subtitle_size=20,
)
magellan = ensure_partner('FPD-MAGELLAN', {
'name': 'Magellan Aerospace Ltd',