Five new boolean flags on res.partner applied to CHILD contacts: x_fc_receives_certs, _qc, _quotes_so, _invoices, and _is_global_contact. Single resolver helper res.partner._fp_resolve_notification_recipients (stream, delivery_location=None) walks location contacts first then company contacts, returning emails for contacts that opted into the stream (or flagged themselves global). Falls back to partner.email when no contact opts in so existing customers keep their exact pre-Sub-6 routing. fp.notification.template._dispatch now maps each trigger event to a stream (so_confirmed→quotes_so, invoice_posted→invoices, shipped→ certs, etc.) and overrides the mail_template's email_to with the resolved list. fp.delivery passes its delivery_address_id so the shipped/CoC email routes through location-scoped contacts when they exist. Partner form gets a new "Communication Routing" tab on child contact forms with the 5 flags (hides the per-stream checkboxes when Global Contact is on, since it overrides them). fusion_plating_certificates → 19.0.4.0.0 (adds the flag fields) fusion_plating_notifications → 19.0.5.0.0 (+depends certificates) Smoke on entech: 11/11 assertions pass including per-stream routing, delivery-location scoping, zero-flag fallback, email-less skip, unknown-stream + global behaviour, and case-insensitive dedup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
338 lines
14 KiB
Python
338 lines
14 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'),
|
|
]
|
|
|
|
# Sub 6 — map each trigger event to a communication stream. Contacts on
|
|
# the customer who opt into that stream (or flag themselves as global)
|
|
# receive the email. Unmapped events fall back to the company partner's
|
|
# own email, preserving pre-Sub-6 behaviour.
|
|
FP_TRIGGER_STREAM = {
|
|
'quote_sent': 'quotes_so',
|
|
'so_confirmed': 'quotes_so',
|
|
'parts_received': 'quotes_so',
|
|
'mo_complete': 'qc',
|
|
'shipped': 'certs',
|
|
'invoice_posted': 'invoices',
|
|
'payment_received': 'invoices',
|
|
'deposit_created': 'invoices',
|
|
}
|
|
|
|
|
|
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, delivery_location=None):
|
|
"""Look up the template for this trigger, render it, and send.
|
|
|
|
Also logs the attempt in fp.notification.log.
|
|
|
|
Sub 6: recipient resolution now goes through
|
|
res.partner._fp_resolve_notification_recipients so per-contact
|
|
flags (certs / qc / quotes_so / invoices / global) and per-
|
|
delivery-location contacts are honoured. Customers who haven't
|
|
set any flags fall back to the company partner's email —
|
|
identical to pre-Sub-6 behaviour.
|
|
"""
|
|
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')
|
|
|
|
# Sub 6 — resolve recipients via the contact-routing helper.
|
|
recipient_emails = []
|
|
if partner:
|
|
stream = FP_TRIGGER_STREAM.get(trigger_event)
|
|
if stream:
|
|
recipient_emails = partner._fp_resolve_notification_recipients(
|
|
stream, delivery_location=delivery_location,
|
|
)
|
|
elif partner.email:
|
|
recipient_emails = [partner.email]
|
|
recipient_str = ', '.join(recipient_emails)
|
|
|
|
email_values = {}
|
|
if attachment_ids:
|
|
email_values['attachment_ids'] = [(6, 0, attachment_ids)]
|
|
if recipient_str:
|
|
# Override the mail.template's default Jinja-rendered email_to
|
|
# with the resolved list. Setting email_to clears partner_ids
|
|
# inherited from the template so we don't double-send.
|
|
email_values['email_to'] = recipient_str
|
|
email_values['partner_ids'] = [(5, 0, 0)]
|
|
|
|
Log = self.env['fp.notification.log']
|
|
try:
|
|
mail_id = template.mail_template_id.send_mail(
|
|
record.id,
|
|
force_send=False,
|
|
email_values=email_values or 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': recipient_str or (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': recipient_str or (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
|