Quote-to-cash PDF reports (portrait + landscape variants, 16 new actions): - Quotation / Sales Order, Work Order Traveller, Packing Slip, Bill of Lading, Certificate of Conformance (portrait added), Invoice, Payment Receipt - Shared fp_portrait_styles + fp_landscape_styles base templates Workflow gap fixes (fusion_plating_bridge_mrp): - Auto-assign recipe from SO coating config in MrpProduction.action_confirm - Auto-create draft CoC (fp.certificate) on MrpProduction.button_mark_done Notifications overhaul (fusion_plating_notifications v2.0): - Expanded TRIGGER_EVENTS to 7 (added quote_sent, mo_complete, shipped, payment_received) - Shared _dispatch method replaces three duplicated send helpers - Auto-attach PDF reports per template config (quote, SO, CoC, invoice, receipt, BoL) - Rebuilt 7 email templates with fusion_claims accent-bar design (info/success color-coded, theme-safe, 600px max-width) - New hooks: MrpProduction done, FpDelivery mark_delivered, AccountPayment post, SaleOrder action_quotation_send Wizards (fusion_plating_configurator): - fp.direct.order.wizard — skip quotation for repeat customers with PO in hand; optional new-revision drawing upload bumps fp.part.catalog revision and links new rev to the SO; creates + confirms the SO in one step - fp.part.catalog.import.wizard — 3-step CSV import with dry-run preview, tolerant parsing (customer by name/email/xmlid, human-readable selections), duplicate detection, create-missing-customers option, single transaction commit - Partner form stat buttons: Direct Order, Import Parts - CSV template download button Tier 1 practical plating features: - T1.1 Hydrogen bake window enforcement (fp.coating.config.requires_bake_relief, auto-create fusion.plating.bake.window on plating WO finish, FpDelivery lockout when window is open) - T1.2 Bath replenishment rules + pending suggestion queue (fusion.plating.bath.replenishment.rule + .suggestion, hook on bath log line create, operator Apply / Dismiss actions) - T1.3 Rack/fixture library (fusion.plating.rack with MTO counter, strip schedule, lifecycle: active → needs_strip → stripping → retired) - T1.4 Rework / strip-and-replate MOs (x_fc_is_rework, x_fc_original_production_id, Create Rework stat button on completed MOs) - T1.5 Parts location (x_fc_current_location computed on mrp.production — "In progress: Alkaline Clean" / "Queued: Bake Oven" / "Ready to Ship") Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
230 lines
9.0 KiB
Python
230 lines
9.0 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
|
|
|
|
if self.attach_quotation and sale_order:
|
|
att = _render_report(
|
|
'fusion_plating_reports.action_report_fp_sale_portrait', sale_order,
|
|
)
|
|
if att:
|
|
ids.append(att)
|
|
if 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)
|
|
if self.attach_coc and portal_job:
|
|
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)
|
|
if self.attach_bol and delivery:
|
|
att = _render_report(
|
|
'fusion_plating_reports.action_report_fp_bol_portrait', delivery,
|
|
)
|
|
if att:
|
|
ids.append(att)
|
|
return ids
|