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>
This commit is contained in:
@@ -8,3 +8,6 @@ from . import fp_notification_log
|
||||
from . import sale_order
|
||||
from . import fp_receiving
|
||||
from . import account_move
|
||||
from . import account_payment
|
||||
from . import mrp_production
|
||||
from . import fp_delivery
|
||||
|
||||
@@ -3,56 +3,24 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
def action_post(self):
|
||||
res = super().action_post()
|
||||
Dispatch = self.env['fp.notification.template']
|
||||
for move in self:
|
||||
if move.move_type == 'out_invoice' and move.partner_id:
|
||||
# Find linked SO
|
||||
so = False
|
||||
if move.invoice_origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', move.invoice_origin)], limit=1,
|
||||
)
|
||||
self._send_fp_notification(
|
||||
'invoice_posted', move, move.partner_id, sale_order=so,
|
||||
if move.move_type != 'out_invoice' or not move.partner_id:
|
||||
continue
|
||||
so = False
|
||||
if move.invoice_origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', move.invoice_origin)], limit=1,
|
||||
)
|
||||
Dispatch._dispatch(
|
||||
'invoice_posted', move, move.partner_id, sale_order=so,
|
||||
)
|
||||
return res
|
||||
|
||||
def _send_fp_notification(self, trigger_event, record, partner, sale_order=None):
|
||||
"""Send a notification email and log it."""
|
||||
template = self.env['fp.notification.template'].search(
|
||||
[('trigger_event', '=', trigger_event), ('active', '=', True)], limit=1,
|
||||
)
|
||||
if not template or not template.mail_template_id:
|
||||
return
|
||||
try:
|
||||
template.mail_template_id.send_mail(record.id, force_send=False)
|
||||
self.env['fp.notification.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': 'sent',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning('FP notification failed (%s): %s', trigger_event, e)
|
||||
self.env['fp.notification.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(e),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class AccountPayment(models.Model):
|
||||
_inherit = 'account.payment'
|
||||
|
||||
def action_post(self):
|
||||
res = super().action_post()
|
||||
Dispatch = self.env['fp.notification.template']
|
||||
for pay in self:
|
||||
# Only customer receipts (inbound payments from customers)
|
||||
if pay.payment_type != 'inbound' or not pay.partner_id:
|
||||
continue
|
||||
if pay.partner_type != 'customer':
|
||||
continue
|
||||
so = False
|
||||
inv = pay.reconciled_invoice_ids[:1]
|
||||
if inv and inv.invoice_origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', inv.invoice_origin)], limit=1,
|
||||
)
|
||||
Dispatch._dispatch(
|
||||
'payment_received', pay, pay.partner_id, sale_order=so,
|
||||
)
|
||||
return res
|
||||
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class FpDelivery(models.Model):
|
||||
_inherit = 'fusion.plating.delivery'
|
||||
|
||||
def action_mark_delivered(self):
|
||||
res = super().action_mark_delivered()
|
||||
Dispatch = self.env['fp.notification.template']
|
||||
for rec in self:
|
||||
if not rec.partner_id:
|
||||
continue
|
||||
so = False
|
||||
if rec.job_ref:
|
||||
# Delivery's job_ref is the MO name; find the SO via MO origin.
|
||||
mo = self.env['mrp.production'].search(
|
||||
[('name', '=', rec.job_ref)], limit=1,
|
||||
)
|
||||
if mo and mo.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', mo.origin)], limit=1,
|
||||
)
|
||||
Dispatch._dispatch(
|
||||
'shipped', rec, rec.partner_id, sale_order=so,
|
||||
)
|
||||
return res
|
||||
@@ -5,15 +5,7 @@
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
TRIGGER_EVENTS = [
|
||||
('so_confirmed', 'Order Confirmed'),
|
||||
('parts_received', 'Parts Received'),
|
||||
('mo_complete', 'Manufacturing Complete'),
|
||||
('shipment', 'Shipment (Carrier)'),
|
||||
('delivery', 'Delivery (Local)'),
|
||||
('invoice_posted', 'Invoice Posted'),
|
||||
('deposit_created', 'Deposit Required'),
|
||||
]
|
||||
from .fp_notification_template import TRIGGER_EVENTS
|
||||
|
||||
|
||||
class FpNotificationLog(models.Model):
|
||||
|
||||
@@ -3,15 +3,20 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
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'),
|
||||
('shipment', 'Shipment (Carrier)'),
|
||||
('delivery', 'Delivery (Local)'),
|
||||
('shipped', 'Shipped / Delivered'),
|
||||
('invoice_posted', 'Invoice Posted'),
|
||||
('payment_received', 'Payment Received'),
|
||||
('deposit_created', 'Deposit Required'),
|
||||
]
|
||||
|
||||
@@ -35,10 +40,14 @@ class FpNotificationTemplate(models.Model):
|
||||
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',
|
||||
@@ -49,3 +58,172 @@ class FpNotificationTemplate(models.Model):
|
||||
('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
|
||||
|
||||
@@ -3,50 +3,18 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpReceiving(models.Model):
|
||||
_inherit = 'fp.receiving'
|
||||
|
||||
def action_accept(self):
|
||||
res = super().action_accept()
|
||||
Dispatch = self.env['fp.notification.template']
|
||||
for rec in self:
|
||||
self._send_fp_notification(
|
||||
Dispatch._dispatch(
|
||||
'parts_received', rec, rec.partner_id,
|
||||
sale_order=rec.sale_order_id,
|
||||
)
|
||||
return res
|
||||
|
||||
def _send_fp_notification(self, trigger_event, record, partner, sale_order=None):
|
||||
"""Send a notification email and log it."""
|
||||
template = self.env['fp.notification.template'].search(
|
||||
[('trigger_event', '=', trigger_event), ('active', '=', True)], limit=1,
|
||||
)
|
||||
if not template or not template.mail_template_id:
|
||||
return
|
||||
try:
|
||||
template.mail_template_id.send_mail(record.id, force_send=False)
|
||||
self.env['fp.notification.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': 'sent',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning('FP notification failed (%s): %s', trigger_event, e)
|
||||
self.env['fp.notification.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(e),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class MrpProduction(models.Model):
|
||||
_inherit = 'mrp.production'
|
||||
|
||||
def button_mark_done(self):
|
||||
res = super().button_mark_done()
|
||||
Dispatch = self.env['fp.notification.template']
|
||||
for mo in self:
|
||||
partner = False
|
||||
so = False
|
||||
if mo.x_fc_portal_job_id:
|
||||
partner = mo.x_fc_portal_job_id.partner_id
|
||||
if mo.origin:
|
||||
so = self.env['sale.order'].search(
|
||||
[('name', '=', mo.origin)], limit=1,
|
||||
)
|
||||
if so and not partner:
|
||||
partner = so.partner_id
|
||||
if not partner:
|
||||
continue
|
||||
Dispatch._dispatch(
|
||||
'mo_complete', mo, partner, sale_order=so,
|
||||
)
|
||||
return res
|
||||
@@ -3,49 +3,27 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
def action_confirm(self):
|
||||
res = super().action_confirm()
|
||||
def action_quotation_send(self):
|
||||
"""Fire the quote_sent trigger when a quotation is emailed."""
|
||||
res = super().action_quotation_send()
|
||||
Dispatch = self.env['fp.notification.template']
|
||||
for order in self:
|
||||
self._send_fp_notification(
|
||||
'so_confirmed', order, order.partner_id, sale_order=order,
|
||||
Dispatch._dispatch(
|
||||
'quote_sent', order, order.partner_id, sale_order=order,
|
||||
)
|
||||
return res
|
||||
|
||||
def _send_fp_notification(self, trigger_event, record, partner, sale_order=None):
|
||||
"""Send a notification email and log it."""
|
||||
template = self.env['fp.notification.template'].search(
|
||||
[('trigger_event', '=', trigger_event), ('active', '=', True)], limit=1,
|
||||
)
|
||||
if not template or not template.mail_template_id:
|
||||
return
|
||||
try:
|
||||
template.mail_template_id.send_mail(record.id, force_send=False)
|
||||
self.env['fp.notification.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': 'sent',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.warning('FP notification failed (%s): %s', trigger_event, e)
|
||||
self.env['fp.notification.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(e),
|
||||
})
|
||||
def action_confirm(self):
|
||||
res = super().action_confirm()
|
||||
Dispatch = self.env['fp.notification.template']
|
||||
for order in self:
|
||||
Dispatch._dispatch(
|
||||
'so_confirmed', order, order.partner_id, sale_order=order,
|
||||
)
|
||||
return res
|
||||
|
||||
Reference in New Issue
Block a user