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:
gsinghpal
2026-04-16 23:41:12 -04:00
parent 7c7ef06057
commit d3dd6376a6
51 changed files with 5231 additions and 197 deletions

View File

@@ -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

View File

@@ -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),
})

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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),
})

View File

@@ -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

View File

@@ -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