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>
294 lines
12 KiB
Python
294 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
# Part of the Fusion Plating product family.
|
|
|
|
import logging
|
|
|
|
from odoo import api, fields, models
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
TRIGGER_EVENTS = [
|
|
('quote_sent', 'Quotation Sent'),
|
|
('so_confirmed', 'Order Confirmed'),
|
|
('parts_received', 'Parts Received'),
|
|
('mo_complete', 'Manufacturing Complete'),
|
|
('shipped', 'Shipped / Delivered'),
|
|
('invoice_posted', 'Invoice Posted'),
|
|
('payment_received', 'Payment Received'),
|
|
('deposit_created', 'Deposit Required'),
|
|
]
|
|
|
|
|
|
class FpNotificationTemplate(models.Model):
|
|
"""Configurable notification wrapper.
|
|
|
|
Each record maps a trigger event to a mail.template and controls
|
|
whether the notification fires and what attachments are included.
|
|
"""
|
|
_name = 'fp.notification.template'
|
|
_description = 'Fusion Plating — Notification Template'
|
|
_order = 'trigger_event'
|
|
|
|
name = fields.Char(string='Template Name', required=True)
|
|
trigger_event = fields.Selection(
|
|
TRIGGER_EVENTS, string='Trigger Event', required=True,
|
|
)
|
|
mail_template_id = fields.Many2one(
|
|
'mail.template', string='Email Template',
|
|
help='The Odoo mail template used to render and send the email.',
|
|
)
|
|
active = fields.Boolean(string='Active', default=True)
|
|
attach_quotation = fields.Boolean(string='Attach Quotation PDF')
|
|
attach_sale_order = fields.Boolean(string='Attach Sales Order PDF')
|
|
attach_coc = fields.Boolean(string='Attach CoC')
|
|
attach_thickness_report = fields.Boolean(string='Attach Thickness Report')
|
|
attach_invoice = fields.Boolean(string='Attach Invoice')
|
|
attach_receipt = fields.Boolean(string='Attach Payment Receipt')
|
|
attach_packing_list = fields.Boolean(string='Attach Packing List')
|
|
attach_bol = fields.Boolean(string='Attach Bill of Lading')
|
|
attach_pod = fields.Boolean(string='Attach Proof of Delivery')
|
|
cc_internal_ids = fields.Many2many(
|
|
'res.users', 'fp_notification_template_cc_rel',
|
|
'template_id', 'user_id', string='CC (Internal)',
|
|
)
|
|
|
|
_sql_constraints = [
|
|
('fp_notification_trigger_uniq', 'unique(trigger_event)',
|
|
'Only one notification template per trigger event.'),
|
|
]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Central dispatch helper — called from every hook.
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def _dispatch(self, trigger_event, record, partner=None, sale_order=None,
|
|
extra_attachment_ids=None):
|
|
"""Look up the template for this trigger, render it, and send.
|
|
|
|
Also logs the attempt in fp.notification.log.
|
|
"""
|
|
template = self.search(
|
|
[('trigger_event', '=', trigger_event), ('active', '=', True)],
|
|
limit=1,
|
|
)
|
|
if not template or not template.mail_template_id:
|
|
return
|
|
partner = partner or getattr(record, 'partner_id', False)
|
|
|
|
# Build attachment list from template config
|
|
attachment_ids = list(extra_attachment_ids or [])
|
|
attachment_names = []
|
|
for att_id in template._collect_attachments(record):
|
|
attachment_ids.append(att_id)
|
|
if attachment_ids:
|
|
attachment_names = self.env['ir.attachment'].browse(attachment_ids).mapped('name')
|
|
|
|
Log = self.env['fp.notification.log']
|
|
try:
|
|
mail_id = template.mail_template_id.send_mail(
|
|
record.id,
|
|
force_send=False,
|
|
email_values={'attachment_ids': [(6, 0, attachment_ids)]} if attachment_ids else None,
|
|
)
|
|
Log.create({
|
|
'template_id': template.id,
|
|
'trigger_event': trigger_event,
|
|
'sale_order_id': sale_order.id if sale_order else False,
|
|
'partner_id': partner.id if partner else False,
|
|
'recipient_email': partner.email if partner else '',
|
|
'attachment_names': ', '.join(attachment_names) if attachment_names else '',
|
|
'status': 'sent',
|
|
'mail_mail_id': mail_id,
|
|
})
|
|
except Exception as exc:
|
|
_logger.warning('FP notification failed (%s): %s', trigger_event, exc)
|
|
Log.create({
|
|
'template_id': template.id,
|
|
'trigger_event': trigger_event,
|
|
'sale_order_id': sale_order.id if sale_order else False,
|
|
'partner_id': partner.id if partner else False,
|
|
'recipient_email': partner.email if partner else '',
|
|
'status': 'failed',
|
|
'error_message': str(exc),
|
|
})
|
|
|
|
def _collect_attachments(self, record):
|
|
"""Return a list of ir.attachment ids to attach to the email based
|
|
on the template's attach_* flags and the record's context.
|
|
"""
|
|
self.ensure_one()
|
|
Attachment = self.env['ir.attachment']
|
|
ids = []
|
|
|
|
# Resolve related records (MO, portal job, SO) from `record`
|
|
portal_job = None
|
|
production = None
|
|
sale_order = None
|
|
invoice = None
|
|
delivery = None
|
|
payment = None
|
|
|
|
model = record._name
|
|
if model == 'sale.order':
|
|
sale_order = record
|
|
portal_job = self.env['fusion.plating.portal.job'].search(
|
|
[('name', 'in', record.mapped('picking_ids.origin'))], limit=1,
|
|
) or None
|
|
elif model == 'account.move':
|
|
invoice = record
|
|
if record.invoice_origin:
|
|
sale_order = self.env['sale.order'].search(
|
|
[('name', '=', record.invoice_origin)], limit=1,
|
|
) or None
|
|
elif model == 'account.payment':
|
|
payment = record
|
|
invoice = record.reconciled_invoice_ids[:1]
|
|
if invoice and invoice.invoice_origin:
|
|
sale_order = self.env['sale.order'].search(
|
|
[('name', '=', invoice.invoice_origin)], limit=1,
|
|
) or None
|
|
elif model == 'mrp.production':
|
|
production = record
|
|
portal_job = record.x_fc_portal_job_id
|
|
if record.origin:
|
|
sale_order = self.env['sale.order'].search(
|
|
[('name', '=', record.origin)], limit=1,
|
|
) or None
|
|
elif model == 'fusion.plating.delivery':
|
|
delivery = record
|
|
if record.job_ref:
|
|
portal_job = self.env['fusion.plating.portal.job'].search(
|
|
[('name', '=', record.job_ref)], limit=1,
|
|
) or None
|
|
elif model == 'fp.receiving':
|
|
sale_order = record.sale_order_id
|
|
|
|
def _render_report(xmlid, rec):
|
|
"""Render a PDF report and return an attachment id."""
|
|
if not rec:
|
|
return None
|
|
try:
|
|
report = self.env.ref(xmlid, raise_if_not_found=False)
|
|
if not report:
|
|
return None
|
|
pdf_bytes, _fmt = self.env['ir.actions.report']._render_qweb_pdf(
|
|
xmlid, res_ids=rec.ids,
|
|
)
|
|
import base64
|
|
att = Attachment.create({
|
|
'name': f'{report.name} - {rec.display_name}.pdf',
|
|
'type': 'binary',
|
|
'datas': base64.b64encode(pdf_bytes),
|
|
'mimetype': 'application/pdf',
|
|
'res_model': rec._name,
|
|
'res_id': rec.id,
|
|
})
|
|
return att.id
|
|
except Exception as exc:
|
|
_logger.warning('Failed to render %s: %s', xmlid, exc)
|
|
return None
|
|
|
|
# Resolve the customer record — partner-level flags gate certain
|
|
# documents so customer preferences override template defaults.
|
|
customer = None
|
|
if sale_order:
|
|
customer = sale_order.partner_id
|
|
elif portal_job:
|
|
customer = portal_job.partner_id
|
|
elif delivery:
|
|
customer = delivery.partner_id
|
|
elif invoice and getattr(invoice, 'partner_id', None):
|
|
customer = invoice.partner_id
|
|
elif production and portal_job:
|
|
customer = portal_job.partner_id
|
|
|
|
def _customer_wants(flag, default=True):
|
|
"""Respect the per-customer flag if the field exists; else default."""
|
|
if customer and flag in customer._fields:
|
|
return bool(getattr(customer, flag))
|
|
return default
|
|
|
|
# Both attach_quotation and attach_sale_order point at the same
|
|
# report today — render once to avoid double attachment.
|
|
if (self.attach_quotation or self.attach_sale_order) and sale_order:
|
|
att = _render_report(
|
|
'fusion_plating_reports.action_report_fp_sale_portrait', sale_order,
|
|
)
|
|
if att:
|
|
ids.append(att)
|
|
# 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'):
|
|
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'))):
|
|
att = _render_report(
|
|
'fusion_plating_reports.action_report_coc_en', portal_job,
|
|
)
|
|
if att:
|
|
ids.append(att)
|
|
if self.attach_invoice and invoice:
|
|
att = _render_report(
|
|
'fusion_plating_reports.action_report_fp_invoice_portrait', invoice,
|
|
)
|
|
if att:
|
|
ids.append(att)
|
|
if self.attach_receipt and payment:
|
|
att = _render_report(
|
|
'fusion_plating_reports.action_report_fp_receipt_portrait', payment,
|
|
)
|
|
if att:
|
|
ids.append(att)
|
|
# Packing slip — gated by customer preference (default True)
|
|
if self.attach_packing_list and delivery and _customer_wants('x_fc_send_packing_slip'):
|
|
att = _render_report(
|
|
'fusion_plating_reports.action_report_fp_packing_slip_portrait', delivery,
|
|
)
|
|
if att:
|
|
ids.append(att)
|
|
# BoL — gated by customer preference (default False)
|
|
if self.attach_bol and delivery and _customer_wants('x_fc_send_bol', default=False):
|
|
att = _render_report(
|
|
'fusion_plating_reports.action_report_fp_bol_portrait', delivery,
|
|
)
|
|
if att:
|
|
ids.append(att)
|
|
return ids
|