# -*- 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 ('shipment_labeled', 'Shipping Label Generated'), # Phase C — fired when tracking_number lands on fusion.shipment ('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', 'shipment_labeled': 'certs', '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