Customers can now pick which shipping-time documents they actually want instead of the shop remembering it per account. Four booleans on res.partner (only shown on companies, not contacts): x_fc_send_coc (default True) Certificate of Conformance x_fc_send_thickness_report (default True) Thickness readings x_fc_send_packing_slip (default True) Packing slip PDF x_fc_send_bol (default False) Bill of Lading Surfaced in a "Plating Documents" page on the customer form. Two downstream gates: 1. fp.notification.template._collect_attachments() now reads the flags when attaching CoC / thickness / packing / BoL PDFs to the shipping confirmation email. Flags missing on the partner (e.g. legacy customers) fall back to the original defaults so nothing regresses. 2. mrp.production.button_mark_done() only auto-creates the quality documents the customer wants. A customer that unchecks both CoC and thickness gets zero certs auto-generated — shop can still create them manually if needed. Note: today a standalone thickness-only report template doesn't exist, so when a customer asks for thickness only (CoC off, thickness on) the dispatcher still attaches the CoC PDF (which carries thickness data) but with CoC creation gated off. A dedicated thickness-only template is a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
270 lines
11 KiB
Python
270 lines
11 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)
|
|
if self.attach_coc and portal_job and _customer_wants('x_fc_send_coc'):
|
|
att = _render_report(
|
|
'fusion_plating_reports.action_report_coc', portal_job,
|
|
)
|
|
if att:
|
|
ids.append(att)
|
|
# Thickness report — gated by customer preference. Today the CoC
|
|
# template embeds thickness readings, so when a customer wants
|
|
# thickness-only we fall back to the CoC report attachment with
|
|
# a distinct filename. A standalone thickness-only template is
|
|
# TBD (not part of this chunk).
|
|
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'))):
|
|
# Avoid double-attaching the same PDF when both are wanted —
|
|
# the CoC already carries the thickness data.
|
|
att = _render_report(
|
|
'fusion_plating_reports.action_report_coc', 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
|