Files
Odoo-Modules/fusion_plating/fusion_plating_notifications/models/fp_notification_template.py
gsinghpal d3dd6376a6 feat(fusion_plating): quote-to-cash infra, notifications, wizards, Tier 1 plating features
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>
2026-04-16 23:41:12 -04:00

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