Files
Odoo-Modules/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py
gsinghpal f08f328688 changes
2026-04-27 00:11:18 -04:00

351 lines
15 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'), # legacy, fired by mrp; kept for back-compat
('job_confirmed', 'Plating Job Confirmed'), # Sub 11 — fp.job lifecycle
('job_complete', 'Plating Job Complete'), # Sub 11 — fp.job.button_mark_done
('shipped', 'Shipped / Delivered'),
('invoice_posted', 'Invoice Posted'),
('payment_received', 'Payment Received'),
('deposit_created', 'Deposit Required'),
('rma_authorised', 'RMA Authorised'), # Sub 12 — RMA lifecycle
('rma_received', 'RMA Parts Received'),
('rma_resolved', 'RMA Resolved'),
]
# 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',
'job_confirmed': 'qc',
'job_complete': 'qc',
'shipped': 'certs',
'invoice_posted': 'invoices',
'payment_received': 'invoices',
'deposit_created': 'invoices',
'rma_authorised': 'qc',
'rma_received': 'qc',
'rma_resolved': 'qc',
}
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]
# Filter out falsy entries — sub-contacts may have no email and the
# resolver returns False/None for them. Joining with bool blows up.
recipient_emails = [e for e in (recipient_emails or []) if e]
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